diff --git a/packages/control/ev/ev.py b/packages/control/ev/ev.py index 7e24057424..418adbeab9 100644 --- a/packages/control/ev/ev.py +++ b/packages/control/ev/ev.py @@ -112,6 +112,9 @@ def soc_interval_expired(self, vehicle_update_data: VehicleUpdateData) -> bool: interval = self.soc_module.general_config.request_interval_charging else: interval = self.soc_module.general_config.request_interval_not_charging + override = self.soc_module.get_request_interval_override() + if override is not None: + interval = min(interval, override) # Zeitstempel prüfen, ob wieder abgefragt werden muss. if timecheck.check_timestamp(self.data.get.soc_request_timestamp, interval-5) is False: # Zeit ist abgelaufen diff --git a/packages/modules/common/configurable_vehicle.py b/packages/modules/common/configurable_vehicle.py index 773b97889c..b1a347a12e 100644 --- a/packages/modules/common/configurable_vehicle.py +++ b/packages/modules/common/configurable_vehicle.py @@ -1,5 +1,6 @@ from enum import Enum import logging +import math import time from typing import Optional, TypeVar, Generic, Callable from helpermodules import timecheck @@ -37,8 +38,10 @@ def __init__(self, calc_while_charging: bool = False, general_config: Optional[GeneralVehicleConfig] = None, calculated_soc_state: Optional[CalculatedSocState] = None, - initializer: Callable = lambda: None) -> None: + initializer: Callable = lambda: None, + request_interval_override: Optional[Callable[[], Optional[float]]] = None) -> None: self.__component_updater = component_updater + self.__request_interval_override = request_interval_override self.vehicle_config = vehicle_config self.calculated_soc_state = calculated_soc_state self.__initializer = initializer @@ -97,7 +100,23 @@ def update(self, vehicle_update_data: VehicleUpdateData): Pub().pub(f"openWB/set/vehicle/{self.vehicle}/soc_module/calculated_soc_state", asdict(self.calculated_soc_state)) + def get_request_interval_override(self) -> Optional[float]: + """Return a module's current maximum request interval in seconds. + + None keeps the regular schedule. A positive interval can only advance + requests and also selects the API instead of calculated/chargepoint SoC. + The callback must be a quick, read-only lookup of module-owned state. + Keep it and the updater in the same closure so their cache/error state + remains shared when the SoC scheduler deep-copies this vehicle object. + """ + interval = self.__request_interval_override() if self.__request_interval_override is not None else None + if interval is not None and (not math.isfinite(interval) or interval <= 0): + raise ValueError("Vehicle request interval override must be finite and greater than zero") + return interval + def _get_carstate_source(self, vehicle_update_data: VehicleUpdateData) -> SocSource: + if self.get_request_interval_override() is not None: + return SocSource.API # Kein SoC vom LP vorhanden oder erwünscht if (vehicle_update_data.soc_from_cp is None or self.general_config.use_soc_from_cp is False or # oder aktueller manueller SoC vorhanden (ausgelesenen SoC während der Ladung korrigieren) diff --git a/packages/modules/common/request_interval_override_test.py b/packages/modules/common/request_interval_override_test.py new file mode 100644 index 0000000000..553646cbaa --- /dev/null +++ b/packages/modules/common/request_interval_override_test.py @@ -0,0 +1,136 @@ +import copy +from unittest.mock import Mock + +import pytest + +from control.ev.ev import Ev +from helpermodules import timecheck +from modules.common import store +from modules.common.abstract_vehicle import GeneralVehicleConfig, VehicleUpdateData +from modules.common.component_state import CarState +from modules.common.configurable_vehicle import ConfigurableVehicle, SocSource +from modules.vehicles.mqtt.config import MqttSocSetup + +NOW = 1652683252 # packages/conftest.py fixes the clock to this timestamp. + + +def vehicle(updater=None, override=None, **kwargs): + return ConfigurableVehicle(MqttSocSetup(), updater or Mock(return_value=CarState(soc=50)), vehicle=0, + request_interval_override=override, **kwargs) + + +@pytest.mark.parametrize("interval", [0, -1, float("inf"), float("nan")]) +def test_invalid_override_is_rejected(interval): + with pytest.raises(ValueError, match="finite and greater than zero"): + vehicle(override=lambda: interval).get_request_interval_override() + + +def test_default_has_no_override(): + assert vehicle().get_request_interval_override() is None + + +@pytest.mark.parametrize("counter, fault_state, charging, override, expected_interval", [ + (0, 0, False, None, 600), + (0, 0, True, None, 300), + (0, 2, False, None, 300), + (3, 2, False, None, 600), + (10, 1, False, 60, 60), # Cache warning still gets one-minute retries. + (10, 2, False, 60, 60), # Independent of the three-error limit. + (0, 0, True, 60, 60), + (0, 0, False, 1200, 600), # Cannot postpone a regular request. + (0, 0, True, 1200, 300), +]) +def test_scheduler_applies_override_after_regular_interval_selection( + counter, fault_state, charging, override, expected_interval, monkeypatch): + ev = Ev(0) + ev.soc_module = vehicle(override=lambda: override, general_config=GeneralVehicleConfig( + request_interval_charging=300, request_interval_not_charging=600)) + ev.data.get.soc_request_timestamp = NOW - 30 + ev.data.get.fault_state = fault_state + ev.data.set.soc_error_counter = counter + check = Mock(return_value=True) + monkeypatch.setattr(timecheck, "check_timestamp", check) + + assert not ev.soc_interval_expired(VehicleUpdateData(charge_state=charging)) + check.assert_called_once_with(NOW - 30, expected_interval - 5) + + +@pytest.mark.parametrize("elapsed, expected", [(30, False), (60, True)]) +def test_retry_uses_last_real_request_time(elapsed, expected): + ev = Ev(0) + ev.soc_module = vehicle(override=lambda: 60) + ev.data.get.soc_request_timestamp = NOW - elapsed + ev.data.get.fault_state = 1 + ev.data.set.soc_error_counter = 10 + assert ev.soc_interval_expired(VehicleUpdateData()) is expected + assert ev.data.get.soc_request_timestamp == NOW - elapsed + + +def test_only_plugged_policy_is_preserved(): + ev = Ev(0) + ev.soc_module = vehicle(override=lambda: 60, general_config=GeneralVehicleConfig(request_only_plugged=True)) + ev.data.get.soc_request_timestamp = NOW - 120 + assert not ev.soc_interval_expired(VehicleUpdateData(plug_state=False)) + assert ev.soc_interval_expired(VehicleUpdateData(plug_state=True)) + + +@pytest.mark.parametrize("override, cp_soc, expected", [ + (60, None, SocSource.API), + (60, 50, SocSource.API), + (None, None, SocSource.CALCULATION), + (None, 50, SocSource.CP), +]) +def test_retry_uses_api_even_when_soc_would_normally_be_calculated(override, cp_soc, expected): + module = vehicle(override=lambda: override, calc_while_charging=True, + general_config=GeneralVehicleConfig(use_soc_from_cp=True)) + update = VehicleUpdateData(charge_state=True, soc_from_cp=cp_soc, timestamp_soc_from_cp=NOW) + assert module._get_carstate_source(update) == expected + + +@pytest.mark.parametrize("has_cache", [False, True]) +def test_module_error_state_and_cache_survive_scheduler_copies(has_cache, monkeypatch): + last_request_failed = False + cached_state = (CarState(soc=50, soc_timestamp=NOW - 300, warning="Portal temporarily unavailable") + if has_cache else None) + portal = Mock(side_effect=[RuntimeError("Portal unavailable"), CarState(soc=60, soc_timestamp=NOW)]) + + def fetch(update): + nonlocal last_request_failed, cached_state + try: + cached_state = portal() + except RuntimeError: + last_request_failed = True + if cached_state is None: + raise + else: + last_request_failed = False + return cached_state + + value_store = Mock() + monkeypatch.setattr(store, "get_car_value_store", Mock(return_value=value_store)) + module = vehicle(updater=fetch, override=lambda: 60 if last_request_failed else None, calc_while_charging=True) + copied = copy.deepcopy(module) + if has_cache: + state = copied._get_carstate_by_source(VehicleUpdateData(), SocSource.API) + assert state.soc == 50 + assert state.soc_timestamp == NOW - 300 + assert state.warning == "Portal temporarily unavailable" + else: + with pytest.raises(Exception, match="Portal unavailable"): + copied._get_carstate_by_source(VehicleUpdateData(), SocSource.API) + assert module.get_request_interval_override() == 60 + + # The next scheduler copy observes the failure, calls the API during charging, + # and its success restores the normal schedule/source on the original object. + next_copy = copy.deepcopy(module) + next_copy.update(VehicleUpdateData(charge_state=True)) + assert portal.call_count == 2 + assert module.get_request_interval_override() is None + assert module._get_carstate_source(VehicleUpdateData(charge_state=True)) == SocSource.CALCULATION + + +def test_new_module_instance_does_not_inherit_old_override(): + old_module = vehicle(override=lambda: 60) + replacement = vehicle() + assert old_module.get_request_interval_override() == 60 + assert replacement.get_request_interval_override() is None 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..8f64e2b151 --- a/packages/modules/vehicles/vweuda/libeuda.py +++ b/packages/modules/vehicles/vweuda/libeuda.py @@ -1,1248 +1,112 @@ -#!/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