From 08f3f8aede42709dc125742f8684114ffe755b84 Mon Sep 17 00:00:00 2001 From: SeaSpotter Date: Fri, 7 Aug 2026 10:53:30 +0200 Subject: [PATCH 1/5] Refactor BMW CarData attributes and container creation Refactor BMW CarData attributes for better compatibility with newer vehicles. Update container creation logic to handle missing descriptors gracefully. --- packages/modules/vehicles/bmw_cardata/soc.py | 81 +++++++++++++------- 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/packages/modules/vehicles/bmw_cardata/soc.py b/packages/modules/vehicles/bmw_cardata/soc.py index 28e7560376..87406acf8c 100644 --- a/packages/modules/vehicles/bmw_cardata/soc.py +++ b/packages/modules/vehicles/bmw_cardata/soc.py @@ -19,10 +19,21 @@ 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. Neue Attribute (falls BMW mal wieder was ändert) +# vorne einfügen und in CONTAINER_DESCRIPTORS unten mit aufnehmen. +# Ältere/bekannt fehlende Attribute nicht entfernen, sondern hinten anhängen – +# so bleibt die Abwärtskompatibilität für Fahrzeuge erhalten, die sie noch +# liefern. S. https://github.com/openWB/core/discussions/3420 +FIELD_SOC_CANDIDATES = [ + "vehicle.drivetrain.electricEngine.charging.level", + "vehicle.drivetrain.batteryManagement.header", + # bei neueren Fahrzeugen (z.B. iX1, manche MINI) das einzige verfügbare Attribut: + "vehicle.powertrain.electric.battery.stateOfCharge.displayed", +] +FIELD_RANGE_CANDIDATES = [ + "vehicle.drivetrain.electricEngine.remainingElectricRange", + "vehicle.drivetrain.electricEngine.kombiRemainingElectricRange", +] FIELD_STATUS = "vehicle.drivetrain.electricEngine.charging.status" FIELD_ODOMETER_CANDIDATES = [ "vehicle.vehicle.travelledDistance", @@ -32,13 +43,17 @@ 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[0], ] +# 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 +115,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 +272,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) From 3aebca669e445c78ae0e38952ec6f150446b5a98 Mon Sep 17 00:00:00 2001 From: SeaSpotter Date: Fri, 7 Aug 2026 10:53:51 +0200 Subject: [PATCH 2/5] Enhance SOC tests with new field handling and retries Added tests for SOC extraction and container creation retries. --- .../modules/vehicles/bmw_cardata/soc_test.py | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/modules/vehicles/bmw_cardata/soc_test.py b/packages/modules/vehicles/bmw_cardata/soc_test.py index 7a30d2e744..c72182125f 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,25 @@ 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_no_soc_raises(self, monkeypatch): mock_response = Mock() mock_response.json.return_value = {"telematicData": {}} @@ -197,6 +216,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 = { From 1d6c1e1c8eaa2061a5159466dd564085bb5bb2f9 Mon Sep 17 00:00:00 2001 From: SeaSpotter Date: Fri, 7 Aug 2026 11:11:57 +0200 Subject: [PATCH 3/5] Refine comments on attribute handling in soc.py Updated comments for clarity on attribute priority and backward compatibility. --- packages/modules/vehicles/bmw_cardata/soc.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/modules/vehicles/bmw_cardata/soc.py b/packages/modules/vehicles/bmw_cardata/soc.py index 87406acf8c..e01dc465ea 100644 --- a/packages/modules/vehicles/bmw_cardata/soc.py +++ b/packages/modules/vehicles/bmw_cardata/soc.py @@ -19,11 +19,13 @@ BMW_AUTH_URL = "https://customer.bmwgroup.com/gcdm/oauth" BMW_API_URL = "https://api-cardata.bmwgroup.com" -# Reihenfolge = Priorität. Neue Attribute (falls BMW mal wieder was ändert) -# vorne einfügen und in CONTAINER_DESCRIPTORS unten mit aufnehmen. -# Ältere/bekannt fehlende Attribute nicht entfernen, sondern hinten anhängen – -# so bleibt die Abwärtskompatibilität für Fahrzeuge erhalten, die sie noch -# liefern. S. https://github.com/openWB/core/discussions/3420 +# 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. +# S. https://github.com/openWB/core/discussions/3420 FIELD_SOC_CANDIDATES = [ "vehicle.drivetrain.electricEngine.charging.level", "vehicle.drivetrain.batteryManagement.header", From a2df5b5b5fc7b827907cccbdff129f2504fa4531 Mon Sep 17 00:00:00 2001 From: SeaSpotter Date: Fri, 7 Aug 2026 11:55:51 +0200 Subject: [PATCH 4/5] Refine BMW CarData SOC attributes and comments Updated comments and added new field candidates for battery management attributes. --- packages/modules/vehicles/bmw_cardata/soc.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/modules/vehicles/bmw_cardata/soc.py b/packages/modules/vehicles/bmw_cardata/soc.py index e01dc465ea..5cbcb1a6d2 100644 --- a/packages/modules/vehicles/bmw_cardata/soc.py +++ b/packages/modules/vehicles/bmw_cardata/soc.py @@ -25,12 +25,15 @@ # 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. -# S. https://github.com/openWB/core/discussions/3420 +# 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", - # bei neueren Fahrzeugen (z.B. iX1, manche MINI) das einzige verfügbare Attribut: "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", @@ -39,6 +42,7 @@ 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", ] @@ -48,7 +52,7 @@ FIELD_STATUS, *FIELD_SOC_CANDIDATES, *FIELD_RANGE_CANDIDATES, - FIELD_ODOMETER_CANDIDATES[0], + *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 From 5cab1e03feebaf60c1d37dc8c0266289e5e9f07f Mon Sep 17 00:00:00 2001 From: SeaSpotter Date: Fri, 7 Aug 2026 11:56:18 +0200 Subject: [PATCH 5/5] Add tests for SOC and odometer fallback behavior --- .../modules/vehicles/bmw_cardata/soc_test.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/modules/vehicles/bmw_cardata/soc_test.py b/packages/modules/vehicles/bmw_cardata/soc_test.py index c72182125f..ded2ed330f 100644 --- a/packages/modules/vehicles/bmw_cardata/soc_test.py +++ b/packages/modules/vehicles/bmw_cardata/soc_test.py @@ -131,6 +131,44 @@ def test_soc_extraction_new_field_fallback(self, monkeypatch): 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": {}}