diff --git a/packages/modules/vehicles/bmw_cardata/soc.py b/packages/modules/vehicles/bmw_cardata/soc.py index 28e7560376..5cbcb1a6d2 100644 --- a/packages/modules/vehicles/bmw_cardata/soc.py +++ b/packages/modules/vehicles/bmw_cardata/soc.py @@ -19,26 +19,47 @@ BMW_AUTH_URL = "https://customer.bmwgroup.com/gcdm/oauth" BMW_API_URL = "https://api-cardata.bmwgroup.com" -FIELD_SOC = "vehicle.drivetrain.electricEngine.charging.level" -FIELD_SOC_ALT = "vehicle.drivetrain.batteryManagement.header" -FIELD_RANGE = "vehicle.drivetrain.electricEngine.remainingElectricRange" -FIELD_RANGE_ALT = "vehicle.drivetrain.electricEngine.kombiRemainingElectricRange" +# Reihenfolge = Priorität beim Auslesen (siehe _extract_first_value): das +# erste Attribut, das in der API-Antwort einen Wert liefert, gewinnt. Alte, +# etablierte Attribute stehen vorne (funktionieren bei den meisten Fahrzeugen +# nach wie vor), neue Fallback-Attribute werden hinten angehängt und greifen +# nur, wenn die vorherigen für das Fahrzeug nicht existieren. Bestehende +# Einträge nicht entfernen – sonst brechen Fahrzeuge, die sie noch liefern. +# Alle Einträge gegen den offiziellen BMW CarData Telematikdatenkatalog +# verifiziert. S. https://github.com/openWB/core/discussions/3420 +FIELD_SOC_CANDIDATES = [ + "vehicle.drivetrain.electricEngine.charging.level", + "vehicle.drivetrain.batteryManagement.header", + "vehicle.powertrain.electric.battery.stateOfCharge.displayed", + # "Neue Klasse" (NK/NA5, ab 2026, z.B. neuer iX3/i3): weder charging.level + # noch batteryManagement.header verfügbar; wird nur bei Fahrtende befüllt. + "vehicle.trip.segment.end.drivetrain.batteryManagement.hvSoc", +] +FIELD_RANGE_CANDIDATES = [ + "vehicle.drivetrain.electricEngine.remainingElectricRange", + "vehicle.drivetrain.electricEngine.kombiRemainingElectricRange", +] FIELD_STATUS = "vehicle.drivetrain.electricEngine.charging.status" FIELD_ODOMETER_CANDIDATES = [ "vehicle.vehicle.travelledDistance", + # wird nur bei Fahrtende befüllt ("Mileage after last drive") "vehicle.trip.segment.end.travelledDistance", ] CONTAINER_NAME = "ChargeStats" CONTAINER_PURPOSE = "openWB" CONTAINER_DESCRIPTORS = [ - "vehicle.drivetrain.electricEngine.charging.status", - "vehicle.drivetrain.electricEngine.charging.level", - "vehicle.drivetrain.batteryManagement.header", - "vehicle.drivetrain.electricEngine.remainingElectricRange", - "vehicle.drivetrain.electricEngine.kombiRemainingElectricRange", - "vehicle.vehicle.travelledDistance", + FIELD_STATUS, + *FIELD_SOC_CANDIDATES, + *FIELD_RANGE_CANDIDATES, + *FIELD_ODOMETER_CANDIDATES, ] +# Manche neueren Fahrzeuge kennen das älteste (erste) SoC-Attribut nicht mehr. +# Legt man einen Container mit einem für das Fahrzeug nicht verfügbaren +# Descriptor an, antwortet die BMW-API dabei offenbar teils mit einem +# Serverfehler statt einer sauberen 400er. Als Fallback wird die +# Container-Erstellung ohne dieses eine Attribut wiederholt. +CONTAINER_DESCRIPTORS_FALLBACK = [d for d in CONTAINER_DESCRIPTORS if d != FIELD_SOC_CANDIDATES[0]] def _get_session(token: Optional[str] = None): @@ -100,17 +121,34 @@ def _post_json(url: str, token: str, payload: dict) -> dict: return response.json() -def _create_container(token: str) -> str: +# Manche neueren Fahrzeuge (z.B. iX1, manche MINI) kennen den Descriptor +# FIELD_SOC_CANDIDATES[0] (charging.level) im CarData-Portal nicht mehr. Legt +# man einen Container mit einem für das Fahrzeug nicht verfügbaren Descriptor +# an, antwortet die BMW-API dabei offenbar teils mit einem Serverfehler statt +# einer sauberen 400er (s. https://github.com/openWB/core/discussions/3420). +# Als Fallback wird die Container-Erstellung ohne diesen Descriptor wiederholt. +def _create_container(token: str, descriptors: List[str] = None, _is_retry: bool = False) -> str: + descriptors = descriptors if descriptors is not None else CONTAINER_DESCRIPTORS log.warning("BMW CarData: Keine aktiven Container gefunden. Erstelle neuen Container...") - result = _post_json( - f"{BMW_API_URL}/customers/containers", - token, - { - "name": CONTAINER_NAME, - "purpose": CONTAINER_PURPOSE, - "technicalDescriptors": CONTAINER_DESCRIPTORS, - }, - ) + try: + result = _post_json( + f"{BMW_API_URL}/customers/containers", + token, + { + "name": CONTAINER_NAME, + "purpose": CONTAINER_PURPOSE, + "technicalDescriptors": descriptors, + }, + ) + except RequestException as e: + if not _is_retry: + log.warning( + "BMW CarData: Container-Erstellung fehlgeschlagen (%s). Versuche erneut ohne " + "'%s' (evtl. für dieses Fahrzeug nicht verfügbar).", e, FIELD_SOC_CANDIDATES[0], + ) + return _create_container(token, CONTAINER_DESCRIPTORS_FALLBACK, _is_retry=True) + raise Exception(f"BMW CarData: Container konnte nicht erstellt werden: {e}") + container_id = result.get("containerId") or result.get("id") if not container_id: raise Exception(f"BMW CarData: Container konnte nicht erstellt werden: {result}") @@ -240,13 +278,8 @@ def fetch_soc(config: BmwCardataSetup, vehicle: int = 0) -> CarState: td = raw.get("telematicData", raw) - soc_raw = _extract_value(td, FIELD_SOC) - if soc_raw is None: - soc_raw = _extract_value(td, FIELD_SOC_ALT) - - range_raw = _extract_value(td, FIELD_RANGE) - if range_raw is None: - range_raw = _extract_value(td, FIELD_RANGE_ALT) + soc_raw = _extract_first_value(td, FIELD_SOC_CANDIDATES) + range_raw = _extract_first_value(td, FIELD_RANGE_CANDIDATES) status = _extract_value(td, FIELD_STATUS) odometer_raw = _extract_first_value(td, FIELD_ODOMETER_CANDIDATES) diff --git a/packages/modules/vehicles/bmw_cardata/soc_test.py b/packages/modules/vehicles/bmw_cardata/soc_test.py index 7a30d2e744..ded2ed330f 100644 --- a/packages/modules/vehicles/bmw_cardata/soc_test.py +++ b/packages/modules/vehicles/bmw_cardata/soc_test.py @@ -6,7 +6,7 @@ from modules.common import store from modules.common.abstract_vehicle import VehicleUpdateData from modules.common.component_context import SingleComponentUpdateContext -from modules.vehicles.bmw_cardata.soc import create_vehicle, fetch_soc +from modules.vehicles.bmw_cardata.soc import FIELD_SOC_CANDIDATES, create_vehicle, fetch_soc from modules.vehicles.bmw_cardata.config import BmwCardataSetup, BmwCardataConfiguration @@ -112,6 +112,63 @@ def test_soc_extraction_fallback_field(self, monkeypatch): assert result.soc == 63 assert result.range == 280 + def test_soc_extraction_new_field_fallback(self, monkeypatch): + # Neuere Fahrzeuge (z.B. iX1) bieten die ersten beiden FIELD_SOC_CANDIDATES + # im CarData-Portal an, nur noch stateOfCharge.displayed. + mock_response = Mock() + mock_response.json.return_value = { + "telematicData": { + "vehicle.powertrain.electric.battery.stateOfCharge.displayed": {"value": "42", "unit": "%"}, + "vehicle.drivetrain.electricEngine.remainingElectricRange": {"value": "210", "unit": "km"}, + } + } + mock_session = Mock() + mock_session.get.return_value = mock_response + mock_session.headers = {} + monkeypatch.setattr("modules.vehicles.bmw_cardata.soc.req.get_http_session", Mock(return_value=mock_session)) + + result = fetch_soc(self._make_config()) + assert result.soc == 42 + assert result.range == 210 + + def test_soc_extraction_neue_klasse_trip_end_fallback(self, monkeypatch): + # "Neue Klasse"-Fahrzeuge (NK/NA5) liefern weder charging.level noch + # batteryManagement.header noch stateOfCharge.displayed, nur den + # Fahrtende-Wert. + mock_response = Mock() + mock_response.json.return_value = { + "telematicData": { + "vehicle.trip.segment.end.drivetrain.batteryManagement.hvSoc": {"value": "33", "unit": "percent"}, + "vehicle.drivetrain.electricEngine.kombiRemainingElectricRange": {"value": "120", "unit": "km"}, + } + } + mock_session = Mock() + mock_session.get.return_value = mock_response + mock_session.headers = {} + monkeypatch.setattr("modules.vehicles.bmw_cardata.soc.req.get_http_session", Mock(return_value=mock_session)) + + result = fetch_soc(self._make_config()) + assert result.soc == 33 + assert result.range == 120 + + def test_odometer_trip_end_fallback(self, monkeypatch): + # Falls vehicle.vehicle.travelledDistance fehlt, wird der + # Fahrtende-Kilometerstand als Fallback genutzt. + mock_response = Mock() + mock_response.json.return_value = { + "telematicData": { + "vehicle.drivetrain.electricEngine.charging.level": {"value": "50", "unit": "%"}, + "vehicle.trip.segment.end.travelledDistance": {"value": "45000", "unit": "km"}, + } + } + mock_session = Mock() + mock_session.get.return_value = mock_response + mock_session.headers = {} + monkeypatch.setattr("modules.vehicles.bmw_cardata.soc.req.get_http_session", Mock(return_value=mock_session)) + + result = fetch_soc(self._make_config()) + assert result.odometer == 45000 + def test_no_soc_raises(self, monkeypatch): mock_response = Mock() mock_response.json.return_value = {"telematicData": {}} @@ -197,6 +254,52 @@ def mock_get(url): assert result.soc == 60 assert config.configuration.container_id == "new-container-id" + def test_container_create_retries_without_unavailable_descriptor(self, monkeypatch): + # Erste POST-Anfrage (voller Descriptor-Satz inkl. FIELD_SOC_CANDIDATES[0]) schlägt fehl, + # wie es z.B. bei neueren Fahrzeugen ohne charging.level beobachtet wurde. + # Zweiter Versuch (ohne FIELD_SOC_CANDIDATES[0]) muss erfolgreich sein. + mock_get_response_empty = Mock() + mock_get_response_empty.json.return_value = {"containers": []} + + mock_get_response_data = Mock() + mock_get_response_data.json.return_value = { + "telematicData": { + "vehicle.powertrain.electric.battery.stateOfCharge.displayed": {"value": "58", "unit": "%"}, + "vehicle.drivetrain.electricEngine.remainingElectricRange": {"value": "150", "unit": "km"}, + } + } + + post_call_count = [0] + + def mock_post(url, json=None): + if "containers" in url: + post_call_count[0] += 1 + if post_call_count[0] == 1: + mock_resp = Mock() + mock_resp.status_code = 500 + raise RequestsHTTPError(response=mock_resp) + assert FIELD_SOC_CANDIDATES[0] not in json["technicalDescriptors"] + return Mock(json=Mock(return_value={"containerId": "fallback-container-id"})) + raise AssertionError(f"Unerwarteter POST an {url}") + + mock_session = Mock() + mock_session.get.return_value = mock_get_response_empty + mock_session.post.side_effect = mock_post + mock_session.headers = {} + monkeypatch.setattr("modules.vehicles.bmw_cardata.soc.req.get_http_session", Mock(return_value=mock_session)) + # Nach erfolgreicher Container-Erstellung wird die Telematik separat via GET geholt + monkeypatch.setattr( + "modules.vehicles.bmw_cardata.soc._fetch_telematic_data", + Mock(return_value=mock_get_response_data.json.return_value), + ) + + config = self._make_config(container_id="") + result = fetch_soc(config) + + assert result.soc == 58 + assert config.configuration.container_id == "fallback-container-id" + assert post_call_count[0] == 2 + def test_container_retry_on_invalid(self, monkeypatch): call_count = [0] good_response = {