diff --git a/packages/modules/vehicles/vweuda/README.txt b/packages/modules/vehicles/vweuda/README.txt deleted file mode 100644 index 2bef5e60d6..0000000000 --- a/packages/modules/vehicles/vweuda/README.txt +++ /dev/null @@ -1,41 +0,0 @@ - -Das ist ein Startpunkt und wird noch Verbesserungen und Korrekturen benötigen. -Dazu werden Rückmeldungen aus der Community benötigt. - -Dazu wird im SoC-Log einiges an Ausgaben auf Level Info ausgegeben. -In einigen Fällen könnte es auch nötig sein, Level Debug/Details einzustellen. - -Die Daten im eu-data-act Portal sind bei meinem Fahrzeug mittlerweile einigermaßen aktuell. -Wenn das Fahrzeug gefahren oder geladen wurde ca. 2 Stunden. - -Vor Nutzung muss die kontinuierliche Abfrage (alle Daten, 15-min) im Portal angestoßen werden. -https://eu-data-act.drivesomethinggreater.com/de/de/eu-data-act.html -Benutzerdefinierte Daten abrufen - All Data - 15 min -Es kann dann mehrere Stunden dauern, bis erste nicht-leere Datenpakete ankommen. - -Das Modul startet je konfiguriertem Fahrzeug einen parallelen Prozess um das Portal zu pollen. -Der Thread prüft im Minutentakt auf Bereitschaft des Portals und lädt dann die neueste zip-Datei herunter. -Die 5 letzten in den zip's enthaltenen json-Dateien werden in ramdisk/vweuda gespeichert zu evtl. Analyse. - -Aus der json wird versucht, folgende Daten zu extrahieren: soc, range, soc_timestamp, odometer. -soc scheint immer vorhanden zu sein. -Range habe ich noch nie bekommen. -Als soc_timestamp nehme ich das maximum aller car_captured_time Felder. -odometer kommt mal und mal nicht. - -Diese Daten werden je VIN für die Abfrage durch die openwb bereitgestellt. - -Der jeweils letzte Stand, der an die openwb gemeldet wird, wird auch persistent gehalten. -Dieser Stand wird bei jeder Abfrage der openwb mit den letzten aus den Portal gelieferten Daten ergänzt. -Damit wird ein Feld nicht ungültig, wenn es vom Portal nicht gemeldet wurde. -Wenn die Reichweite/range leer ist, wird diese aus Batterie-Kapazität, SoC und Durchschnittsverbrauch berechnet. - -Solange keine Daten vom Portal kommen werden entsprechende Meldungen im soc-log ausgegeben. - -Die Konfiguration des Moduls ist gleichgeblieben. - -Nach dem ersten Start ist es normal, daß erst mal keine Daten vorhanden sind. -Auch wenn das Portal schon nicht-leere Datenpakete liefert, kann es mehrere Abfrage-Intervalle dauern, bis erste Ergebnisse in openWB ankommen. - -Quellen: - https://github.com/mikrohard/hass-vw-eu-data-act diff --git a/packages/modules/vehicles/vweuda/api.py b/packages/modules/vehicles/vweuda/api.py new file mode 100644 index 0000000000..52c8b99bea --- /dev/null +++ b/packages/modules/vehicles/vweuda/api.py @@ -0,0 +1,238 @@ +"""Synchronous VW EU Data Act login and dataset downloads.""" + +import io +import json +import re +import zipfile +from html.parser import HTMLParser +from typing import Optional, Union +from urllib.parse import urlencode, urljoin, urlparse + +from requests import HTTPError, RequestException, Response, Session + +BASE_URL = "https://eu-data-act.drivesomethinggreater.com" +OIDC_AUTHORIZE_URL = "https://identity.vwgroup.io/oidc/v1/authorize" +CALLBACK_LOGIN_PATH = "/services/callbacklogin" +METADATA_PATH = "/proxy_api/euda-apim/datarequest/vehicles/{vin}/metadata/partial" +LIST_PATH = "/proxy_api/euda-apim/datadelivery/vehicles/{vin}/{identifier}/list" +DOWNLOAD_PATH = "/proxy_api/euda-apim/datadelivery/vehicles/{vin}/{identifier}/download" +NO_CONTENT_SUFFIX = "_no_content_found.zip" +HTTP_TIMEOUT = (5, 15) +USER_AGENT = "vw_eu_data_act/0.3.3" + +# OIDC client ID and portal brand state. SEAT and CUPRA share a client ID. +BRANDS = { + "volkswagen": ("9b58543e-1c15-4193-91d5-8a14145bebb0@apps_vw-dilab_com", "VOLKSWAGEN_PASSENGER_CARS"), + "audi": ("cc29b87a-5e9a-4362-aecf-5adea6b01bbb@apps_vw-dilab_com", "AUDI"), + "skoda": ("3ea88bf9-1d4e-4a68-b3ad-4098c1f1d246@apps_vw-dilab_com", "SKODA"), + "seat": ("f85e5b69-e3b2-43aa-9c0d-1b7d0e0b576f@apps_vw-dilab_com", "SEAT"), + "cupra": ("f85e5b69-e3b2-43aa-9c0d-1b7d0e0b576f@apps_vw-dilab_com", "CUPRA"), +} +BRAND_PREFIXES = { + "audi": ("WAU", "WA1", "WUA", "WU1", "99A", "AAA", "TRU"), + "skoda": ("NAD", "TMB", "Y6U"), + "cupra": ("VSS",), # SEAT/CUPRA cannot be distinguished by this prefix. +} + + +def brand_for_vin(vin: str) -> str: + return next((brand for brand, prefixes in BRAND_PREFIXES.items() if vin[:3] in prefixes), "volkswagen") + + +class ApiError(Exception): + """Portal/network failure, with HTTP status when available.""" + + def __init__(self, message: str, *, status: Optional[int] = None) -> None: + super().__init__(message) + self.status = status + + +class AuthError(ApiError): + """Login failed or the identity provider's form changed.""" + + +class DatasetError(ApiError): + """Malformed archive; it must not prevent processing newer datasets.""" + + +class _LoginForm(HTMLParser): + """Collect the first HTML form and its input fields.""" + + def __init__(self, html: str) -> None: + super().__init__() + self.action = None + self.fields = {} + self._inside = False + self._done = False + self.feed(html) + + def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None: + attrs = dict(attrs) + if tag == "form" and not self._done and not self._inside: + self.action = attrs.get("action") + self._inside = True + elif tag == "input" and self._inside and attrs.get("name"): + self.fields[attrs["name"]] = attrs.get("value") or "" + + def handle_endtag(self, tag: str) -> None: + if tag == "form" and self._inside: + self._inside, self._done = False, True + + +def _template_model(html: str) -> dict: + # raw_decode also handles braces inside JSON strings, unlike brace counting. + index = html.find("templateModel") + start = html.find("{", index) if index >= 0 else -1 + try: + return json.JSONDecoder().raw_decode(html[start:])[0] if start >= 0 else {} + except ValueError: + return {} + + +def _login_error(html: str) -> Optional[str]: + model = _template_model(html) + error = model.get("error") or model.get("errorCode") + if isinstance(error, dict): + return error.get("text") or error.get("errorCode") or str(error) + return str(error) if error else None + + +def _login_fields(html: str) -> tuple[dict, Optional[str]]: + form = _LoginForm(html) + model = _template_model(html) + for key in ("hmac", "relayState"): + if model.get(key): + form.fields[key] = model[key] + csrf = re.search(r"csrf_token\s*[:=]\s*['\"]([^'\"]+)['\"]", html) + if csrf: + form.fields.setdefault("_csrf", csrf.group(1)) + if not all(key in form.fields for key in ("hmac", "_csrf")): + raise AuthError(_login_error(html) or "Could not parse identity login form") + return form.fields, form.action + + +def _check_login_status(response: Response) -> None: + status = response.status_code + if status >= 500 or status in (404, 429): + raise ApiError(f"Identity provider error (HTTP {status})", status=status) + if status >= 400: + raise AuthError(_login_error(response.text) or f"Login rejected (HTTP {status})") + + +class EudaApiClient: + """The synchronous caller owns and closes the session.""" + + def __init__(self, session: Session, email: str, password: str, brand: str = "volkswagen") -> None: + self._session = session + self._email, self._password, self._brand = email, password, brand + self._logged_in = False + + def _request(self, method: str, url: str, **kwargs) -> Response: + headers = {"User-Agent": USER_AGENT, **kwargs.pop("headers", {})} + try: + return self._session.request(method, url, headers=headers, timeout=HTTP_TIMEOUT, **kwargs) + except HTTPError as error: + # req.get_http_session() raises in its response hook. Keep the full + # response for VW login status handling and the 401/403 retry below. + if error.response is None: + raise ApiError(f"HTTP error during {method}: {error}") from error + return error.response + except RequestException as error: + raise ApiError(f"Network error during {method}: {error}") from error + + @staticmethod + def _build_authorize_url(brand: str = "volkswagen") -> str: + client_id, state = BRANDS.get(brand, BRANDS["volkswagen"]) + # The portal's redirect/authentication servlet fails for non-browser clients. + return OIDC_AUTHORIZE_URL + "?" + urlencode({ + "client_id": client_id, "state": f"de__en__{state}", "response_type": "code", + "scope": "openid cars profile", "redirect_uri": BASE_URL + "/login", "prompt": "login", + }) + + def _post_login_form(self, url: str, *, data: dict, referer: str) -> Response: + """Follow at most ten redirects, stopping at the session-setting callback.""" + response = self._request("POST", url, data=data, headers={"Referer": referer}, allow_redirects=False) + for hop in range(11): + location = response.headers.get("Location") + parsed = urlparse(response.url) + callback = parsed.netloc == urlparse(BASE_URL).netloc and parsed.path.startswith(CALLBACK_LOGIN_PATH) + if callback or response.status_code not in (301, 302, 303) or not location: + return response + previous_url = response.url + next_url = urljoin(previous_url, location) + response.close() + if hop == 10: + raise ApiError("Login redirect chain exceeded 10 hops") + response = self._request("GET", next_url, headers={"Referer": previous_url}, allow_redirects=False) + + def login(self) -> None: + """Authorize → email form → password form → portal callback.""" + self._logged_in = False + with self._request("GET", self._build_authorize_url(self._brand)) as response: + _check_login_status(response) + signin_url = response.url + fields, action = _login_fields(response.text) + + fields["email"] = self._email + with self._request("POST", urljoin(signin_url, action or ""), data=fields, + headers={"Referer": signin_url}) as response: + _check_login_status(response) + authenticate_url = response.url + fields, action = _login_fields(response.text) + + fields.update(email=self._email, password=self._password) + # relayState belongs in the POST body, not in the action URL as well. + action_url = urljoin(authenticate_url, action) if action else authenticate_url.split("?", 1)[0] + with self._post_login_form(action_url, data=fields, referer=authenticate_url) as response: + _check_login_status(response) + parsed = urlparse(response.url) + if parsed.netloc != urlparse(BASE_URL).netloc or not parsed.path.startswith(CALLBACK_LOGIN_PATH): + raise AuthError(_login_error(response.text) or "Login did not reach the portal callback") + if not response.cookies.get("access_token"): + raise AuthError("Login callback did not establish a session (access_token cookie missing)") + self._logged_in = True + + def _authenticated_get(self, url: str, headers: Optional[dict] = None) -> Response: + """Share the same one-time reauthentication for JSON and ZIP requests.""" + if not self._logged_in: + self.login() + for attempt in range(2): + response = self._request("GET", url, headers=headers or {}) + status = response.status_code + if status in (401, 403) and attempt == 0: + response.close() + self.login() + continue + if status >= 400: + response.close() + raise ApiError(f"Portal request failed (HTTP {status})", status=status) + return response + + def _get_json(self, url: str, headers: Optional[dict] = None) -> Union[dict, list]: + with self._authenticated_get(url, headers) as response: + try: + return response.json() + except ValueError as error: + raise ApiError("Invalid JSON from portal") from error + + def get_metadata(self, vin: str) -> dict: + return self._get_json(BASE_URL + METADATA_PATH.format(vin=vin)) + + def list_datasets(self, vin: str, identifier: str) -> list[dict]: + url = BASE_URL + LIST_PATH.format(vin=vin, identifier=identifier) + # The endpoint returns HTTP 500 without the matching request type. + data = self._get_json(url, headers={"type": "partial"}) + return data if isinstance(data, list) else data.get("files", []) + + def download_dataset(self, vin: str, identifier: str, name: str) -> tuple[str, dict]: + url = BASE_URL + DOWNLOAD_PATH.format(vin=vin, identifier=identifier) + with self._authenticated_get(url, {"filename": name, "type": "partial"}) as response: + try: + with zipfile.ZipFile(io.BytesIO(response.content)) as archive: + member = next((name for name in archive.namelist() if name.lower().endswith(".json")), None) + if member is None: + raise DatasetError("No JSON inside downloaded dataset") + data = json.loads(archive.read(member).decode("utf-8")) + except (zipfile.BadZipFile, ValueError) as error: + raise DatasetError("Could not read downloaded dataset") from error + return name.removesuffix(".zip") + ".json", data diff --git a/packages/modules/vehicles/vweuda/api_test.py b/packages/modules/vehicles/vweuda/api_test.py new file mode 100644 index 0000000000..1a05fb24fc --- /dev/null +++ b/packages/modules/vehicles/vweuda/api_test.py @@ -0,0 +1,102 @@ +import io +import json +import zipfile +from unittest.mock import Mock +from urllib.parse import parse_qs + +import pytest +from modules.common.req import get_http_session + +from modules.vehicles.vweuda import api + + +def test_login_stops_at_callback_and_posts_relay_state(requests_mock): + signin = "https://identity.vwgroup.io/signin-service/login" + authenticate = signin + "/authenticate" + callback = api.BASE_URL + api.CALLBACK_LOGIN_PATH + "?code=secret" + cms = api.BASE_URL + "/missing-cms-page" + form = '
' + form += '' + requests_mock.get(api.OIDC_AUTHORIZE_URL, status_code=302, headers={"Location": signin}) + requests_mock.get(signin, text=form) + password_page = '' + requests_mock.post(signin, status_code=302, headers={"Location": authenticate + "?relayState=relay"}) + requests_mock.get(authenticate, text=password_page) + credentials = requests_mock.post(authenticate, status_code=302, headers={"Location": callback}) + requests_mock.get(callback, status_code=302, headers={ + "Location": cms, "Set-Cookie": "access_token=token; Path=/; Secure", + }) + with get_http_session() as session: + client = api.EudaApiClient(session, "test@example.com", "password") + client.login() + assert client._logged_in + assert all(request.url != cms for request in requests_mock.request_history) + assert credentials.last_request.query == "" + fields = parse_qs(credentials.last_request.text) + assert fields["password"] == ["password"] + assert fields["relayState"] == ["relay"] + + +def test_callback_without_cookie_is_not_successful(requests_mock, monkeypatch): + callback = api.BASE_URL + api.CALLBACK_LOGIN_PATH + with get_http_session() as session: + client = api.EudaApiClient(session, "test@example.com", "password") + form = '' + requests_mock.get(api.OIDC_AUTHORIZE_URL, text=form) + requests_mock.post(api.OIDC_AUTHORIZE_URL, text=form) + requests_mock.get(callback, status_code=302) + monkeypatch.setattr(client, "_post_login_form", lambda *args, **kwargs: session.get(callback)) + with pytest.raises(api.AuthError, match="cookie missing"): + client.login() + assert not client._logged_in + + +@pytest.mark.parametrize("status", [404, 429, 500, 503]) +def test_transient_login_errors_remain_retryable(requests_mock, status): + requests_mock.get(api.OIDC_AUTHORIZE_URL, status_code=status) + with get_http_session() as session: + with pytest.raises(api.ApiError) as error: + api.EudaApiClient(session, "test@example.com", "password").login() + assert not isinstance(error.value, api.AuthError) + assert error.value.status == status + + +def test_json_session_expiry_retries_only_once(requests_mock, monkeypatch): + url = api.BASE_URL + "/test-json" + response = requests_mock.get(url, [{"status_code": 401}, {"status_code": 403}]) + with get_http_session() as session: + client = api.EudaApiClient(session, "test@example.com", "password") + client._logged_in = True + login = Mock() + monkeypatch.setattr(client, "login", login) + with pytest.raises(api.ApiError) as error: + client._get_json(url) + assert error.value.status == 403 + login.assert_called_once() + assert response.call_count == 2 + + +def test_helper_http_error_preserves_login_error_body(requests_mock): + requests_mock.get(api.OIDC_AUTHORIZE_URL, status_code=400, + text='') + with get_http_session() as session: + with pytest.raises(api.AuthError, match="Account locked"): + api.EudaApiClient(session, "test@example.com", "password").login() + + +def test_download_session_expiry_preserves_zip_decoding(requests_mock, monkeypatch): + payload = {"vin": "WVW00000000000001", "Data": []} + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as handle: + handle.writestr("data.json", json.dumps(payload)) + url = api.BASE_URL + api.DOWNLOAD_PATH.format(vin=payload["vin"], identifier="id") + requests_mock.get(url, [{"status_code": 401}, {"content": archive.getvalue()}]) + with get_http_session() as session: + client = api.EudaApiClient(session, "test@example.com", "password") + client._logged_in = True + login = Mock() + monkeypatch.setattr(client, "login", login) + name, data = client.download_dataset(payload["vin"], "id", "data.zip") + assert name == "data.json" + assert data == payload + login.assert_called_once() diff --git a/packages/modules/vehicles/vweuda/libeuda.py b/packages/modules/vehicles/vweuda/libeuda.py old mode 100755 new mode 100644 index 6ac67824e0..c84b439e6f --- a/packages/modules/vehicles/vweuda/libeuda.py +++ b/packages/modules/vehicles/vweuda/libeuda.py @@ -1,1248 +1,115 @@ -#!/usr/bin/env python3 -"""Client for the VW EU Data Act portal (OIDC login + data delivery).""" +"""Synchronous portal requests with a module-owned in-memory cache.""" -from __future__ import annotations - -from typing import Union -from asyncio import new_event_loop, set_event_loop -import uuid import logging -import aiohttp -import io -import json import re -import zipfile -from html.parser import HTMLParser -from urllib.parse import urlencode, urljoin, urlparse -from datetime import datetime, timezone, timedelta -import time -import threading -import asyncio -# from helpermodules.pub import Pub -import glob -import os -import os.path -from pathlib import Path -from collections import deque -from helpermodules.constants import RAMDISK_PATH -from modules.common.abstract_vehicle import VehicleUpdateData -from modules.vehicles.vweuda.config import VWEUDA - -"""Constants for the VW EU Data Act integration.""" - -# --- Portal / OIDC endpoints --------------------------------------------- -BASE_URL = "https://eu-data-act.drivesomethinggreater.com" -IDENTITY_BASE = "https://identity.vwgroup.io" - -# Brand is part of the OIDC state; VW passenger cars by default. -# BRAND = "VOLKSWAGEN_PASSENGER_CARS" -CALLBACK_LOGIN_PATH = "/services/callbacklogin" - -BRANDS: dict[str, dict[str, str]] = { - "volkswagen": { - "display_name": "Volkswagen", - "client_id": "9b58543e-1c15-4193-91d5-8a14145bebb0@apps_vw-dilab_com", - "state": "VOLKSWAGEN_PASSENGER_CARS", - }, - "audi": { - "display_name": "Audi", - "client_id": "cc29b87a-5e9a-4362-aecf-5adea6b01bbb@apps_vw-dilab_com", - "state": "AUDI", - }, - "skoda": { - "display_name": "Škoda", - "client_id": "3ea88bf9-1d4e-4a68-b3ad-4098c1f1d246@apps_vw-dilab_com", - "state": "SKODA", - }, - "seat": { - "display_name": "SEAT", - "client_id": "f85e5b69-e3b2-43aa-9c0d-1b7d0e0b576f@apps_vw-dilab_com", - "state": "SEAT", - }, - "cupra": { - "display_name": "CUPRA", - "client_id": "f85e5b69-e3b2-43aa-9c0d-1b7d0e0b576f@apps_vw-dilab_com", - "state": "CUPRA", - }, -} - - -DEFAULT_BRAND = "volkswagen" -DEFAULT_COUNTRY = "de" -DEFAULT_LANGUAGE = "en" - - -def ano_part(original): - return original[0] + ('*' * (len(original) - 2)) + original[-1] - - -def ano_email(original): - user, at, domain = original.partition('@') - domain_name, dot, tld = domain.rpartition('.') - return '{}@{}.{}'.format(ano_part(user), ano_part(domain_name), tld) - - -def ano_vin(vin: str) -> str: - return vin[:-9] + "*********" - - -def get_oidc_client_id(brand: str = DEFAULT_BRAND) -> str: - """Return the OIDC client_id for the given brand.""" - return BRANDS.get(brand, BRANDS[DEFAULT_BRAND])["client_id"] - - -def get_oidc_state(brand: str = DEFAULT_BRAND) -> str: - """Return the OIDC state for the given brand.""" - brand_state = BRANDS.get(brand, BRANDS[DEFAULT_BRAND])["state"] - return f"{DEFAULT_COUNTRY}__{DEFAULT_LANGUAGE}__{brand_state}" - - -# OIDC: we build the authorize URL directly instead of using the portal's -# /services/redirect/authentication servlet, which returns HTTP 500 for -# non-browser clients (it depends on AEM browser session state). -OIDC_AUTHORIZE_URL = IDENTITY_BASE + "/oidc/v1/authorize" -# OIDC_CLIENT_ID = "9b58543e-1c15-4193-91d5-8a14145bebb0@apps_vw-dilab_com" -OIDC_SCOPE = "openid cars profile" -OIDC_REDIRECT_URI = BASE_URL + "/login" -# state encodes country__language__brand (echoed back to the portal callback). -# DEFAULT_COUNTRY = "si" -# DEFAULT_LANGUAGE = "sl" -CONF_BRAND = "brand" -# OIDC_STATE = f"{DEFAULT_COUNTRY}__{DEFAULT_LANGUAGE}__{BRAND}" - -# Legacy constants for backward compatibility (default to VW) -OIDC_CLIENT_ID = BRANDS[DEFAULT_BRAND]["client_id"] -OIDC_STATE = get_oidc_state(DEFAULT_BRAND) - -# proxy_api paths (relative to BASE_URL) -VEHICLES_PATH = "/proxy_api/consent/me/vehicles" -RELATION_PATH = "/proxy_api/vum/v2/users/me/relations/{vin}" -METADATA_PATH = "/proxy_api/euda-apim/datarequest/vehicles/{vin}/metadata/partial" -LIST_PATH = "/proxy_api/euda-apim/datadelivery/vehicles/{vin}/{identifier}/list" -DOWNLOAD_PATH = "/proxy_api/euda-apim/datadelivery/vehicles/{vin}/{identifier}/download" - -DOMAIN = "vw_eu_data_act" -VERSION = "0.3.3" -USER_AGENT = f"{DOMAIN}/{VERSION}" - -# --- Config entry keys ---------------------------------------------------- -CONF_EMAIL = "email" -CONF_PASSWORD = "password" -CONF_VIN = "vin" -CONF_IDENTIFIER = "identifier" -CONF_NICKNAME = "nickname" - -# --- Scheduling ----------------------------------------------------------- -# Datasets land ~every 15 min; refresh shortly after the next expected drop. -DATASET_INTERVAL = timedelta(minutes=15) -POST_DATASET_BUFFER = timedelta(seconds=45) -RETRY_INTERVAL = timedelta(minutes=1) -MIN_INTERVAL = timedelta(seconds=30) - -# Files with this suffix carry no payload and are skipped. -NO_CONTENT_SUFFIX = "_no_content_found.zip" - -POLL_INTERVAL = 60 # polling interval in seconds -CYCLE_INTERVAL = 600 # cycle interval in seconds -INITIAL_RESULT_WAIT = 30 # seconds to wait for the first background result -EUDA_THREADNAME = "soc_bt_ev" -UTC = None -KEEP_JSON = 5 -DATA_PATH = Path(__file__).resolve().parents[4] / "data" / "modules" / "vweuda" -JSON_PATH = Path(str(RAMDISK_PATH) + '/vweuda') -storeFileName = '/data_' -MODULE_TYPE = 'vweuda' - -# VIN-Brand map -VIN_BRAND_MAP = { - "WAU": "audi", # Audi car - "WA1": "audi", # Audi SUV - "WUA": "audi", # Audi Sport car - "WU1": "audi", # Audi Sport SUV - "99A": "audi", # Audi 2016- - "AAA": "audi", # Audi South-Africa- - "TRU": "audi", # Audi Hungary - "VSS": "cupra", # Seat/Cupra - assume cupra as default - "NAD": "skoda", # Skoda - "TMB": "skoda", # Skoda (Czech Republic) - "Y6U": "skoda", # Skoda Auto made by Eurocar (Ukraine) - "VWV": "volkswagen", # Volkswagen Spain - "WVG": "volkswagen", # SUV/Touran - "WVW": "volkswagen", # Passenger Cars - "WV1": "volkswagen", # Commercial Vehicles - "WV2": "volkswagen", # Commercial Vehicles - "WV3": "volkswagen", # Commercial Vehicles - "WV4": "volkswagen", # Commercial Vehicles - "WV5": "volkswagen", # Commercial Vehicles -} - - -_LOGGER = logging.getLogger(__name__) - - -class ApiError(Exception): - """Generic API failure. - - Carries the HTTP ``status`` when the failure came from an HTTP response, so - callers can branch on it (e.g. retry 5xx) without grepping the message - string. ``None`` for non-HTTP failures (connection errors, bad JSON, …). - """ - - def __init__(self, message: str, *, status: int | None = None) -> None: - super().__init__(message) - self.status = status - - -class AuthError(ApiError): - """Authentication failed or session expired.""" - - -# Non-5xx statuses the identity provider returns for reasons unrelated to the -# credentials: 404 when the sign-in service is (re)deploying / a step URL is -# temporarily missing, 429 when we are rate limited. -_TRANSIENT_LOGIN_STATUSES = frozenset({404, 429}) - - -def _is_transient_login_status(status: int) -> bool: - """True when a login-step HTTP status is an upstream problem, not bad credentials.""" - return status >= 500 or status in _TRANSIENT_LOGIN_STATUSES - - -# Redirect statuses the identity provider / portal use while bouncing the -# browser back after the credentials POST. All of them are followed with a GET. -_LOGIN_REDIRECT_STATUSES = frozenset({301, 302, 303}) -# Observed chain is 6 hops (authenticate -> oauth/sso -> consent -> oauth -# callback/success -> portal /login -> /services/callbacklogin); anything much -# longer is a loop. -_MAX_LOGIN_REDIRECTS = 10 -# Cookie the portal callback sets once the OIDC code has been exchanged. -_SESSION_COOKIE = "access_token" - - -def _redact_query(url: str) -> str: - """Drop the query string for logging: redirect URLs carry the OIDC code.""" - return url.split("?", 1)[0] - - -def _is_portal_callback(url: str) -> bool: - """True if ``url`` is the portal's OIDC callback (``/services/callbacklogin``).""" - parsed = urlparse(url) - return parsed.netloc == urlparse(BASE_URL).netloc and parsed.path.startswith( - CALLBACK_LOGIN_PATH - ) - - -class _FormParser(HTMLParser): - """Extract the first