From e2fa750aa8c7b46dc896de7e985342f6f3cde73a Mon Sep 17 00:00:00 2001
From: Richard <42204099+rikroe@users.noreply.github.com>
Date: Thu, 31 Oct 2024 21:52:22 +0100
Subject: [PATCH 1/6] Allow for captcha in North America
---
bimmer_connected/account.py | 7 +++-
bimmer_connected/api/authentication.py | 38 ++++++++++++------
bimmer_connected/const.py | 6 +--
bimmer_connected/tests/test_account.py | 25 ++++++++----
.../source/_static/captcha_north_america.html | 39 +++++++++++++++++++
docs/source/captcha.rst | 15 +++++++
docs/source/index.rst | 1 +
7 files changed, 107 insertions(+), 24 deletions(-)
create mode 100644 docs/source/_static/captcha_north_america.html
create mode 100644 docs/source/captcha.rst
diff --git a/bimmer_connected/account.py b/bimmer_connected/account.py
index a1076139..3bba6279 100644
--- a/bimmer_connected/account.py
+++ b/bimmer_connected/account.py
@@ -53,9 +53,12 @@ class MyBMWAccount:
use_metric_units: InitVar[Optional[bool]] = None
"""Deprecated. All returned values are metric units (km, l)."""
+ hcaptcha_token: InitVar[Optional[str]] = None
+ """Optional. Required for North America region."""
+
vehicles: List[MyBMWVehicle] = field(default_factory=list, init=False)
- def __post_init__(self, password, log_responses, observer_position, verify, use_metric_units):
+ def __post_init__(self, password, log_responses, observer_position, verify, use_metric_units, hcaptcha_token):
"""Initialize the account."""
if use_metric_units is not None:
@@ -66,7 +69,7 @@ def __post_init__(self, password, log_responses, observer_position, verify, use_
if self.config is None:
self.config = MyBMWClientConfiguration(
- MyBMWAuthentication(self.username, password, self.region, verify=verify),
+ MyBMWAuthentication(self.username, password, self.region, verify=verify, hcaptcha_token=hcaptcha_token),
log_responses=log_responses,
observer_position=observer_position,
verify=verify,
diff --git a/bimmer_connected/api/authentication.py b/bimmer_connected/api/authentication.py
index 324f8635..882db9f6 100644
--- a/bimmer_connected/api/authentication.py
+++ b/bimmer_connected/api/authentication.py
@@ -53,6 +53,7 @@ def __init__(
expires_at: Optional[datetime.datetime] = None,
refresh_token: Optional[str] = None,
gcid: Optional[str] = None,
+ hcaptcha_token: Optional[str] = None,
verify: httpx._types.VerifyTypes = True,
):
self.username: str = username
@@ -64,6 +65,7 @@ def __init__(
self.session_id: str = str(uuid4())
self._lock: Optional[asyncio.Lock] = None
self.gcid: Optional[str] = gcid
+ self.hcaptcha_token: Optional[str] = hcaptcha_token
# Use external SSL context. Required in Home Assistant due to event loop blocking when httpx loads
# SSL certificates from disk. If not given, uses httpx defaults.
self.verify: Optional[httpx._types.VerifyTypes] = verify
@@ -183,19 +185,31 @@ async def _login_row_na(self):
"code_challenge_method": "S256",
}
+ authenticate_headers = {}
+ if self.region == Regions.NORTH_AMERICA:
+ if not self.hcaptcha_token:
+ raise MyBMWAPIError("Missing hCaptcha token for North America login")
+ authenticate_headers = {
+ "hcaptchatoken": self.hcaptcha_token,
+ }
# Call authenticate endpoint first time (with user/pw) and get authentication
- response = await client.post(
- authenticate_url,
- data=dict(
- oauth_base_values,
- **{
- "grant_type": "authorization_code",
- "username": self.username,
- "password": self.password,
- },
- ),
- )
- authorization = httpx.URL(response.json()["redirect_to"]).params["authorization"]
+ try:
+ response = await client.post(
+ authenticate_url,
+ headers=authenticate_headers,
+ data=dict(
+ oauth_base_values,
+ **{
+ "grant_type": "authorization_code",
+ "username": self.username,
+ "password": self.password,
+ },
+ ),
+ )
+ authorization = httpx.URL(response.json()["redirect_to"]).params["authorization"]
+ finally:
+ # Always reset hCaptcha token after first login attempt
+ self.hcaptcha_token = None
# With authorization, call authenticate endpoint second time to get code
response = await client.post(
diff --git a/bimmer_connected/const.py b/bimmer_connected/const.py
index a007f66c..3ce11169 100644
--- a/bimmer_connected/const.py
+++ b/bimmer_connected/const.py
@@ -39,9 +39,9 @@ class Regions(str, Enum):
}
APP_VERSIONS = {
- Regions.NORTH_AMERICA: "4.7.2(35379)",
- Regions.REST_OF_WORLD: "4.7.2(35379)",
- Regions.CHINA: "4.7.2(35379)",
+ Regions.NORTH_AMERICA: "4.9.2(36892)",
+ Regions.REST_OF_WORLD: "4.9.2(36892)",
+ Regions.CHINA: "4.9.2(36892)",
}
HTTPX_TIMEOUT = 30.0
diff --git a/bimmer_connected/tests/test_account.py b/bimmer_connected/tests/test_account.py
index 5d27fa23..784976b6 100644
--- a/bimmer_connected/tests/test_account.py
+++ b/bimmer_connected/tests/test_account.py
@@ -13,7 +13,7 @@
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, CarBrands
+from bimmer_connected.const import ATTR_CAPABILITIES, VEHICLES_URL, CarBrands, Regions
from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError, MyBMWQuotaError
from . import (
@@ -32,13 +32,29 @@
@pytest.mark.asyncio
-async def test_login_row_na(bmw_fixture: respx.Router):
+async def test_login_row(bmw_fixture: respx.Router):
"""Test the login flow."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, get_region_from_name(TEST_REGION_STRING))
await account.get_vehicles()
assert account is not None
+@pytest.mark.asyncio
+async def test_login_na(bmw_fixture: respx.Router):
+ """Test the login flow for North America."""
+ account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, Regions.NORTH_AMERICA, hcaptcha_token="SOME_TOKEN")
+ await account.get_vehicles()
+ assert account is not None
+
+
+@pytest.mark.asyncio
+async def test_login_na_without_hcaptcha(bmw_fixture: respx.Router):
+ """Test the login flow."""
+ with pytest.raises(MyBMWAPIError):
+ account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, Regions.NORTH_AMERICA)
+ await account.get_vehicles()
+
+
@pytest.mark.asyncio
async def test_login_refresh_token_row_na_expired(bmw_fixture: respx.Router):
"""Test the login flow using refresh_token."""
@@ -745,8 +761,3 @@ async def test_pillow_unavailable(monkeypatch: pytest.MonkeyPatch, bmw_fixture:
await account.get_vehicles()
assert account is not None
assert len(account.vehicles) > 0
-
- account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, get_region_from_name("north_america"))
- await account.get_vehicles()
- assert account is not None
- assert len(account.vehicles) > 0
diff --git a/docs/source/_static/captcha_north_america.html b/docs/source/_static/captcha_north_america.html
new file mode 100644
index 00000000..982ddcbd
--- /dev/null
+++ b/docs/source/_static/captcha_north_america.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+ Form with hCaptcha
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/source/captcha.rst b/docs/source/captcha.rst
new file mode 100644
index 00000000..f5012c64
--- /dev/null
+++ b/docs/source/captcha.rst
@@ -0,0 +1,15 @@
+Captcha (North America)
+=======================
+
+Login to the :code:`north_america` region requires a captcha to be solved. Submit below form and use the returned token when creating the account object.
+
+::
+
+ account = MyBMWAccount(USERNAME, PASSWORD, Regions.REST_OF_WORLD, hcaptcha_token=HCAPTCHA_TOKEN)
+
+.. note::
+ Only the first login requires a captcha to be solved. Follow-up logins using refresh token do not require a captcha.
+ This requires the tokens to be stored in a file (see :ref:`cli`) or in the python object itself.
+
+.. raw:: html
+ :file: _static/captcha_north_america.html
\ No newline at end of file
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 2765f4ae..738a79c8 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -12,6 +12,7 @@
:maxdepth: 2
:glob:
+ captcha
development/*
From 9a59e72723ff13b7823f88af87af3918bc383844 Mon Sep 17 00:00:00 2001
From: Richard <42204099+rikroe@users.noreply.github.com>
Date: Thu, 31 Oct 2024 22:29:23 +0100
Subject: [PATCH 2/6] Fix for sphinx-rtd-theme>=3.0
---
docs/source/conf.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 787c9625..37d7ccc6 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -86,7 +86,7 @@
# documentation.
#
html_theme_options = {
- 'display_version': True,
+ 'version_selector': True,
}
# Add any paths that contain custom static files (such as style sheets) here,
From bf246840ca6dbb0bb6884cecc04cdacaa13ca967 Mon Sep 17 00:00:00 2001
From: Richard <42204099+rikroe@users.noreply.github.com>
Date: Fri, 1 Nov 2024 17:08:41 +0100
Subject: [PATCH 3/6] Add MyBMWCaptchaMissingError
---
bimmer_connected/api/authentication.py | 4 ++--
bimmer_connected/models.py | 4 ++++
bimmer_connected/tests/test_account.py | 10 ++++++++--
3 files changed, 14 insertions(+), 4 deletions(-)
diff --git a/bimmer_connected/api/authentication.py b/bimmer_connected/api/authentication.py
index 882db9f6..55037e3c 100644
--- a/bimmer_connected/api/authentication.py
+++ b/bimmer_connected/api/authentication.py
@@ -34,7 +34,7 @@
OAUTH_CONFIG_URL,
X_USER_AGENT,
)
-from bimmer_connected.models import MyBMWAPIError
+from bimmer_connected.models import MyBMWAPIError, MyBMWCaptchaMissingError
EXPIRES_AT_OFFSET = datetime.timedelta(seconds=HTTPX_TIMEOUT * 2)
@@ -188,7 +188,7 @@ async def _login_row_na(self):
authenticate_headers = {}
if self.region == Regions.NORTH_AMERICA:
if not self.hcaptcha_token:
- raise MyBMWAPIError("Missing hCaptcha token for North America login")
+ raise MyBMWCaptchaMissingError("Missing hCaptcha token for North America login")
authenticate_headers = {
"hcaptchatoken": self.hcaptcha_token,
}
diff --git a/bimmer_connected/models.py b/bimmer_connected/models.py
index b0b78b68..d2f8bf78 100644
--- a/bimmer_connected/models.py
+++ b/bimmer_connected/models.py
@@ -201,6 +201,10 @@ class MyBMWAuthError(MyBMWAPIError):
"""Auth-related error from BMW API (HTTP status codes 401 and 403)."""
+class MyBMWCaptchaMissingError(MyBMWAPIError):
+ """Indicate missing captcha for login."""
+
+
class MyBMWQuotaError(MyBMWAPIError):
"""Quota exceeded on BMW API."""
diff --git a/bimmer_connected/tests/test_account.py b/bimmer_connected/tests/test_account.py
index 784976b6..69b73de8 100644
--- a/bimmer_connected/tests/test_account.py
+++ b/bimmer_connected/tests/test_account.py
@@ -14,7 +14,13 @@
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, CarBrands, Regions
-from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError, MyBMWQuotaError
+from bimmer_connected.models import (
+ GPSPosition,
+ MyBMWAPIError,
+ MyBMWAuthError,
+ MyBMWCaptchaMissingError,
+ MyBMWQuotaError,
+)
from . import (
RESPONSE_DIR,
@@ -50,7 +56,7 @@ async def test_login_na(bmw_fixture: respx.Router):
@pytest.mark.asyncio
async def test_login_na_without_hcaptcha(bmw_fixture: respx.Router):
"""Test the login flow."""
- with pytest.raises(MyBMWAPIError):
+ with pytest.raises(MyBMWCaptchaMissingError):
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, Regions.NORTH_AMERICA)
await account.get_vehicles()
From 0d7184412f12027be1dbec57680d80c0ea597ddf Mon Sep 17 00:00:00 2001
From: Richard <42204099+rikroe@users.noreply.github.com>
Date: Fri, 1 Nov 2024 17:08:49 +0100
Subject: [PATCH 4/6] Add sitekey to const
---
bimmer_connected/const.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/bimmer_connected/const.py b/bimmer_connected/const.py
index 3ce11169..eda687d9 100644
--- a/bimmer_connected/const.py
+++ b/bimmer_connected/const.py
@@ -38,6 +38,11 @@ class Regions(str, Enum):
Regions.REST_OF_WORLD: "NGYxYzg1YTMtNzU4Zi1hMzdkLWJiYjYtZjg3MDQ0OTRhY2Zh",
}
+HCAPTCHA_SITE_KEYS = {
+ Regions.NORTH_AMERICA: "dc24de9a-9844-438b-b542-60067ff4dbe9",
+ "_": "10000000-ffff-ffff-ffff-000000000001",
+}
+
APP_VERSIONS = {
Regions.NORTH_AMERICA: "4.9.2(36892)",
Regions.REST_OF_WORLD: "4.9.2(36892)",
From 8d0092ad94e0b98eced85532126d5fc6e5b1c99e Mon Sep 17 00:00:00 2001
From: Richard <42204099+rikroe@users.noreply.github.com>
Date: Sat, 2 Nov 2024 11:05:08 +0100
Subject: [PATCH 5/6] Add --captcha-token to CLI
---
bimmer_connected/cli.py | 7 +++++--
bimmer_connected/tests/test_cli.py | 30 +++++++++++++++++++++++++++++-
2 files changed, 34 insertions(+), 3 deletions(-)
diff --git a/bimmer_connected/cli.py b/bimmer_connected/cli.py
index a23d3b81..f74e5335 100644
--- a/bimmer_connected/cli.py
+++ b/bimmer_connected/cli.py
@@ -40,7 +40,7 @@ def main_parser() -> argparse.ArgumentParser:
)
parser.add_argument("--disable-oauth-store", help="Disable storing the OAuth2 tokens.", action="store_true")
- subparsers = parser.add_subparsers(dest="cmd")
+ subparsers = parser.add_subparsers(dest="cmd", description="Command", required=True)
subparsers.required = True
status_parser = subparsers.add_parser("status", description="Get the current status of the vehicle.")
@@ -311,6 +311,7 @@ def _add_default_arguments(parser: argparse.ArgumentParser):
parser.add_argument("username", help="Connected Drive username")
parser.add_argument("password", help="Connected Drive password")
parser.add_argument("region", choices=valid_regions(), help="Region of the Connected Drive account")
+ parser.add_argument("--captcha-token", type=str, nargs="?", help="Captcha token required for North America.")
def _add_position_arguments(parser: argparse.ArgumentParser):
@@ -331,7 +332,9 @@ 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))
+ account = MyBMWAccount(
+ args.username, args.password, get_region_from_name(args.region), hcaptcha_token=args.captcha_token
+ )
if args.oauth_store.exists():
with contextlib.suppress(json.JSONDecodeError):
diff --git a/bimmer_connected/tests/test_cli.py b/bimmer_connected/tests/test_cli.py
index ebe0cef1..2a31d528 100644
--- a/bimmer_connected/tests/test_cli.py
+++ b/bimmer_connected/tests/test_cli.py
@@ -64,7 +64,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."""
+ """Test the status command JSON output without filtering by VIN."""
sys.argv = ["bimmerconnected", "status", "-j", *ARGS_USER_PW_REGION]
bimmer_connected.cli.main()
@@ -296,3 +296,31 @@ def test_login_invalid_refresh_token(cli_home_dir: Path, bmw_fixture: respx.Rout
assert bmw_fixture.routes["vehicles"].calls[0].request.headers["authorization"] == "Bearer some_token_string"
assert (cli_home_dir / ".bimmer_connected.json").exists() is True
+
+
+@pytest.mark.usefixtures("bmw_fixture")
+@pytest.mark.usefixtures("cli_home_dir")
+def test_captcha_set(capsys: pytest.CaptureFixture):
+ """Test login for North America if captcha is given."""
+
+ ARGS_USER_PW_REGION = ["myuser", "mypassword", "north_america"]
+ sys.argv = ["bimmerconnected", "status", "-j", "--captcha-token", "SOME_CAPTCHA_TOKEN", *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.usefixtures("cli_home_dir")
+def test_captcha_unavailable(capsys: pytest.CaptureFixture):
+ """Test login for North America failing if no captcha token was given."""
+
+ ARGS_USER_PW_REGION = ["myuser", "mypassword", "north_america"]
+ sys.argv = ["bimmerconnected", "status", "-j", *ARGS_USER_PW_REGION]
+ with contextlib.suppress(SystemExit):
+ bimmer_connected.cli.main()
+ result = capsys.readouterr()
+ assert result.err.strip() == "MyBMWCaptchaMissingError: Missing hCaptcha token for North America login"
From 3a533aadf4c07b3f4dc9b3830173e60ff83cd006 Mon Sep 17 00:00:00 2001
From: Richard <42204099+rikroe@users.noreply.github.com>
Date: Sat, 2 Nov 2024 11:14:29 +0100
Subject: [PATCH 6/6] Update CLI documentation
---
README.rst | 3 +++
docs/source/captcha.rst | 4 +++-
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/README.rst b/README.rst
index 6ee08580..b544cda8 100644
--- a/README.rst
+++ b/README.rst
@@ -43,6 +43,9 @@ Please be aware that :code:`bimmer_connected` is an :code:`async` library when u
The description of the :code:`modules` can be found in the `module documentation
`_.
+.. note::
+ Login to **north american** accounts requires a captcha to be solved. See `Captcha (North America) `_ for more information.
+
Example in an :code:`asyncio` event loop
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
::
diff --git a/docs/source/captcha.rst b/docs/source/captcha.rst
index f5012c64..b820c8db 100644
--- a/docs/source/captcha.rst
+++ b/docs/source/captcha.rst
@@ -7,9 +7,11 @@ Login to the :code:`north_america` region requires a captcha to be solved. Submi
account = MyBMWAccount(USERNAME, PASSWORD, Regions.REST_OF_WORLD, hcaptcha_token=HCAPTCHA_TOKEN)
+When using the CLI, pass the token via the :code:`--hcaptcha-token` argument (see `CLI documentation `_).
+
.. note::
Only the first login requires a captcha to be solved. Follow-up logins using refresh token do not require a captcha.
- This requires the tokens to be stored in a file (see :ref:`cli`) or in the python object itself.
+ This requires the tokens to be stored in a file (default behavior when using the CLI) or in the python object itself.
.. raw:: html
:file: _static/captcha_north_america.html
\ No newline at end of file