Skip to content

Commit be324e5

Browse files
authored
Merge pull request #266 from renaudallard/main
Add charging history and tyre diagnosis daily-poll sensors
2 parents ab599d8 + 3fc868b commit be324e5

11 files changed

Lines changed: 654 additions & 23 deletions

File tree

README.md

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,13 +247,49 @@ 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+
## Magic SOC — Driving Consumption Prediction (Experimental)
251+
252+
Magic SOC predicts battery drain during driving using real-time odometer distance and learned consumption rates. It provides a sub-integer SOC estimate that updates more frequently than BMW's native integer SOC. **Disabled by default** — enable via Settings.
253+
254+
- **Enable via**: Settings → Devices & Services → BMW CarData → Configure → Settings → Enable Magic SOC
255+
- **Sensor**: `vehicle.magic_soc` per vehicle (BEV only; PHEVs get passthrough BMW SOC)
256+
- **How it works**: Anchors on BMW's reported SOC at trip start, then subtracts `distance × learned_consumption / capacity` as the vehicle drives. Re-anchors when BMW sends a fresh SOC mid-drive — if the drift is < 0.5pp, keeps the sub-integer prediction for smoother display.
257+
- **Consumption learning**: Uses EMA (Exponential Moving Average) with adaptive rate. Default 0.21 kWh/km globally, with per-model defaults (e.g. i4 eDrive40 = 0.18 kWh/km). Learns from completed trips where both SOC drop and distance are available. Requires at least 5 trips before the learning rate settles to 20%.
258+
- **Trip detection**: Combines BMW's `isMoving` signal, GPS-derived motion, and odometer changes. Handles MQTT bursts and GPS gaps gracefully.
259+
- **Capacity**: Uses live `maxEnergy` from BMW (reflects real degradation), falls back to `batterySizeMax`, then to per-model defaults.
260+
- **Reset**: A "Reset Consumption Learning" button appears under Configuration entities for each BEV.
261+
262+
**Limitations**: This is experimental. Accuracy depends on BMW sending timely SOC and mileage data. Preheating, extended idle with accessories, and firmware glitches can cause temporary inaccuracy. PHEVs are excluded from prediction (hybrid powertrain makes distance-based estimation unreliable).
263+
264+
## Charging History (Optional)
265+
266+
The integration can fetch your BMW charging session history from the past 30 days. This is **disabled by default** to conserve your API quota.
267+
268+
- **Enable via**: Settings → Devices & Services → BMW CarData → Configure → Settings → Enable Charging History
269+
- **API cost**: 1 call per vehicle per day (from your 50-call daily quota)
270+
- **Sensor**: Creates a diagnostic sensor per vehicle showing session count and last charge date
271+
- **Attributes**: Full session data including start/end SOC, energy consumed, duration, location, and cost information
272+
- **Manual trigger**: Use `cardata.fetch_charging_history` service in Developer Tools
273+
274+
## Tyre Diagnosis (Optional)
275+
276+
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.
277+
278+
- **Enable via**: Settings → Devices & Services → BMW CarData → Configure → Settings → Enable Tyre Diagnosis
279+
- **API cost**: 1 call per vehicle per day (from your 50-call daily quota)
280+
- **Sensor**: Creates a diagnostic sensor per vehicle showing aggregate tyre status
281+
- **Attributes**: Per-wheel data including dimension, wear, season, manufacturer, defect status, and production date
282+
- **Manual trigger**: Use `cardata.fetch_tyre_diagnosis` service in Developer Tools
283+
250284
## Developer Tools Services
251285

252286
Home Assistant's Developer Tools expose helper services for manual API checks:
253287

254288
- `cardata.fetch_telematic_data` fetches the current contents of the configured telematics container for a VIN and logs the raw payload.
255289
- `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.
256290
- `cardata.fetch_basic_data` calls `GET /customers/vehicles/{vin}/basicData` to retrieve static metadata (model name, series, etc.) for the specified VIN.
291+
- `cardata.fetch_charging_history` fetches the last 30 days of charging sessions for a VIN. Uses 1 API call per vehicle.
292+
- `cardata.fetch_tyre_diagnosis` fetches tyre health and wear data for a VIN. Uses 1 API call per vehicle.
257293
- `migrations` call for proper renaming the sensors from old installations
258294

259295
## API Quota and MQTT Streaming
@@ -263,8 +299,9 @@ BMW imposes a **50 calls/day** limit on the CarData API. This integration does n
263299
- **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).
264300
- **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.
265301
- **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.
302+
- **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.
303+
- **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.
304+
- **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.
268305
- **Rate limiting**: If BMW returns a 429 (rate limited) response, the integration backs off automatically with exponential delay.
269306

270307
## Requirements
@@ -318,6 +355,7 @@ The integration is organized into focused modules:
318355
| `stream_circuit_breaker.py` | Circuit breaker for reconnection rate limiting |
319356
| `stream_reconnect.py` | Reconnection, unauthorized handling, retry scheduling |
320357
| `motion_detection.py` | GPS centroid movement detection, parking zone logic |
358+
| `sensor_diagnostics.py` | Diagnostic sensors: connection, metadata, efficiency, charging history, tyre diagnosis |
321359
| `sensor.py` / `binary_sensor.py` / `device_tracker.py` | Home Assistant entity platforms |
322360
| `config_flow.py` | Setup, reauthorization, and options UI flows |
323361
| `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

0 commit comments

Comments
 (0)