diff --git a/packages/modules/vehicles/vwid/README.txt b/packages/modules/vehicles/vwid/README.txt index bcb537c95f..ff54f6c8cd 100644 --- a/packages/modules/vehicles/vwid/README.txt +++ b/packages/modules/vehicles/vwid/README.txt @@ -1,4 +1,41 @@ -Quellen: - https://github.com/TA2k/ioBroker.vw-connect - https://github.com/robinostlund/volkswagencarnet +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 30 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/vwid/libeuda.py b/packages/modules/vehicles/vwid/libeuda.py new file mode 100755 index 0000000000..e27610a6bf --- /dev/null +++ b/packages/modules/vehicles/vwid/libeuda.py @@ -0,0 +1,913 @@ +#!/usr/bin/env python3 +"""Client for the VW EU Data Act portal (OIDC login + data delivery).""" + +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 modules.common.store import RAMDISK_PATH +from modules.common.abstract_vehicle import VehicleUpdateData +from modules.vehicles.vwid.config import VWId + +"""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 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" + +USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36" +) + +# --- 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 +EUDA_THREADNAME = "soc_bt_ev" +UTC = None +KEEP_JSON = 30 +DATA_PATH = Path(__file__).resolve().parents[4] / "data" / "modules" / "vwid" +JSON_PATH = Path(str(RAMDISK_PATH) + '/vweuda') +storeFileName = '/data_' + +# 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.""" + + +class AuthError(ApiError): + """Authentication failed or session expired.""" + + +class _FormParser(HTMLParser): + """Extract the first