Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
8077c6f
Add initial BMW CarData vehicle module with UI
Mar 28, 2026
9311857
Add files via upload
GERDerDennis Mar 28, 2026
96b1804
Add files via upload
GERDerDennis Mar 28, 2026
395d82b
Add files via upload
GERDerDennis Mar 28, 2026
f4e1208
Create bmw_cardata
GERDerDennis Mar 28, 2026
c9823e7
Delete web/bmw_cardata
GERDerDennis Mar 28, 2026
e6f09f2
Create bmw_cardata_auth_start.php
GERDerDennis Mar 28, 2026
e5f37b4
Add files via upload
GERDerDennis Mar 28, 2026
13ce119
Add files via upload
GERDerDennis Mar 28, 2026
4ce8597
Delete packages/modules/vehicles/bmw_cardata/auth.py
GERDerDennis Mar 28, 2026
9f2ef63
Delete packages/modules/vehicles/bmw_cardata/auth_start.py
GERDerDennis Mar 28, 2026
2dc1e30
Delete packages/modules/vehicles/bmw_cardata/auth_status.py
GERDerDennis Mar 28, 2026
93aff36
Add files via upload
GERDerDennis Mar 29, 2026
f034f26
Add files via upload
GERDerDennis Mar 29, 2026
6f3eee5
Add files via upload
GERDerDennis Mar 29, 2026
58e4abe
Add unit tests for BMW CarData SoC module
GERDerDennis Mar 31, 2026
4c92233
Remove test mode, add auth status fields for broker integration
GERDerDennis Apr 1, 2026
2237d53
Replace urllib with req.get_http_session(), remove test mode
GERDerDennis Apr 1, 2026
09ea5c6
Remove test mode UI, simplify settings, update API limit hints
GERDerDennis Apr 1, 2026
83e57ca
Store auth data in broker via updateConfiguration, send to PHP on poll
GERDerDennis Apr 1, 2026
70efc85
Update
GERDerDennis Apr 1, 2026
d2dabbd
Fix unit tests: use requests mock, add container_id to test config
GERDerDennis Apr 1, 2026
1a54611
Improve auth status error handling, add explicit error cases
GERDerDennis Apr 1, 2026
ac6b597
Refactor soc.py: extract helper functions, improve error handling
GERDerDennis Apr 2, 2026
2422882
Update soc_test.py
GERDerDennis Apr 2, 2026
a405d04
Update soc_test.py
GERDerDennis Apr 2, 2026
5eab873
Remove legacy CLI path, improve container recovery with HTTPError.res…
GERDerDennis Apr 2, 2026
cfdede0
Add tests for token refresh, container auto-create and retry flow
GERDerDennis Apr 2, 2026
ce42a0a
Add CURLOPT_SSL_VERIFYHOST=2 for TLS hostname validation
GERDerDennis Apr 2, 2026
5604736
Add CURLOPT_SSL_VERIFYHOST=2 for TLS hostname validation
GERDerDennis Apr 2, 2026
65f2387
Return HTTP 400 on missing client_id
GERDerDennis Apr 2, 2026
4ed95d8
Remove vehicle.vue - moved to openwb-ui-settings repo
GERDerDennis Apr 2, 2026
0ce1c04
Move PHP auth files to public/modules/vehicles/bmw_cardata/
GERDerDennis Apr 2, 2026
292589f
Move PHP auth files to public/modules/vehicles/bmw_cardata/
GERDerDennis Apr 2, 2026
d465524
Fix W292: add newline at end of file
GERDerDennis Apr 2, 2026
256275a
Fix W292: add newline at end of file
GERDerDennis Apr 2, 2026
c8c68d2
Fix W292: add newline at end of file
GERDerDennis Apr 2, 2026
32277ee
Fix W292: add newline at end of file
GERDerDennis Apr 2, 2026
b47ab7b
Add files via upload
GERDerDennis Apr 3, 2026
1d6898e
Update soc_test.py
GERDerDennis Apr 3, 2026
ed3d33e
Remove unused imports HTTPError and store
GERDerDennis Apr 10, 2026
83dcb77
ix W292/W391 whitespace issues
GERDerDennis Apr 10, 2026
df94045
Add default request intervals to prevent API rate limit
GERDerDennis Apr 23, 2026
99a39ab
Add kombiRemainingElectricRange as fallback for range field (BMW i3 c…
GERDerDennis Apr 23, 2026
9354012
Update soc.py
GERDerDennis Apr 24, 2026
2b9e0d4
Merge branch 'master' into feature/bmw-cardata
GERDerDennis Apr 24, 2026
4e39fd2
Update soc.py
GERDerDennis Apr 28, 2026
9a5c2d2
Update soc_test.py
GERDerDennis Apr 28, 2026
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
50 changes: 46 additions & 4 deletions packages/modules/vehicles/bmw_cardata/soc.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

from requests.exceptions import RequestException

from control import data as control_data
from dataclass_utils import asdict
from helpermodules.pub import Pub
from modules.common import req
from modules.common.abstract_device import DeviceDescriptor
from modules.common.abstract_vehicle import VehicleUpdateData
Expand All @@ -19,6 +22,7 @@
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"
FIELD_STATUS = "vehicle.drivetrain.electricEngine.charging.status"
FIELD_ODOMETER_CANDIDATES = [
"vehicle.vehicle.travelledDistance",
Expand All @@ -32,6 +36,7 @@
"vehicle.drivetrain.electricEngine.charging.level",
"vehicle.drivetrain.batteryManagement.header",
"vehicle.drivetrain.electricEngine.remainingElectricRange",
"vehicle.drivetrain.electricEngine.kombiRemainingElectricRange",
"vehicle.vehicle.travelledDistance",
]

Expand Down Expand Up @@ -119,7 +124,7 @@ def _fetch_telematic_data(token: str, vin: str, container_id: str):
return _get_json(url, token)


def get_valid_token(cfg: BmwCardataConfiguration) -> str:
def get_valid_token(cfg: BmwCardataConfiguration, vehicle: int, config: BmwCardataSetup) -> str:
if not cfg.access_token:
Comment thread
benderl marked this conversation as resolved.
Resolved
raise Exception("BMW CarData: Keine Tokens gefunden. Bitte BMW-Kopplung in der UI durchführen.")

Expand All @@ -146,6 +151,34 @@ def get_valid_token(cfg: BmwCardataConfiguration) -> str:
cfg.expires_at = time.time() + new.get("expires_in", 3600) - 60

log.info("BMW CarData: Token-Refresh erfolgreich.")

# Neuen Token zurück in MQTT schreiben – auch für alle anderen BMW CarData
# Fahrzeuge mit derselben Client ID damit diese nicht invalidiert werden
try:
for ev_key, ev in control_data.data.ev_data.items():
try:
soc_module = ev.soc_module
if soc_module is None:
continue
other_config = soc_module.vehicle_config
if (hasattr(other_config, 'type') and
other_config.type == "bmw_cardata" and
other_config.configuration.client_id == cfg.client_id):
other_config.configuration.access_token = cfg.access_token
other_config.configuration.refresh_token = cfg.refresh_token
other_config.configuration.expires_at = cfg.expires_at
Pub().pub(
f"openWB/set/vehicle/{ev.num}/soc_module/config",
asdict(other_config)
)
Comment thread
benderl marked this conversation as resolved.
Resolved
log.info(
"BMW CarData: Token in MQTT aktualisiert für Fahrzeug %s.", ev.num
)
except Exception as e:
log.warning("BMW CarData: Token-Sync für Fahrzeug %s fehlgeschlagen: %s", ev_key, e)
except Exception as e:
log.warning("BMW CarData: Token-Sync fehlgeschlagen: %s", e)

return cfg.access_token


Expand Down Expand Up @@ -175,15 +208,15 @@ def get_container_id(cfg: BmwCardataConfiguration, token: str) -> str:
return container_id


def fetch_soc(config: BmwCardataSetup) -> CarState:
def fetch_soc(config: BmwCardataSetup, vehicle: int = 0) -> CarState:
cfg = config.configuration

if not cfg.client_id:
raise Exception("BMW CarData: client_id nicht konfiguriert!")
if not cfg.vin:
raise Exception("BMW CarData: VIN nicht konfiguriert!")

token = get_valid_token(cfg)
token = get_valid_token(cfg, vehicle, config)
container_id = get_container_id(cfg, token)

try:
Expand Down Expand Up @@ -212,6 +245,8 @@ def fetch_soc(config: BmwCardataSetup) -> CarState:
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)
Comment thread
benderl marked this conversation as resolved.
Resolved
Comment on lines 247 to +249

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIELD_RANGE_ALT is used as a fallback when parsing telematics data, but the container is created with CONTAINER_DESCRIPTORS that currently does not include vehicle.drivetrain.electricEngine.kombiRemainingElectricRange. For vehicles that only expose the kombi field, the API may never return it, leaving range_raw as None. Add the alt descriptor to CONTAINER_DESCRIPTORS (and consider forcing a container re-create when range is missing and the container was created without this descriptor).

Copilot uses AI. Check for mistakes.
status = _extract_value(td, FIELD_STATUS)
odometer_raw = _extract_first_value(td, FIELD_ODOMETER_CANDIDATES)

Expand All @@ -222,6 +257,13 @@ def fetch_soc(config: BmwCardataSetup) -> CarState:
if soc is None:
raise Exception("BMW CarData: Kein SoC-Wert in API-Antwort gefunden!")

if vehicle_range is None and cfg.container_id:
log.warning(
"BMW CarData: Kein Reichweitenwert im Container gefunden – "
"Container wird neu erstellt um fehlende Datenpunkte zu ergänzen."
)
cfg.container_id = ""

log.info(
"BMW CarData: SoC=%s%%, Reichweite=%s km, Status=%s, Odometer=%s km",
soc,
Expand All @@ -234,7 +276,7 @@ def fetch_soc(config: BmwCardataSetup) -> CarState:

def create_vehicle(vehicle_config: BmwCardataSetup, vehicle: int):
def updater(vehicle_update_data: VehicleUpdateData) -> CarState:
return fetch_soc(vehicle_config)
return fetch_soc(vehicle_config, vehicle)

return ConfigurableVehicle(
vehicle_config=vehicle_config,
Expand Down
17 changes: 17 additions & 0 deletions packages/modules/vehicles/bmw_cardata/soc_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,23 @@ def test_no_soc_raises(self, monkeypatch):
with pytest.raises(Exception, match="Kein SoC"):
fetch_soc(self._make_config())

def test_range_fallback_kombi_field(self, monkeypatch):
mock_response = Mock()
mock_response.json.return_value = {
"telematicData": {
"vehicle.drivetrain.electricEngine.charging.level": {"value": "80", "unit": "%"},
"vehicle.drivetrain.electricEngine.kombiRemainingElectricRange": {"value": "190", "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 == 80
assert result.range == 190

def test_token_refresh_on_expired(self, monkeypatch):
mock_response = Mock()
mock_response.json.return_value = {
Expand Down
Loading