mirror of
https://github.com/home-assistant/core.git
synced 2025-07-15 01:07:10 +00:00
Update BMW connected drive to async (#71827)
* Change BMW connected drive to async * Fix coordinator exceptions, fix tests * Fix using deprecated property * Write HA state directly * Remove code that cannnot throw Exceptions from try/except * Use public async_write_ha_state Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Fix login using refresh_token if token expired * MyPy fixes * Fix pytest, bump dependency * Replace OptionFlow listener with explicit refresh * Remove uneeded async_get_entry * Update test to include change on OptionsFlow * Bump bimmer_connected to 0.9.0 * Migrate renamed entitity unique_ids * Don't replace async_migrate_entries, add tests * Rename existing_entry to existing_entry_id Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update tests * Import full EntityRegistry * Fix comment * Increase timeout to 60s * Rely on library timeout Co-authored-by: rikroe <rikroe@users.noreply.github.com> Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
f33151ff8b
commit
cd769a55c2
@ -1,31 +1,27 @@
|
|||||||
"""Reads vehicle status from BMW connected drive portal."""
|
"""Reads vehicle status from MyBMW portal."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from bimmer_connected.vehicle import ConnectedDriveVehicle
|
from bimmer_connected.vehicle import MyBMWVehicle
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform
|
||||||
CONF_DEVICE_ID,
|
|
||||||
CONF_ENTITY_ID,
|
|
||||||
CONF_NAME,
|
|
||||||
CONF_PASSWORD,
|
|
||||||
CONF_REGION,
|
|
||||||
CONF_USERNAME,
|
|
||||||
Platform,
|
|
||||||
)
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers import discovery
|
from homeassistant.helpers import discovery, entity_registry as er
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.entity import DeviceInfo, Entity
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .const import ATTR_VIN, ATTRIBUTION, CONF_READ_ONLY, DATA_HASS_CONFIG, DOMAIN
|
from .const import ATTR_VIN, ATTRIBUTION, CONF_READ_ONLY, DATA_HASS_CONFIG, DOMAIN
|
||||||
from .coordinator import BMWDataUpdateCoordinator
|
from .coordinator import BMWDataUpdateCoordinator
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
||||||
|
|
||||||
SERVICE_SCHEMA = vol.Schema(
|
SERVICE_SCHEMA = vol.Schema(
|
||||||
@ -74,18 +70,56 @@ def _async_migrate_options_from_data_if_missing(
|
|||||||
hass.config_entries.async_update_entry(entry, data=data, options=options)
|
hass.config_entries.async_update_entry(entry, data=data, options=options)
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_migrate_entries(
|
||||||
|
hass: HomeAssistant, config_entry: ConfigEntry
|
||||||
|
) -> bool:
|
||||||
|
"""Migrate old entry."""
|
||||||
|
entity_registry = er.async_get(hass)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None:
|
||||||
|
replacements = {
|
||||||
|
"charging_level_hv": "remaining_battery_percent",
|
||||||
|
"fuel_percent": "remaining_fuel_percent",
|
||||||
|
}
|
||||||
|
if (key := entry.unique_id.split("-")[-1]) in replacements:
|
||||||
|
new_unique_id = entry.unique_id.replace(key, replacements[key])
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migrating entity '%s' unique_id from '%s' to '%s'",
|
||||||
|
entry.entity_id,
|
||||||
|
entry.unique_id,
|
||||||
|
new_unique_id,
|
||||||
|
)
|
||||||
|
if existing_entity_id := entity_registry.async_get_entity_id(
|
||||||
|
entry.domain, entry.platform, new_unique_id
|
||||||
|
):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Cannot migrate to unique_id '%s', already exists for '%s'",
|
||||||
|
new_unique_id,
|
||||||
|
existing_entity_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"new_unique_id": new_unique_id,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up BMW Connected Drive from a config entry."""
|
"""Set up BMW Connected Drive from a config entry."""
|
||||||
|
|
||||||
_async_migrate_options_from_data_if_missing(hass, entry)
|
_async_migrate_options_from_data_if_missing(hass, entry)
|
||||||
|
|
||||||
|
await _async_migrate_entries(hass, entry)
|
||||||
|
|
||||||
# Set up one data coordinator per account/config entry
|
# Set up one data coordinator per account/config entry
|
||||||
coordinator = BMWDataUpdateCoordinator(
|
coordinator = BMWDataUpdateCoordinator(
|
||||||
hass,
|
hass,
|
||||||
username=entry.data[CONF_USERNAME],
|
entry=entry,
|
||||||
password=entry.data[CONF_PASSWORD],
|
|
||||||
region=entry.data[CONF_REGION],
|
|
||||||
read_only=entry.options[CONF_READ_ONLY],
|
|
||||||
)
|
)
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
@ -109,9 +143,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add event listener for option flow changes
|
|
||||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@ -127,20 +158,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
class BMWBaseEntity(CoordinatorEntity[BMWDataUpdateCoordinator]):
|
||||||
"""Handle options update."""
|
|
||||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
|
||||||
|
|
||||||
|
|
||||||
class BMWConnectedDriveBaseEntity(CoordinatorEntity[BMWDataUpdateCoordinator], Entity):
|
|
||||||
"""Common base for BMW entities."""
|
"""Common base for BMW entities."""
|
||||||
|
|
||||||
|
coordinator: BMWDataUpdateCoordinator
|
||||||
_attr_attribution = ATTRIBUTION
|
_attr_attribution = ATTRIBUTION
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: BMWDataUpdateCoordinator,
|
coordinator: BMWDataUpdateCoordinator,
|
||||||
vehicle: ConnectedDriveVehicle,
|
vehicle: MyBMWVehicle,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize entity."""
|
"""Initialize entity."""
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
|
@ -1,18 +1,15 @@
|
|||||||
"""Reads vehicle status from BMW connected drive portal."""
|
"""Reads vehicle status from BMW MyBMW portal."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, cast
|
from typing import Any
|
||||||
|
|
||||||
from bimmer_connected.vehicle import ConnectedDriveVehicle
|
from bimmer_connected.vehicle import MyBMWVehicle
|
||||||
from bimmer_connected.vehicle_status import (
|
from bimmer_connected.vehicle.doors_windows import LockState
|
||||||
ChargingState,
|
from bimmer_connected.vehicle.fuel_and_battery import ChargingState
|
||||||
ConditionBasedServiceReport,
|
from bimmer_connected.vehicle.reports import ConditionBasedService
|
||||||
LockState,
|
|
||||||
VehicleStatus,
|
|
||||||
)
|
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
BinarySensorDeviceClass,
|
BinarySensorDeviceClass,
|
||||||
@ -24,7 +21,7 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.util.unit_system import UnitSystem
|
from homeassistant.util.unit_system import UnitSystem
|
||||||
|
|
||||||
from . import BMWConnectedDriveBaseEntity
|
from . import BMWBaseEntity
|
||||||
from .const import DOMAIN, UNIT_MAP
|
from .const import DOMAIN, UNIT_MAP
|
||||||
from .coordinator import BMWDataUpdateCoordinator
|
from .coordinator import BMWDataUpdateCoordinator
|
||||||
|
|
||||||
@ -32,20 +29,20 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def _condition_based_services(
|
def _condition_based_services(
|
||||||
vehicle_state: VehicleStatus, unit_system: UnitSystem
|
vehicle: MyBMWVehicle, unit_system: UnitSystem
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
extra_attributes = {}
|
extra_attributes = {}
|
||||||
for report in vehicle_state.condition_based_services:
|
for report in vehicle.condition_based_services.messages:
|
||||||
extra_attributes.update(_format_cbs_report(report, unit_system))
|
extra_attributes.update(_format_cbs_report(report, unit_system))
|
||||||
return extra_attributes
|
return extra_attributes
|
||||||
|
|
||||||
|
|
||||||
def _check_control_messages(vehicle_state: VehicleStatus) -> dict[str, Any]:
|
def _check_control_messages(vehicle: MyBMWVehicle) -> dict[str, Any]:
|
||||||
extra_attributes: dict[str, Any] = {}
|
extra_attributes: dict[str, Any] = {}
|
||||||
if vehicle_state.has_check_control_messages:
|
if vehicle.check_control_messages.has_check_control_messages:
|
||||||
cbs_list = [
|
cbs_list = [
|
||||||
message.description_short
|
message.description_short
|
||||||
for message in vehicle_state.check_control_messages
|
for message in vehicle.check_control_messages.messages
|
||||||
]
|
]
|
||||||
extra_attributes["check_control_messages"] = cbs_list
|
extra_attributes["check_control_messages"] = cbs_list
|
||||||
else:
|
else:
|
||||||
@ -54,18 +51,18 @@ def _check_control_messages(vehicle_state: VehicleStatus) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _format_cbs_report(
|
def _format_cbs_report(
|
||||||
report: ConditionBasedServiceReport, unit_system: UnitSystem
|
report: ConditionBasedService, unit_system: UnitSystem
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
result: dict[str, Any] = {}
|
result: dict[str, Any] = {}
|
||||||
service_type = report.service_type.lower().replace("_", " ")
|
service_type = report.service_type.lower().replace("_", " ")
|
||||||
result[f"{service_type} status"] = report.state.value
|
result[f"{service_type} status"] = report.state.value
|
||||||
if report.due_date is not None:
|
if report.due_date is not None:
|
||||||
result[f"{service_type} date"] = report.due_date.strftime("%Y-%m-%d")
|
result[f"{service_type} date"] = report.due_date.strftime("%Y-%m-%d")
|
||||||
if report.due_distance is not None:
|
if report.due_distance.value and report.due_distance.unit:
|
||||||
distance = round(
|
distance = round(
|
||||||
unit_system.length(
|
unit_system.length(
|
||||||
report.due_distance[0],
|
report.due_distance.value,
|
||||||
UNIT_MAP.get(report.due_distance[1], report.due_distance[1]),
|
UNIT_MAP.get(report.due_distance.unit, report.due_distance.unit),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
result[f"{service_type} distance"] = f"{distance} {unit_system.length_unit}"
|
result[f"{service_type} distance"] = f"{distance} {unit_system.length_unit}"
|
||||||
@ -76,7 +73,7 @@ def _format_cbs_report(
|
|||||||
class BMWRequiredKeysMixin:
|
class BMWRequiredKeysMixin:
|
||||||
"""Mixin for required keys."""
|
"""Mixin for required keys."""
|
||||||
|
|
||||||
value_fn: Callable[[VehicleStatus], bool]
|
value_fn: Callable[[MyBMWVehicle], bool]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -85,7 +82,7 @@ class BMWBinarySensorEntityDescription(
|
|||||||
):
|
):
|
||||||
"""Describes BMW binary_sensor entity."""
|
"""Describes BMW binary_sensor entity."""
|
||||||
|
|
||||||
attr_fn: Callable[[VehicleStatus, UnitSystem], dict[str, Any]] | None = None
|
attr_fn: Callable[[MyBMWVehicle, UnitSystem], dict[str, Any]] | None = None
|
||||||
|
|
||||||
|
|
||||||
SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = (
|
SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = (
|
||||||
@ -95,8 +92,10 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = (
|
|||||||
device_class=BinarySensorDeviceClass.OPENING,
|
device_class=BinarySensorDeviceClass.OPENING,
|
||||||
icon="mdi:car-door-lock",
|
icon="mdi:car-door-lock",
|
||||||
# device class opening: On means open, Off means closed
|
# device class opening: On means open, Off means closed
|
||||||
value_fn=lambda s: not s.all_lids_closed,
|
value_fn=lambda v: not v.doors_and_windows.all_lids_closed,
|
||||||
attr_fn=lambda s, u: {lid.name: lid.state.value for lid in s.lids},
|
attr_fn=lambda v, u: {
|
||||||
|
lid.name: lid.state.value for lid in v.doors_and_windows.lids
|
||||||
|
},
|
||||||
),
|
),
|
||||||
BMWBinarySensorEntityDescription(
|
BMWBinarySensorEntityDescription(
|
||||||
key="windows",
|
key="windows",
|
||||||
@ -104,8 +103,10 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = (
|
|||||||
device_class=BinarySensorDeviceClass.OPENING,
|
device_class=BinarySensorDeviceClass.OPENING,
|
||||||
icon="mdi:car-door",
|
icon="mdi:car-door",
|
||||||
# device class opening: On means open, Off means closed
|
# device class opening: On means open, Off means closed
|
||||||
value_fn=lambda s: not s.all_windows_closed,
|
value_fn=lambda v: not v.doors_and_windows.all_windows_closed,
|
||||||
attr_fn=lambda s, u: {window.name: window.state.value for window in s.windows},
|
attr_fn=lambda v, u: {
|
||||||
|
window.name: window.state.value for window in v.doors_and_windows.windows
|
||||||
|
},
|
||||||
),
|
),
|
||||||
BMWBinarySensorEntityDescription(
|
BMWBinarySensorEntityDescription(
|
||||||
key="door_lock_state",
|
key="door_lock_state",
|
||||||
@ -114,29 +115,19 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = (
|
|||||||
icon="mdi:car-key",
|
icon="mdi:car-key",
|
||||||
# device class lock: On means unlocked, Off means locked
|
# device class lock: On means unlocked, Off means locked
|
||||||
# Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED
|
# Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED
|
||||||
value_fn=lambda s: s.door_lock_state
|
value_fn=lambda v: v.doors_and_windows.door_lock_state
|
||||||
not in {LockState.LOCKED, LockState.SECURED},
|
not in {LockState.LOCKED, LockState.SECURED},
|
||||||
attr_fn=lambda s, u: {
|
attr_fn=lambda v, u: {
|
||||||
"door_lock_state": s.door_lock_state.value,
|
"door_lock_state": v.doors_and_windows.door_lock_state.value
|
||||||
"last_update_reason": s.last_update_reason,
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
BMWBinarySensorEntityDescription(
|
|
||||||
key="lights_parking",
|
|
||||||
name="Parking lights",
|
|
||||||
device_class=BinarySensorDeviceClass.LIGHT,
|
|
||||||
icon="mdi:car-parking-lights",
|
|
||||||
# device class light: On means light detected, Off means no light
|
|
||||||
value_fn=lambda s: cast(bool, s.are_parking_lights_on),
|
|
||||||
attr_fn=lambda s, u: {"lights_parking": s.parking_lights.value},
|
|
||||||
),
|
|
||||||
BMWBinarySensorEntityDescription(
|
BMWBinarySensorEntityDescription(
|
||||||
key="condition_based_services",
|
key="condition_based_services",
|
||||||
name="Condition based services",
|
name="Condition based services",
|
||||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||||
icon="mdi:wrench",
|
icon="mdi:wrench",
|
||||||
# device class problem: On means problem detected, Off means no problem
|
# device class problem: On means problem detected, Off means no problem
|
||||||
value_fn=lambda s: not s.are_all_cbs_ok,
|
value_fn=lambda v: v.condition_based_services.is_service_required,
|
||||||
attr_fn=_condition_based_services,
|
attr_fn=_condition_based_services,
|
||||||
),
|
),
|
||||||
BMWBinarySensorEntityDescription(
|
BMWBinarySensorEntityDescription(
|
||||||
@ -145,8 +136,8 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = (
|
|||||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||||
icon="mdi:car-tire-alert",
|
icon="mdi:car-tire-alert",
|
||||||
# device class problem: On means problem detected, Off means no problem
|
# device class problem: On means problem detected, Off means no problem
|
||||||
value_fn=lambda s: cast(bool, s.has_check_control_messages),
|
value_fn=lambda v: v.check_control_messages.has_check_control_messages,
|
||||||
attr_fn=lambda s, u: _check_control_messages(s),
|
attr_fn=lambda v, u: _check_control_messages(v),
|
||||||
),
|
),
|
||||||
# electric
|
# electric
|
||||||
BMWBinarySensorEntityDescription(
|
BMWBinarySensorEntityDescription(
|
||||||
@ -155,10 +146,9 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = (
|
|||||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||||
icon="mdi:ev-station",
|
icon="mdi:ev-station",
|
||||||
# device class power: On means power detected, Off means no power
|
# device class power: On means power detected, Off means no power
|
||||||
value_fn=lambda s: cast(bool, s.charging_status == ChargingState.CHARGING),
|
value_fn=lambda v: v.fuel_and_battery.charging_status == ChargingState.CHARGING,
|
||||||
attr_fn=lambda s, u: {
|
attr_fn=lambda v, u: {
|
||||||
"charging_status": s.charging_status.value,
|
"charging_status": str(v.fuel_and_battery.charging_status),
|
||||||
"last_charging_end_result": s.last_charging_end_result,
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
BMWBinarySensorEntityDescription(
|
BMWBinarySensorEntityDescription(
|
||||||
@ -166,8 +156,7 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = (
|
|||||||
name="Connection status",
|
name="Connection status",
|
||||||
device_class=BinarySensorDeviceClass.PLUG,
|
device_class=BinarySensorDeviceClass.PLUG,
|
||||||
icon="mdi:car-electric",
|
icon="mdi:car-electric",
|
||||||
value_fn=lambda s: cast(str, s.connection_status) == "CONNECTED",
|
value_fn=lambda v: v.fuel_and_battery.is_charger_connected,
|
||||||
attr_fn=lambda s, u: {"connection_status": s.connection_status},
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -177,11 +166,11 @@ async def async_setup_entry(
|
|||||||
config_entry: ConfigEntry,
|
config_entry: ConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the BMW ConnectedDrive binary sensors from config entry."""
|
"""Set up the BMW binary sensors from config entry."""
|
||||||
coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
|
|
||||||
entities = [
|
entities = [
|
||||||
BMWConnectedDriveSensor(coordinator, vehicle, description, hass.config.units)
|
BMWBinarySensor(coordinator, vehicle, description, hass.config.units)
|
||||||
for vehicle in coordinator.account.vehicles
|
for vehicle in coordinator.account.vehicles
|
||||||
for description in SENSOR_TYPES
|
for description in SENSOR_TYPES
|
||||||
if description.key in vehicle.available_attributes
|
if description.key in vehicle.available_attributes
|
||||||
@ -189,7 +178,7 @@ async def async_setup_entry(
|
|||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, BinarySensorEntity):
|
class BMWBinarySensor(BMWBaseEntity, BinarySensorEntity):
|
||||||
"""Representation of a BMW vehicle binary sensor."""
|
"""Representation of a BMW vehicle binary sensor."""
|
||||||
|
|
||||||
entity_description: BMWBinarySensorEntityDescription
|
entity_description: BMWBinarySensorEntityDescription
|
||||||
@ -197,7 +186,7 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, BinarySensorEntity):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: BMWDataUpdateCoordinator,
|
coordinator: BMWDataUpdateCoordinator,
|
||||||
vehicle: ConnectedDriveVehicle,
|
vehicle: MyBMWVehicle,
|
||||||
description: BMWBinarySensorEntityDescription,
|
description: BMWBinarySensorEntityDescription,
|
||||||
unit_system: UnitSystem,
|
unit_system: UnitSystem,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -217,14 +206,12 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, BinarySensorEntity):
|
|||||||
self.entity_description.key,
|
self.entity_description.key,
|
||||||
self.vehicle.name,
|
self.vehicle.name,
|
||||||
)
|
)
|
||||||
vehicle_state = self.vehicle.status
|
self._attr_is_on = self.entity_description.value_fn(self.vehicle)
|
||||||
|
|
||||||
self._attr_is_on = self.entity_description.value_fn(vehicle_state)
|
|
||||||
|
|
||||||
if self.entity_description.attr_fn:
|
if self.entity_description.attr_fn:
|
||||||
self._attr_extra_state_attributes = dict(
|
self._attr_extra_state_attributes = dict(
|
||||||
self._attrs,
|
self._attrs,
|
||||||
**self.entity_description.attr_fn(vehicle_state, self._unit_system),
|
**self.entity_description.attr_fn(self.vehicle, self._unit_system),
|
||||||
)
|
)
|
||||||
|
|
||||||
super()._handle_coordinator_update()
|
super()._handle_coordinator_update()
|
||||||
|
@ -1,19 +1,20 @@
|
|||||||
"""Support for BMW connected drive button entities."""
|
"""Support for MyBMW button entities."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from bimmer_connected.vehicle import ConnectedDriveVehicle
|
from bimmer_connected.vehicle import MyBMWVehicle
|
||||||
|
from bimmer_connected.vehicle.remote_services import RemoteServiceStatus
|
||||||
|
|
||||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from . import BMWConnectedDriveBaseEntity
|
from . import BMWBaseEntity
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -27,7 +28,9 @@ class BMWButtonEntityDescription(ButtonEntityDescription):
|
|||||||
"""Class describing BMW button entities."""
|
"""Class describing BMW button entities."""
|
||||||
|
|
||||||
enabled_when_read_only: bool = False
|
enabled_when_read_only: bool = False
|
||||||
remote_function: str | None = None
|
remote_function: Callable[
|
||||||
|
[MyBMWVehicle], Coroutine[Any, Any, RemoteServiceStatus]
|
||||||
|
] | None = None
|
||||||
account_function: Callable[[BMWDataUpdateCoordinator], Coroutine] | None = None
|
account_function: Callable[[BMWDataUpdateCoordinator], Coroutine] | None = None
|
||||||
|
|
||||||
|
|
||||||
@ -36,31 +39,31 @@ BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = (
|
|||||||
key="light_flash",
|
key="light_flash",
|
||||||
icon="mdi:car-light-alert",
|
icon="mdi:car-light-alert",
|
||||||
name="Flash Lights",
|
name="Flash Lights",
|
||||||
remote_function="trigger_remote_light_flash",
|
remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_light_flash(),
|
||||||
),
|
),
|
||||||
BMWButtonEntityDescription(
|
BMWButtonEntityDescription(
|
||||||
key="sound_horn",
|
key="sound_horn",
|
||||||
icon="mdi:bullhorn",
|
icon="mdi:bullhorn",
|
||||||
name="Sound Horn",
|
name="Sound Horn",
|
||||||
remote_function="trigger_remote_horn",
|
remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_horn(),
|
||||||
),
|
),
|
||||||
BMWButtonEntityDescription(
|
BMWButtonEntityDescription(
|
||||||
key="activate_air_conditioning",
|
key="activate_air_conditioning",
|
||||||
icon="mdi:hvac",
|
icon="mdi:hvac",
|
||||||
name="Activate Air Conditioning",
|
name="Activate Air Conditioning",
|
||||||
remote_function="trigger_remote_air_conditioning",
|
remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning(),
|
||||||
),
|
),
|
||||||
BMWButtonEntityDescription(
|
BMWButtonEntityDescription(
|
||||||
key="deactivate_air_conditioning",
|
key="deactivate_air_conditioning",
|
||||||
icon="mdi:hvac-off",
|
icon="mdi:hvac-off",
|
||||||
name="Deactivate Air Conditioning",
|
name="Deactivate Air Conditioning",
|
||||||
remote_function="trigger_remote_air_conditioning_stop",
|
remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning_stop(),
|
||||||
),
|
),
|
||||||
BMWButtonEntityDescription(
|
BMWButtonEntityDescription(
|
||||||
key="find_vehicle",
|
key="find_vehicle",
|
||||||
icon="mdi:crosshairs-question",
|
icon="mdi:crosshairs-question",
|
||||||
name="Find Vehicle",
|
name="Find Vehicle",
|
||||||
remote_function="trigger_remote_vehicle_finder",
|
remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_vehicle_finder(),
|
||||||
),
|
),
|
||||||
BMWButtonEntityDescription(
|
BMWButtonEntityDescription(
|
||||||
key="refresh",
|
key="refresh",
|
||||||
@ -77,7 +80,7 @@ async def async_setup_entry(
|
|||||||
config_entry: ConfigEntry,
|
config_entry: ConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the BMW ConnectedDrive buttons from config entry."""
|
"""Set up the BMW buttons from config entry."""
|
||||||
coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
|
|
||||||
entities: list[BMWButton] = []
|
entities: list[BMWButton] = []
|
||||||
@ -95,15 +98,15 @@ async def async_setup_entry(
|
|||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
class BMWButton(BMWConnectedDriveBaseEntity, ButtonEntity):
|
class BMWButton(BMWBaseEntity, ButtonEntity):
|
||||||
"""Representation of a BMW Connected Drive button."""
|
"""Representation of a MyBMW button."""
|
||||||
|
|
||||||
entity_description: BMWButtonEntityDescription
|
entity_description: BMWButtonEntityDescription
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: BMWDataUpdateCoordinator,
|
coordinator: BMWDataUpdateCoordinator,
|
||||||
vehicle: ConnectedDriveVehicle,
|
vehicle: MyBMWVehicle,
|
||||||
description: BMWButtonEntityDescription,
|
description: BMWButtonEntityDescription,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize BMW vehicle sensor."""
|
"""Initialize BMW vehicle sensor."""
|
||||||
@ -116,12 +119,7 @@ class BMWButton(BMWConnectedDriveBaseEntity, ButtonEntity):
|
|||||||
async def async_press(self) -> None:
|
async def async_press(self) -> None:
|
||||||
"""Press the button."""
|
"""Press the button."""
|
||||||
if self.entity_description.remote_function:
|
if self.entity_description.remote_function:
|
||||||
await self.hass.async_add_executor_job(
|
await self.entity_description.remote_function(self.vehicle)
|
||||||
getattr(
|
|
||||||
self.vehicle.remote_services,
|
|
||||||
self.entity_description.remote_function,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif self.entity_description.account_function:
|
elif self.entity_description.account_function:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"The 'Refresh from cloud' button is deprecated. Use the 'homeassistant.update_entity' "
|
"The 'Refresh from cloud' button is deprecated. Use the 'homeassistant.update_entity' "
|
||||||
|
@ -3,8 +3,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from bimmer_connected.account import ConnectedDriveAccount
|
from bimmer_connected.account import MyBMWAccount
|
||||||
from bimmer_connected.country_selector import get_region_from_name
|
from bimmer_connected.api.regions import get_region_from_name
|
||||||
|
from httpx import HTTPError
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries, core, exceptions
|
from homeassistant import config_entries, core, exceptions
|
||||||
@ -31,22 +32,23 @@ async def validate_input(
|
|||||||
|
|
||||||
Data has the keys from DATA_SCHEMA with values provided by the user.
|
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||||
"""
|
"""
|
||||||
try:
|
account = MyBMWAccount(
|
||||||
await hass.async_add_executor_job(
|
|
||||||
ConnectedDriveAccount,
|
|
||||||
data[CONF_USERNAME],
|
data[CONF_USERNAME],
|
||||||
data[CONF_PASSWORD],
|
data[CONF_PASSWORD],
|
||||||
get_region_from_name(data[CONF_REGION]),
|
get_region_from_name(data[CONF_REGION]),
|
||||||
)
|
)
|
||||||
except OSError as ex:
|
|
||||||
|
try:
|
||||||
|
await account.get_vehicles()
|
||||||
|
except HTTPError as ex:
|
||||||
raise CannotConnect from ex
|
raise CannotConnect from ex
|
||||||
|
|
||||||
# Return info that you want to store in the config entry.
|
# Return info that you want to store in the config entry.
|
||||||
return {"title": f"{data[CONF_USERNAME]}{data.get(CONF_SOURCE, '')}"}
|
return {"title": f"{data[CONF_USERNAME]}{data.get(CONF_SOURCE, '')}"}
|
||||||
|
|
||||||
|
|
||||||
class BMWConnectedDriveConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
class BMWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for BMW ConnectedDrive."""
|
"""Handle a config flow for MyBMW."""
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
@ -78,16 +80,16 @@ class BMWConnectedDriveConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(
|
def async_get_options_flow(
|
||||||
config_entry: config_entries.ConfigEntry,
|
config_entry: config_entries.ConfigEntry,
|
||||||
) -> BMWConnectedDriveOptionsFlow:
|
) -> BMWOptionsFlow:
|
||||||
"""Return a BWM ConnectedDrive option flow."""
|
"""Return a MyBMW option flow."""
|
||||||
return BMWConnectedDriveOptionsFlow(config_entry)
|
return BMWOptionsFlow(config_entry)
|
||||||
|
|
||||||
|
|
||||||
class BMWConnectedDriveOptionsFlow(config_entries.OptionsFlow):
|
class BMWOptionsFlow(config_entries.OptionsFlow):
|
||||||
"""Handle a option flow for BMW ConnectedDrive."""
|
"""Handle a option flow for MyBMW."""
|
||||||
|
|
||||||
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
||||||
"""Initialize BMW ConnectedDrive option flow."""
|
"""Initialize MyBMW option flow."""
|
||||||
self.config_entry = config_entry
|
self.config_entry = config_entry
|
||||||
self.options = dict(config_entry.options)
|
self.options = dict(config_entry.options)
|
||||||
|
|
||||||
@ -102,6 +104,16 @@ class BMWConnectedDriveOptionsFlow(config_entries.OptionsFlow):
|
|||||||
) -> FlowResult:
|
) -> FlowResult:
|
||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
|
# Manually update & reload the config entry after options change.
|
||||||
|
# Required as each successful login will store the latest refresh_token
|
||||||
|
# using async_update_entry, which would otherwise trigger a full reload
|
||||||
|
# if the options would be refreshed using a listener.
|
||||||
|
changed = self.hass.config_entries.async_update_entry(
|
||||||
|
self.config_entry,
|
||||||
|
options=user_input,
|
||||||
|
)
|
||||||
|
if changed:
|
||||||
|
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
|
||||||
return self.async_create_entry(title="", data=user_input)
|
return self.async_create_entry(title="", data=user_input)
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="account_options",
|
step_id="account_options",
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
"""Const file for the BMW Connected Drive integration."""
|
"""Const file for the MyBMW integration."""
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
LENGTH_KILOMETERS,
|
LENGTH_KILOMETERS,
|
||||||
LENGTH_MILES,
|
LENGTH_MILES,
|
||||||
@ -7,7 +7,7 @@ from homeassistant.const import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
DOMAIN = "bmw_connected_drive"
|
DOMAIN = "bmw_connected_drive"
|
||||||
ATTRIBUTION = "Data provided by BMW Connected Drive"
|
ATTRIBUTION = "Data provided by MyBMW"
|
||||||
|
|
||||||
ATTR_DIRECTION = "direction"
|
ATTR_DIRECTION = "direction"
|
||||||
ATTR_VIN = "vin"
|
ATTR_VIN = "vin"
|
||||||
@ -15,6 +15,7 @@ ATTR_VIN = "vin"
|
|||||||
CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"]
|
CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"]
|
||||||
CONF_READ_ONLY = "read_only"
|
CONF_READ_ONLY = "read_only"
|
||||||
CONF_ACCOUNT = "account"
|
CONF_ACCOUNT = "account"
|
||||||
|
CONF_REFRESH_TOKEN = "refresh_token"
|
||||||
|
|
||||||
DATA_HASS_CONFIG = "hass_config"
|
DATA_HASS_CONFIG = "hass_config"
|
||||||
|
|
||||||
|
@ -4,14 +4,17 @@ from __future__ import annotations
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import async_timeout
|
from bimmer_connected.account import MyBMWAccount
|
||||||
from bimmer_connected.account import ConnectedDriveAccount
|
from bimmer_connected.api.regions import get_region_from_name
|
||||||
from bimmer_connected.country_selector import get_region_from_name
|
from bimmer_connected.vehicle.models import GPSPosition
|
||||||
|
from httpx import HTTPError, TimeoutException
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=300)
|
SCAN_INTERVAL = timedelta(seconds=300)
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@ -20,53 +23,56 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
class BMWDataUpdateCoordinator(DataUpdateCoordinator):
|
class BMWDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
"""Class to manage fetching BMW data."""
|
"""Class to manage fetching BMW data."""
|
||||||
|
|
||||||
account: ConnectedDriveAccount
|
account: MyBMWAccount
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, hass: HomeAssistant, *, entry: ConfigEntry) -> None:
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
*,
|
|
||||||
username: str,
|
|
||||||
password: str,
|
|
||||||
region: str,
|
|
||||||
read_only: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize account-wide BMW data updater."""
|
"""Initialize account-wide BMW data updater."""
|
||||||
# Storing username & password in coordinator is needed until a new library version
|
self.account = MyBMWAccount(
|
||||||
# that does not do blocking IO on init.
|
entry.data[CONF_USERNAME],
|
||||||
self._username = username
|
entry.data[CONF_PASSWORD],
|
||||||
self._password = password
|
get_region_from_name(entry.data[CONF_REGION]),
|
||||||
self._region = get_region_from_name(region)
|
observer_position=GPSPosition(hass.config.latitude, hass.config.longitude),
|
||||||
|
)
|
||||||
|
self.read_only = entry.options[CONF_READ_ONLY]
|
||||||
|
self._entry = entry
|
||||||
|
|
||||||
self.account = None
|
if CONF_REFRESH_TOKEN in entry.data:
|
||||||
self.read_only = read_only
|
self.account.set_refresh_token(entry.data[CONF_REFRESH_TOKEN])
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
name=f"{DOMAIN}-{username}",
|
name=f"{DOMAIN}-{entry.data['username']}",
|
||||||
update_interval=SCAN_INTERVAL,
|
update_interval=SCAN_INTERVAL,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _async_update_data(self) -> None:
|
async def _async_update_data(self) -> None:
|
||||||
"""Fetch data from BMW."""
|
"""Fetch data from BMW."""
|
||||||
|
old_refresh_token = self.account.refresh_token
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_timeout.timeout(15):
|
await self.account.get_vehicles()
|
||||||
if isinstance(self.account, ConnectedDriveAccount):
|
except (HTTPError, TimeoutException) as err:
|
||||||
# pylint: disable=protected-access
|
self._update_config_entry_refresh_token(None)
|
||||||
await self.hass.async_add_executor_job(self.account._get_vehicles)
|
raise UpdateFailed(f"Error communicating with BMW API: {err}") from err
|
||||||
else:
|
|
||||||
self.account = await self.hass.async_add_executor_job(
|
if self.account.refresh_token != old_refresh_token:
|
||||||
ConnectedDriveAccount,
|
self._update_config_entry_refresh_token(self.account.refresh_token)
|
||||||
self._username,
|
_LOGGER.debug(
|
||||||
self._password,
|
"bimmer_connected: refresh token %s > %s",
|
||||||
self._region,
|
old_refresh_token,
|
||||||
|
self.account.refresh_token,
|
||||||
)
|
)
|
||||||
self.account.set_observer_position(
|
|
||||||
self.hass.config.latitude, self.hass.config.longitude
|
def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None:
|
||||||
)
|
"""Update or delete the refresh_token in the Config Entry."""
|
||||||
except OSError as err:
|
data = {
|
||||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
**self._entry.data,
|
||||||
|
CONF_REFRESH_TOKEN: refresh_token,
|
||||||
|
}
|
||||||
|
if not refresh_token:
|
||||||
|
data.pop(CONF_REFRESH_TOKEN)
|
||||||
|
self.hass.config_entries.async_update_entry(self._entry, data=data)
|
||||||
|
|
||||||
def notify_listeners(self) -> None:
|
def notify_listeners(self) -> None:
|
||||||
"""Notify all listeners to refresh HA state machine."""
|
"""Notify all listeners to refresh HA state machine."""
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
"""Device tracker for BMW Connected Drive vehicles."""
|
"""Device tracker for MyBMW vehicles."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from bimmer_connected.vehicle import ConnectedDriveVehicle
|
from bimmer_connected.vehicle import MyBMWVehicle
|
||||||
|
|
||||||
from homeassistant.components.device_tracker import SOURCE_TYPE_GPS
|
from homeassistant.components.device_tracker import SOURCE_TYPE_GPS
|
||||||
from homeassistant.components.device_tracker.config_entry import TrackerEntity
|
from homeassistant.components.device_tracker.config_entry import TrackerEntity
|
||||||
@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from . import BMWConnectedDriveBaseEntity
|
from . import BMWBaseEntity
|
||||||
from .const import ATTR_DIRECTION, DOMAIN
|
from .const import ATTR_DIRECTION, DOMAIN
|
||||||
from .coordinator import BMWDataUpdateCoordinator
|
from .coordinator import BMWDataUpdateCoordinator
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ async def async_setup_entry(
|
|||||||
config_entry: ConfigEntry,
|
config_entry: ConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the BMW ConnectedDrive tracker from config entry."""
|
"""Set up the MyBMW tracker from config entry."""
|
||||||
coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
entities: list[BMWDeviceTracker] = []
|
entities: list[BMWDeviceTracker] = []
|
||||||
|
|
||||||
@ -39,8 +39,8 @@ async def async_setup_entry(
|
|||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity):
|
class BMWDeviceTracker(BMWBaseEntity, TrackerEntity):
|
||||||
"""BMW Connected Drive device tracker."""
|
"""MyBMW device tracker."""
|
||||||
|
|
||||||
_attr_force_update = False
|
_attr_force_update = False
|
||||||
_attr_icon = "mdi:car"
|
_attr_icon = "mdi:car"
|
||||||
@ -48,7 +48,7 @@ class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: BMWDataUpdateCoordinator,
|
coordinator: BMWDataUpdateCoordinator,
|
||||||
vehicle: ConnectedDriveVehicle,
|
vehicle: MyBMWVehicle,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the Tracker."""
|
"""Initialize the Tracker."""
|
||||||
super().__init__(coordinator, vehicle)
|
super().__init__(coordinator, vehicle)
|
||||||
@ -59,13 +59,13 @@ class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity):
|
|||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict:
|
def extra_state_attributes(self) -> dict:
|
||||||
"""Return entity specific state attributes."""
|
"""Return entity specific state attributes."""
|
||||||
return dict(self._attrs, **{ATTR_DIRECTION: self.vehicle.status.gps_heading})
|
return {**self._attrs, ATTR_DIRECTION: self.vehicle.vehicle_location.heading}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def latitude(self) -> float | None:
|
def latitude(self) -> float | None:
|
||||||
"""Return latitude value of the device."""
|
"""Return latitude value of the device."""
|
||||||
return (
|
return (
|
||||||
self.vehicle.status.gps_position[0]
|
self.vehicle.vehicle_location.location[0]
|
||||||
if self.vehicle.is_vehicle_tracking_enabled
|
if self.vehicle.is_vehicle_tracking_enabled
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@ -74,7 +74,7 @@ class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity):
|
|||||||
def longitude(self) -> float | None:
|
def longitude(self) -> float | None:
|
||||||
"""Return longitude value of the device."""
|
"""Return longitude value of the device."""
|
||||||
return (
|
return (
|
||||||
self.vehicle.status.gps_position[1]
|
self.vehicle.vehicle_location.location[1]
|
||||||
if self.vehicle.is_vehicle_tracking_enabled
|
if self.vehicle.is_vehicle_tracking_enabled
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
@ -4,15 +4,15 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from bimmer_connected.vehicle import ConnectedDriveVehicle
|
from bimmer_connected.vehicle import MyBMWVehicle
|
||||||
from bimmer_connected.vehicle_status import LockState
|
from bimmer_connected.vehicle.doors_windows import LockState
|
||||||
|
|
||||||
from homeassistant.components.lock import LockEntity
|
from homeassistant.components.lock import LockEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from . import BMWConnectedDriveBaseEntity
|
from . import BMWBaseEntity
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import BMWDataUpdateCoordinator
|
from .coordinator import BMWDataUpdateCoordinator
|
||||||
|
|
||||||
@ -25,7 +25,7 @@ async def async_setup_entry(
|
|||||||
config_entry: ConfigEntry,
|
config_entry: ConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the BMW ConnectedDrive binary sensors from config entry."""
|
"""Set up the MyBMW lock from config entry."""
|
||||||
coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
|
|
||||||
entities: list[BMWLock] = []
|
entities: list[BMWLock] = []
|
||||||
@ -36,13 +36,13 @@ async def async_setup_entry(
|
|||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
class BMWLock(BMWConnectedDriveBaseEntity, LockEntity):
|
class BMWLock(BMWBaseEntity, LockEntity):
|
||||||
"""Representation of a BMW vehicle lock."""
|
"""Representation of a MyBMW vehicle lock."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: BMWDataUpdateCoordinator,
|
coordinator: BMWDataUpdateCoordinator,
|
||||||
vehicle: ConnectedDriveVehicle,
|
vehicle: MyBMWVehicle,
|
||||||
attribute: str,
|
attribute: str,
|
||||||
sensor_name: str,
|
sensor_name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -55,7 +55,7 @@ class BMWLock(BMWConnectedDriveBaseEntity, LockEntity):
|
|||||||
self._sensor_name = sensor_name
|
self._sensor_name = sensor_name
|
||||||
self.door_lock_state_available = DOOR_LOCK_STATE in vehicle.available_attributes
|
self.door_lock_state_available = DOOR_LOCK_STATE in vehicle.available_attributes
|
||||||
|
|
||||||
def lock(self, **kwargs: Any) -> None:
|
async def async_lock(self, **kwargs: Any) -> None:
|
||||||
"""Lock the car."""
|
"""Lock the car."""
|
||||||
_LOGGER.debug("%s: locking doors", self.vehicle.name)
|
_LOGGER.debug("%s: locking doors", self.vehicle.name)
|
||||||
# Only update the HA state machine if the vehicle reliably reports its lock state
|
# Only update the HA state machine if the vehicle reliably reports its lock state
|
||||||
@ -63,10 +63,10 @@ class BMWLock(BMWConnectedDriveBaseEntity, LockEntity):
|
|||||||
# Optimistic state set here because it takes some time before the
|
# Optimistic state set here because it takes some time before the
|
||||||
# update callback response
|
# update callback response
|
||||||
self._attr_is_locked = True
|
self._attr_is_locked = True
|
||||||
self.schedule_update_ha_state()
|
self.async_write_ha_state()
|
||||||
self.vehicle.remote_services.trigger_remote_door_lock()
|
await self.vehicle.remote_services.trigger_remote_door_lock()
|
||||||
|
|
||||||
def unlock(self, **kwargs: Any) -> None:
|
async def async_unlock(self, **kwargs: Any) -> None:
|
||||||
"""Unlock the car."""
|
"""Unlock the car."""
|
||||||
_LOGGER.debug("%s: unlocking doors", self.vehicle.name)
|
_LOGGER.debug("%s: unlocking doors", self.vehicle.name)
|
||||||
# Only update the HA state machine if the vehicle reliably reports its lock state
|
# Only update the HA state machine if the vehicle reliably reports its lock state
|
||||||
@ -74,8 +74,8 @@ class BMWLock(BMWConnectedDriveBaseEntity, LockEntity):
|
|||||||
# Optimistic state set here because it takes some time before the
|
# Optimistic state set here because it takes some time before the
|
||||||
# update callback response
|
# update callback response
|
||||||
self._attr_is_locked = False
|
self._attr_is_locked = False
|
||||||
self.schedule_update_ha_state()
|
self.async_write_ha_state()
|
||||||
self.vehicle.remote_services.trigger_remote_door_unlock()
|
await self.vehicle.remote_services.trigger_remote_door_unlock()
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _handle_coordinator_update(self) -> None:
|
def _handle_coordinator_update(self) -> None:
|
||||||
@ -83,16 +83,14 @@ class BMWLock(BMWConnectedDriveBaseEntity, LockEntity):
|
|||||||
_LOGGER.debug("Updating lock data of %s", self.vehicle.name)
|
_LOGGER.debug("Updating lock data of %s", self.vehicle.name)
|
||||||
# Only update the HA state machine if the vehicle reliably reports its lock state
|
# Only update the HA state machine if the vehicle reliably reports its lock state
|
||||||
if self.door_lock_state_available:
|
if self.door_lock_state_available:
|
||||||
vehicle_state = self.vehicle.status
|
self._attr_is_locked = self.vehicle.doors_and_windows.door_lock_state in {
|
||||||
self._attr_is_locked = vehicle_state.door_lock_state in {
|
|
||||||
LockState.LOCKED,
|
LockState.LOCKED,
|
||||||
LockState.SECURED,
|
LockState.SECURED,
|
||||||
}
|
}
|
||||||
self._attr_extra_state_attributes = dict(
|
self._attr_extra_state_attributes = dict(
|
||||||
self._attrs,
|
self._attrs,
|
||||||
**{
|
**{
|
||||||
"door_lock_state": vehicle_state.door_lock_state.value,
|
"door_lock_state": self.vehicle.doors_and_windows.door_lock_state.value,
|
||||||
"last_update_reason": vehicle_state.last_update_reason,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"domain": "bmw_connected_drive",
|
"domain": "bmw_connected_drive",
|
||||||
"name": "BMW Connected Drive",
|
"name": "BMW Connected Drive",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
||||||
"requirements": ["bimmer_connected==0.8.12"],
|
"requirements": ["bimmer_connected==0.9.0"],
|
||||||
"codeowners": ["@gerard33", "@rikroe"],
|
"codeowners": ["@gerard33", "@rikroe"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from bimmer_connected.vehicle import ConnectedDriveVehicle
|
from bimmer_connected.vehicle import MyBMWVehicle
|
||||||
|
|
||||||
from homeassistant.components.notify import (
|
from homeassistant.components.notify import (
|
||||||
ATTR_DATA,
|
ATTR_DATA,
|
||||||
@ -52,14 +52,14 @@ def get_service(
|
|||||||
class BMWNotificationService(BaseNotificationService):
|
class BMWNotificationService(BaseNotificationService):
|
||||||
"""Send Notifications to BMW."""
|
"""Send Notifications to BMW."""
|
||||||
|
|
||||||
def __init__(self, targets: dict[str, ConnectedDriveVehicle]) -> None:
|
def __init__(self, targets: dict[str, MyBMWVehicle]) -> None:
|
||||||
"""Set up the notification service."""
|
"""Set up the notification service."""
|
||||||
self.targets: dict[str, ConnectedDriveVehicle] = targets
|
self.targets: dict[str, MyBMWVehicle] = targets
|
||||||
|
|
||||||
def send_message(self, message: str = "", **kwargs: Any) -> None:
|
def send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||||
"""Send a message or POI to the car."""
|
"""Send a message or POI to the car."""
|
||||||
for vehicle in kwargs[ATTR_TARGET]:
|
for vehicle in kwargs[ATTR_TARGET]:
|
||||||
vehicle = cast(ConnectedDriveVehicle, vehicle)
|
vehicle = cast(MyBMWVehicle, vehicle)
|
||||||
_LOGGER.debug("Sending message to %s", vehicle.name)
|
_LOGGER.debug("Sending message to %s", vehicle.name)
|
||||||
|
|
||||||
# Extract params from data dict
|
# Extract params from data dict
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
"""Support for reading vehicle status from BMW connected drive portal."""
|
"""Support for reading vehicle status from MyBMW portal."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@ -6,7 +6,8 @@ from dataclasses import dataclass
|
|||||||
import logging
|
import logging
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from bimmer_connected.vehicle import ConnectedDriveVehicle
|
from bimmer_connected.vehicle import MyBMWVehicle
|
||||||
|
from bimmer_connected.vehicle.models import ValueWithUnit
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
@ -27,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
from homeassistant.helpers.typing import StateType
|
from homeassistant.helpers.typing import StateType
|
||||||
from homeassistant.util.unit_system import UnitSystem
|
from homeassistant.util.unit_system import UnitSystem
|
||||||
|
|
||||||
from . import BMWConnectedDriveBaseEntity
|
from . import BMWBaseEntity
|
||||||
from .const import DOMAIN, UNIT_MAP
|
from .const import DOMAIN, UNIT_MAP
|
||||||
from .coordinator import BMWDataUpdateCoordinator
|
from .coordinator import BMWDataUpdateCoordinator
|
||||||
|
|
||||||
@ -38,44 +39,54 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
class BMWSensorEntityDescription(SensorEntityDescription):
|
class BMWSensorEntityDescription(SensorEntityDescription):
|
||||||
"""Describes BMW sensor entity."""
|
"""Describes BMW sensor entity."""
|
||||||
|
|
||||||
|
key_class: str | None = None
|
||||||
unit_metric: str | None = None
|
unit_metric: str | None = None
|
||||||
unit_imperial: str | None = None
|
unit_imperial: str | None = None
|
||||||
value: Callable = lambda x, y: x
|
value: Callable = lambda x, y: x
|
||||||
|
|
||||||
|
|
||||||
def convert_and_round(
|
def convert_and_round(
|
||||||
state: tuple,
|
state: ValueWithUnit,
|
||||||
converter: Callable[[float | None, str], float],
|
converter: Callable[[float | None, str], float],
|
||||||
precision: int,
|
precision: int,
|
||||||
) -> float | None:
|
) -> float | None:
|
||||||
"""Safely convert and round a value from a Tuple[value, unit]."""
|
"""Safely convert and round a value from ValueWithUnit."""
|
||||||
if state[0] is None:
|
if state.value and state.unit:
|
||||||
|
return round(
|
||||||
|
converter(state.value, UNIT_MAP.get(state.unit, state.unit)), precision
|
||||||
|
)
|
||||||
|
if state.value:
|
||||||
|
return state.value
|
||||||
return None
|
return None
|
||||||
return round(converter(state[0], UNIT_MAP.get(state[1], state[1])), precision)
|
|
||||||
|
|
||||||
|
|
||||||
SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = {
|
SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = {
|
||||||
# --- Generic ---
|
# --- Generic ---
|
||||||
"charging_start_time": BMWSensorEntityDescription(
|
"charging_start_time": BMWSensorEntityDescription(
|
||||||
key="charging_start_time",
|
key="charging_start_time",
|
||||||
|
key_class="fuel_and_battery",
|
||||||
device_class=SensorDeviceClass.TIMESTAMP,
|
device_class=SensorDeviceClass.TIMESTAMP,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
),
|
),
|
||||||
"charging_end_time": BMWSensorEntityDescription(
|
"charging_end_time": BMWSensorEntityDescription(
|
||||||
key="charging_end_time",
|
key="charging_end_time",
|
||||||
|
key_class="fuel_and_battery",
|
||||||
device_class=SensorDeviceClass.TIMESTAMP,
|
device_class=SensorDeviceClass.TIMESTAMP,
|
||||||
),
|
),
|
||||||
"charging_time_label": BMWSensorEntityDescription(
|
"charging_time_label": BMWSensorEntityDescription(
|
||||||
key="charging_time_label",
|
key="charging_time_label",
|
||||||
|
key_class="fuel_and_battery",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
),
|
),
|
||||||
"charging_status": BMWSensorEntityDescription(
|
"charging_status": BMWSensorEntityDescription(
|
||||||
key="charging_status",
|
key="charging_status",
|
||||||
|
key_class="fuel_and_battery",
|
||||||
icon="mdi:ev-station",
|
icon="mdi:ev-station",
|
||||||
value=lambda x, y: x.value,
|
value=lambda x, y: x.value,
|
||||||
),
|
),
|
||||||
"charging_level_hv": BMWSensorEntityDescription(
|
"remaining_battery_percent": BMWSensorEntityDescription(
|
||||||
key="charging_level_hv",
|
key="remaining_battery_percent",
|
||||||
|
key_class="fuel_and_battery",
|
||||||
unit_metric=PERCENTAGE,
|
unit_metric=PERCENTAGE,
|
||||||
unit_imperial=PERCENTAGE,
|
unit_imperial=PERCENTAGE,
|
||||||
device_class=SensorDeviceClass.BATTERY,
|
device_class=SensorDeviceClass.BATTERY,
|
||||||
@ -90,6 +101,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = {
|
|||||||
),
|
),
|
||||||
"remaining_range_total": BMWSensorEntityDescription(
|
"remaining_range_total": BMWSensorEntityDescription(
|
||||||
key="remaining_range_total",
|
key="remaining_range_total",
|
||||||
|
key_class="fuel_and_battery",
|
||||||
icon="mdi:map-marker-distance",
|
icon="mdi:map-marker-distance",
|
||||||
unit_metric=LENGTH_KILOMETERS,
|
unit_metric=LENGTH_KILOMETERS,
|
||||||
unit_imperial=LENGTH_MILES,
|
unit_imperial=LENGTH_MILES,
|
||||||
@ -97,6 +109,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = {
|
|||||||
),
|
),
|
||||||
"remaining_range_electric": BMWSensorEntityDescription(
|
"remaining_range_electric": BMWSensorEntityDescription(
|
||||||
key="remaining_range_electric",
|
key="remaining_range_electric",
|
||||||
|
key_class="fuel_and_battery",
|
||||||
icon="mdi:map-marker-distance",
|
icon="mdi:map-marker-distance",
|
||||||
unit_metric=LENGTH_KILOMETERS,
|
unit_metric=LENGTH_KILOMETERS,
|
||||||
unit_imperial=LENGTH_MILES,
|
unit_imperial=LENGTH_MILES,
|
||||||
@ -104,6 +117,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = {
|
|||||||
),
|
),
|
||||||
"remaining_range_fuel": BMWSensorEntityDescription(
|
"remaining_range_fuel": BMWSensorEntityDescription(
|
||||||
key="remaining_range_fuel",
|
key="remaining_range_fuel",
|
||||||
|
key_class="fuel_and_battery",
|
||||||
icon="mdi:map-marker-distance",
|
icon="mdi:map-marker-distance",
|
||||||
unit_metric=LENGTH_KILOMETERS,
|
unit_metric=LENGTH_KILOMETERS,
|
||||||
unit_imperial=LENGTH_MILES,
|
unit_imperial=LENGTH_MILES,
|
||||||
@ -111,13 +125,15 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = {
|
|||||||
),
|
),
|
||||||
"remaining_fuel": BMWSensorEntityDescription(
|
"remaining_fuel": BMWSensorEntityDescription(
|
||||||
key="remaining_fuel",
|
key="remaining_fuel",
|
||||||
|
key_class="fuel_and_battery",
|
||||||
icon="mdi:gas-station",
|
icon="mdi:gas-station",
|
||||||
unit_metric=VOLUME_LITERS,
|
unit_metric=VOLUME_LITERS,
|
||||||
unit_imperial=VOLUME_GALLONS,
|
unit_imperial=VOLUME_GALLONS,
|
||||||
value=lambda x, hass: convert_and_round(x, hass.config.units.volume, 2),
|
value=lambda x, hass: convert_and_round(x, hass.config.units.volume, 2),
|
||||||
),
|
),
|
||||||
"fuel_percent": BMWSensorEntityDescription(
|
"remaining_fuel_percent": BMWSensorEntityDescription(
|
||||||
key="fuel_percent",
|
key="remaining_fuel_percent",
|
||||||
|
key_class="fuel_and_battery",
|
||||||
icon="mdi:gas-station",
|
icon="mdi:gas-station",
|
||||||
unit_metric=PERCENTAGE,
|
unit_metric=PERCENTAGE,
|
||||||
unit_imperial=PERCENTAGE,
|
unit_imperial=PERCENTAGE,
|
||||||
@ -130,16 +146,16 @@ async def async_setup_entry(
|
|||||||
config_entry: ConfigEntry,
|
config_entry: ConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the BMW ConnectedDrive sensors from config entry."""
|
"""Set up the MyBMW sensors from config entry."""
|
||||||
unit_system = hass.config.units
|
unit_system = hass.config.units
|
||||||
coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
|
|
||||||
entities: list[BMWConnectedDriveSensor] = []
|
entities: list[BMWSensor] = []
|
||||||
|
|
||||||
for vehicle in coordinator.account.vehicles:
|
for vehicle in coordinator.account.vehicles:
|
||||||
entities.extend(
|
entities.extend(
|
||||||
[
|
[
|
||||||
BMWConnectedDriveSensor(coordinator, vehicle, description, unit_system)
|
BMWSensor(coordinator, vehicle, description, unit_system)
|
||||||
for attribute_name in vehicle.available_attributes
|
for attribute_name in vehicle.available_attributes
|
||||||
if (description := SENSOR_TYPES.get(attribute_name))
|
if (description := SENSOR_TYPES.get(attribute_name))
|
||||||
]
|
]
|
||||||
@ -148,7 +164,7 @@ async def async_setup_entry(
|
|||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity):
|
class BMWSensor(BMWBaseEntity, SensorEntity):
|
||||||
"""Representation of a BMW vehicle sensor."""
|
"""Representation of a BMW vehicle sensor."""
|
||||||
|
|
||||||
entity_description: BMWSensorEntityDescription
|
entity_description: BMWSensorEntityDescription
|
||||||
@ -156,7 +172,7 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: BMWDataUpdateCoordinator,
|
coordinator: BMWDataUpdateCoordinator,
|
||||||
vehicle: ConnectedDriveVehicle,
|
vehicle: MyBMWVehicle,
|
||||||
description: BMWSensorEntityDescription,
|
description: BMWSensorEntityDescription,
|
||||||
unit_system: UnitSystem,
|
unit_system: UnitSystem,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -178,7 +194,13 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity):
|
|||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Updating sensor '%s' of %s", self.entity_description.key, self.vehicle.name
|
"Updating sensor '%s' of %s", self.entity_description.key, self.vehicle.name
|
||||||
)
|
)
|
||||||
state = getattr(self.vehicle.status, self.entity_description.key)
|
if self.entity_description.key_class is None:
|
||||||
|
state = getattr(self.vehicle, self.entity_description.key)
|
||||||
|
else:
|
||||||
|
state = getattr(
|
||||||
|
getattr(self.vehicle, self.entity_description.key_class),
|
||||||
|
self.entity_description.key,
|
||||||
|
)
|
||||||
self._attr_native_value = cast(
|
self._attr_native_value = cast(
|
||||||
StateType, self.entity_description.value(state, self.hass)
|
StateType, self.entity_description.value(state, self.hass)
|
||||||
)
|
)
|
||||||
|
@ -397,7 +397,7 @@ beautifulsoup4==4.11.1
|
|||||||
bellows==0.30.0
|
bellows==0.30.0
|
||||||
|
|
||||||
# homeassistant.components.bmw_connected_drive
|
# homeassistant.components.bmw_connected_drive
|
||||||
bimmer_connected==0.8.12
|
bimmer_connected==0.9.0
|
||||||
|
|
||||||
# homeassistant.components.bizkaibus
|
# homeassistant.components.bizkaibus
|
||||||
bizkaibus==0.1.1
|
bizkaibus==0.1.1
|
||||||
|
@ -312,7 +312,7 @@ beautifulsoup4==4.11.1
|
|||||||
bellows==0.30.0
|
bellows==0.30.0
|
||||||
|
|
||||||
# homeassistant.components.bmw_connected_drive
|
# homeassistant.components.bmw_connected_drive
|
||||||
bimmer_connected==0.8.12
|
bimmer_connected==0.9.0
|
||||||
|
|
||||||
# homeassistant.components.blebox
|
# homeassistant.components.blebox
|
||||||
blebox_uniapi==1.3.3
|
blebox_uniapi==1.3.3
|
||||||
|
@ -1 +1,28 @@
|
|||||||
"""Tests for the for the BMW Connected Drive integration."""
|
"""Tests for the for the BMW Connected Drive integration."""
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components.bmw_connected_drive.const import (
|
||||||
|
CONF_READ_ONLY,
|
||||||
|
DOMAIN as BMW_DOMAIN,
|
||||||
|
)
|
||||||
|
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
|
||||||
|
|
||||||
|
FIXTURE_USER_INPUT = {
|
||||||
|
CONF_USERNAME: "user@domain.com",
|
||||||
|
CONF_PASSWORD: "p4ssw0rd",
|
||||||
|
CONF_REGION: "rest_of_world",
|
||||||
|
}
|
||||||
|
|
||||||
|
FIXTURE_CONFIG_ENTRY = {
|
||||||
|
"entry_id": "1",
|
||||||
|
"domain": BMW_DOMAIN,
|
||||||
|
"title": FIXTURE_USER_INPUT[CONF_USERNAME],
|
||||||
|
"data": {
|
||||||
|
CONF_USERNAME: FIXTURE_USER_INPUT[CONF_USERNAME],
|
||||||
|
CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD],
|
||||||
|
CONF_REGION: FIXTURE_USER_INPUT[CONF_REGION],
|
||||||
|
},
|
||||||
|
"options": {CONF_READ_ONLY: False},
|
||||||
|
"source": config_entries.SOURCE_USER,
|
||||||
|
"unique_id": f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_REGION]}",
|
||||||
|
}
|
||||||
|
@ -1,35 +1,20 @@
|
|||||||
"""Test the for the BMW Connected Drive config flow."""
|
"""Test the for the BMW Connected Drive config flow."""
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from httpx import HTTPError
|
||||||
|
|
||||||
from homeassistant import config_entries, data_entry_flow
|
from homeassistant import config_entries, data_entry_flow
|
||||||
from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN
|
from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN
|
||||||
from homeassistant.components.bmw_connected_drive.const import CONF_READ_ONLY
|
from homeassistant.components.bmw_connected_drive.const import CONF_READ_ONLY
|
||||||
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
|
from homeassistant.const import CONF_USERNAME
|
||||||
|
|
||||||
|
from . import FIXTURE_CONFIG_ENTRY, FIXTURE_USER_INPUT
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
FIXTURE_USER_INPUT = {
|
|
||||||
CONF_USERNAME: "user@domain.com",
|
|
||||||
CONF_PASSWORD: "p4ssw0rd",
|
|
||||||
CONF_REGION: "rest_of_world",
|
|
||||||
}
|
|
||||||
FIXTURE_COMPLETE_ENTRY = FIXTURE_USER_INPUT.copy()
|
FIXTURE_COMPLETE_ENTRY = FIXTURE_USER_INPUT.copy()
|
||||||
FIXTURE_IMPORT_ENTRY = FIXTURE_USER_INPUT.copy()
|
FIXTURE_IMPORT_ENTRY = FIXTURE_USER_INPUT.copy()
|
||||||
|
|
||||||
FIXTURE_CONFIG_ENTRY = {
|
|
||||||
"entry_id": "1",
|
|
||||||
"domain": DOMAIN,
|
|
||||||
"title": FIXTURE_USER_INPUT[CONF_USERNAME],
|
|
||||||
"data": {
|
|
||||||
CONF_USERNAME: FIXTURE_USER_INPUT[CONF_USERNAME],
|
|
||||||
CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD],
|
|
||||||
CONF_REGION: FIXTURE_USER_INPUT[CONF_REGION],
|
|
||||||
},
|
|
||||||
"options": {CONF_READ_ONLY: False},
|
|
||||||
"source": config_entries.SOURCE_USER,
|
|
||||||
"unique_id": f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_REGION]}",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_show_form(hass):
|
async def test_show_form(hass):
|
||||||
"""Test that the form is served with no input."""
|
"""Test that the form is served with no input."""
|
||||||
@ -48,8 +33,8 @@ async def test_connection_error(hass):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"bimmer_connected.account.ConnectedDriveAccount._get_oauth_token",
|
"bimmer_connected.api.authentication.MyBMWAuthentication.login",
|
||||||
side_effect=OSError,
|
side_effect=HTTPError("login failure"),
|
||||||
):
|
):
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@ -65,7 +50,7 @@ async def test_connection_error(hass):
|
|||||||
async def test_full_user_flow_implementation(hass):
|
async def test_full_user_flow_implementation(hass):
|
||||||
"""Test registering an integration and finishing flow works."""
|
"""Test registering an integration and finishing flow works."""
|
||||||
with patch(
|
with patch(
|
||||||
"bimmer_connected.account.ConnectedDriveAccount._get_vehicles",
|
"bimmer_connected.account.MyBMWAccount.get_vehicles",
|
||||||
return_value=[],
|
return_value=[],
|
||||||
), patch(
|
), patch(
|
||||||
"homeassistant.components.bmw_connected_drive.async_setup_entry",
|
"homeassistant.components.bmw_connected_drive.async_setup_entry",
|
||||||
@ -86,7 +71,7 @@ async def test_full_user_flow_implementation(hass):
|
|||||||
async def test_options_flow_implementation(hass):
|
async def test_options_flow_implementation(hass):
|
||||||
"""Test config flow options."""
|
"""Test config flow options."""
|
||||||
with patch(
|
with patch(
|
||||||
"bimmer_connected.account.ConnectedDriveAccount._get_vehicles",
|
"bimmer_connected.account.MyBMWAccount.get_vehicles",
|
||||||
return_value=[],
|
return_value=[],
|
||||||
), patch(
|
), patch(
|
||||||
"homeassistant.components.bmw_connected_drive.async_setup_entry",
|
"homeassistant.components.bmw_connected_drive.async_setup_entry",
|
||||||
@ -104,13 +89,13 @@ async def test_options_flow_implementation(hass):
|
|||||||
|
|
||||||
result = await hass.config_entries.options.async_configure(
|
result = await hass.config_entries.options.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
user_input={CONF_READ_ONLY: False},
|
user_input={CONF_READ_ONLY: True},
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
assert result["data"] == {
|
assert result["data"] == {
|
||||||
CONF_READ_ONLY: False,
|
CONF_READ_ONLY: True,
|
||||||
}
|
}
|
||||||
|
|
||||||
assert len(mock_setup_entry.mock_calls) == 1
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
136
tests/components/bmw_connected_drive/test_init.py
Normal file
136
tests/components/bmw_connected_drive/test_init.py
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
"""Test Axis component setup process."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.bmw_connected_drive.const import DOMAIN as BMW_DOMAIN
|
||||||
|
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
|
from . import FIXTURE_CONFIG_ENTRY
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
VIN = "WBYYYYYYYYYYYYYYY"
|
||||||
|
VEHICLE_NAME = "i3 (+ REX)"
|
||||||
|
VEHICLE_NAME_SLUG = "i3_rex"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"entitydata,old_unique_id,new_unique_id",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"domain": SENSOR_DOMAIN,
|
||||||
|
"platform": BMW_DOMAIN,
|
||||||
|
"unique_id": f"{VIN}-charging_level_hv",
|
||||||
|
"suggested_object_id": f"{VEHICLE_NAME} charging_level_hv",
|
||||||
|
"disabled_by": None,
|
||||||
|
},
|
||||||
|
f"{VIN}-charging_level_hv",
|
||||||
|
f"{VIN}-remaining_battery_percent",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"domain": SENSOR_DOMAIN,
|
||||||
|
"platform": BMW_DOMAIN,
|
||||||
|
"unique_id": f"{VIN}-remaining_range_total",
|
||||||
|
"suggested_object_id": f"{VEHICLE_NAME} remaining_range_total",
|
||||||
|
"disabled_by": None,
|
||||||
|
},
|
||||||
|
f"{VIN}-remaining_range_total",
|
||||||
|
f"{VIN}-remaining_range_total",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_migrate_unique_ids(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entitydata: dict,
|
||||||
|
old_unique_id: str,
|
||||||
|
new_unique_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Test successful migration of entity unique_ids."""
|
||||||
|
mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
entity_registry = er.async_get(hass)
|
||||||
|
entity: er.RegistryEntry = entity_registry.async_get_or_create(
|
||||||
|
**entitydata,
|
||||||
|
config_entry=mock_config_entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert entity.unique_id == old_unique_id
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"bimmer_connected.account.MyBMWAccount.get_vehicles",
|
||||||
|
return_value=[],
|
||||||
|
):
|
||||||
|
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
entity_migrated = entity_registry.async_get(entity.entity_id)
|
||||||
|
assert entity_migrated
|
||||||
|
assert entity_migrated.unique_id == new_unique_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"entitydata,old_unique_id,new_unique_id",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"domain": SENSOR_DOMAIN,
|
||||||
|
"platform": BMW_DOMAIN,
|
||||||
|
"unique_id": f"{VIN}-charging_level_hv",
|
||||||
|
"suggested_object_id": f"{VEHICLE_NAME} charging_level_hv",
|
||||||
|
"disabled_by": None,
|
||||||
|
},
|
||||||
|
f"{VIN}-charging_level_hv",
|
||||||
|
f"{VIN}-remaining_battery_percent",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_dont_migrate_unique_ids(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entitydata: dict,
|
||||||
|
old_unique_id: str,
|
||||||
|
new_unique_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Test successful migration of entity unique_ids."""
|
||||||
|
mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
entity_registry = er.async_get(hass)
|
||||||
|
|
||||||
|
# create existing entry with new_unique_id
|
||||||
|
existing_entity = entity_registry.async_get_or_create(
|
||||||
|
SENSOR_DOMAIN,
|
||||||
|
BMW_DOMAIN,
|
||||||
|
unique_id=f"{VIN}-remaining_battery_percent",
|
||||||
|
suggested_object_id=f"{VEHICLE_NAME} remaining_battery_percent",
|
||||||
|
config_entry=mock_config_entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
entity: er.RegistryEntry = entity_registry.async_get_or_create(
|
||||||
|
**entitydata,
|
||||||
|
config_entry=mock_config_entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert entity.unique_id == old_unique_id
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"bimmer_connected.account.MyBMWAccount.get_vehicles",
|
||||||
|
return_value=[],
|
||||||
|
):
|
||||||
|
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
entity_migrated = entity_registry.async_get(entity.entity_id)
|
||||||
|
assert entity_migrated
|
||||||
|
assert entity_migrated.unique_id == old_unique_id
|
||||||
|
|
||||||
|
entity_not_changed = entity_registry.async_get(existing_entity.entity_id)
|
||||||
|
assert entity_not_changed
|
||||||
|
assert entity_not_changed.unique_id == new_unique_id
|
||||||
|
|
||||||
|
assert entity_migrated != entity_not_changed
|
Loading…
x
Reference in New Issue
Block a user