Skip to content

Commit 5d8152d

Browse files
committed
Add charging history and tyre diagnosis daily-poll sensors
Fetch charging session history and tyre health data from BMW API, disabled by default with API quota warnings. Poll budget automatically adjusts to account for daily calls, keeping total scheduled usage within budget. Each feature adds a diagnostic sensor per vehicle, a settings toggle, and a manual service trigger. Null-safety guards handle missing or malformed API responses gracefully.
1 parent ab599d8 commit 5d8152d

11 files changed

Lines changed: 640 additions & 23 deletions

File tree

README.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,13 +247,35 @@ Each EV/PHEV vehicle gets two button entities to reset learned efficiency:
247247

248248
These buttons appear in the vehicle's device page under Configuration entities.
249249

250+
## Charging History (Optional)
251+
252+
The integration can fetch your BMW charging session history from the past 30 days. This is **disabled by default** to conserve your API quota.
253+
254+
- **Enable via**: Settings → Devices & Services → BMW CarData → Configure → Settings → Enable Charging History
255+
- **API cost**: 1 call per vehicle per day (from your 50-call daily quota)
256+
- **Sensor**: Creates a diagnostic sensor per vehicle showing session count and last charge date
257+
- **Attributes**: Full session data including start/end SOC, energy consumed, duration, location, and cost information
258+
- **Manual trigger**: Use `cardata.fetch_charging_history` service in Developer Tools
259+
260+
## Tyre Diagnosis (Optional)
261+
262+
The integration can fetch tyre health and wear data from BMW's Smart Maintenance system. This is **disabled by default** to conserve your API quota.
263+
264+
- **Enable via**: Settings → Devices & Services → BMW CarData → Configure → Settings → Enable Tyre Diagnosis
265+
- **API cost**: 1 call per vehicle per day (from your 50-call daily quota)
266+
- **Sensor**: Creates a diagnostic sensor per vehicle showing aggregate tyre status
267+
- **Attributes**: Per-wheel data including dimension, wear, season, manufacturer, defect status, and production date
268+
- **Manual trigger**: Use `cardata.fetch_tyre_diagnosis` service in Developer Tools
269+
250270
## Developer Tools Services
251271

252272
Home Assistant's Developer Tools expose helper services for manual API checks:
253273

254274
- `cardata.fetch_telematic_data` fetches the current contents of the configured telematics container for a VIN and logs the raw payload.
255275
- `cardata.fetch_vehicle_mappings` calls `GET /customers/vehicles/mappings` and logs the mapping details (including PRIMARY or SECONDARY status). Only primary mappings return data; some vehicles do not support secondary users, in which case the mapped user is considered the primary one.
256276
- `cardata.fetch_basic_data` calls `GET /customers/vehicles/{vin}/basicData` to retrieve static metadata (model name, series, etc.) for the specified VIN.
277+
- `cardata.fetch_charging_history` fetches the last 30 days of charging sessions for a VIN. Uses 1 API call per vehicle.
278+
- `cardata.fetch_tyre_diagnosis` fetches tyre health and wear data for a VIN. Uses 1 API call per vehicle.
257279
- `migrations` call for proper renaming the sensors from old installations
258280

259281
## API Quota and MQTT Streaming
@@ -263,8 +285,9 @@ BMW imposes a **50 calls/day** limit on the CarData API. This integration does n
263285
- **MQTT Stream (real-time)**: The MQTT stream is unlimited and provides real-time updates for events like door locks, motion state, charging power, etc. GPS coordinates are paired using BMW payload timestamps (same GPS fix detection) with an arrival-time fallback, so location updates work even when latitude and longitude arrive in separate MQTT messages. In direct BMW mode, token refresh during MQTT reconnection is lock-free to avoid blocking the connection, and the MQTT connection is proactively reconnected with fresh credentials to prevent session expiry (~1 hour).
264286
- **Trip-end polling**: When a vehicle stops moving (trip ends), the integration triggers an immediate API poll to capture post-trip battery state. This ensures SOC is updated even when the MQTT stream only delivers GPS/mileage but not SOC (common on some models). A per-VIN 10-minute cooldown prevents GPS burst flapping from burning API quota.
265287
- **Charge-end polling**: When charging completes or stops, the integration triggers an immediate API poll to get the actual BMW SOC for learning calibration of the predicted SOC sensor, subject to the same per-VIN cooldown.
266-
- **Fallback polling**: The integration polls every 12 hours as a fallback in case MQTT stream fails or after Home Assistant restarts. VINs with fresh MQTT data are skipped individually, so in multi-car setups only stale VINs consume API calls.
267-
- **Multi-VIN setups**: All vehicles share the same 50 call/day limit.
288+
- **Fallback polling**: The integration polls periodically as a fallback in case MQTT stream fails or after Home Assistant restarts. VINs with fresh MQTT data are skipped individually, so in multi-car setups only stale VINs consume API calls.
289+
- **Daily optional features**: When Charging History and/or Tyre Diagnosis are enabled, each adds 1 API call per vehicle per day. The polling interval automatically increases to compensate — e.g. with both features on 2 cars, polling stretches from 2h to 2.4h per VIN.
290+
- **Multi-VIN setups**: All vehicles share the same 50 call/day limit. The poll interval scales with VIN count plus any enabled daily features. Each VIN is guaranteed at least 1 poll per day; BMW's 429 backoff handles actual quota enforcement.
268291
- **Rate limiting**: If BMW returns a 429 (rate limited) response, the integration backs off automatically with exponential delay.
269292

270293
## Requirements
@@ -318,6 +341,7 @@ The integration is organized into focused modules:
318341
| `stream_circuit_breaker.py` | Circuit breaker for reconnection rate limiting |
319342
| `stream_reconnect.py` | Reconnection, unauthorized handling, retry scheduling |
320343
| `motion_detection.py` | GPS centroid movement detection, parking zone logic |
344+
| `sensor_diagnostics.py` | Diagnostic sensors: connection, metadata, efficiency, charging history, tyre diagnosis |
321345
| `sensor.py` / `binary_sensor.py` / `device_tracker.py` | Home Assistant entity platforms |
322346
| `config_flow.py` | Setup, reauthorization, and options UI flows |
323347
| `bootstrap.py` | VIN discovery, metadata fetch, container creation |

custom_components/cardata/config_flow.py

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@
5353
OPTION_CUSTOM_MQTT_TLS,
5454
OPTION_CUSTOM_MQTT_TOPIC_PREFIX,
5555
OPTION_CUSTOM_MQTT_USERNAME,
56+
OPTION_ENABLE_CHARGING_HISTORY,
5657
OPTION_ENABLE_MAGIC_SOC,
58+
OPTION_ENABLE_TYRE_DIAGNOSIS,
5759
)
5860
from .utils import redact_vin
5961

@@ -373,13 +375,14 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Flo
373375

374376
async def async_step_action_settings(self, user_input: dict[str, Any] | None = None) -> FlowResult:
375377
if user_input is not None:
376-
# If Magic SOC is being disabled, remove its entities from the registry
377-
was_enabled = self._config_entry.options.get(OPTION_ENABLE_MAGIC_SOC, False)
378-
now_enabled = user_input[OPTION_ENABLE_MAGIC_SOC]
379-
if was_enabled and not now_enabled:
380-
from homeassistant.helpers import entity_registry as er
378+
from homeassistant.helpers import entity_registry as er
379+
380+
entity_reg = er.async_get(self.hass)
381381

382-
entity_reg = er.async_get(self.hass)
382+
# If Magic SOC is being disabled, remove its entities from the registry
383+
was_magic = self._config_entry.options.get(OPTION_ENABLE_MAGIC_SOC, False)
384+
now_magic = user_input[OPTION_ENABLE_MAGIC_SOC]
385+
if was_magic and not now_magic:
383386
for entity in er.async_entries_for_config_entry(entity_reg, self._config_entry.entry_id):
384387
if entity.unique_id and (
385388
entity.unique_id.endswith("_vehicle.magic_soc")
@@ -388,15 +391,40 @@ async def async_step_action_settings(self, user_input: dict[str, Any] | None = N
388391
_LOGGER.info("Removing Magic SOC entity %s", entity.entity_id)
389392
entity_reg.async_remove(entity.entity_id)
390393

394+
# If Charging History is being disabled, remove its entities
395+
was_ch = self._config_entry.options.get(OPTION_ENABLE_CHARGING_HISTORY, False)
396+
now_ch = user_input[OPTION_ENABLE_CHARGING_HISTORY]
397+
if was_ch and not now_ch:
398+
for entity in er.async_entries_for_config_entry(entity_reg, self._config_entry.entry_id):
399+
if entity.unique_id and entity.unique_id.endswith("_diagnostics_charging_history"):
400+
_LOGGER.info("Removing Charging History entity %s", entity.entity_id)
401+
entity_reg.async_remove(entity.entity_id)
402+
403+
# If Tyre Diagnosis is being disabled, remove its entities
404+
was_td = self._config_entry.options.get(OPTION_ENABLE_TYRE_DIAGNOSIS, False)
405+
now_td = user_input[OPTION_ENABLE_TYRE_DIAGNOSIS]
406+
if was_td and not now_td:
407+
for entity in er.async_entries_for_config_entry(entity_reg, self._config_entry.entry_id):
408+
if entity.unique_id and entity.unique_id.endswith("_diagnostics_tyre_diagnosis"):
409+
_LOGGER.info("Removing Tyre Diagnosis entity %s", entity.entity_id)
410+
entity_reg.async_remove(entity.entity_id)
411+
391412
options = dict(self._config_entry.options)
392-
options[OPTION_ENABLE_MAGIC_SOC] = user_input[OPTION_ENABLE_MAGIC_SOC]
413+
options[OPTION_ENABLE_MAGIC_SOC] = now_magic
414+
options[OPTION_ENABLE_CHARGING_HISTORY] = now_ch
415+
options[OPTION_ENABLE_TYRE_DIAGNOSIS] = now_td
393416
return self.async_create_entry(title="", data=options)
394-
current = self._config_entry.options.get(OPTION_ENABLE_MAGIC_SOC, False)
417+
418+
current_magic = self._config_entry.options.get(OPTION_ENABLE_MAGIC_SOC, False)
419+
current_ch = self._config_entry.options.get(OPTION_ENABLE_CHARGING_HISTORY, False)
420+
current_td = self._config_entry.options.get(OPTION_ENABLE_TYRE_DIAGNOSIS, False)
395421
return self.async_show_form(
396422
step_id="action_settings",
397423
data_schema=vol.Schema(
398424
{
399-
vol.Optional(OPTION_ENABLE_MAGIC_SOC, default=current): bool,
425+
vol.Optional(OPTION_ENABLE_MAGIC_SOC, default=current_magic): bool,
426+
vol.Optional(OPTION_ENABLE_CHARGING_HISTORY, default=current_ch): bool,
427+
vol.Optional(OPTION_ENABLE_TYRE_DIAGNOSIS, default=current_td): bool,
400428
}
401429
),
402430
)

custom_components/cardata/const.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,11 @@
9090
DEBUG_LOG = False
9191
DIAGNOSTIC_LOG_INTERVAL = 30 # How often we print stream logs in seconds
9292
BOOTSTRAP_COMPLETE = "bootstrap_complete"
93-
# Staleness threshold per VIN - scales with number of cars to stay within API quota
94-
# 1 car = 1h, 2 cars = 2h, etc. → worst case ~24 API calls/day regardless of car count
95-
STALE_THRESHOLD_PER_VIN = 60 * 60 # 1 hour per VIN
93+
# Telematic polling budget — target ~24 scheduled API polls/day, leaving headroom
94+
# for bootstrap, trip-end events, etc. within BMW's 50-call daily quota.
95+
# When daily optional features (charging history, tyre diagnosis) are enabled,
96+
# the polling budget is reduced to keep total calls constant.
97+
TARGET_DAILY_POLLS = 24
9698
HTTP_TIMEOUT = 30 # Timeout for HTTP API requests in seconds
9799
TRIP_POLL_COOLDOWN_SECONDS = 600 # Min seconds between trip-end polls per VIN
98100
VEHICLE_METADATA = "vehicle_metadata"
@@ -111,6 +113,8 @@
111113
DEFAULT_CUSTOM_MQTT_TOPIC_PREFIX = "bmw/"
112114
OPTION_DIAGNOSTIC_INTERVAL = "diagnostic_log_interval"
113115
OPTION_ENABLE_MAGIC_SOC = "enable_magic_soc"
116+
OPTION_ENABLE_CHARGING_HISTORY = "enable_charging_history"
117+
OPTION_ENABLE_TYRE_DIAGNOSIS = "enable_tyre_diagnosis"
114118

115119
# Error message constants (for consistent error detection)
116120
ERR_TOKEN_REFRESH_IN_PROGRESS = "Token refresh already in progress"
@@ -260,5 +264,8 @@
260264
"i7": 101.7,
261265
}
262266

267+
# Daily fetch interval for optional endpoints (charging history, tyre diagnosis)
268+
DAILY_FETCH_INTERVAL = 86400 # 24 hours
269+
263270
# Key for storing deduplicated allowed VINs in entry data
264271
ALLOWED_VINS_KEY = "allowed_vins"

custom_components/cardata/coordinator.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,18 @@ class CardataCoordinator:
136136
# Whether Magic SOC sensor creation is enabled (off by default)
137137
enable_magic_soc: bool = field(default=False, init=False)
138138

139+
# Whether optional daily-poll features are enabled (off by default)
140+
enable_charging_history: bool = field(default=False, init=False)
141+
enable_tyre_diagnosis: bool = field(default=False, init=False)
142+
143+
# Storage for daily-poll data (VIN → parsed response)
144+
_charging_history: dict[str, list[dict[str, Any]]] = field(default_factory=dict, init=False)
145+
_tyre_diagnosis: dict[str, dict[str, Any]] = field(default_factory=dict, init=False)
146+
147+
# Per-VIN timestamp of last daily fetch (unix time)
148+
_last_charging_history_fetch: dict[str, float] = field(default_factory=dict, init=False)
149+
_last_tyre_diagnosis_fetch: dict[str, float] = field(default_factory=dict, init=False)
150+
139151
# Callback set by sensor.py to create virtual sensors after platform setup
140152
_create_sensor_callback: Callable[[str, str], None] | None = field(default=None, init=False, repr=False)
141153

@@ -164,6 +176,8 @@ class CardataCoordinator:
164176
_signal_new_image: str = field(default="", init=False)
165177
_signal_metadata: str = field(default="", init=False)
166178
_signal_efficiency_learning: str = field(default="", init=False)
179+
_signal_charging_history: str = field(default="", init=False)
180+
_signal_tyre_diagnosis: str = field(default="", init=False)
167181

168182
def __post_init__(self) -> None:
169183
"""Initialize cached values after dataclass creation."""
@@ -174,6 +188,8 @@ def __post_init__(self) -> None:
174188
self._signal_new_image = f"{DOMAIN}_{self.entry_id}_new_image"
175189
self._signal_metadata = f"{DOMAIN}_{self.entry_id}_metadata"
176190
self._signal_efficiency_learning = f"{DOMAIN}_{self.entry_id}_efficiency_learning"
191+
self._signal_charging_history = f"{DOMAIN}_{self.entry_id}_charging_history"
192+
self._signal_tyre_diagnosis = f"{DOMAIN}_{self.entry_id}_tyre_diagnosis"
177193

178194
@property
179195
def signal_new_sensor(self) -> str:
@@ -203,6 +219,36 @@ def signal_metadata(self) -> str:
203219
def signal_efficiency_learning(self) -> str:
204220
return self._signal_efficiency_learning
205221

222+
@property
223+
def signal_charging_history(self) -> str:
224+
return self._signal_charging_history
225+
226+
@property
227+
def signal_tyre_diagnosis(self) -> str:
228+
return self._signal_tyre_diagnosis
229+
230+
# --- Daily-poll data access ---
231+
232+
def update_charging_history(self, vin: str, sessions: list[dict[str, Any]]) -> None:
233+
"""Store charging history and dispatch signal."""
234+
self._charging_history[vin] = sessions
235+
self._last_charging_history_fetch[vin] = time.time()
236+
self._safe_dispatcher_send(self.signal_charging_history, vin)
237+
238+
def get_charging_history(self, vin: str) -> list[dict[str, Any]]:
239+
"""Return stored charging history for a VIN."""
240+
return self._charging_history.get(vin, [])
241+
242+
def update_tyre_diagnosis(self, vin: str, data: dict[str, Any]) -> None:
243+
"""Store tyre diagnosis and dispatch signal."""
244+
self._tyre_diagnosis[vin] = data
245+
self._last_tyre_diagnosis_fetch[vin] = time.time()
246+
self._safe_dispatcher_send(self.signal_tyre_diagnosis, vin)
247+
248+
def get_tyre_diagnosis(self, vin: str) -> dict[str, Any]:
249+
"""Return stored tyre diagnosis for a VIN."""
250+
return self._tyre_diagnosis.get(vin, {})
251+
206252
# --- Derived motion detection from GPS ---
207253

208254
def _update_location_tracking(self, vin: str, lat: float, lon: float) -> bool:

custom_components/cardata/lifecycle.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@
6666
OPTION_CUSTOM_MQTT_USERNAME,
6767
OPTION_DEBUG_LOG,
6868
OPTION_DIAGNOSTIC_INTERVAL,
69+
OPTION_ENABLE_CHARGING_HISTORY,
6970
OPTION_ENABLE_MAGIC_SOC,
71+
OPTION_ENABLE_TYRE_DIAGNOSIS,
7072
OPTION_MQTT_KEEPALIVE,
7173
SOC_LEARNING_STORAGE_KEY,
7274
SOC_LEARNING_STORAGE_VERSION,
@@ -197,6 +199,8 @@ async def async_setup_cardata(hass: HomeAssistant, entry: ConfigEntry) -> bool:
197199
coordinator = CardataCoordinator(hass=hass, entry_id=entry.entry_id)
198200
coordinator.diagnostic_interval = diagnostic_interval
199201
coordinator.enable_magic_soc = bool(options.get(OPTION_ENABLE_MAGIC_SOC, False))
202+
coordinator.enable_charging_history = bool(options.get(OPTION_ENABLE_CHARGING_HISTORY, False))
203+
coordinator.enable_tyre_diagnosis = bool(options.get(OPTION_ENABLE_TYRE_DIAGNOSIS, False))
200204

201205
# Store session start time for ghost cleanup
202206
# This prevents removing devices that existed before this HA restart

custom_components/cardata/sensor.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,13 @@
6666
from .coordinator import CardataCoordinator
6767
from .entity import CardataEntity
6868
from .runtime import CardataRuntimeData
69-
from .sensor_diagnostics import CardataDiagnosticsSensor, CardataEfficiencyLearningSensor, CardataVehicleMetadataSensor
69+
from .sensor_diagnostics import (
70+
CardataChargingHistorySensor,
71+
CardataDiagnosticsSensor,
72+
CardataEfficiencyLearningSensor,
73+
CardataTyreDiagnosisSensor,
74+
CardataVehicleMetadataSensor,
75+
)
7076
from .sensor_helpers import (
7177
convert_value_for_unit,
7278
get_device_class_for_unit,
@@ -304,6 +310,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
304310
entities: dict[tuple[str, str], CardataSensor] = {}
305311
metadata_entities: dict[str, CardataVehicleMetadataSensor] = {}
306312
efficiency_entities: dict[str, CardataEfficiencyLearningSensor] = {}
313+
charging_history_entities: dict[str, CardataChargingHistorySensor] = {}
314+
tyre_diagnosis_entities: dict[str, CardataTyreDiagnosisSensor] = {}
315+
316+
def ensure_charging_history_sensor(vin: str) -> None:
317+
"""Ensure charging history sensor exists for VIN when option is enabled."""
318+
if vin in charging_history_entities:
319+
return
320+
if not coordinator.enable_charging_history:
321+
return
322+
charging_history_entities[vin] = CardataChargingHistorySensor(coordinator, vin)
323+
async_add_entities([charging_history_entities[vin]], True)
324+
325+
def ensure_tyre_diagnosis_sensor(vin: str) -> None:
326+
"""Ensure tyre diagnosis sensor exists for VIN when option is enabled."""
327+
if vin in tyre_diagnosis_entities:
328+
return
329+
if not coordinator.enable_tyre_diagnosis:
330+
return
331+
tyre_diagnosis_entities[vin] = CardataTyreDiagnosisSensor(coordinator, vin)
332+
async_add_entities([tyre_diagnosis_entities[vin]], True)
307333

308334
def ensure_efficiency_learning_sensor(vin: str) -> None:
309335
"""Ensure efficiency learning sensor exists for EVs/PHEVs with battery management."""
@@ -413,9 +439,25 @@ async def async_handle_update_for_creation(vin: str, descriptor: str) -> None:
413439
async def async_handle_metadata_update(vin: str) -> None:
414440
ensure_metadata_sensor(vin)
415441
ensure_efficiency_learning_sensor(vin)
442+
ensure_charging_history_sensor(vin)
443+
ensure_tyre_diagnosis_sensor(vin)
416444

417445
entry.async_on_unload(async_dispatcher_connect(hass, coordinator.signal_metadata, async_handle_metadata_update))
418446

447+
async def async_handle_charging_history(vin: str) -> None:
448+
ensure_charging_history_sensor(vin)
449+
450+
entry.async_on_unload(
451+
async_dispatcher_connect(hass, coordinator.signal_charging_history, async_handle_charging_history)
452+
)
453+
454+
async def async_handle_tyre_diagnosis(vin: str) -> None:
455+
ensure_tyre_diagnosis_sensor(vin)
456+
457+
entry.async_on_unload(
458+
async_dispatcher_connect(hass, coordinator.signal_tyre_diagnosis, async_handle_tyre_diagnosis)
459+
)
460+
419461
# Restore enabled sensors from entity registry
420462
# Wrap in try/except to ensure diagnostic sensors are always created even if restoration fails
421463
try:
@@ -444,6 +486,14 @@ async def async_handle_metadata_update(vin: str) -> None:
444486
ensure_efficiency_learning_sensor(vin)
445487
continue
446488

489+
if descriptor == "diagnostics_charging_history":
490+
ensure_charging_history_sensor(vin)
491+
continue
492+
493+
if descriptor == "diagnostics_tyre_diagnosis":
494+
ensure_tyre_diagnosis_sensor(vin)
495+
continue
496+
447497
ensure_entity(vin, descriptor, assume_sensor=True)
448498
except Exception as err:
449499
_LOGGER.warning("Error restoring sensors from entity registry: %s", err)
@@ -466,6 +516,8 @@ async def async_handle_metadata_update(vin: str) -> None:
466516
for vin in all_vins:
467517
ensure_metadata_sensor(vin)
468518
ensure_efficiency_learning_sensor(vin)
519+
ensure_charging_history_sensor(vin)
520+
ensure_tyre_diagnosis_sensor(vin)
469521
except Exception as err:
470522
_LOGGER.warning("Error creating metadata sensors: %s", err)
471523

0 commit comments

Comments
 (0)