Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 60 additions & 27 deletions packages/modules/vehicles/bmw_cardata/soc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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)

Expand Down
105 changes: 104 additions & 1 deletion packages/modules/vehicles/bmw_cardata/soc_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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": {}}
Expand Down Expand Up @@ -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 = {
Expand Down