Skip to content

Commit a7f9134

Browse files
committed
Add manual battery capacity number entity
Introduce a Number entity to allow users to manually specify battery capacity per VIN and make it take priority over auto-detected values. Adds a new number platform (Platform.NUMBER) and a new manual battery capacity descriptor (vehicle.manual_battery_capacity) plus a human-readable title. Coordinator: stores per-VIN manual capacity, exposes get/set methods, respects entity registry disabled state, and uses manual value ahead of live/cached/default capacities in anchoring and SOC prediction logic. Also bumps integration version to 4.8.2 and registers the new entities (default disabled in registry).
1 parent 2f30933 commit a7f9134

6 files changed

Lines changed: 265 additions & 7 deletions

File tree

custom_components/cardata/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
Platform.DEVICE_TRACKER,
8080
Platform.IMAGE,
8181
Platform.BUTTON,
82+
Platform.NUMBER,
8283
]
8384

8485

custom_components/cardata/const.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@
5656
# Magic SOC sensor (driving consumption prediction)
5757
MAGIC_SOC_DESCRIPTOR = "vehicle.magic_soc"
5858

59+
# Manual battery capacity (user input, takes priority over automatic detection)
60+
MANUAL_CAPACITY_DESCRIPTOR = "vehicle.manual_battery_capacity"
61+
5962
DEFAULT_SCOPE = "authenticate_user openid cardata:api:read cardata:streaming:read"
6063
DEVICE_CODE_URL = "https://customer.bmwgroup.com/gcdm/oauth/device/code"
6164
TOKEN_URL = "https://customer.bmwgroup.com/gcdm/oauth/token"

custom_components/cardata/coordinator.py

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
from homeassistant.core import HomeAssistant
4040
from homeassistant.helpers.dispatcher import async_dispatcher_send
41+
from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry
4142
from homeassistant.helpers.event import async_call_later
4243

4344
from .const import (
@@ -151,6 +152,10 @@ class CardataCoordinator:
151152
# Flag to track if _allowed_vins has been initialized (distinguishes "not set" from "empty")
152153
_allowed_vins_initialized: bool = field(default=False, init=False)
153154

155+
# Manual battery capacity (user input, takes priority over automatic detection)
156+
# Per-VIN storage: VIN -> capacity in kWh (None = not set, use auto-detection)
157+
_manual_battery_capacity: dict[str, float | None] = field(default_factory=dict, init=False)
158+
154159
# Cached signal strings (initialized in __post_init__ for performance)
155160
_signal_new_sensor: str = field(default="", init=False)
156161
_signal_new_binary: str = field(default="", init=False)
@@ -261,6 +266,48 @@ def record_telematic_poll(self, vin: str) -> None:
261266
"""Record that a telematic API poll succeeded for this VIN."""
262267
self._last_poll_at[vin] = time.time()
263268

269+
def get_manual_battery_capacity(self, vin: str) -> float | None:
270+
"""Get manual battery capacity for a VIN (user input).
271+
272+
Returns:
273+
Manual capacity in kWh, or None if not set or entity is disabled
274+
"""
275+
# Check if we have a stored value
276+
capacity = self._manual_battery_capacity.get(vin)
277+
if capacity is None:
278+
return None
279+
280+
# Check if the entity is disabled in entity registry
281+
from .const import MANUAL_CAPACITY_DESCRIPTOR
282+
283+
entity_registry = async_get_entity_registry(self.hass)
284+
unique_id = f"{vin}_{MANUAL_CAPACITY_DESCRIPTOR}"
285+
entity_id = entity_registry.async_get_entity_id("number", DOMAIN, unique_id)
286+
287+
if entity_id:
288+
entity_entry = entity_registry.async_get(entity_id)
289+
if entity_entry and entity_entry.disabled_by is not None:
290+
# Entity is disabled, ignore stored value and use auto-detection
291+
_LOGGER.debug(
292+
"Manual battery capacity entity is disabled for %s - using auto-detection",
293+
redact_vin(vin),
294+
)
295+
return None
296+
297+
return capacity
298+
299+
def set_manual_battery_capacity(self, vin: str, capacity_kwh: float | None) -> None:
300+
"""Set manual battery capacity for a VIN.
301+
302+
Args:
303+
vin: Vehicle identification number
304+
capacity_kwh: Battery capacity in kWh, or None to disable manual override
305+
"""
306+
if capacity_kwh is None or capacity_kwh <= 0:
307+
self._manual_battery_capacity.pop(vin, None)
308+
else:
309+
self._manual_battery_capacity[vin] = capacity_kwh
310+
264311
def _is_metadata_bev(self, vin: str) -> bool:
265312
"""Check if vehicle metadata identifies this as a BEV (not PHEV/ICE).
266313
@@ -392,10 +439,25 @@ def _anchor_soc_session(self, vin: str, vehicle_state: dict[str, DescriptorState
392439
_LOGGER.debug("Cannot anchor session for %s: no SOC data available", redact_vin(vin))
393440
return
394441

395-
# Get battery capacity (prefer maxEnergy, fallback to batterySizeMax, then metadata)
442+
# Get battery capacity (prefer manual input, then maxEnergy, fallback to batterySizeMax, then existing session)
396443
capacity_kwh: float | None = None
397-
capacity_state = vehicle_state.get("vehicle.drivetrain.batteryManagement.maxEnergy")
398-
capacity_kwh = _descriptor_float(capacity_state)
444+
445+
# Priority 1: Manual capacity from user (if set)
446+
manual_capacity = self.get_manual_battery_capacity(vin)
447+
if manual_capacity is not None and manual_capacity > 0:
448+
capacity_kwh = manual_capacity
449+
_LOGGER.debug(
450+
"Using manual battery capacity for %s: %.1f kWh",
451+
redact_vin(vin),
452+
capacity_kwh,
453+
)
454+
455+
# Priority 2: maxEnergy descriptor
456+
if capacity_kwh is None or capacity_kwh <= 0:
457+
capacity_state = vehicle_state.get("vehicle.drivetrain.batteryManagement.maxEnergy")
458+
capacity_kwh = _descriptor_float(capacity_state)
459+
460+
# Priority 3: batterySizeMax descriptor
399461
if capacity_kwh is None or capacity_kwh <= 0:
400462
capacity_state = vehicle_state.get("vehicle.drivetrain.batteryManagement.batterySizeMax")
401463
capacity_kwh = _descriptor_float(capacity_state)
@@ -602,17 +664,35 @@ def _anchor_driving_session(self, vin: str, vehicle_state: dict[str, DescriptorS
602664
except (TypeError, ValueError):
603665
return
604666

605-
# Get battery capacity (prefer live descriptor, fallback to cached)
606-
capacity_state = vehicle_state.get("vehicle.drivetrain.batteryManagement.batterySizeMax")
607-
capacity_kwh = _descriptor_float(capacity_state)
667+
# Get battery capacity (prefer manual input, then live descriptor, fallback to cached)
668+
capacity_kwh: float | None = None
669+
670+
# Priority 1: Manual capacity from user (if set)
671+
manual_capacity = self.get_manual_battery_capacity(vin)
672+
if manual_capacity is not None and manual_capacity > 0:
673+
capacity_kwh = manual_capacity
674+
_LOGGER.debug(
675+
"Soc prediction: Using manual battery capacity for %s: %.1f kWh",
676+
redact_vin(vin),
677+
capacity_kwh,
678+
)
679+
680+
# Priority 2: Live descriptors
681+
if capacity_kwh is None or capacity_kwh <= 0:
682+
capacity_state = vehicle_state.get("vehicle.drivetrain.batteryManagement.batterySizeMax")
683+
capacity_kwh = _descriptor_float(capacity_state)
608684
if capacity_kwh is None or capacity_kwh <= 0:
609685
capacity_state = vehicle_state.get("vehicle.drivetrain.batteryManagement.maxEnergy")
610686
capacity_kwh = _descriptor_float(capacity_state)
687+
688+
# Store live capacity if found
611689
if capacity_kwh is not None and capacity_kwh > 0:
612690
self._magic_soc.update_battery_capacity(vin, capacity_kwh)
613691
else:
692+
# Priority 3: Cached capacity
614693
capacity_kwh = self._magic_soc.get_last_known_capacity(vin)
615694
if capacity_kwh is None or capacity_kwh <= 0:
695+
# Priority 4: Model default
616696
capacity_kwh = self._magic_soc.get_default_capacity(vin)
617697
if capacity_kwh is None or capacity_kwh <= 0:
618698
_LOGGER.debug(

custom_components/cardata/descriptor_titles.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,4 +322,5 @@
322322
# Derived/calculated sensors
323323
"vehicle.predicted_soc": "Battery EV Predicted State Of Charge",
324324
"vehicle.magic_soc": "Magic SOC",
325+
"vehicle.manual_battery_capacity": "Battery HV Manual Capacity",
325326
}

custom_components/cardata/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,5 @@
1313
"requirements": [
1414
"paho-mqtt>=1.6.1"
1515
],
16-
"version": "4.8.0"
16+
"version": "4.8.2"
1717
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# Copyright (c) 2025, Kris Van Biesen <kvanbiesen@gmail.com>, Renaud Allard <renaud@allard.it>, Jyri Saukkonen <jyri.saukkonen+jjyksi@gmail.com>
2+
# All rights reserved.
3+
#
4+
# Redistribution and use in source and binary forms, with or without
5+
# modification, are permitted provided that the following conditions are met:
6+
#
7+
# 1. Redistributions of source code must retain the above copyright notice,
8+
# this list of conditions and the following disclaimer.
9+
#
10+
# 2. Redistributions in binary form must reproduce the above copyright notice,
11+
# this list of conditions and the following disclaimer in the documentation
12+
# and/or other materials provided with the distribution.
13+
#
14+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
15+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
17+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
18+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
19+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
20+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
21+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
22+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
23+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
24+
# POSSIBILITY OF SUCH DAMAGE.
25+
26+
"""Number entities for BMW CarData integration."""
27+
28+
from __future__ import annotations
29+
30+
import logging
31+
from typing import TYPE_CHECKING
32+
33+
from homeassistant.components.number import NumberEntity, NumberMode
34+
from homeassistant.config_entries import ConfigEntry
35+
from homeassistant.const import EntityCategory, UnitOfEnergy
36+
from homeassistant.core import HomeAssistant
37+
from homeassistant.helpers.device_registry import DeviceInfo
38+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
39+
from homeassistant.helpers.restore_state import RestoreEntity
40+
41+
from .const import DOMAIN, MANUAL_CAPACITY_DESCRIPTOR
42+
from .utils import redact_vin
43+
44+
if TYPE_CHECKING:
45+
from .coordinator import CardataCoordinator
46+
from .runtime import CardataRuntimeData
47+
48+
_LOGGER = logging.getLogger(__name__)
49+
50+
51+
async def async_setup_entry(
52+
hass: HomeAssistant,
53+
entry: ConfigEntry,
54+
async_add_entities: AddEntitiesCallback,
55+
) -> None:
56+
"""Set up BMW CarData number entities."""
57+
runtime: CardataRuntimeData = hass.data[DOMAIN][entry.entry_id]
58+
coordinator = runtime.coordinator
59+
60+
entities: list[NumberEntity] = []
61+
62+
# Create manual battery capacity input for each known EV/PHEV vehicle
63+
for vin in coordinator.data.keys():
64+
# Check if this vehicle has HV battery (EV/PHEV)
65+
vehicle_data = coordinator.data.get(vin, {})
66+
if "vehicle.drivetrain.batteryManagement.header" in vehicle_data:
67+
vehicle_name = coordinator.names.get(vin, redact_vin(vin))
68+
69+
entities.append(
70+
ManualBatteryCapacityNumber(
71+
coordinator=coordinator,
72+
vin=vin,
73+
vehicle_name=vehicle_name,
74+
entry_id=entry.entry_id,
75+
)
76+
)
77+
_LOGGER.debug("Created manual battery capacity input for %s (%s)", vehicle_name, redact_vin(vin))
78+
79+
if entities:
80+
async_add_entities(entities)
81+
_LOGGER.debug("Added %d number entities", len(entities))
82+
83+
84+
class ManualBatteryCapacityNumber(NumberEntity, RestoreEntity):
85+
"""Number entity for manual battery capacity input."""
86+
87+
_attr_icon = "mdi:car-battery"
88+
_attr_entity_category = EntityCategory.CONFIG
89+
_attr_has_entity_name = True
90+
_attr_native_min_value = 0.0
91+
_attr_native_max_value = 150.0
92+
_attr_native_step = 0.1
93+
_attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
94+
_attr_mode = NumberMode.BOX
95+
_attr_entity_registry_enabled_default = False
96+
97+
def __init__(
98+
self,
99+
coordinator: CardataCoordinator,
100+
vin: str,
101+
vehicle_name: str,
102+
entry_id: str,
103+
) -> None:
104+
"""Initialize the number entity."""
105+
self._coordinator = coordinator
106+
self._vin = vin
107+
self._attr_unique_id = f"{vin}_{MANUAL_CAPACITY_DESCRIPTOR}"
108+
self._attr_name = "Manual Battery Capacity"
109+
self._attr_device_info = DeviceInfo(
110+
identifiers={(DOMAIN, vin)},
111+
name=vehicle_name,
112+
)
113+
114+
async def async_added_to_hass(self) -> None:
115+
"""Restore previous value when entity is added."""
116+
await super().async_added_to_hass()
117+
118+
# Restore previous value
119+
last_state = await self.async_get_last_state()
120+
last_number_data = await self.async_get_last_number_data()
121+
122+
if last_number_data is not None and last_number_data.native_value is not None:
123+
value = last_number_data.native_value
124+
# Store restored value in coordinator
125+
if value > 0:
126+
self._coordinator.set_manual_battery_capacity(self._vin, value)
127+
_LOGGER.debug(
128+
"Restored manual battery capacity for %s: %.1f kWh",
129+
redact_vin(self._vin),
130+
value,
131+
)
132+
self._attr_native_value = value
133+
elif last_state is not None and last_state.state not in ("unknown", "unavailable"):
134+
try:
135+
value = float(last_state.state)
136+
if value > 0:
137+
self._coordinator.set_manual_battery_capacity(self._vin, value)
138+
_LOGGER.debug(
139+
"Restored manual battery capacity for %s: %.1f kWh",
140+
redact_vin(self._vin),
141+
value,
142+
)
143+
self._attr_native_value = value
144+
except (ValueError, TypeError):
145+
self._attr_native_value = 0.0
146+
else:
147+
# Default to 0 (disabled/not set)
148+
self._attr_native_value = 0.0
149+
150+
@property
151+
def native_value(self) -> float | None:
152+
"""Return the current value."""
153+
return self._attr_native_value
154+
155+
async def async_set_native_value(self, value: float) -> None:
156+
"""Set new value."""
157+
self._attr_native_value = value
158+
# Store in coordinator for immediate use
159+
if value > 0:
160+
self._coordinator.set_manual_battery_capacity(self._vin, value)
161+
_LOGGER.info(
162+
"Manual battery capacity set for %s: %.1f kWh",
163+
redact_vin(self._vin),
164+
value,
165+
)
166+
else:
167+
# Value of 0 disables manual override
168+
self._coordinator.set_manual_battery_capacity(self._vin, None)
169+
_LOGGER.info(
170+
"Manual battery capacity cleared for %s (auto-detect enabled)",
171+
redact_vin(self._vin),
172+
)
173+
self.async_write_ha_state()

0 commit comments

Comments
 (0)