mirror of
https://github.com/home-assistant/core.git
synced 2026-03-08 04:59:20 +00:00
Compare commits
2 Commits
knx-sensor
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
802aa991a9 | ||
|
|
f055c6c7fd |
@@ -123,7 +123,6 @@ homeassistant.components.blueprint.*
|
||||
homeassistant.components.bluesound.*
|
||||
homeassistant.components.bluetooth.*
|
||||
homeassistant.components.bluetooth_adapters.*
|
||||
homeassistant.components.bmw_connected_drive.*
|
||||
homeassistant.components.bond.*
|
||||
homeassistant.components.bosch_alarm.*
|
||||
homeassistant.components.braviatv.*
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -234,8 +234,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/bluetooth/ @bdraco
|
||||
/homeassistant/components/bluetooth_adapters/ @bdraco
|
||||
/tests/components/bluetooth_adapters/ @bdraco
|
||||
/homeassistant/components/bmw_connected_drive/ @gerard33 @rikroe
|
||||
/tests/components/bmw_connected_drive/ @gerard33 @rikroe
|
||||
/homeassistant/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto
|
||||
/tests/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto
|
||||
/homeassistant/components/bosch_alarm/ @mag1024 @sanjay900
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
"""Reads vehicle status from MyBMW portal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
discovery,
|
||||
entity_registry as er,
|
||||
)
|
||||
|
||||
from .const import ATTR_VIN, CONF_READ_ONLY, DOMAIN
|
||||
from .coordinator import BMWConfigEntry, BMWDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SERVICE_SCHEMA = vol.Schema(
|
||||
vol.Any(
|
||||
{vol.Required(ATTR_VIN): cv.string},
|
||||
{vol.Required(CONF_DEVICE_ID): cv.string},
|
||||
)
|
||||
)
|
||||
|
||||
DEFAULT_OPTIONS = {
|
||||
CONF_READ_ONLY: False,
|
||||
}
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.DEVICE_TRACKER,
|
||||
Platform.LOCK,
|
||||
Platform.NOTIFY,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
SERVICE_UPDATE_STATE = "update_state"
|
||||
|
||||
|
||||
@callback
|
||||
def _async_migrate_options_from_data_if_missing(
|
||||
hass: HomeAssistant, entry: BMWConfigEntry
|
||||
) -> None:
|
||||
data = dict(entry.data)
|
||||
options = dict(entry.options)
|
||||
|
||||
if CONF_READ_ONLY in data or list(options) != list(DEFAULT_OPTIONS):
|
||||
options = dict(
|
||||
DEFAULT_OPTIONS,
|
||||
**{k: v for k, v in options.items() if k in DEFAULT_OPTIONS},
|
||||
)
|
||||
options[CONF_READ_ONLY] = data.pop(CONF_READ_ONLY, False)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, data=data, options=options)
|
||||
|
||||
|
||||
async def _async_migrate_entries(
|
||||
hass: HomeAssistant, config_entry: BMWConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate old entry."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
@callback
|
||||
def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None:
|
||||
replacements = {
|
||||
Platform.SENSOR.value: {
|
||||
"charging_level_hv": "fuel_and_battery.remaining_battery_percent",
|
||||
"fuel_percent": "fuel_and_battery.remaining_fuel_percent",
|
||||
"ac_current_limit": "charging_profile.ac_current_limit",
|
||||
"charging_start_time": "fuel_and_battery.charging_start_time",
|
||||
"charging_end_time": "fuel_and_battery.charging_end_time",
|
||||
"charging_status": "fuel_and_battery.charging_status",
|
||||
"charging_target": "fuel_and_battery.charging_target",
|
||||
"remaining_battery_percent": "fuel_and_battery.remaining_battery_percent",
|
||||
"remaining_range_total": "fuel_and_battery.remaining_range_total",
|
||||
"remaining_range_electric": "fuel_and_battery.remaining_range_electric",
|
||||
"remaining_range_fuel": "fuel_and_battery.remaining_range_fuel",
|
||||
"remaining_fuel": "fuel_and_battery.remaining_fuel",
|
||||
"remaining_fuel_percent": "fuel_and_battery.remaining_fuel_percent",
|
||||
"activity": "climate.activity",
|
||||
}
|
||||
}
|
||||
if (key := entry.unique_id.split("-")[-1]) in replacements.get(
|
||||
entry.domain, []
|
||||
):
|
||||
new_unique_id = entry.unique_id.replace(
|
||||
key, replacements[entry.domain][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: BMWConfigEntry) -> bool:
|
||||
"""Set up BMW Connected Drive from a config 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
|
||||
coordinator = BMWDataUpdateCoordinator(
|
||||
hass,
|
||||
config_entry=entry,
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
# Set up all platforms except notify
|
||||
await hass.config_entries.async_forward_entry_setups(
|
||||
entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
|
||||
)
|
||||
|
||||
# set up notify platform, no entry support for notify platform yet,
|
||||
# have to use discovery to load platform.
|
||||
hass.async_create_task(
|
||||
discovery.async_load_platform(
|
||||
hass,
|
||||
Platform.NOTIFY,
|
||||
DOMAIN,
|
||||
{CONF_NAME: DOMAIN, CONF_ENTITY_ID: entry.entry_id},
|
||||
{},
|
||||
)
|
||||
)
|
||||
|
||||
# Clean up vehicles which are not assigned to the account anymore
|
||||
account_vehicles = {(DOMAIN, v.vin) for v in coordinator.account.vehicles}
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry_id=entry.entry_id
|
||||
)
|
||||
for device in device_entries:
|
||||
if not device.identifiers.intersection(account_vehicles):
|
||||
device_registry.async_update_device(
|
||||
device.id, remove_config_entry_id=entry.entry_id
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: BMWConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(
|
||||
entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
|
||||
)
|
||||
@@ -1,254 +0,0 @@
|
||||
"""Reads vehicle status from BMW MyBMW portal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
from bimmer_connected.vehicle.doors_windows import LockState
|
||||
from bimmer_connected.vehicle.fuel_and_battery import ChargingState
|
||||
from bimmer_connected.vehicle.reports import ConditionBasedService
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.unit_system import UnitSystem
|
||||
|
||||
from . import BMWConfigEntry
|
||||
from .const import UNIT_MAP
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ALLOWED_CONDITION_BASED_SERVICE_KEYS = {
|
||||
"BRAKE_FLUID",
|
||||
"BRAKE_PADS_FRONT",
|
||||
"BRAKE_PADS_REAR",
|
||||
"EMISSION_CHECK",
|
||||
"ENGINE_OIL",
|
||||
"OIL",
|
||||
"TIRE_WEAR_FRONT",
|
||||
"TIRE_WEAR_REAR",
|
||||
"VEHICLE_CHECK",
|
||||
"VEHICLE_TUV",
|
||||
}
|
||||
LOGGED_CONDITION_BASED_SERVICE_WARNINGS: set[str] = set()
|
||||
|
||||
ALLOWED_CHECK_CONTROL_MESSAGE_KEYS = {
|
||||
"ENGINE_OIL",
|
||||
"TIRE_PRESSURE",
|
||||
"WASHING_FLUID",
|
||||
}
|
||||
LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS: set[str] = set()
|
||||
|
||||
|
||||
def _condition_based_services(
|
||||
vehicle: MyBMWVehicle, unit_system: UnitSystem
|
||||
) -> dict[str, Any]:
|
||||
extra_attributes = {}
|
||||
for report in vehicle.condition_based_services.messages:
|
||||
if (
|
||||
report.service_type not in ALLOWED_CONDITION_BASED_SERVICE_KEYS
|
||||
and report.service_type not in LOGGED_CONDITION_BASED_SERVICE_WARNINGS
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"'%s' not an allowed condition based service (%s)",
|
||||
report.service_type,
|
||||
report,
|
||||
)
|
||||
LOGGED_CONDITION_BASED_SERVICE_WARNINGS.add(report.service_type)
|
||||
continue
|
||||
|
||||
extra_attributes.update(_format_cbs_report(report, unit_system))
|
||||
return extra_attributes
|
||||
|
||||
|
||||
def _check_control_messages(vehicle: MyBMWVehicle) -> dict[str, Any]:
|
||||
extra_attributes: dict[str, Any] = {}
|
||||
for message in vehicle.check_control_messages.messages:
|
||||
if (
|
||||
message.description_short not in ALLOWED_CHECK_CONTROL_MESSAGE_KEYS
|
||||
and message.description_short not in LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"'%s' not an allowed check control message (%s)",
|
||||
message.description_short,
|
||||
message,
|
||||
)
|
||||
LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS.add(message.description_short)
|
||||
continue
|
||||
|
||||
extra_attributes[message.description_short.lower()] = message.state.value
|
||||
return extra_attributes
|
||||
|
||||
|
||||
def _format_cbs_report(
|
||||
report: ConditionBasedService, unit_system: UnitSystem
|
||||
) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
service_type = report.service_type.lower()
|
||||
result[service_type] = report.state.value
|
||||
if report.due_date is not None:
|
||||
result[f"{service_type}_date"] = report.due_date.strftime("%Y-%m-%d")
|
||||
if report.due_distance.value and report.due_distance.unit:
|
||||
distance = round(
|
||||
unit_system.length(
|
||||
report.due_distance.value,
|
||||
UNIT_MAP.get(report.due_distance.unit, report.due_distance.unit),
|
||||
)
|
||||
)
|
||||
result[f"{service_type}_distance"] = f"{distance} {unit_system.length_unit}"
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class BMWBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes BMW binary_sensor entity."""
|
||||
|
||||
value_fn: Callable[[MyBMWVehicle], bool]
|
||||
attr_fn: Callable[[MyBMWVehicle, UnitSystem], dict[str, Any]] | None = None
|
||||
is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled
|
||||
|
||||
|
||||
SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = (
|
||||
BMWBinarySensorEntityDescription(
|
||||
key="lids",
|
||||
translation_key="lids",
|
||||
device_class=BinarySensorDeviceClass.OPENING,
|
||||
# device class opening: On means open, Off means closed
|
||||
value_fn=lambda v: not v.doors_and_windows.all_lids_closed,
|
||||
attr_fn=lambda v, u: {
|
||||
lid.name: lid.state.value for lid in v.doors_and_windows.lids
|
||||
},
|
||||
),
|
||||
BMWBinarySensorEntityDescription(
|
||||
key="windows",
|
||||
translation_key="windows",
|
||||
device_class=BinarySensorDeviceClass.OPENING,
|
||||
# device class opening: On means open, Off means closed
|
||||
value_fn=lambda v: not v.doors_and_windows.all_windows_closed,
|
||||
attr_fn=lambda v, u: {
|
||||
window.name: window.state.value for window in v.doors_and_windows.windows
|
||||
},
|
||||
),
|
||||
BMWBinarySensorEntityDescription(
|
||||
key="door_lock_state",
|
||||
translation_key="door_lock_state",
|
||||
device_class=BinarySensorDeviceClass.LOCK,
|
||||
# device class lock: On means unlocked, Off means locked
|
||||
# Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED
|
||||
value_fn=lambda v: (
|
||||
v.doors_and_windows.door_lock_state
|
||||
not in {LockState.LOCKED, LockState.SECURED}
|
||||
),
|
||||
attr_fn=lambda v, u: {
|
||||
"door_lock_state": v.doors_and_windows.door_lock_state.value
|
||||
},
|
||||
),
|
||||
BMWBinarySensorEntityDescription(
|
||||
key="condition_based_services",
|
||||
translation_key="condition_based_services",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
# device class problem: On means problem detected, Off means no problem
|
||||
value_fn=lambda v: v.condition_based_services.is_service_required,
|
||||
attr_fn=_condition_based_services,
|
||||
),
|
||||
BMWBinarySensorEntityDescription(
|
||||
key="check_control_messages",
|
||||
translation_key="check_control_messages",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
# device class problem: On means problem detected, Off means no problem
|
||||
value_fn=lambda v: v.check_control_messages.has_check_control_messages,
|
||||
attr_fn=lambda v, u: _check_control_messages(v),
|
||||
),
|
||||
# electric
|
||||
BMWBinarySensorEntityDescription(
|
||||
key="charging_status",
|
||||
translation_key="charging_status",
|
||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
# device class power: On means power detected, Off means no power
|
||||
value_fn=lambda v: v.fuel_and_battery.charging_status == ChargingState.CHARGING,
|
||||
is_available=lambda v: v.has_electric_drivetrain,
|
||||
),
|
||||
BMWBinarySensorEntityDescription(
|
||||
key="connection_status",
|
||||
translation_key="connection_status",
|
||||
device_class=BinarySensorDeviceClass.PLUG,
|
||||
value_fn=lambda v: v.fuel_and_battery.is_charger_connected,
|
||||
is_available=lambda v: v.has_electric_drivetrain,
|
||||
),
|
||||
BMWBinarySensorEntityDescription(
|
||||
key="is_pre_entry_climatization_enabled",
|
||||
translation_key="is_pre_entry_climatization_enabled",
|
||||
value_fn=lambda v: (
|
||||
v.charging_profile.is_pre_entry_climatization_enabled
|
||||
if v.charging_profile
|
||||
else False
|
||||
),
|
||||
is_available=lambda v: v.has_electric_drivetrain,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BMWConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the BMW binary sensors from config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities = [
|
||||
BMWBinarySensor(coordinator, vehicle, description, hass.config.units)
|
||||
for vehicle in coordinator.account.vehicles
|
||||
for description in SENSOR_TYPES
|
||||
if description.is_available(vehicle)
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BMWBinarySensor(BMWBaseEntity, BinarySensorEntity):
|
||||
"""Representation of a BMW vehicle binary sensor."""
|
||||
|
||||
entity_description: BMWBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
description: BMWBinarySensorEntityDescription,
|
||||
unit_system: UnitSystem,
|
||||
) -> None:
|
||||
"""Initialize sensor."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
self.entity_description = description
|
||||
self._unit_system = unit_system
|
||||
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
_LOGGER.debug(
|
||||
"Updating binary sensor '%s' of %s",
|
||||
self.entity_description.key,
|
||||
self.vehicle.name,
|
||||
)
|
||||
self._attr_is_on = self.entity_description.value_fn(self.vehicle)
|
||||
|
||||
if self.entity_description.attr_fn:
|
||||
self._attr_extra_state_attributes = self.entity_description.attr_fn(
|
||||
self.vehicle, self._unit_system
|
||||
)
|
||||
|
||||
super()._handle_coordinator_update()
|
||||
@@ -1,127 +0,0 @@
|
||||
"""Support for MyBMW button entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
from bimmer_connected.vehicle.remote_services import RemoteServiceStatus
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class BMWButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Class describing BMW button entities."""
|
||||
|
||||
remote_function: Callable[[MyBMWVehicle], Coroutine[Any, Any, RemoteServiceStatus]]
|
||||
enabled_when_read_only: bool = False
|
||||
is_available: Callable[[MyBMWVehicle], bool] = lambda _: True
|
||||
|
||||
|
||||
BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = (
|
||||
BMWButtonEntityDescription(
|
||||
key="light_flash",
|
||||
translation_key="light_flash",
|
||||
remote_function=lambda vehicle: (
|
||||
vehicle.remote_services.trigger_remote_light_flash()
|
||||
),
|
||||
),
|
||||
BMWButtonEntityDescription(
|
||||
key="sound_horn",
|
||||
translation_key="sound_horn",
|
||||
remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_horn(),
|
||||
),
|
||||
BMWButtonEntityDescription(
|
||||
key="activate_air_conditioning",
|
||||
translation_key="activate_air_conditioning",
|
||||
remote_function=lambda vehicle: (
|
||||
vehicle.remote_services.trigger_remote_air_conditioning()
|
||||
),
|
||||
),
|
||||
BMWButtonEntityDescription(
|
||||
key="deactivate_air_conditioning",
|
||||
translation_key="deactivate_air_conditioning",
|
||||
remote_function=lambda vehicle: (
|
||||
vehicle.remote_services.trigger_remote_air_conditioning_stop()
|
||||
),
|
||||
is_available=lambda vehicle: vehicle.is_remote_climate_stop_enabled,
|
||||
),
|
||||
BMWButtonEntityDescription(
|
||||
key="find_vehicle",
|
||||
translation_key="find_vehicle",
|
||||
remote_function=lambda vehicle: (
|
||||
vehicle.remote_services.trigger_remote_vehicle_finder()
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BMWConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the BMW buttons from config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[BMWButton] = []
|
||||
|
||||
for vehicle in coordinator.account.vehicles:
|
||||
entities.extend(
|
||||
[
|
||||
BMWButton(coordinator, vehicle, description)
|
||||
for description in BUTTON_TYPES
|
||||
if (not coordinator.read_only and description.is_available(vehicle))
|
||||
or (coordinator.read_only and description.enabled_when_read_only)
|
||||
]
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BMWButton(BMWBaseEntity, ButtonEntity):
|
||||
"""Representation of a MyBMW button."""
|
||||
|
||||
entity_description: BMWButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
description: BMWButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize BMW vehicle sensor."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
try:
|
||||
await self.entity_description.remote_function(self.vehicle)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
|
||||
self.coordinator.async_update_listeners()
|
||||
@@ -1,277 +0,0 @@
|
||||
"""Config flow for BMW ConnectedDrive integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from bimmer_connected.api.authentication import MyBMWAuthentication
|
||||
from bimmer_connected.api.regions import get_region_from_name
|
||||
from bimmer_connected.models import (
|
||||
MyBMWAPIError,
|
||||
MyBMWAuthError,
|
||||
MyBMWCaptchaMissingError,
|
||||
)
|
||||
from httpx import RequestError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
from . import DOMAIN
|
||||
from .const import (
|
||||
CONF_ALLOWED_REGIONS,
|
||||
CONF_CAPTCHA_REGIONS,
|
||||
CONF_CAPTCHA_TOKEN,
|
||||
CONF_CAPTCHA_URL,
|
||||
CONF_GCID,
|
||||
CONF_READ_ONLY,
|
||||
CONF_REFRESH_TOKEN,
|
||||
)
|
||||
from .coordinator import BMWConfigEntry
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_REGION): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=CONF_ALLOWED_REGIONS,
|
||||
translation_key="regions",
|
||||
)
|
||||
),
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
RECONFIGURE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
CAPTCHA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_CAPTCHA_TOKEN): str,
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
auth = MyBMWAuthentication(
|
||||
data[CONF_USERNAME],
|
||||
data[CONF_PASSWORD],
|
||||
get_region_from_name(data[CONF_REGION]),
|
||||
hcaptcha_token=data.get(CONF_CAPTCHA_TOKEN),
|
||||
verify=get_default_context(),
|
||||
)
|
||||
|
||||
try:
|
||||
await auth.login()
|
||||
except MyBMWCaptchaMissingError as ex:
|
||||
raise MissingCaptcha from ex
|
||||
except MyBMWAuthError as ex:
|
||||
raise InvalidAuth from ex
|
||||
except (MyBMWAPIError, RequestError) as ex:
|
||||
raise CannotConnect from ex
|
||||
|
||||
# Return info that you want to store in the config entry.
|
||||
retval = {"title": f"{data[CONF_USERNAME]}{data.get(CONF_SOURCE, '')}"}
|
||||
if auth.refresh_token:
|
||||
retval[CONF_REFRESH_TOKEN] = auth.refresh_token
|
||||
if auth.gcid:
|
||||
retval[CONF_GCID] = auth.gcid
|
||||
return retval
|
||||
|
||||
|
||||
class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for MyBMW."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
self._existing_entry_data: dict[str, Any] = {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = self.data.pop("errors", {})
|
||||
|
||||
if user_input is not None and not errors:
|
||||
unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}"
|
||||
await self.async_set_unique_id(unique_id)
|
||||
|
||||
# Unique ID cannot change for reauth/reconfigure
|
||||
if self.source not in {SOURCE_REAUTH, SOURCE_RECONFIGURE}:
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Store user input for later use
|
||||
self.data.update(user_input)
|
||||
|
||||
# North America and Rest of World require captcha token
|
||||
if (
|
||||
self.data.get(CONF_REGION) in CONF_CAPTCHA_REGIONS
|
||||
and CONF_CAPTCHA_TOKEN not in self.data
|
||||
):
|
||||
return await self.async_step_captcha()
|
||||
|
||||
info = None
|
||||
try:
|
||||
info = await validate_input(self.hass, self.data)
|
||||
except MissingCaptcha:
|
||||
errors["base"] = "missing_captcha"
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
finally:
|
||||
self.data.pop(CONF_CAPTCHA_TOKEN, None)
|
||||
|
||||
if info:
|
||||
entry_data = {
|
||||
**self.data,
|
||||
CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN),
|
||||
CONF_GCID: info.get(CONF_GCID),
|
||||
}
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data=entry_data
|
||||
)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(),
|
||||
data=entry_data,
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=info["title"],
|
||||
data=entry_data,
|
||||
)
|
||||
|
||||
schema = self.add_suggested_values_to_schema(
|
||||
DATA_SCHEMA,
|
||||
self._existing_entry_data or self.data,
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
|
||||
|
||||
async def async_step_change_password(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show the change password step."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_user(self._existing_entry_data | user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="change_password",
|
||||
data_schema=RECONFIGURE_SCHEMA,
|
||||
description_placeholders={
|
||||
CONF_USERNAME: self._existing_entry_data[CONF_USERNAME],
|
||||
CONF_REGION: self._existing_entry_data[CONF_REGION],
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle configuration by re-auth."""
|
||||
self._existing_entry_data = dict(entry_data)
|
||||
return await self.async_step_change_password()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a reconfiguration flow initialized by the user."""
|
||||
self._existing_entry_data = dict(self._get_reconfigure_entry().data)
|
||||
return await self.async_step_change_password()
|
||||
|
||||
async def async_step_captcha(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show captcha form."""
|
||||
if user_input and user_input.get(CONF_CAPTCHA_TOKEN):
|
||||
self.data[CONF_CAPTCHA_TOKEN] = user_input[CONF_CAPTCHA_TOKEN].strip()
|
||||
return await self.async_step_user(self.data)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="captcha",
|
||||
data_schema=CAPTCHA_SCHEMA,
|
||||
description_placeholders={
|
||||
"captcha_url": CONF_CAPTCHA_URL.format(region=self.data[CONF_REGION])
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: BMWConfigEntry,
|
||||
) -> BMWOptionsFlow:
|
||||
"""Return a MyBMW option flow."""
|
||||
return BMWOptionsFlow()
|
||||
|
||||
|
||||
class BMWOptionsFlow(OptionsFlow):
|
||||
"""Handle a option flow for MyBMW."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the options."""
|
||||
return await self.async_step_account_options()
|
||||
|
||||
async def async_step_account_options(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
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_show_form(
|
||||
step_id="account_options",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_READ_ONLY,
|
||||
default=self.config_entry.options.get(CONF_READ_ONLY, False),
|
||||
): bool,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
||||
|
||||
class MissingCaptcha(HomeAssistantError):
|
||||
"""Error to indicate the captcha token is missing."""
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Const file for the MyBMW integration."""
|
||||
|
||||
from homeassistant.const import UnitOfLength, UnitOfVolume
|
||||
|
||||
DOMAIN = "bmw_connected_drive"
|
||||
|
||||
ATTR_DIRECTION = "direction"
|
||||
ATTR_VIN = "vin"
|
||||
|
||||
CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"]
|
||||
CONF_CAPTCHA_REGIONS = ["north_america", "rest_of_world"]
|
||||
CONF_READ_ONLY = "read_only"
|
||||
CONF_ACCOUNT = "account"
|
||||
CONF_REFRESH_TOKEN = "refresh_token"
|
||||
CONF_GCID = "gcid"
|
||||
CONF_CAPTCHA_TOKEN = "captcha_token"
|
||||
CONF_CAPTCHA_URL = (
|
||||
"https://bimmer-connected.readthedocs.io/en/stable/captcha/{region}.html"
|
||||
)
|
||||
|
||||
DATA_HASS_CONFIG = "hass_config"
|
||||
|
||||
UNIT_MAP = {
|
||||
"KILOMETERS": UnitOfLength.KILOMETERS,
|
||||
"MILES": UnitOfLength.MILES,
|
||||
"LITERS": UnitOfVolume.LITERS,
|
||||
"GALLONS": UnitOfVolume.GALLONS,
|
||||
}
|
||||
|
||||
SCAN_INTERVALS = {
|
||||
"china": 300,
|
||||
"north_america": 600,
|
||||
"rest_of_world": 300,
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Coordinator for BMW."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from bimmer_connected.account import MyBMWAccount
|
||||
from bimmer_connected.api.regions import get_region_from_name
|
||||
from bimmer_connected.models import (
|
||||
GPSPosition,
|
||||
MyBMWAPIError,
|
||||
MyBMWAuthError,
|
||||
MyBMWCaptchaMissingError,
|
||||
)
|
||||
from httpx import RequestError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type BMWConfigEntry = ConfigEntry[BMWDataUpdateCoordinator]
|
||||
|
||||
|
||||
class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Class to manage fetching BMW data."""
|
||||
|
||||
account: MyBMWAccount
|
||||
config_entry: BMWConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, *, config_entry: BMWConfigEntry) -> None:
|
||||
"""Initialize account-wide BMW data updater."""
|
||||
self.account = MyBMWAccount(
|
||||
config_entry.data[CONF_USERNAME],
|
||||
config_entry.data[CONF_PASSWORD],
|
||||
get_region_from_name(config_entry.data[CONF_REGION]),
|
||||
observer_position=GPSPosition(hass.config.latitude, hass.config.longitude),
|
||||
verify=get_default_context(),
|
||||
)
|
||||
self.read_only: bool = config_entry.options[CONF_READ_ONLY]
|
||||
|
||||
if CONF_REFRESH_TOKEN in config_entry.data:
|
||||
self.account.set_refresh_token(
|
||||
refresh_token=config_entry.data[CONF_REFRESH_TOKEN],
|
||||
gcid=config_entry.data.get(CONF_GCID),
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"{DOMAIN}-{config_entry.data[CONF_USERNAME]}",
|
||||
update_interval=timedelta(
|
||||
seconds=SCAN_INTERVALS[config_entry.data[CONF_REGION]]
|
||||
),
|
||||
)
|
||||
|
||||
# Default to false on init so _async_update_data logic works
|
||||
self.last_update_success = False
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data from BMW."""
|
||||
old_refresh_token = self.account.refresh_token
|
||||
|
||||
try:
|
||||
await self.account.get_vehicles()
|
||||
except MyBMWCaptchaMissingError as err:
|
||||
# If a captcha is required (user/password login flow), always trigger the reauth flow
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_captcha",
|
||||
) from err
|
||||
except MyBMWAuthError as err:
|
||||
# Allow one retry interval before raising AuthFailed to avoid flaky API issues
|
||||
if self.last_update_success:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"exception": str(err)},
|
||||
) from err
|
||||
# Clear refresh token and trigger reauth if previous update failed as well
|
||||
self._update_config_entry_refresh_token(None)
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
) from err
|
||||
except (MyBMWAPIError, RequestError) as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"exception": str(err)},
|
||||
) from err
|
||||
|
||||
if self.account.refresh_token != old_refresh_token:
|
||||
self._update_config_entry_refresh_token(self.account.refresh_token)
|
||||
|
||||
def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None:
|
||||
"""Update or delete the refresh_token in the Config Entry."""
|
||||
data = {
|
||||
**self.config_entry.data,
|
||||
CONF_REFRESH_TOKEN: refresh_token,
|
||||
}
|
||||
if not refresh_token:
|
||||
data.pop(CONF_REFRESH_TOKEN)
|
||||
self.hass.config_entries.async_update_entry(self.config_entry, data=data)
|
||||
@@ -1,86 +0,0 @@
|
||||
"""Device tracker for MyBMW vehicles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
|
||||
from homeassistant.components.device_tracker import TrackerEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BMWConfigEntry
|
||||
from .const import ATTR_DIRECTION
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BMWConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the MyBMW tracker from config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities: list[BMWDeviceTracker] = []
|
||||
|
||||
for vehicle in coordinator.account.vehicles:
|
||||
entities.append(BMWDeviceTracker(coordinator, vehicle))
|
||||
if not vehicle.is_vehicle_tracking_enabled:
|
||||
_LOGGER.info(
|
||||
(
|
||||
"Tracking is (currently) disabled for vehicle %s (%s), defaulting"
|
||||
" to unknown"
|
||||
),
|
||||
vehicle.name,
|
||||
vehicle.vin,
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BMWDeviceTracker(BMWBaseEntity, TrackerEntity):
|
||||
"""MyBMW device tracker."""
|
||||
|
||||
_attr_force_update = False
|
||||
_attr_translation_key = "car"
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
) -> None:
|
||||
"""Initialize the Tracker."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
self._attr_unique_id = vehicle.vin
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return entity specific state attributes."""
|
||||
return {ATTR_DIRECTION: self.vehicle.vehicle_location.heading}
|
||||
|
||||
@property
|
||||
def latitude(self) -> float | None:
|
||||
"""Return latitude value of the device."""
|
||||
return (
|
||||
self.vehicle.vehicle_location.location[0]
|
||||
if self.vehicle.is_vehicle_tracking_enabled
|
||||
and self.vehicle.vehicle_location.location
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def longitude(self) -> float | None:
|
||||
"""Return longitude value of the device."""
|
||||
return (
|
||||
self.vehicle.vehicle_location.location[1]
|
||||
if self.vehicle.is_vehicle_tracking_enabled
|
||||
and self.vehicle.vehicle_location.location
|
||||
else None
|
||||
)
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Diagnostics support for the BMW Connected Drive integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from bimmer_connected.utils import MyBMWJSONEncoder
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from . import BMWConfigEntry
|
||||
from .const import CONF_REFRESH_TOKEN
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
|
||||
|
||||
TO_REDACT_INFO = [CONF_USERNAME, CONF_PASSWORD, CONF_REFRESH_TOKEN]
|
||||
TO_REDACT_DATA = [
|
||||
"lat",
|
||||
"latitude",
|
||||
"lon",
|
||||
"longitude",
|
||||
"heading",
|
||||
"vin",
|
||||
"licensePlate",
|
||||
"city",
|
||||
"street",
|
||||
"streetNumber",
|
||||
"postalCode",
|
||||
"phone",
|
||||
"formatted",
|
||||
"subtitle",
|
||||
]
|
||||
|
||||
|
||||
def vehicle_to_dict(vehicle: MyBMWVehicle | None) -> dict:
|
||||
"""Convert a MyBMWVehicle to a dictionary using MyBMWJSONEncoder."""
|
||||
retval: dict = json.loads(json.dumps(vehicle, cls=MyBMWJSONEncoder))
|
||||
return retval
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: BMWConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
coordinator.account.config.log_responses = True
|
||||
await coordinator.account.get_vehicles(force_init=True)
|
||||
|
||||
diagnostics_data = {
|
||||
"info": async_redact_data(config_entry.data, TO_REDACT_INFO),
|
||||
"data": [
|
||||
async_redact_data(vehicle_to_dict(vehicle), TO_REDACT_DATA)
|
||||
for vehicle in coordinator.account.vehicles
|
||||
],
|
||||
"fingerprint": async_redact_data(
|
||||
[asdict(r) for r in coordinator.account.get_stored_responses()],
|
||||
TO_REDACT_DATA,
|
||||
),
|
||||
}
|
||||
|
||||
coordinator.account.config.log_responses = False
|
||||
|
||||
return diagnostics_data
|
||||
|
||||
|
||||
async def async_get_device_diagnostics(
|
||||
hass: HomeAssistant, config_entry: BMWConfigEntry, device: DeviceEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a device."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
coordinator.account.config.log_responses = True
|
||||
await coordinator.account.get_vehicles(force_init=True)
|
||||
|
||||
vin = next(iter(device.identifiers))[1]
|
||||
vehicle = coordinator.account.get_vehicle(vin)
|
||||
|
||||
diagnostics_data = {
|
||||
"info": async_redact_data(config_entry.data, TO_REDACT_INFO),
|
||||
"data": async_redact_data(vehicle_to_dict(vehicle), TO_REDACT_DATA),
|
||||
# Always have to get the full fingerprint as the VIN is redacted beforehand by the library
|
||||
"fingerprint": async_redact_data(
|
||||
[asdict(r) for r in coordinator.account.get_stored_responses()],
|
||||
TO_REDACT_DATA,
|
||||
),
|
||||
}
|
||||
|
||||
coordinator.account.config.log_responses = False
|
||||
|
||||
return diagnostics_data
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Base for all BMW entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
|
||||
|
||||
class BMWBaseEntity(CoordinatorEntity[BMWDataUpdateCoordinator]):
|
||||
"""Common base for BMW entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
) -> None:
|
||||
"""Initialize entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.vehicle = vehicle
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, vehicle.vin)},
|
||||
manufacturer=vehicle.brand.name,
|
||||
model=vehicle.name,
|
||||
name=vehicle.name,
|
||||
serial_number=vehicle.vin,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self._handle_coordinator_update()
|
||||
@@ -1,102 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"charging_status": {
|
||||
"default": "mdi:ev-station"
|
||||
},
|
||||
"check_control_messages": {
|
||||
"default": "mdi:car-tire-alert"
|
||||
},
|
||||
"condition_based_services": {
|
||||
"default": "mdi:wrench"
|
||||
},
|
||||
"connection_status": {
|
||||
"default": "mdi:car-electric"
|
||||
},
|
||||
"door_lock_state": {
|
||||
"default": "mdi:car-key"
|
||||
},
|
||||
"is_pre_entry_climatization_enabled": {
|
||||
"default": "mdi:car-seat-heater"
|
||||
},
|
||||
"lids": {
|
||||
"default": "mdi:car-door-lock"
|
||||
},
|
||||
"windows": {
|
||||
"default": "mdi:car-door"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"activate_air_conditioning": {
|
||||
"default": "mdi:hvac"
|
||||
},
|
||||
"deactivate_air_conditioning": {
|
||||
"default": "mdi:hvac-off"
|
||||
},
|
||||
"find_vehicle": {
|
||||
"default": "mdi:crosshairs-question"
|
||||
},
|
||||
"light_flash": {
|
||||
"default": "mdi:car-light-alert"
|
||||
},
|
||||
"sound_horn": {
|
||||
"default": "mdi:bullhorn"
|
||||
}
|
||||
},
|
||||
"device_tracker": {
|
||||
"car": {
|
||||
"default": "mdi:car"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"target_soc": {
|
||||
"default": "mdi:battery-charging-medium"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"ac_limit": {
|
||||
"default": "mdi:current-ac"
|
||||
},
|
||||
"charging_mode": {
|
||||
"default": "mdi:vector-point-select"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"charging_status": {
|
||||
"default": "mdi:ev-station"
|
||||
},
|
||||
"charging_target": {
|
||||
"default": "mdi:battery-charging-high"
|
||||
},
|
||||
"climate_status": {
|
||||
"default": "mdi:fan"
|
||||
},
|
||||
"mileage": {
|
||||
"default": "mdi:speedometer"
|
||||
},
|
||||
"remaining_fuel": {
|
||||
"default": "mdi:gas-station"
|
||||
},
|
||||
"remaining_fuel_percent": {
|
||||
"default": "mdi:gas-station"
|
||||
},
|
||||
"remaining_range_electric": {
|
||||
"default": "mdi:map-marker-distance"
|
||||
},
|
||||
"remaining_range_fuel": {
|
||||
"default": "mdi:map-marker-distance"
|
||||
},
|
||||
"remaining_range_total": {
|
||||
"default": "mdi:map-marker-distance"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"charging": {
|
||||
"default": "mdi:ev-station"
|
||||
},
|
||||
"climate": {
|
||||
"default": "mdi:fan"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
"""Support for BMW car locks with BMW ConnectedDrive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
from bimmer_connected.vehicle.doors_windows import LockState
|
||||
|
||||
from homeassistant.components.lock import LockEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
DOOR_LOCK_STATE = "door_lock_state"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BMWConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the MyBMW lock from config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
if not coordinator.read_only:
|
||||
async_add_entities(
|
||||
BMWLock(coordinator, vehicle) for vehicle in coordinator.account.vehicles
|
||||
)
|
||||
|
||||
|
||||
class BMWLock(BMWBaseEntity, LockEntity):
|
||||
"""Representation of a MyBMW vehicle lock."""
|
||||
|
||||
_attr_translation_key = "lock"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
) -> None:
|
||||
"""Initialize the lock."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
|
||||
self._attr_unique_id = f"{vehicle.vin}-lock"
|
||||
self.door_lock_state_available = vehicle.is_lsc_enabled
|
||||
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
"""Lock the car."""
|
||||
_LOGGER.debug("%s: locking doors", self.vehicle.name)
|
||||
# Only update the HA state machine if the vehicle reliably reports its lock state
|
||||
if self.door_lock_state_available:
|
||||
# Optimistic state set here because it takes some time before the
|
||||
# update callback response
|
||||
self._attr_is_locked = True
|
||||
self.async_write_ha_state()
|
||||
try:
|
||||
await self.vehicle.remote_services.trigger_remote_door_lock()
|
||||
except MyBMWAPIError as ex:
|
||||
# Set the state to unknown if the command fails
|
||||
self._attr_is_locked = None
|
||||
self.async_write_ha_state()
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
finally:
|
||||
# Always update the listeners to get the latest state
|
||||
self.coordinator.async_update_listeners()
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Unlock the car."""
|
||||
_LOGGER.debug("%s: unlocking doors", self.vehicle.name)
|
||||
# Only update the HA state machine if the vehicle reliably reports its lock state
|
||||
if self.door_lock_state_available:
|
||||
# Optimistic state set here because it takes some time before the
|
||||
# update callback response
|
||||
self._attr_is_locked = False
|
||||
self.async_write_ha_state()
|
||||
try:
|
||||
await self.vehicle.remote_services.trigger_remote_door_unlock()
|
||||
except MyBMWAPIError as ex:
|
||||
# Set the state to unknown if the command fails
|
||||
self._attr_is_locked = None
|
||||
self.async_write_ha_state()
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
finally:
|
||||
# Always update the listeners to get the latest state
|
||||
self.coordinator.async_update_listeners()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
_LOGGER.debug("Updating lock data of %s", self.vehicle.name)
|
||||
|
||||
# Only update the HA state machine if the vehicle reliably reports its lock state
|
||||
if self.door_lock_state_available:
|
||||
self._attr_is_locked = self.vehicle.doors_and_windows.door_lock_state in {
|
||||
LockState.LOCKED,
|
||||
LockState.SECURED,
|
||||
}
|
||||
self._attr_extra_state_attributes = {
|
||||
DOOR_LOCK_STATE: self.vehicle.doors_and_windows.door_lock_state.value
|
||||
}
|
||||
|
||||
super()._handle_coordinator_update()
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "bmw_connected_drive",
|
||||
"name": "BMW Connected Drive",
|
||||
"codeowners": ["@gerard33", "@rikroe"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["bimmer_connected"],
|
||||
"requirements": ["bimmer-connected[china]==0.17.3"]
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Support for BMW notifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError, PointOfInterest
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.notify import (
|
||||
ATTR_DATA,
|
||||
ATTR_TARGET,
|
||||
BaseNotificationService,
|
||||
)
|
||||
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
ATTR_LOCATION_ATTRIBUTES = ["street", "city", "postal_code", "country"]
|
||||
|
||||
POI_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_LATITUDE): cv.latitude,
|
||||
vol.Required(ATTR_LONGITUDE): cv.longitude,
|
||||
vol.Optional("street"): cv.string,
|
||||
vol.Optional("city"): cv.string,
|
||||
vol.Optional("postal_code"): cv.string,
|
||||
vol.Optional("country"): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_service(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> BMWNotificationService:
|
||||
"""Get the BMW notification service."""
|
||||
config_entry: BMWConfigEntry | None = hass.config_entries.async_get_entry(
|
||||
(discovery_info or {})[CONF_ENTITY_ID]
|
||||
)
|
||||
|
||||
targets = {}
|
||||
if (
|
||||
config_entry
|
||||
and (coordinator := config_entry.runtime_data)
|
||||
and not coordinator.read_only
|
||||
):
|
||||
targets.update({v.name: v for v in coordinator.account.vehicles})
|
||||
return BMWNotificationService(targets)
|
||||
|
||||
|
||||
class BMWNotificationService(BaseNotificationService):
|
||||
"""Send Notifications to BMW."""
|
||||
|
||||
vehicle_targets: dict[str, MyBMWVehicle]
|
||||
|
||||
def __init__(self, targets: dict[str, MyBMWVehicle]) -> None:
|
||||
"""Set up the notification service."""
|
||||
self.vehicle_targets = targets
|
||||
|
||||
@property
|
||||
def targets(self) -> dict[str, Any] | None:
|
||||
"""Return a dictionary of registered targets."""
|
||||
return self.vehicle_targets
|
||||
|
||||
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||
"""Send a message or POI to the car."""
|
||||
|
||||
try:
|
||||
# Verify data schema
|
||||
poi_data = kwargs.get(ATTR_DATA) or {}
|
||||
POI_SCHEMA(poi_data)
|
||||
|
||||
# Create the POI object
|
||||
poi = PointOfInterest(
|
||||
lat=poi_data.pop(ATTR_LATITUDE),
|
||||
lon=poi_data.pop(ATTR_LONGITUDE),
|
||||
name=(message or None),
|
||||
**poi_data,
|
||||
)
|
||||
|
||||
except (vol.Invalid, TypeError, ValueError) as ex:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_poi",
|
||||
translation_placeholders={
|
||||
"poi_exception": str(ex),
|
||||
},
|
||||
) from ex
|
||||
|
||||
for vehicle in kwargs[ATTR_TARGET]:
|
||||
vehicle = cast(MyBMWVehicle, vehicle)
|
||||
_LOGGER.debug("Sending message to %s", vehicle.name)
|
||||
|
||||
try:
|
||||
await vehicle.remote_services.trigger_send_poi(poi)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
@@ -1,118 +0,0 @@
|
||||
"""Number platform for BMW."""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class BMWNumberEntityDescription(NumberEntityDescription):
|
||||
"""Describes BMW number entity."""
|
||||
|
||||
value_fn: Callable[[MyBMWVehicle], float | int | None]
|
||||
remote_service: Callable[[MyBMWVehicle, float | int], Coroutine[Any, Any, Any]]
|
||||
is_available: Callable[[MyBMWVehicle], bool] = lambda _: False
|
||||
dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None
|
||||
|
||||
|
||||
NUMBER_TYPES: list[BMWNumberEntityDescription] = [
|
||||
BMWNumberEntityDescription(
|
||||
key="target_soc",
|
||||
translation_key="target_soc",
|
||||
device_class=NumberDeviceClass.BATTERY,
|
||||
is_available=lambda v: v.is_remote_set_target_soc_enabled,
|
||||
native_max_value=100.0,
|
||||
native_min_value=20.0,
|
||||
native_step=5.0,
|
||||
mode=NumberMode.SLIDER,
|
||||
value_fn=lambda v: v.fuel_and_battery.charging_target,
|
||||
remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update(
|
||||
target_soc=int(o)
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BMWConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the MyBMW number from config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[BMWNumber] = []
|
||||
|
||||
for vehicle in coordinator.account.vehicles:
|
||||
if not coordinator.read_only:
|
||||
entities.extend(
|
||||
[
|
||||
BMWNumber(coordinator, vehicle, description)
|
||||
for description in NUMBER_TYPES
|
||||
if description.is_available(vehicle)
|
||||
]
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BMWNumber(BMWBaseEntity, NumberEntity):
|
||||
"""Representation of BMW Number entity."""
|
||||
|
||||
entity_description: BMWNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
description: BMWNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize an BMW Number."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the entity value to represent the entity state."""
|
||||
return self.entity_description.value_fn(self.vehicle)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Update to the vehicle."""
|
||||
_LOGGER.debug(
|
||||
"Executing '%s' on vehicle '%s' to value '%s'",
|
||||
self.entity_description.key,
|
||||
self.vehicle.vin,
|
||||
value,
|
||||
)
|
||||
try:
|
||||
await self.entity_description.remote_service(self.vehicle, value)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
|
||||
self.coordinator.async_update_listeners()
|
||||
@@ -1,107 +0,0 @@
|
||||
# + in comment indicates requirement for quality scale
|
||||
# - in comment indicates issue to be fixed, not impacting quality scale
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Does not have custom services
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules:
|
||||
status: done
|
||||
comment: |
|
||||
- 2 states writes in async_added_to_hass() required for platforms that redefine _handle_coordinator_update()
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
- test_show_form doesn't really add anything
|
||||
- Patch bimmer_connected imports with homeassistant.components.bmw_connected_drive.bimmer_connected imports
|
||||
+ Ensure that configs flows end in CREATE_ENTRY or ABORT
|
||||
- Parameterize test_authentication_error, test_api_error and test_connection_error
|
||||
+ test_full_user_flow_implementation doesn't assert unique id of created entry
|
||||
+ test that aborts when a mocked config entry already exists
|
||||
+ don't test on internals (e.g. `coordinator.last_update_success`) but rather on the resulting state (change)
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
Does not have custom services
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration doesn't have any events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
Does not have custom services
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage:
|
||||
status: done
|
||||
comment: |
|
||||
- Use constants in tests where possible
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: This integration doesn't use discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: This integration doesn't use discovery.
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: todo
|
||||
comment: >
|
||||
To be discussed.
|
||||
We cannot regularly get new devices/vehicles due to API quota limitations.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
Other than reauthentication, this integration doesn't have any cases where raising an issue is needed.
|
||||
stale-devices:
|
||||
status: todo
|
||||
comment: >
|
||||
To be discussed.
|
||||
We cannot regularly check for stale devices/vehicles due to API quota limitations.
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: todo
|
||||
comment: >
|
||||
To be discussed.
|
||||
The library requires a custom client for API authentication, with custom auth lifecycle and user agents.
|
||||
strict-typing: done
|
||||
@@ -1,132 +0,0 @@
|
||||
"""Select platform for BMW."""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
from bimmer_connected.vehicle.charging_profile import ChargingMode
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.const import UnitOfElectricCurrent
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class BMWSelectEntityDescription(SelectEntityDescription):
|
||||
"""Describes BMW sensor entity."""
|
||||
|
||||
current_option: Callable[[MyBMWVehicle], str]
|
||||
remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]]
|
||||
is_available: Callable[[MyBMWVehicle], bool] = lambda _: False
|
||||
dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None
|
||||
|
||||
|
||||
SELECT_TYPES: tuple[BMWSelectEntityDescription, ...] = (
|
||||
BMWSelectEntityDescription(
|
||||
key="ac_limit",
|
||||
translation_key="ac_limit",
|
||||
is_available=lambda v: v.is_remote_set_ac_limit_enabled,
|
||||
dynamic_options=lambda v: [
|
||||
str(lim)
|
||||
for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr]
|
||||
],
|
||||
current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr]
|
||||
remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update(
|
||||
ac_limit=int(o)
|
||||
),
|
||||
unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
),
|
||||
BMWSelectEntityDescription(
|
||||
key="charging_mode",
|
||||
translation_key="charging_mode",
|
||||
is_available=lambda v: v.is_charging_plan_supported,
|
||||
options=[c.value.lower() for c in ChargingMode if c != ChargingMode.UNKNOWN],
|
||||
current_option=lambda v: v.charging_profile.charging_mode.value.lower(), # type: ignore[union-attr]
|
||||
remote_service=lambda v, o: v.remote_services.trigger_charging_profile_update(
|
||||
charging_mode=ChargingMode(o)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BMWConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the MyBMW lock from config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[BMWSelect] = []
|
||||
|
||||
for vehicle in coordinator.account.vehicles:
|
||||
if not coordinator.read_only:
|
||||
entities.extend(
|
||||
[
|
||||
BMWSelect(coordinator, vehicle, description)
|
||||
for description in SELECT_TYPES
|
||||
if description.is_available(vehicle)
|
||||
]
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BMWSelect(BMWBaseEntity, SelectEntity):
|
||||
"""Representation of BMW select entity."""
|
||||
|
||||
entity_description: BMWSelectEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
description: BMWSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize an BMW select."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
|
||||
if description.dynamic_options:
|
||||
self._attr_options = description.dynamic_options(vehicle)
|
||||
self._attr_current_option = description.current_option(vehicle)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
_LOGGER.debug(
|
||||
"Updating select '%s' of %s", self.entity_description.key, self.vehicle.name
|
||||
)
|
||||
self._attr_current_option = self.entity_description.current_option(self.vehicle)
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Update to the vehicle."""
|
||||
_LOGGER.debug(
|
||||
"Executing '%s' on vehicle '%s' to value '%s'",
|
||||
self.entity_description.key,
|
||||
self.vehicle.vin,
|
||||
option,
|
||||
)
|
||||
try:
|
||||
await self.entity_description.remote_service(self.vehicle, option)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
|
||||
self.coordinator.async_update_listeners()
|
||||
@@ -1,250 +0,0 @@
|
||||
"""Support for reading vehicle status from MyBMW portal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from bimmer_connected.models import StrEnum, ValueWithUnit
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
from bimmer_connected.vehicle.climate import ClimateActivityState
|
||||
from bimmer_connected.vehicle.fuel_and_battery import ChargingState
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
STATE_UNKNOWN,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfLength,
|
||||
UnitOfPressure,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import BMWConfigEntry
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BMWSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes BMW sensor entity."""
|
||||
|
||||
key_class: str | None = None
|
||||
is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled
|
||||
|
||||
|
||||
TIRES = ["front_left", "front_right", "rear_left", "rear_right"]
|
||||
|
||||
SENSOR_TYPES: list[BMWSensorEntityDescription] = [
|
||||
BMWSensorEntityDescription(
|
||||
key="charging_profile.ac_current_limit",
|
||||
translation_key="ac_current_limit",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.charging_start_time",
|
||||
translation_key="charging_start_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_registry_enabled_default=False,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.charging_end_time",
|
||||
translation_key="charging_end_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.charging_status",
|
||||
translation_key="charging_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[s.value.lower() for s in ChargingState if s != ChargingState.UNKNOWN],
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.charging_target",
|
||||
translation_key="charging_target",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.remaining_battery_percent",
|
||||
translation_key="remaining_battery_percent",
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="mileage",
|
||||
translation_key="mileage",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.remaining_range_total",
|
||||
translation_key="remaining_range_total",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.remaining_range_electric",
|
||||
translation_key="remaining_range_electric",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.remaining_range_fuel",
|
||||
translation_key="remaining_range_fuel",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.remaining_fuel",
|
||||
translation_key="remaining_fuel",
|
||||
device_class=SensorDeviceClass.VOLUME_STORAGE,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="fuel_and_battery.remaining_fuel_percent",
|
||||
translation_key="remaining_fuel_percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain,
|
||||
),
|
||||
BMWSensorEntityDescription(
|
||||
key="climate.activity",
|
||||
translation_key="climate_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
s.value.lower()
|
||||
for s in ClimateActivityState
|
||||
if s != ClimateActivityState.UNKNOWN
|
||||
],
|
||||
is_available=lambda v: v.is_remote_climate_stop_enabled,
|
||||
),
|
||||
*[
|
||||
BMWSensorEntityDescription(
|
||||
key=f"tires.{tire}.current_pressure",
|
||||
translation_key=f"{tire}_current_pressure",
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
native_unit_of_measurement=UnitOfPressure.KPA,
|
||||
suggested_unit_of_measurement=UnitOfPressure.BAR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.tires is not None,
|
||||
)
|
||||
for tire in TIRES
|
||||
],
|
||||
*[
|
||||
BMWSensorEntityDescription(
|
||||
key=f"tires.{tire}.target_pressure",
|
||||
translation_key=f"{tire}_target_pressure",
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
native_unit_of_measurement=UnitOfPressure.KPA,
|
||||
suggested_unit_of_measurement=UnitOfPressure.BAR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
is_available=lambda v: v.is_lsc_enabled and v.tires is not None,
|
||||
)
|
||||
for tire in TIRES
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BMWConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the MyBMW sensors from config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities = [
|
||||
BMWSensor(coordinator, vehicle, description)
|
||||
for vehicle in coordinator.account.vehicles
|
||||
for description in SENSOR_TYPES
|
||||
if description.is_available(vehicle)
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BMWSensor(BMWBaseEntity, SensorEntity):
|
||||
"""Representation of a BMW vehicle sensor."""
|
||||
|
||||
entity_description: BMWSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
description: BMWSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize BMW vehicle sensor."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
_LOGGER.debug(
|
||||
"Updating sensor '%s' of %s", self.entity_description.key, self.vehicle.name
|
||||
)
|
||||
|
||||
key_path = self.entity_description.key.split(".")
|
||||
state = getattr(self.vehicle, key_path.pop(0))
|
||||
|
||||
for key in key_path:
|
||||
state = getattr(state, key)
|
||||
|
||||
# For datetime without tzinfo, we assume it to be the same timezone as the HA instance
|
||||
if isinstance(state, datetime.datetime) and state.tzinfo is None:
|
||||
state = state.replace(tzinfo=dt_util.get_default_time_zone())
|
||||
# For enum types, we only want the value
|
||||
elif isinstance(state, ValueWithUnit):
|
||||
state = state.value
|
||||
# Get lowercase values from StrEnum
|
||||
elif isinstance(state, StrEnum):
|
||||
state = state.value.lower()
|
||||
if state == STATE_UNKNOWN:
|
||||
state = None
|
||||
|
||||
self._attr_native_value = state
|
||||
super()._handle_coordinator_update()
|
||||
@@ -1,248 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"missing_captcha": "Captcha validation missing"
|
||||
},
|
||||
"step": {
|
||||
"captcha": {
|
||||
"data": {
|
||||
"captcha_token": "Captcha token"
|
||||
},
|
||||
"data_description": {
|
||||
"captcha_token": "One-time token retrieved from the captcha challenge."
|
||||
},
|
||||
"description": "A captcha is required for BMW login. Visit the external website to complete the challenge and submit the form. Copy the resulting token into the field below.\n\n{captcha_url}\n\nNo data will be exposed outside of your Home Assistant instance.",
|
||||
"title": "Are you a robot?"
|
||||
},
|
||||
"change_password": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::bmw_connected_drive::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "Update your MyBMW/MINI Connected password for account `{username}` in region `{region}`."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"region": "ConnectedDrive region",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "The password of your MyBMW/MINI Connected account.",
|
||||
"region": "The region of your MyBMW/MINI Connected account.",
|
||||
"username": "The email address of your MyBMW/MINI Connected account."
|
||||
},
|
||||
"description": "Connect to your MyBMW/MINI Connected account to retrieve vehicle data."
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"charging_status": {
|
||||
"name": "Charging status"
|
||||
},
|
||||
"check_control_messages": {
|
||||
"name": "Check control messages"
|
||||
},
|
||||
"condition_based_services": {
|
||||
"name": "Condition-based services"
|
||||
},
|
||||
"connection_status": {
|
||||
"name": "Connection status"
|
||||
},
|
||||
"door_lock_state": {
|
||||
"name": "Door lock state"
|
||||
},
|
||||
"is_pre_entry_climatization_enabled": {
|
||||
"name": "Pre-entry climatization"
|
||||
},
|
||||
"lids": {
|
||||
"name": "Lids"
|
||||
},
|
||||
"windows": {
|
||||
"name": "Windows"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"activate_air_conditioning": {
|
||||
"name": "Activate air conditioning"
|
||||
},
|
||||
"deactivate_air_conditioning": {
|
||||
"name": "Deactivate air conditioning"
|
||||
},
|
||||
"find_vehicle": {
|
||||
"name": "Find vehicle"
|
||||
},
|
||||
"light_flash": {
|
||||
"name": "Flash lights"
|
||||
},
|
||||
"sound_horn": {
|
||||
"name": "Sound horn"
|
||||
}
|
||||
},
|
||||
"lock": {
|
||||
"lock": {
|
||||
"name": "[%key:component::lock::title%]"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"target_soc": {
|
||||
"name": "Target SoC"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"ac_limit": {
|
||||
"name": "AC charging limit"
|
||||
},
|
||||
"charging_mode": {
|
||||
"name": "Charging mode",
|
||||
"state": {
|
||||
"delayed_charging": "Delayed charging",
|
||||
"immediate_charging": "Immediate charging",
|
||||
"no_action": "No action"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"ac_current_limit": {
|
||||
"name": "AC current limit"
|
||||
},
|
||||
"charging_end_time": {
|
||||
"name": "Charging end time"
|
||||
},
|
||||
"charging_start_time": {
|
||||
"name": "Charging start time"
|
||||
},
|
||||
"charging_status": {
|
||||
"name": "Charging status",
|
||||
"state": {
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"complete": "Complete",
|
||||
"default": "Default",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"finished_fully_charged": "Finished, fully charged",
|
||||
"finished_not_full": "Finished, not full",
|
||||
"fully_charged": "Fully charged",
|
||||
"invalid": "Invalid",
|
||||
"not_charging": "Not charging",
|
||||
"plugged_in": "Plugged in",
|
||||
"target_reached": "Target reached",
|
||||
"waiting_for_charging": "Waiting for charging"
|
||||
}
|
||||
},
|
||||
"charging_target": {
|
||||
"name": "Charging target"
|
||||
},
|
||||
"climate_status": {
|
||||
"name": "Climate status",
|
||||
"state": {
|
||||
"cooling": "Cooling",
|
||||
"heating": "Heating",
|
||||
"inactive": "Inactive",
|
||||
"standby": "[%key:common::state::standby%]",
|
||||
"ventilation": "Ventilation"
|
||||
}
|
||||
},
|
||||
"front_left_current_pressure": {
|
||||
"name": "Front left tire pressure"
|
||||
},
|
||||
"front_left_target_pressure": {
|
||||
"name": "Front left target pressure"
|
||||
},
|
||||
"front_right_current_pressure": {
|
||||
"name": "Front right tire pressure"
|
||||
},
|
||||
"front_right_target_pressure": {
|
||||
"name": "Front right target pressure"
|
||||
},
|
||||
"mileage": {
|
||||
"name": "Mileage"
|
||||
},
|
||||
"rear_left_current_pressure": {
|
||||
"name": "Rear left tire pressure"
|
||||
},
|
||||
"rear_left_target_pressure": {
|
||||
"name": "Rear left target pressure"
|
||||
},
|
||||
"rear_right_current_pressure": {
|
||||
"name": "Rear right tire pressure"
|
||||
},
|
||||
"rear_right_target_pressure": {
|
||||
"name": "Rear right target pressure"
|
||||
},
|
||||
"remaining_battery_percent": {
|
||||
"name": "Remaining battery percent"
|
||||
},
|
||||
"remaining_fuel": {
|
||||
"name": "Remaining fuel"
|
||||
},
|
||||
"remaining_fuel_percent": {
|
||||
"name": "Remaining fuel percent"
|
||||
},
|
||||
"remaining_range_electric": {
|
||||
"name": "Remaining range electric"
|
||||
},
|
||||
"remaining_range_fuel": {
|
||||
"name": "Remaining range fuel"
|
||||
},
|
||||
"remaining_range_total": {
|
||||
"name": "Remaining range total"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"charging": {
|
||||
"name": "Charging"
|
||||
},
|
||||
"climate": {
|
||||
"name": "Climate"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_auth": {
|
||||
"message": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"invalid_poi": {
|
||||
"message": "Invalid data for point of interest: {poi_exception}"
|
||||
},
|
||||
"missing_captcha": {
|
||||
"message": "Login requires captcha validation"
|
||||
},
|
||||
"remote_service_error": {
|
||||
"message": "Error executing remote service on vehicle. {exception}"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Error updating vehicle data. {exception}"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"account_options": {
|
||||
"data": {
|
||||
"read_only": "Read-only mode"
|
||||
},
|
||||
"data_description": {
|
||||
"read_only": "Only retrieve values and send POI data, but don't offer any services that can change the vehicle state."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"regions": {
|
||||
"options": {
|
||||
"china": "China",
|
||||
"north_america": "North America",
|
||||
"rest_of_world": "Rest of world"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
"""Switch platform for BMW."""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
from bimmer_connected.vehicle.fuel_and_battery import ChargingState
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN, BMWConfigEntry
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
from .entity import BMWBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class BMWSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Describes BMW switch entity."""
|
||||
|
||||
value_fn: Callable[[MyBMWVehicle], bool]
|
||||
remote_service_on: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]]
|
||||
remote_service_off: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]]
|
||||
is_available: Callable[[MyBMWVehicle], bool] = lambda _: False
|
||||
dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None
|
||||
|
||||
|
||||
CHARGING_STATE_ON = {
|
||||
ChargingState.CHARGING,
|
||||
ChargingState.COMPLETE,
|
||||
ChargingState.FULLY_CHARGED,
|
||||
ChargingState.FINISHED_FULLY_CHARGED,
|
||||
ChargingState.FINISHED_NOT_FULL,
|
||||
ChargingState.TARGET_REACHED,
|
||||
}
|
||||
|
||||
NUMBER_TYPES: list[BMWSwitchEntityDescription] = [
|
||||
BMWSwitchEntityDescription(
|
||||
key="climate",
|
||||
translation_key="climate",
|
||||
is_available=lambda v: v.is_remote_climate_stop_enabled,
|
||||
value_fn=lambda v: v.climate.is_climate_on,
|
||||
remote_service_on=lambda v: v.remote_services.trigger_remote_air_conditioning(),
|
||||
remote_service_off=lambda v: (
|
||||
v.remote_services.trigger_remote_air_conditioning_stop()
|
||||
),
|
||||
),
|
||||
BMWSwitchEntityDescription(
|
||||
key="charging",
|
||||
translation_key="charging",
|
||||
is_available=lambda v: v.is_remote_charge_stop_enabled,
|
||||
value_fn=lambda v: v.fuel_and_battery.charging_status in CHARGING_STATE_ON,
|
||||
remote_service_on=lambda v: v.remote_services.trigger_charge_start(),
|
||||
remote_service_off=lambda v: v.remote_services.trigger_charge_stop(),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BMWConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the MyBMW switch from config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[BMWSwitch] = []
|
||||
|
||||
for vehicle in coordinator.account.vehicles:
|
||||
if not coordinator.read_only:
|
||||
entities.extend(
|
||||
[
|
||||
BMWSwitch(coordinator, vehicle, description)
|
||||
for description in NUMBER_TYPES
|
||||
if description.is_available(vehicle)
|
||||
]
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BMWSwitch(BMWBaseEntity, SwitchEntity):
|
||||
"""Representation of BMW Switch entity."""
|
||||
|
||||
entity_description: BMWSwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
description: BMWSwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize an BMW Switch."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return the entity value to represent the entity state."""
|
||||
return self.entity_description.value_fn(self.vehicle)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
try:
|
||||
await self.entity_description.remote_service_on(self.vehicle)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
self.coordinator.async_update_listeners()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
try:
|
||||
await self.entity_description.remote_service_off(self.vehicle)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_service_error",
|
||||
translation_placeholders={"exception": str(ex)},
|
||||
) from ex
|
||||
self.coordinator.async_update_listeners()
|
||||
@@ -50,8 +50,12 @@ rules:
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Device can't be discovered
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Device can't be discovered
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Virtual integration: MINI Connected."""
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"domain": "mini_connected",
|
||||
"name": "MINI Connected",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "bmw_connected_drive"
|
||||
}
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -100,7 +100,6 @@ FLOWS = {
|
||||
"bluemaestro",
|
||||
"bluesound",
|
||||
"bluetooth",
|
||||
"bmw_connected_drive",
|
||||
"bond",
|
||||
"bosch_alarm",
|
||||
"bosch_shc",
|
||||
|
||||
@@ -817,12 +817,6 @@
|
||||
"config_flow": false,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
"bmw_connected_drive": {
|
||||
"name": "BMW Connected Drive",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"bond": {
|
||||
"name": "Bond",
|
||||
"integration_type": "hub",
|
||||
@@ -4207,11 +4201,6 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"mini_connected": {
|
||||
"name": "MINI Connected",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "bmw_connected_drive"
|
||||
},
|
||||
"minio": {
|
||||
"name": "Minio",
|
||||
"integration_type": "hub",
|
||||
|
||||
10
mypy.ini
generated
10
mypy.ini
generated
@@ -985,16 +985,6 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.bmw_connected_drive.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.bond.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
3
requirements_all.txt
generated
3
requirements_all.txt
generated
@@ -631,9 +631,6 @@ beautifulsoup4==4.13.3
|
||||
# homeassistant.components.beewi_smartclim
|
||||
# beewi-smartclim==0.0.10
|
||||
|
||||
# homeassistant.components.bmw_connected_drive
|
||||
bimmer-connected[china]==0.17.3
|
||||
|
||||
# homeassistant.components.bizkaibus
|
||||
bizkaibus==0.1.1
|
||||
|
||||
|
||||
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@@ -571,9 +571,6 @@ base36==0.1.1
|
||||
# homeassistant.components.scrape
|
||||
beautifulsoup4==4.13.3
|
||||
|
||||
# homeassistant.components.bmw_connected_drive
|
||||
bimmer-connected[china]==0.17.3
|
||||
|
||||
# homeassistant.components.esphome
|
||||
bleak-esphome==3.7.1
|
||||
|
||||
|
||||
@@ -1186,7 +1186,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
|
||||
"bluetooth",
|
||||
"bluetooth_adapters",
|
||||
"bluetooth_le_tracker",
|
||||
"bmw_connected_drive",
|
||||
"bond",
|
||||
"bosch_shc",
|
||||
"braviatv",
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Tests for the for the BMW Connected Drive integration."""
|
||||
|
||||
from bimmer_connected.const import (
|
||||
REMOTE_SERVICE_V4_BASE_URL,
|
||||
VEHICLE_CHARGING_BASE_URL,
|
||||
VEHICLE_POI_URL,
|
||||
)
|
||||
import respx
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.bmw_connected_drive.const import (
|
||||
CONF_CAPTCHA_TOKEN,
|
||||
CONF_GCID,
|
||||
CONF_READ_ONLY,
|
||||
CONF_REFRESH_TOKEN,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
FIXTURE_USER_INPUT = {
|
||||
CONF_USERNAME: "user@domain.com",
|
||||
CONF_PASSWORD: "p4ssw0rd",
|
||||
CONF_REGION: "rest_of_world",
|
||||
}
|
||||
FIXTURE_CAPTCHA_INPUT = {
|
||||
CONF_CAPTCHA_TOKEN: "captcha_token",
|
||||
}
|
||||
FIXTURE_USER_INPUT_W_CAPTCHA = FIXTURE_USER_INPUT | FIXTURE_CAPTCHA_INPUT
|
||||
FIXTURE_REFRESH_TOKEN = "another_token_string"
|
||||
FIXTURE_GCID = "DUMMY"
|
||||
|
||||
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],
|
||||
CONF_REFRESH_TOKEN: FIXTURE_REFRESH_TOKEN,
|
||||
CONF_GCID: FIXTURE_GCID,
|
||||
},
|
||||
"options": {CONF_READ_ONLY: False},
|
||||
"source": config_entries.SOURCE_USER,
|
||||
"unique_id": f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_USERNAME]}",
|
||||
}
|
||||
|
||||
REMOTE_SERVICE_EXC_REASON = "HTTPStatusError: 502 Bad Gateway"
|
||||
REMOTE_SERVICE_EXC_TRANSLATION = (
|
||||
"Error executing remote service on vehicle. HTTPStatusError: 502 Bad Gateway"
|
||||
)
|
||||
|
||||
BIMMER_CONNECTED_LOGIN_PATCH = (
|
||||
"homeassistant.components.bmw_connected_drive.config_flow.MyBMWAuthentication.login"
|
||||
)
|
||||
BIMMER_CONNECTED_VEHICLE_PATCH = (
|
||||
"homeassistant.components.bmw_connected_drive.coordinator.MyBMWAccount.get_vehicles"
|
||||
)
|
||||
|
||||
|
||||
async def setup_mocked_integration(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Mock a fully setup config entry and all components based on fixtures."""
|
||||
|
||||
# Mock config entry and add to HA
|
||||
mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return mock_config_entry
|
||||
|
||||
|
||||
def check_remote_service_call(
|
||||
router: respx.MockRouter,
|
||||
remote_service: str | None = None,
|
||||
remote_service_params: dict | None = None,
|
||||
remote_service_payload: dict | None = None,
|
||||
):
|
||||
"""Check if the last call was a successful remote service call."""
|
||||
|
||||
# Check if remote service call was made correctly
|
||||
if remote_service:
|
||||
# Get remote service call
|
||||
first_remote_service_call: respx.models.Call = next(
|
||||
c
|
||||
for c in router.calls
|
||||
if c.request.url.path.startswith(REMOTE_SERVICE_V4_BASE_URL)
|
||||
or c.request.url.path.startswith(
|
||||
VEHICLE_CHARGING_BASE_URL.replace("/{vin}", "")
|
||||
)
|
||||
or c.request.url.path.endswith(VEHICLE_POI_URL.rsplit("/", maxsplit=1)[-1])
|
||||
)
|
||||
assert (
|
||||
first_remote_service_call.request.url.path.endswith(remote_service) is True
|
||||
)
|
||||
assert first_remote_service_call.has_response is True
|
||||
assert first_remote_service_call.response.is_success is True
|
||||
|
||||
# test params.
|
||||
# we don't test payload as this creates a lot of noise in the tests
|
||||
# and is end-to-end tested with the HA states
|
||||
if remote_service_params:
|
||||
assert (
|
||||
dict(first_remote_service_call.request.url.params.items())
|
||||
== remote_service_params
|
||||
)
|
||||
|
||||
# Send POI doesn't return a status response, so we can't check it
|
||||
if remote_service == "send-to-car":
|
||||
return
|
||||
|
||||
# Now check final result
|
||||
last_event_status_call = next(
|
||||
c for c in reversed(router.calls) if c.request.url.path.endswith("eventStatus")
|
||||
)
|
||||
|
||||
assert last_event_status_call is not None
|
||||
assert (
|
||||
last_event_status_call.request.url.path
|
||||
== "/eadrax-vrccs/v3/presentation/remote-commands/eventStatus"
|
||||
)
|
||||
assert last_event_status_call.has_response is True
|
||||
assert last_event_status_call.response.is_success is True
|
||||
assert last_event_status_call.response.json() == {"eventStatus": "EXECUTED"}
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Fixtures for BMW tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from bimmer_connected.tests import ALL_CHARGING_SETTINGS, ALL_PROFILES, ALL_STATES
|
||||
from bimmer_connected.tests.common import MyBMWMockRouter
|
||||
from bimmer_connected.vehicle import remote_services
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bmw_fixture(monkeypatch: pytest.MonkeyPatch) -> Generator[respx.MockRouter]:
|
||||
"""Patch MyBMW login API calls."""
|
||||
|
||||
# we use the library's mock router to mock the API calls, but only with a subset of vehicles
|
||||
router = MyBMWMockRouter(
|
||||
vehicles_to_load=[
|
||||
"WBA00000000DEMO01",
|
||||
"WBA00000000DEMO02",
|
||||
"WBA00000000DEMO03",
|
||||
"WBY00000000REXI01",
|
||||
],
|
||||
profiles=ALL_PROFILES,
|
||||
states=ALL_STATES,
|
||||
charging_settings=ALL_CHARGING_SETTINGS,
|
||||
)
|
||||
|
||||
# we don't want to wait when triggering a remote service
|
||||
monkeypatch.setattr(
|
||||
remote_services,
|
||||
"_POLLING_CYCLE",
|
||||
0,
|
||||
)
|
||||
|
||||
with router:
|
||||
yield router
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,932 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_entity_state_attrs[button.i3_rex_activate_air_conditioning-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.i3_rex_activate_air_conditioning',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Activate air conditioning',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Activate air conditioning',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'activate_air_conditioning',
|
||||
'unique_id': 'WBY00000000REXI01-activate_air_conditioning',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i3_rex_activate_air_conditioning-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'i3 (+ REX) Activate air conditioning',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.i3_rex_activate_air_conditioning',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i3_rex_find_vehicle-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.i3_rex_find_vehicle',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Find vehicle',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Find vehicle',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'find_vehicle',
|
||||
'unique_id': 'WBY00000000REXI01-find_vehicle',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i3_rex_find_vehicle-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'i3 (+ REX) Find vehicle',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.i3_rex_find_vehicle',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i3_rex_flash_lights-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.i3_rex_flash_lights',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Flash lights',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Flash lights',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'light_flash',
|
||||
'unique_id': 'WBY00000000REXI01-light_flash',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i3_rex_flash_lights-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'i3 (+ REX) Flash lights',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.i3_rex_flash_lights',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i3_rex_sound_horn-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.i3_rex_sound_horn',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Sound horn',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Sound horn',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'sound_horn',
|
||||
'unique_id': 'WBY00000000REXI01-sound_horn',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i3_rex_sound_horn-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'i3 (+ REX) Sound horn',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.i3_rex_sound_horn',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i4_edrive40_activate_air_conditioning-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.i4_edrive40_activate_air_conditioning',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Activate air conditioning',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Activate air conditioning',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'activate_air_conditioning',
|
||||
'unique_id': 'WBA00000000DEMO02-activate_air_conditioning',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i4_edrive40_activate_air_conditioning-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'i4 eDrive40 Activate air conditioning',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.i4_edrive40_activate_air_conditioning',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i4_edrive40_deactivate_air_conditioning-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.i4_edrive40_deactivate_air_conditioning',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Deactivate air conditioning',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Deactivate air conditioning',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'deactivate_air_conditioning',
|
||||
'unique_id': 'WBA00000000DEMO02-deactivate_air_conditioning',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i4_edrive40_deactivate_air_conditioning-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'i4 eDrive40 Deactivate air conditioning',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.i4_edrive40_deactivate_air_conditioning',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i4_edrive40_find_vehicle-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.i4_edrive40_find_vehicle',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Find vehicle',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Find vehicle',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'find_vehicle',
|
||||
'unique_id': 'WBA00000000DEMO02-find_vehicle',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i4_edrive40_find_vehicle-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'i4 eDrive40 Find vehicle',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.i4_edrive40_find_vehicle',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i4_edrive40_flash_lights-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.i4_edrive40_flash_lights',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Flash lights',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Flash lights',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'light_flash',
|
||||
'unique_id': 'WBA00000000DEMO02-light_flash',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i4_edrive40_flash_lights-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'i4 eDrive40 Flash lights',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.i4_edrive40_flash_lights',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i4_edrive40_sound_horn-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.i4_edrive40_sound_horn',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Sound horn',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Sound horn',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'sound_horn',
|
||||
'unique_id': 'WBA00000000DEMO02-sound_horn',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.i4_edrive40_sound_horn-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'i4 eDrive40 Sound horn',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.i4_edrive40_sound_horn',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.ix_xdrive50_activate_air_conditioning-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.ix_xdrive50_activate_air_conditioning',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Activate air conditioning',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Activate air conditioning',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'activate_air_conditioning',
|
||||
'unique_id': 'WBA00000000DEMO01-activate_air_conditioning',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.ix_xdrive50_activate_air_conditioning-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'iX xDrive50 Activate air conditioning',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.ix_xdrive50_activate_air_conditioning',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.ix_xdrive50_deactivate_air_conditioning-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.ix_xdrive50_deactivate_air_conditioning',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Deactivate air conditioning',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Deactivate air conditioning',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'deactivate_air_conditioning',
|
||||
'unique_id': 'WBA00000000DEMO01-deactivate_air_conditioning',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.ix_xdrive50_deactivate_air_conditioning-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'iX xDrive50 Deactivate air conditioning',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.ix_xdrive50_deactivate_air_conditioning',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.ix_xdrive50_find_vehicle-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.ix_xdrive50_find_vehicle',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Find vehicle',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Find vehicle',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'find_vehicle',
|
||||
'unique_id': 'WBA00000000DEMO01-find_vehicle',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.ix_xdrive50_find_vehicle-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'iX xDrive50 Find vehicle',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.ix_xdrive50_find_vehicle',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.ix_xdrive50_flash_lights-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.ix_xdrive50_flash_lights',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Flash lights',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Flash lights',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'light_flash',
|
||||
'unique_id': 'WBA00000000DEMO01-light_flash',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.ix_xdrive50_flash_lights-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'iX xDrive50 Flash lights',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.ix_xdrive50_flash_lights',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.ix_xdrive50_sound_horn-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.ix_xdrive50_sound_horn',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Sound horn',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Sound horn',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'sound_horn',
|
||||
'unique_id': 'WBA00000000DEMO01-sound_horn',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.ix_xdrive50_sound_horn-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'iX xDrive50 Sound horn',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.ix_xdrive50_sound_horn',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.m340i_xdrive_activate_air_conditioning-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.m340i_xdrive_activate_air_conditioning',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Activate air conditioning',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Activate air conditioning',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'activate_air_conditioning',
|
||||
'unique_id': 'WBA00000000DEMO03-activate_air_conditioning',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.m340i_xdrive_activate_air_conditioning-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'M340i xDrive Activate air conditioning',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.m340i_xdrive_activate_air_conditioning',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.m340i_xdrive_deactivate_air_conditioning-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.m340i_xdrive_deactivate_air_conditioning',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Deactivate air conditioning',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Deactivate air conditioning',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'deactivate_air_conditioning',
|
||||
'unique_id': 'WBA00000000DEMO03-deactivate_air_conditioning',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.m340i_xdrive_deactivate_air_conditioning-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'M340i xDrive Deactivate air conditioning',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.m340i_xdrive_deactivate_air_conditioning',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.m340i_xdrive_find_vehicle-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.m340i_xdrive_find_vehicle',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Find vehicle',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Find vehicle',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'find_vehicle',
|
||||
'unique_id': 'WBA00000000DEMO03-find_vehicle',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.m340i_xdrive_find_vehicle-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'M340i xDrive Find vehicle',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.m340i_xdrive_find_vehicle',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.m340i_xdrive_flash_lights-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.m340i_xdrive_flash_lights',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Flash lights',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Flash lights',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'light_flash',
|
||||
'unique_id': 'WBA00000000DEMO03-light_flash',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.m340i_xdrive_flash_lights-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'M340i xDrive Flash lights',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.m340i_xdrive_flash_lights',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.m340i_xdrive_sound_horn-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.m340i_xdrive_sound_horn',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Sound horn',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Sound horn',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'sound_horn',
|
||||
'unique_id': 'WBA00000000DEMO03-sound_horn',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[button.m340i_xdrive_sound_horn-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'M340i xDrive Sound horn',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.m340i_xdrive_sound_horn',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,205 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_entity_state_attrs[lock.i3_rex_lock-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'lock',
|
||||
'entity_category': None,
|
||||
'entity_id': 'lock.i3_rex_lock',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Lock',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Lock',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'lock',
|
||||
'unique_id': 'WBY00000000REXI01-lock',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[lock.i3_rex_lock-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'door_lock_state': 'UNLOCKED',
|
||||
'friendly_name': 'i3 (+ REX) Lock',
|
||||
'supported_features': <LockEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'lock.i3_rex_lock',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unlocked',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[lock.i4_edrive40_lock-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'lock',
|
||||
'entity_category': None,
|
||||
'entity_id': 'lock.i4_edrive40_lock',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Lock',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Lock',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'lock',
|
||||
'unique_id': 'WBA00000000DEMO02-lock',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[lock.i4_edrive40_lock-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'door_lock_state': 'LOCKED',
|
||||
'friendly_name': 'i4 eDrive40 Lock',
|
||||
'supported_features': <LockEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'lock.i4_edrive40_lock',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'locked',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[lock.ix_xdrive50_lock-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'lock',
|
||||
'entity_category': None,
|
||||
'entity_id': 'lock.ix_xdrive50_lock',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Lock',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Lock',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'lock',
|
||||
'unique_id': 'WBA00000000DEMO01-lock',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[lock.ix_xdrive50_lock-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'door_lock_state': 'LOCKED',
|
||||
'friendly_name': 'iX xDrive50 Lock',
|
||||
'supported_features': <LockEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'lock.ix_xdrive50_lock',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'locked',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[lock.m340i_xdrive_lock-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'lock',
|
||||
'entity_category': None,
|
||||
'entity_id': 'lock.m340i_xdrive_lock',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Lock',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Lock',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'lock',
|
||||
'unique_id': 'WBA00000000DEMO03-lock',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[lock.m340i_xdrive_lock-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'door_lock_state': 'LOCKED',
|
||||
'friendly_name': 'M340i xDrive Lock',
|
||||
'supported_features': <LockEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'lock.m340i_xdrive_lock',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'locked',
|
||||
})
|
||||
# ---
|
||||
@@ -1,119 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_entity_state_attrs[number.i4_edrive40_target_soc-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max': 100.0,
|
||||
'min': 20.0,
|
||||
'mode': <NumberMode.SLIDER: 'slider'>,
|
||||
'step': 5.0,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'number',
|
||||
'entity_category': None,
|
||||
'entity_id': 'number.i4_edrive40_target_soc',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Target SoC',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Target SoC',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'target_soc',
|
||||
'unique_id': 'WBA00000000DEMO02-target_soc',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[number.i4_edrive40_target_soc-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'battery',
|
||||
'friendly_name': 'i4 eDrive40 Target SoC',
|
||||
'max': 100.0,
|
||||
'min': 20.0,
|
||||
'mode': <NumberMode.SLIDER: 'slider'>,
|
||||
'step': 5.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'number.i4_edrive40_target_soc',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '80',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[number.ix_xdrive50_target_soc-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max': 100.0,
|
||||
'min': 20.0,
|
||||
'mode': <NumberMode.SLIDER: 'slider'>,
|
||||
'step': 5.0,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'number',
|
||||
'entity_category': None,
|
||||
'entity_id': 'number.ix_xdrive50_target_soc',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Target SoC',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Target SoC',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'target_soc',
|
||||
'unique_id': 'WBA00000000DEMO01-target_soc',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[number.ix_xdrive50_target_soc-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'battery',
|
||||
'friendly_name': 'iX xDrive50 Target SoC',
|
||||
'max': 100.0,
|
||||
'min': 20.0,
|
||||
'mode': <NumberMode.SLIDER: 'slider'>,
|
||||
'step': 5.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'number.ix_xdrive50_target_soc',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '80',
|
||||
})
|
||||
# ---
|
||||
@@ -1,343 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_entity_state_attrs[select.i3_rex_charging_mode-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'immediate_charging',
|
||||
'delayed_charging',
|
||||
'no_action',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'select',
|
||||
'entity_category': None,
|
||||
'entity_id': 'select.i3_rex_charging_mode',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Charging mode',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Charging mode',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'charging_mode',
|
||||
'unique_id': 'WBY00000000REXI01-charging_mode',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[select.i3_rex_charging_mode-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'i3 (+ REX) Charging mode',
|
||||
'options': list([
|
||||
'immediate_charging',
|
||||
'delayed_charging',
|
||||
'no_action',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'select.i3_rex_charging_mode',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'delayed_charging',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[select.i4_edrive40_ac_charging_limit-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
'10',
|
||||
'11',
|
||||
'12',
|
||||
'13',
|
||||
'14',
|
||||
'15',
|
||||
'16',
|
||||
'20',
|
||||
'32',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'select',
|
||||
'entity_category': None,
|
||||
'entity_id': 'select.i4_edrive40_ac_charging_limit',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'AC charging limit',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'AC charging limit',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'ac_limit',
|
||||
'unique_id': 'WBA00000000DEMO02-ac_limit',
|
||||
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[select.i4_edrive40_ac_charging_limit-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'i4 eDrive40 AC charging limit',
|
||||
'options': list([
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
'10',
|
||||
'11',
|
||||
'12',
|
||||
'13',
|
||||
'14',
|
||||
'15',
|
||||
'16',
|
||||
'20',
|
||||
'32',
|
||||
]),
|
||||
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'select.i4_edrive40_ac_charging_limit',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '16',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[select.i4_edrive40_charging_mode-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'immediate_charging',
|
||||
'delayed_charging',
|
||||
'no_action',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'select',
|
||||
'entity_category': None,
|
||||
'entity_id': 'select.i4_edrive40_charging_mode',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Charging mode',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Charging mode',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'charging_mode',
|
||||
'unique_id': 'WBA00000000DEMO02-charging_mode',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[select.i4_edrive40_charging_mode-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'i4 eDrive40 Charging mode',
|
||||
'options': list([
|
||||
'immediate_charging',
|
||||
'delayed_charging',
|
||||
'no_action',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'select.i4_edrive40_charging_mode',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'immediate_charging',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[select.ix_xdrive50_ac_charging_limit-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
'10',
|
||||
'11',
|
||||
'12',
|
||||
'13',
|
||||
'14',
|
||||
'15',
|
||||
'16',
|
||||
'20',
|
||||
'32',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'select',
|
||||
'entity_category': None,
|
||||
'entity_id': 'select.ix_xdrive50_ac_charging_limit',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'AC charging limit',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'AC charging limit',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'ac_limit',
|
||||
'unique_id': 'WBA00000000DEMO01-ac_limit',
|
||||
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[select.ix_xdrive50_ac_charging_limit-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'iX xDrive50 AC charging limit',
|
||||
'options': list([
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
'10',
|
||||
'11',
|
||||
'12',
|
||||
'13',
|
||||
'14',
|
||||
'15',
|
||||
'16',
|
||||
'20',
|
||||
'32',
|
||||
]),
|
||||
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'select.ix_xdrive50_ac_charging_limit',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '16',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[select.ix_xdrive50_charging_mode-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'immediate_charging',
|
||||
'delayed_charging',
|
||||
'no_action',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'select',
|
||||
'entity_category': None,
|
||||
'entity_id': 'select.ix_xdrive50_charging_mode',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Charging mode',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Charging mode',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'charging_mode',
|
||||
'unique_id': 'WBA00000000DEMO01-charging_mode',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[select.ix_xdrive50_charging_mode-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'iX xDrive50 Charging mode',
|
||||
'options': list([
|
||||
'immediate_charging',
|
||||
'delayed_charging',
|
||||
'no_action',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'select.ix_xdrive50_charging_mode',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'immediate_charging',
|
||||
})
|
||||
# ---
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,197 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_entity_state_attrs[switch.i4_edrive40_climate-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': None,
|
||||
'entity_id': 'switch.i4_edrive40_climate',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Climate',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Climate',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'climate',
|
||||
'unique_id': 'WBA00000000DEMO02-climate',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[switch.i4_edrive40_climate-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'i4 eDrive40 Climate',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.i4_edrive40_climate',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[switch.ix_xdrive50_charging-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': None,
|
||||
'entity_id': 'switch.ix_xdrive50_charging',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Charging',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Charging',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'charging',
|
||||
'unique_id': 'WBA00000000DEMO01-charging',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[switch.ix_xdrive50_charging-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'iX xDrive50 Charging',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.ix_xdrive50_charging',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[switch.ix_xdrive50_climate-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': None,
|
||||
'entity_id': 'switch.ix_xdrive50_climate',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Climate',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Climate',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'climate',
|
||||
'unique_id': 'WBA00000000DEMO01-climate',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[switch.ix_xdrive50_climate-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'iX xDrive50 Climate',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.ix_xdrive50_climate',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[switch.m340i_xdrive_climate-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': None,
|
||||
'entity_id': 'switch.m340i_xdrive_climate',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Climate',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Climate',
|
||||
'platform': 'bmw_connected_drive',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'climate',
|
||||
'unique_id': 'WBA00000000DEMO03-climate',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state_attrs[switch.m340i_xdrive_climate-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'M340i xDrive Climate',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.m340i_xdrive_climate',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Test BMW binary sensors."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import setup_mocked_integration
|
||||
|
||||
from tests.common import snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-06-22 10:30:00+00:00")
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_entity_state_attrs(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test binary sensor states and attributes."""
|
||||
|
||||
# Setup component
|
||||
with patch(
|
||||
"homeassistant.components.bmw_connected_drive.PLATFORMS",
|
||||
[Platform.BINARY_SENSOR],
|
||||
):
|
||||
mock_config_entry = await setup_mocked_integration(hass)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
@@ -1,210 +0,0 @@
|
||||
"""Test BMW buttons."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from bimmer_connected.models import MyBMWRemoteServiceError
|
||||
from bimmer_connected.vehicle.remote_services import RemoteServices
|
||||
import pytest
|
||||
import respx
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import (
|
||||
REMOTE_SERVICE_EXC_TRANSLATION,
|
||||
check_remote_service_call,
|
||||
setup_mocked_integration,
|
||||
)
|
||||
|
||||
from tests.common import snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_entity_state_attrs(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test button options and values."""
|
||||
|
||||
# Setup component
|
||||
with patch(
|
||||
"homeassistant.components.bmw_connected_drive.PLATFORMS",
|
||||
[Platform.BUTTON],
|
||||
):
|
||||
mock_config_entry = await setup_mocked_integration(hass)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "remote_service"),
|
||||
[
|
||||
("button.i4_edrive40_flash_lights", "light-flash"),
|
||||
("button.i4_edrive40_sound_horn", "horn-blow"),
|
||||
],
|
||||
)
|
||||
async def test_service_call_success(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
remote_service: str,
|
||||
bmw_fixture: respx.Router,
|
||||
) -> None:
|
||||
"""Test successful button press."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
|
||||
# Test
|
||||
await hass.services.async_call(
|
||||
"button",
|
||||
"press",
|
||||
blocking=True,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
check_remote_service_call(bmw_fixture, remote_service)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
async def test_service_call_fail(
|
||||
hass: HomeAssistant,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test failed button press."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
entity_id = "switch.i4_edrive40_climate"
|
||||
old_value = hass.states.get(entity_id).state
|
||||
|
||||
# Setup exception
|
||||
monkeypatch.setattr(
|
||||
RemoteServices,
|
||||
"trigger_remote_service",
|
||||
AsyncMock(
|
||||
side_effect=MyBMWRemoteServiceError("HTTPStatusError: 502 Bad Gateway")
|
||||
),
|
||||
)
|
||||
|
||||
# Test
|
||||
with pytest.raises(HomeAssistantError, match=REMOTE_SERVICE_EXC_TRANSLATION):
|
||||
await hass.services.async_call(
|
||||
"button",
|
||||
"press",
|
||||
blocking=True,
|
||||
target={"entity_id": "button.i4_edrive40_activate_air_conditioning"},
|
||||
)
|
||||
assert hass.states.get(entity_id).state == old_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"entity_id",
|
||||
"state_entity_id",
|
||||
"new_value",
|
||||
"old_value",
|
||||
"remote_service",
|
||||
"remote_service_params",
|
||||
),
|
||||
[
|
||||
(
|
||||
"button.i4_edrive40_activate_air_conditioning",
|
||||
"switch.i4_edrive40_climate",
|
||||
"on",
|
||||
"off",
|
||||
"climate-now",
|
||||
{"action": "START"},
|
||||
),
|
||||
(
|
||||
"button.i4_edrive40_deactivate_air_conditioning",
|
||||
"switch.i4_edrive40_climate",
|
||||
"off",
|
||||
"on",
|
||||
"climate-now",
|
||||
{"action": "STOP"},
|
||||
),
|
||||
(
|
||||
"button.i4_edrive40_find_vehicle",
|
||||
"device_tracker.i4_edrive40",
|
||||
"not_home",
|
||||
"home",
|
||||
"vehicle-finder",
|
||||
{},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_service_call_success_state_change(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
state_entity_id: str,
|
||||
new_value: str,
|
||||
old_value: str,
|
||||
remote_service: str,
|
||||
remote_service_params: dict,
|
||||
bmw_fixture: respx.Router,
|
||||
) -> None:
|
||||
"""Test successful button press with state change."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
hass.states.async_set(state_entity_id, old_value)
|
||||
assert hass.states.get(state_entity_id).state == old_value
|
||||
|
||||
# Test
|
||||
await hass.services.async_call(
|
||||
"button",
|
||||
"press",
|
||||
blocking=True,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
check_remote_service_call(bmw_fixture, remote_service, remote_service_params)
|
||||
assert hass.states.get(state_entity_id).state == new_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "state_entity_id", "new_attrs", "old_attrs"),
|
||||
[
|
||||
(
|
||||
"button.i4_edrive40_find_vehicle",
|
||||
"device_tracker.i4_edrive40",
|
||||
{"latitude": 12.345, "longitude": 34.5678, "direction": 121},
|
||||
{"latitude": 48.177334, "longitude": 11.556274, "direction": 180},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_service_call_success_attr_change(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
state_entity_id: str,
|
||||
new_attrs: dict,
|
||||
old_attrs: dict,
|
||||
bmw_fixture: respx.Router,
|
||||
) -> None:
|
||||
"""Test successful button press with attribute change."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
|
||||
assert {
|
||||
k: v
|
||||
for k, v in hass.states.get(state_entity_id).attributes.items()
|
||||
if k in old_attrs
|
||||
} == old_attrs
|
||||
|
||||
# Test
|
||||
await hass.services.async_call(
|
||||
"button",
|
||||
"press",
|
||||
blocking=True,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
check_remote_service_call(bmw_fixture)
|
||||
assert {
|
||||
k: v
|
||||
for k, v in hass.states.get(state_entity_id).attributes.items()
|
||||
if k in new_attrs
|
||||
} == new_attrs
|
||||
@@ -1,311 +0,0 @@
|
||||
"""Test the for the BMW Connected Drive config flow."""
|
||||
|
||||
from copy import deepcopy
|
||||
from unittest.mock import patch
|
||||
|
||||
from bimmer_connected.api.authentication import MyBMWAuthentication
|
||||
from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError
|
||||
from httpx import RequestError
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN
|
||||
from homeassistant.components.bmw_connected_drive.const import (
|
||||
CONF_CAPTCHA_TOKEN,
|
||||
CONF_READ_ONLY,
|
||||
CONF_REFRESH_TOKEN,
|
||||
)
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from . import (
|
||||
BIMMER_CONNECTED_LOGIN_PATCH,
|
||||
BIMMER_CONNECTED_VEHICLE_PATCH,
|
||||
FIXTURE_CAPTCHA_INPUT,
|
||||
FIXTURE_CONFIG_ENTRY,
|
||||
FIXTURE_GCID,
|
||||
FIXTURE_REFRESH_TOKEN,
|
||||
FIXTURE_USER_INPUT,
|
||||
FIXTURE_USER_INPUT_W_CAPTCHA,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
FIXTURE_COMPLETE_ENTRY = FIXTURE_CONFIG_ENTRY["data"]
|
||||
FIXTURE_IMPORT_ENTRY = {**FIXTURE_USER_INPUT, CONF_REFRESH_TOKEN: None}
|
||||
|
||||
|
||||
def login_sideeffect(self: MyBMWAuthentication):
|
||||
"""Mock logging in and setting a refresh token."""
|
||||
self.refresh_token = FIXTURE_REFRESH_TOKEN
|
||||
self.gcid = FIXTURE_GCID
|
||||
|
||||
|
||||
async def test_full_user_flow_implementation(hass: HomeAssistant) -> None:
|
||||
"""Test registering an integration and finishing flow works."""
|
||||
with (
|
||||
patch(
|
||||
BIMMER_CONNECTED_LOGIN_PATCH,
|
||||
side_effect=login_sideeffect,
|
||||
autospec=True,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.bmw_connected_drive.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
data=deepcopy(FIXTURE_USER_INPUT),
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "captcha"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_CAPTCHA_INPUT
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME]
|
||||
assert result["data"] == FIXTURE_COMPLETE_ENTRY
|
||||
assert (
|
||||
result["result"].unique_id
|
||||
== f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_USERNAME]}"
|
||||
)
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "error"),
|
||||
[
|
||||
(MyBMWAuthError("Login failed"), "invalid_auth"),
|
||||
(RequestError("Connection reset"), "cannot_connect"),
|
||||
(MyBMWAPIError("400 Bad Request"), "cannot_connect"),
|
||||
],
|
||||
)
|
||||
async def test_error_display_with_successful_login(
|
||||
hass: HomeAssistant, side_effect: Exception, error: str
|
||||
) -> None:
|
||||
"""Test we show user form on MyBMW authentication error and are still able to succeed."""
|
||||
|
||||
with patch(
|
||||
BIMMER_CONNECTED_LOGIN_PATCH,
|
||||
side_effect=side_effect,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA),
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": error}
|
||||
|
||||
with (
|
||||
patch(
|
||||
BIMMER_CONNECTED_LOGIN_PATCH,
|
||||
side_effect=login_sideeffect,
|
||||
autospec=True,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.bmw_connected_drive.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
deepcopy(FIXTURE_USER_INPUT),
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "captcha"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_CAPTCHA_INPUT
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME]
|
||||
assert result["data"] == FIXTURE_COMPLETE_ENTRY
|
||||
assert (
|
||||
result["result"].unique_id
|
||||
== f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_USERNAME]}"
|
||||
)
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_unique_id_existing(hass: HomeAssistant) -> None:
|
||||
"""Test registering an integration and when the unique id already exists."""
|
||||
|
||||
mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with (
|
||||
patch(
|
||||
BIMMER_CONNECTED_LOGIN_PATCH,
|
||||
side_effect=login_sideeffect,
|
||||
autospec=True,
|
||||
),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
data=deepcopy(FIXTURE_USER_INPUT),
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
async def test_captcha_flow_missing_error(hass: HomeAssistant) -> None:
|
||||
"""Test the external flow with captcha failing once and succeeding the second time."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
data=deepcopy(FIXTURE_USER_INPUT),
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "captcha"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_CAPTCHA_TOKEN: " "}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "missing_captcha"}
|
||||
|
||||
|
||||
async def test_options_flow_implementation(hass: HomeAssistant) -> None:
|
||||
"""Test config flow options."""
|
||||
with (
|
||||
patch(
|
||||
BIMMER_CONNECTED_VEHICLE_PATCH,
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.bmw_connected_drive.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry,
|
||||
):
|
||||
config_entry_args = deepcopy(FIXTURE_CONFIG_ENTRY)
|
||||
config_entry = MockConfigEntry(**config_entry_args)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "account_options"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_READ_ONLY: True},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_READ_ONLY: True,
|
||||
}
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 2
|
||||
|
||||
|
||||
async def test_reauth(hass: HomeAssistant) -> None:
|
||||
"""Test the reauth form."""
|
||||
with (
|
||||
patch(
|
||||
BIMMER_CONNECTED_LOGIN_PATCH,
|
||||
side_effect=login_sideeffect,
|
||||
autospec=True,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.bmw_connected_drive.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry,
|
||||
):
|
||||
wrong_password = "wrong"
|
||||
|
||||
config_entry_with_wrong_password = deepcopy(FIXTURE_CONFIG_ENTRY)
|
||||
config_entry_with_wrong_password["data"][CONF_PASSWORD] = wrong_password
|
||||
|
||||
config_entry = MockConfigEntry(**config_entry_with_wrong_password)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.data == config_entry_with_wrong_password["data"]
|
||||
|
||||
result = await config_entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "change_password"
|
||||
assert set(result["data_schema"].schema) == {CONF_PASSWORD}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "captcha"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_CAPTCHA_INPUT
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert config_entry.data == FIXTURE_COMPLETE_ENTRY
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 2
|
||||
|
||||
|
||||
async def test_reconfigure(hass: HomeAssistant) -> None:
|
||||
"""Test the reconfiguration form."""
|
||||
with (
|
||||
patch(
|
||||
BIMMER_CONNECTED_LOGIN_PATCH, side_effect=login_sideeffect, autospec=True
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.bmw_connected_drive.async_setup_entry",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "change_password"
|
||||
assert set(result["data_schema"].schema) == {CONF_PASSWORD}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "captcha"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_CAPTCHA_INPUT
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
assert config_entry.data == FIXTURE_COMPLETE_ENTRY
|
||||
@@ -1,244 +0,0 @@
|
||||
"""Test BMW coordinator for general availability/unavailability of entities and raising issues."""
|
||||
|
||||
from copy import deepcopy
|
||||
from unittest.mock import patch
|
||||
|
||||
from bimmer_connected.models import (
|
||||
MyBMWAPIError,
|
||||
MyBMWAuthError,
|
||||
MyBMWCaptchaMissingError,
|
||||
)
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.bmw_connected_drive import DOMAIN
|
||||
from homeassistant.components.bmw_connected_drive.const import (
|
||||
CONF_REFRESH_TOKEN,
|
||||
SCAN_INTERVALS,
|
||||
)
|
||||
from homeassistant.const import CONF_REGION
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from . import BIMMER_CONNECTED_VEHICLE_PATCH, FIXTURE_CONFIG_ENTRY
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
FIXTURE_ENTITY_STATES = {
|
||||
"binary_sensor.m340i_xdrive_door_lock_state": "off",
|
||||
"lock.m340i_xdrive_lock": "locked",
|
||||
"lock.i3_rex_lock": "unlocked",
|
||||
"number.ix_xdrive50_target_soc": "80",
|
||||
"sensor.ix_xdrive50_rear_left_tire_pressure": "2.61",
|
||||
"sensor.ix_xdrive50_rear_right_tire_pressure": "2.69",
|
||||
}
|
||||
FIXTURE_DEFAULT_REGION = FIXTURE_CONFIG_ENTRY["data"][CONF_REGION]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
async def test_config_entry_update(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test if the coordinator updates the refresh token in config entry."""
|
||||
config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY)
|
||||
config_entry_fixure["data"][CONF_REFRESH_TOKEN] = "old_token"
|
||||
config_entry = MockConfigEntry(**config_entry_fixure)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
assert (
|
||||
hass.config_entries.async_get_entry(config_entry.entry_id).data[
|
||||
CONF_REFRESH_TOKEN
|
||||
]
|
||||
== "old_token"
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (
|
||||
hass.config_entries.async_get_entry(config_entry.entry_id).data[
|
||||
CONF_REFRESH_TOKEN
|
||||
]
|
||||
== "another_token_string"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
async def test_update_failed(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test a failing API call."""
|
||||
config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Test if entities show data correctly
|
||||
for entity_id, state in FIXTURE_ENTITY_STATES.items():
|
||||
assert hass.states.get(entity_id).state == state
|
||||
|
||||
# On API error, entities should be unavailable
|
||||
freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION])
|
||||
with patch(
|
||||
BIMMER_CONNECTED_VEHICLE_PATCH,
|
||||
side_effect=MyBMWAPIError("Test error"),
|
||||
):
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for entity_id in FIXTURE_ENTITY_STATES:
|
||||
assert hass.states.get(entity_id).state == "unavailable"
|
||||
|
||||
# And should recover on next update
|
||||
freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION])
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for entity_id, state in FIXTURE_ENTITY_STATES.items():
|
||||
assert hass.states.get(entity_id).state == state
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
async def test_auth_failed_as_update_failed(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test a single auth failure not initializing reauth flow."""
|
||||
config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Test if entities show data correctly
|
||||
for entity_id, state in FIXTURE_ENTITY_STATES.items():
|
||||
assert hass.states.get(entity_id).state == state
|
||||
|
||||
# Due to flaky API, we allow one retry on AuthError and raise as UpdateFailed
|
||||
freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION])
|
||||
with patch(
|
||||
BIMMER_CONNECTED_VEHICLE_PATCH,
|
||||
side_effect=MyBMWAuthError("Test error"),
|
||||
):
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for entity_id in FIXTURE_ENTITY_STATES:
|
||||
assert hass.states.get(entity_id).state == "unavailable"
|
||||
|
||||
# And should recover on next update
|
||||
freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION])
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for entity_id, state in FIXTURE_ENTITY_STATES.items():
|
||||
assert hass.states.get(entity_id).state == state
|
||||
|
||||
# Verify that no issues are raised and no reauth flow is initialized
|
||||
assert len(issue_registry.issues) == 0
|
||||
assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
async def test_auth_failed_init_reauth(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test a two subsequent auth failures initializing reauth flow."""
|
||||
|
||||
config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Test if entities show data correctly
|
||||
for entity_id, state in FIXTURE_ENTITY_STATES.items():
|
||||
assert hass.states.get(entity_id).state == state
|
||||
assert len(issue_registry.issues) == 0
|
||||
|
||||
# Due to flaky API, we allow one retry on AuthError and raise as UpdateFailed
|
||||
freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION])
|
||||
with patch(
|
||||
BIMMER_CONNECTED_VEHICLE_PATCH,
|
||||
side_effect=MyBMWAuthError("Test error"),
|
||||
):
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for entity_id in FIXTURE_ENTITY_STATES:
|
||||
assert hass.states.get(entity_id).state == "unavailable"
|
||||
assert len(issue_registry.issues) == 0
|
||||
|
||||
# On second failure, we should initialize reauth flow
|
||||
freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION])
|
||||
with patch(
|
||||
BIMMER_CONNECTED_VEHICLE_PATCH,
|
||||
side_effect=MyBMWAuthError("Test error"),
|
||||
):
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for entity_id in FIXTURE_ENTITY_STATES:
|
||||
assert hass.states.get(entity_id).state == "unavailable"
|
||||
assert len(issue_registry.issues) == 1
|
||||
|
||||
reauth_issue = issue_registry.async_get_issue(
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"config_entry_reauth_{DOMAIN}_{config_entry.entry_id}",
|
||||
)
|
||||
assert reauth_issue.active is True
|
||||
|
||||
# Check if reauth flow is initialized correctly
|
||||
flow = hass.config_entries.flow.async_get(reauth_issue.data["flow_id"])
|
||||
assert flow["handler"] == DOMAIN
|
||||
assert flow["context"]["source"] == "reauth"
|
||||
assert flow["context"]["unique_id"] == config_entry.unique_id
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
async def test_captcha_reauth(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test a CaptchaError initializing reauth flow."""
|
||||
config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Test if entities show data correctly
|
||||
for entity_id, state in FIXTURE_ENTITY_STATES.items():
|
||||
assert hass.states.get(entity_id).state == state
|
||||
|
||||
# If library decides a captcha is needed, we should initialize reauth flow
|
||||
freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION])
|
||||
with patch(
|
||||
BIMMER_CONNECTED_VEHICLE_PATCH,
|
||||
side_effect=MyBMWCaptchaMissingError("Missing hCaptcha token"),
|
||||
):
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for entity_id in FIXTURE_ENTITY_STATES:
|
||||
assert hass.states.get(entity_id).state == "unavailable"
|
||||
assert len(issue_registry.issues) == 1
|
||||
|
||||
reauth_issue = issue_registry.async_get_issue(
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"config_entry_reauth_{DOMAIN}_{config_entry.entry_id}",
|
||||
)
|
||||
assert reauth_issue.active is True
|
||||
|
||||
# Check if reauth flow is initialized correctly
|
||||
flow = hass.config_entries.flow.async_get(reauth_issue.data["flow_id"])
|
||||
assert flow["handler"] == DOMAIN
|
||||
assert flow["context"]["source"] == "reauth"
|
||||
assert flow["context"]["unique_id"] == config_entry.unique_id
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Test BMW diagnostics."""
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.bmw_connected_drive.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from . import setup_mocked_integration
|
||||
|
||||
from tests.components.diagnostics import (
|
||||
get_diagnostics_for_config_entry,
|
||||
get_diagnostics_for_device,
|
||||
)
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
@pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11, tzinfo=datetime.UTC))
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_config_entry_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test config entry diagnostics."""
|
||||
|
||||
mock_config_entry = await setup_mocked_integration(hass)
|
||||
|
||||
diagnostics = await get_diagnostics_for_config_entry(
|
||||
hass, hass_client, mock_config_entry
|
||||
)
|
||||
|
||||
assert diagnostics == snapshot
|
||||
|
||||
|
||||
@pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11, tzinfo=datetime.UTC))
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_device_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test device diagnostics."""
|
||||
|
||||
mock_config_entry = await setup_mocked_integration(hass)
|
||||
|
||||
reg_device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "WBY00000000REXI01")},
|
||||
)
|
||||
assert reg_device is not None
|
||||
|
||||
diagnostics = await get_diagnostics_for_device(
|
||||
hass, hass_client, mock_config_entry, reg_device
|
||||
)
|
||||
|
||||
assert diagnostics == snapshot
|
||||
|
||||
|
||||
@pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11, tzinfo=datetime.UTC))
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_device_diagnostics_vehicle_not_found(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test device diagnostics when the vehicle cannot be found."""
|
||||
|
||||
mock_config_entry = await setup_mocked_integration(hass)
|
||||
|
||||
reg_device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "WBY00000000REXI01")},
|
||||
)
|
||||
assert reg_device is not None
|
||||
|
||||
# Change vehicle identifier so that vehicle will not be found
|
||||
device_registry.async_update_device(
|
||||
reg_device.id, new_identifiers={(DOMAIN, "WBY00000000REXI99")}
|
||||
)
|
||||
|
||||
diagnostics = await get_diagnostics_for_device(
|
||||
hass, hass_client, mock_config_entry, reg_device
|
||||
)
|
||||
|
||||
assert diagnostics == snapshot
|
||||
@@ -1,261 +0,0 @@
|
||||
"""Test Axis component setup process."""
|
||||
|
||||
from copy import deepcopy
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.bmw_connected_drive import DEFAULT_OPTIONS
|
||||
from homeassistant.components.bmw_connected_drive.const import CONF_READ_ONLY, DOMAIN
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from . import BIMMER_CONNECTED_VEHICLE_PATCH, FIXTURE_CONFIG_ENTRY
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
BINARY_SENSOR_DOMAIN = Platform.BINARY_SENSOR.value
|
||||
SENSOR_DOMAIN = Platform.SENSOR.value
|
||||
|
||||
VIN = "WBYYYYYYYYYYYYYYY"
|
||||
VEHICLE_NAME = "i3 (+ REX)"
|
||||
VEHICLE_NAME_SLUG = "i3_rex"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.parametrize(
|
||||
"options",
|
||||
[
|
||||
DEFAULT_OPTIONS,
|
||||
{"other_value": 1, **DEFAULT_OPTIONS},
|
||||
{},
|
||||
],
|
||||
)
|
||||
async def test_migrate_options(
|
||||
hass: HomeAssistant,
|
||||
options: dict,
|
||||
) -> None:
|
||||
"""Test successful migration of options."""
|
||||
|
||||
config_entry = deepcopy(FIXTURE_CONFIG_ENTRY)
|
||||
config_entry["options"] = options
|
||||
|
||||
mock_config_entry = MockConfigEntry(**config_entry)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(
|
||||
hass.config_entries.async_get_entry(mock_config_entry.entry_id).options
|
||||
) == len(DEFAULT_OPTIONS)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
async def test_migrate_options_from_data(hass: HomeAssistant) -> None:
|
||||
"""Test successful migration of options."""
|
||||
|
||||
config_entry = deepcopy(FIXTURE_CONFIG_ENTRY)
|
||||
config_entry["options"] = {}
|
||||
config_entry["data"].update({CONF_READ_ONLY: False})
|
||||
|
||||
mock_config_entry = MockConfigEntry(**config_entry)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
updated_config_entry = hass.config_entries.async_get_entry(
|
||||
mock_config_entry.entry_id
|
||||
)
|
||||
assert len(updated_config_entry.options) == len(DEFAULT_OPTIONS)
|
||||
assert CONF_READ_ONLY not in updated_config_entry.data
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entitydata", "old_unique_id", "new_unique_id"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"domain": SENSOR_DOMAIN,
|
||||
"platform": 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}-fuel_and_battery.remaining_battery_percent",
|
||||
),
|
||||
(
|
||||
{
|
||||
"domain": SENSOR_DOMAIN,
|
||||
"platform": 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}-fuel_and_battery.remaining_range_total",
|
||||
),
|
||||
(
|
||||
{
|
||||
"domain": SENSOR_DOMAIN,
|
||||
"platform": DOMAIN,
|
||||
"unique_id": f"{VIN}-mileage",
|
||||
"suggested_object_id": f"{VEHICLE_NAME} mileage",
|
||||
"disabled_by": None,
|
||||
},
|
||||
f"{VIN}-mileage",
|
||||
f"{VIN}-mileage",
|
||||
),
|
||||
(
|
||||
{
|
||||
"domain": SENSOR_DOMAIN,
|
||||
"platform": DOMAIN,
|
||||
"unique_id": f"{VIN}-charging_status",
|
||||
"suggested_object_id": f"{VEHICLE_NAME} Charging Status",
|
||||
"disabled_by": None,
|
||||
},
|
||||
f"{VIN}-charging_status",
|
||||
f"{VIN}-fuel_and_battery.charging_status",
|
||||
),
|
||||
(
|
||||
{
|
||||
"domain": BINARY_SENSOR_DOMAIN,
|
||||
"platform": DOMAIN,
|
||||
"unique_id": f"{VIN}-charging_status",
|
||||
"suggested_object_id": f"{VEHICLE_NAME} Charging Status",
|
||||
"disabled_by": None,
|
||||
},
|
||||
f"{VIN}-charging_status",
|
||||
f"{VIN}-charging_status",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_migrate_unique_ids(
|
||||
hass: HomeAssistant,
|
||||
entitydata: dict,
|
||||
old_unique_id: str,
|
||||
new_unique_id: str,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test successful migration of entity unique_ids."""
|
||||
confg_entry = deepcopy(FIXTURE_CONFIG_ENTRY)
|
||||
mock_config_entry = MockConfigEntry(**confg_entry)
|
||||
mock_config_entry.add_to_hass(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_VEHICLE_PATCH,
|
||||
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": 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}-fuel_and_battery.remaining_battery_percent",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_dont_migrate_unique_ids(
|
||||
hass: HomeAssistant,
|
||||
entitydata: dict,
|
||||
old_unique_id: str,
|
||||
new_unique_id: str,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test successful migration of entity unique_ids."""
|
||||
confg_entry = deepcopy(FIXTURE_CONFIG_ENTRY)
|
||||
mock_config_entry = MockConfigEntry(**confg_entry)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
# create existing entry with new_unique_id
|
||||
existing_entity = entity_registry.async_get_or_create(
|
||||
SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
unique_id=f"{VIN}-fuel_and_battery.remaining_battery_percent",
|
||||
suggested_object_id=f"{VEHICLE_NAME} fuel_and_battery.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_VEHICLE_PATCH,
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
async def test_remove_stale_devices(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test remove stale device registry entries."""
|
||||
config_entry = deepcopy(FIXTURE_CONFIG_ENTRY)
|
||||
mock_config_entry = MockConfigEntry(**config_entry)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
identifiers={(DOMAIN, "stale_device_id")},
|
||||
)
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
|
||||
assert len(device_entries) == 1
|
||||
device_entry = device_entries[0]
|
||||
assert device_entry.identifiers == {(DOMAIN, "stale_device_id")}
|
||||
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
|
||||
# Check that the test vehicles are still available but not the stale device
|
||||
assert len(device_entries) > 0
|
||||
remaining_device_identifiers = set().union(*(d.identifiers for d in device_entries))
|
||||
assert not {(DOMAIN, "stale_device_id")}.intersection(remaining_device_identifiers)
|
||||
@@ -1,143 +0,0 @@
|
||||
"""Test BMW locks."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from bimmer_connected.models import MyBMWRemoteServiceError
|
||||
from bimmer_connected.vehicle.remote_services import RemoteServices
|
||||
import pytest
|
||||
import respx
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.recorder.history import get_significant_states
|
||||
from homeassistant.const import STATE_UNKNOWN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import (
|
||||
REMOTE_SERVICE_EXC_REASON,
|
||||
REMOTE_SERVICE_EXC_TRANSLATION,
|
||||
check_remote_service_call,
|
||||
setup_mocked_integration,
|
||||
)
|
||||
|
||||
from tests.common import snapshot_platform
|
||||
from tests.components.recorder.common import async_wait_recording_done
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-06-22 10:30:00+00:00")
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_entity_state_attrs(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test lock states and attributes."""
|
||||
|
||||
# Setup component
|
||||
with patch(
|
||||
"homeassistant.components.bmw_connected_drive.PLATFORMS", [Platform.LOCK]
|
||||
):
|
||||
mock_config_entry = await setup_mocked_integration(hass)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("recorder_mock")
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "new_value", "old_value", "service", "remote_service"),
|
||||
[
|
||||
(
|
||||
"lock.m340i_xdrive_lock",
|
||||
"locked",
|
||||
"unlocked",
|
||||
"lock",
|
||||
"door-lock",
|
||||
),
|
||||
("lock.m340i_xdrive_lock", "unlocked", "locked", "unlock", "door-unlock"),
|
||||
],
|
||||
)
|
||||
async def test_service_call_success(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
new_value: str,
|
||||
old_value: str,
|
||||
service: str,
|
||||
remote_service: str,
|
||||
bmw_fixture: respx.Router,
|
||||
) -> None:
|
||||
"""Test successful service call."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
hass.states.async_set(entity_id, old_value)
|
||||
assert hass.states.get(entity_id).state == old_value
|
||||
|
||||
now = dt_util.utcnow()
|
||||
|
||||
# Test
|
||||
await hass.services.async_call(
|
||||
"lock",
|
||||
service,
|
||||
blocking=True,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
check_remote_service_call(bmw_fixture, remote_service)
|
||||
assert hass.states.get(entity_id).state == new_value
|
||||
|
||||
# wait for the recorder to really store the data
|
||||
await async_wait_recording_done(hass)
|
||||
states = await hass.async_add_executor_job(
|
||||
get_significant_states, hass, now, None, [entity_id]
|
||||
)
|
||||
assert any(s for s in states[entity_id] if s.state == STATE_UNKNOWN) is False
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.usefixtures("recorder_mock")
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "service"),
|
||||
[
|
||||
("lock.m340i_xdrive_lock", "lock"),
|
||||
("lock.m340i_xdrive_lock", "unlock"),
|
||||
],
|
||||
)
|
||||
async def test_service_call_fail(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
service: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test failed service call."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
old_value = hass.states.get(entity_id).state
|
||||
|
||||
now = dt_util.utcnow()
|
||||
|
||||
# Setup exception
|
||||
monkeypatch.setattr(
|
||||
RemoteServices,
|
||||
"trigger_remote_service",
|
||||
AsyncMock(side_effect=MyBMWRemoteServiceError(REMOTE_SERVICE_EXC_REASON)),
|
||||
)
|
||||
|
||||
# Test
|
||||
with pytest.raises(HomeAssistantError, match=REMOTE_SERVICE_EXC_TRANSLATION):
|
||||
await hass.services.async_call(
|
||||
"lock",
|
||||
service,
|
||||
blocking=True,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
assert hass.states.get(entity_id).state == old_value
|
||||
|
||||
# wait for the recorder to really store the data
|
||||
await async_wait_recording_done(hass)
|
||||
states = await hass.async_add_executor_job(
|
||||
get_significant_states, hass, now, None, [entity_id]
|
||||
)
|
||||
assert states[entity_id][-2].state == STATE_UNKNOWN
|
||||
@@ -1,154 +0,0 @@
|
||||
"""Test BMW numbers."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError, MyBMWRemoteServiceError
|
||||
from bimmer_connected.tests.common import POI_DATA
|
||||
from bimmer_connected.vehicle.remote_services import RemoteServices
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
|
||||
from . import (
|
||||
REMOTE_SERVICE_EXC_TRANSLATION,
|
||||
check_remote_service_call,
|
||||
setup_mocked_integration,
|
||||
)
|
||||
|
||||
|
||||
async def test_legacy_notify_service_simple(
|
||||
hass: HomeAssistant,
|
||||
bmw_fixture: respx.Router,
|
||||
) -> None:
|
||||
"""Test successful sending of POIs."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
|
||||
# Minimal required data
|
||||
await hass.services.async_call(
|
||||
"notify",
|
||||
"bmw_connected_drive_ix_xdrive50",
|
||||
{
|
||||
"message": POI_DATA.get("name"),
|
||||
"data": {
|
||||
"latitude": POI_DATA.get("lat"),
|
||||
"longitude": POI_DATA.get("lon"),
|
||||
},
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
check_remote_service_call(bmw_fixture, "send-to-car")
|
||||
|
||||
bmw_fixture.reset()
|
||||
|
||||
# Full data
|
||||
await hass.services.async_call(
|
||||
"notify",
|
||||
"bmw_connected_drive_ix_xdrive50",
|
||||
{
|
||||
"message": POI_DATA.get("name"),
|
||||
"data": {
|
||||
"latitude": POI_DATA.get("lat"),
|
||||
"longitude": POI_DATA.get("lon"),
|
||||
"street": POI_DATA.get("street"),
|
||||
"city": POI_DATA.get("city"),
|
||||
"postal_code": POI_DATA.get("postal_code"),
|
||||
"country": POI_DATA.get("country"),
|
||||
},
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
check_remote_service_call(bmw_fixture, "send-to-car")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.parametrize(
|
||||
("data", "exc_translation"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"latitude": POI_DATA.get("lat"),
|
||||
},
|
||||
r"Invalid data for point of interest: required key not provided @ data\['longitude'\]",
|
||||
),
|
||||
(
|
||||
{
|
||||
"latitude": POI_DATA.get("lat"),
|
||||
"longitude": "text",
|
||||
},
|
||||
r"Invalid data for point of interest: invalid longitude for dictionary value @ data\['longitude'\]",
|
||||
),
|
||||
(
|
||||
{
|
||||
"latitude": POI_DATA.get("lat"),
|
||||
"longitude": 9999,
|
||||
},
|
||||
r"Invalid data for point of interest: invalid longitude for dictionary value @ data\['longitude'\]",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_service_call_invalid_input(
|
||||
hass: HomeAssistant,
|
||||
data: dict,
|
||||
exc_translation: str,
|
||||
) -> None:
|
||||
"""Test invalid inputs."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
|
||||
with pytest.raises(ServiceValidationError, match=exc_translation):
|
||||
await hass.services.async_call(
|
||||
"notify",
|
||||
"bmw_connected_drive_ix_xdrive50",
|
||||
{
|
||||
"message": POI_DATA.get("name"),
|
||||
"data": data,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.parametrize(
|
||||
("raised", "expected"),
|
||||
[
|
||||
(MyBMWRemoteServiceError, HomeAssistantError),
|
||||
(MyBMWAPIError, HomeAssistantError),
|
||||
],
|
||||
)
|
||||
async def test_service_call_fail(
|
||||
hass: HomeAssistant,
|
||||
raised: Exception,
|
||||
expected: Exception,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test exception handling."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
|
||||
# Setup exception
|
||||
monkeypatch.setattr(
|
||||
RemoteServices,
|
||||
"trigger_remote_service",
|
||||
AsyncMock(side_effect=raised("HTTPStatusError: 502 Bad Gateway")),
|
||||
)
|
||||
|
||||
# Test
|
||||
with pytest.raises(expected, match=REMOTE_SERVICE_EXC_TRANSLATION):
|
||||
await hass.services.async_call(
|
||||
"notify",
|
||||
"bmw_connected_drive_ix_xdrive50",
|
||||
{
|
||||
"message": POI_DATA.get("name"),
|
||||
"data": {
|
||||
"latitude": POI_DATA.get("lat"),
|
||||
"longitude": POI_DATA.get("lon"),
|
||||
},
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Test BMW numbers."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError, MyBMWRemoteServiceError
|
||||
from bimmer_connected.vehicle.remote_services import RemoteServices
|
||||
import pytest
|
||||
import respx
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import (
|
||||
REMOTE_SERVICE_EXC_REASON,
|
||||
REMOTE_SERVICE_EXC_TRANSLATION,
|
||||
check_remote_service_call,
|
||||
setup_mocked_integration,
|
||||
)
|
||||
|
||||
from tests.common import snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_entity_state_attrs(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test number options and values."""
|
||||
|
||||
# Setup component
|
||||
with patch(
|
||||
"homeassistant.components.bmw_connected_drive.PLATFORMS",
|
||||
[Platform.NUMBER],
|
||||
):
|
||||
mock_config_entry = await setup_mocked_integration(hass)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "new_value", "old_value", "remote_service"),
|
||||
[
|
||||
("number.i4_edrive40_target_soc", "80", "100", "charging-settings"),
|
||||
],
|
||||
)
|
||||
async def test_service_call_success(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
new_value: str,
|
||||
old_value: str,
|
||||
remote_service: str,
|
||||
bmw_fixture: respx.Router,
|
||||
) -> None:
|
||||
"""Test successful number change."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
hass.states.async_set(entity_id, old_value)
|
||||
assert hass.states.get(entity_id).state == old_value
|
||||
|
||||
# Test
|
||||
await hass.services.async_call(
|
||||
"number",
|
||||
"set_value",
|
||||
service_data={"value": new_value},
|
||||
blocking=True,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
check_remote_service_call(bmw_fixture, remote_service)
|
||||
assert hass.states.get(entity_id).state == new_value
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "value"),
|
||||
[
|
||||
("number.i4_edrive40_target_soc", "81"),
|
||||
],
|
||||
)
|
||||
async def test_service_call_invalid_input(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
"""Test not allowed values for number inputs."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
old_value = hass.states.get(entity_id).state
|
||||
|
||||
# Test
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Target SoC must be an integer between 20 and 100 that is a multiple of 5.",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
"number",
|
||||
"set_value",
|
||||
service_data={"value": value},
|
||||
blocking=True,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
assert hass.states.get(entity_id).state == old_value
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.parametrize(
|
||||
("raised", "expected", "exc_translation"),
|
||||
[
|
||||
(
|
||||
MyBMWRemoteServiceError(REMOTE_SERVICE_EXC_REASON),
|
||||
HomeAssistantError,
|
||||
REMOTE_SERVICE_EXC_TRANSLATION,
|
||||
),
|
||||
(
|
||||
MyBMWAPIError(REMOTE_SERVICE_EXC_REASON),
|
||||
HomeAssistantError,
|
||||
REMOTE_SERVICE_EXC_TRANSLATION,
|
||||
),
|
||||
(
|
||||
ValueError(
|
||||
"Target SoC must be an integer between 20 and 100 that is a multiple of 5."
|
||||
),
|
||||
ValueError,
|
||||
"Target SoC must be an integer between 20 and 100 that is a multiple of 5.",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_service_call_fail(
|
||||
hass: HomeAssistant,
|
||||
raised: Exception,
|
||||
expected: Exception,
|
||||
exc_translation: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test exception handling."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
entity_id = "number.i4_edrive40_target_soc"
|
||||
old_value = hass.states.get(entity_id).state
|
||||
|
||||
# Setup exception
|
||||
monkeypatch.setattr(
|
||||
RemoteServices,
|
||||
"trigger_remote_service",
|
||||
AsyncMock(side_effect=raised),
|
||||
)
|
||||
|
||||
# Test
|
||||
with pytest.raises(expected, match=exc_translation):
|
||||
await hass.services.async_call(
|
||||
"number",
|
||||
"set_value",
|
||||
service_data={"value": "80"},
|
||||
blocking=True,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
assert hass.states.get(entity_id).state == old_value
|
||||
@@ -1,199 +0,0 @@
|
||||
"""Test BMW selects."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError, MyBMWRemoteServiceError
|
||||
from bimmer_connected.vehicle.remote_services import RemoteServices
|
||||
import pytest
|
||||
import respx
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.bmw_connected_drive import DOMAIN
|
||||
from homeassistant.components.bmw_connected_drive.select import SELECT_TYPES
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
|
||||
from . import (
|
||||
REMOTE_SERVICE_EXC_REASON,
|
||||
REMOTE_SERVICE_EXC_TRANSLATION,
|
||||
check_remote_service_call,
|
||||
setup_mocked_integration,
|
||||
)
|
||||
|
||||
from tests.common import snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_entity_state_attrs(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test select options and values.."""
|
||||
|
||||
# Setup component
|
||||
with patch(
|
||||
"homeassistant.components.bmw_connected_drive.PLATFORMS",
|
||||
[Platform.SELECT],
|
||||
):
|
||||
mock_config_entry = await setup_mocked_integration(hass)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "new_value", "old_value", "remote_service"),
|
||||
[
|
||||
(
|
||||
"select.i3_rex_charging_mode",
|
||||
"immediate_charging",
|
||||
"delayed_charging",
|
||||
"charging-profile",
|
||||
),
|
||||
("select.i4_edrive40_ac_charging_limit", "12", "16", "charging-settings"),
|
||||
(
|
||||
"select.i4_edrive40_charging_mode",
|
||||
"delayed_charging",
|
||||
"immediate_charging",
|
||||
"charging-profile",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_service_call_success(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
new_value: str,
|
||||
old_value: str,
|
||||
remote_service: str,
|
||||
bmw_fixture: respx.Router,
|
||||
) -> None:
|
||||
"""Test successful input change."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
hass.states.async_set(entity_id, old_value)
|
||||
assert hass.states.get(entity_id).state == old_value
|
||||
|
||||
# Test
|
||||
await hass.services.async_call(
|
||||
"select",
|
||||
"select_option",
|
||||
service_data={"option": new_value},
|
||||
blocking=True,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
check_remote_service_call(bmw_fixture, remote_service)
|
||||
assert hass.states.get(entity_id).state == new_value
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "value"),
|
||||
[
|
||||
("select.i4_edrive40_ac_charging_limit", "17"),
|
||||
("select.i4_edrive40_charging_mode", "bonkers_mode"),
|
||||
],
|
||||
)
|
||||
async def test_service_call_invalid_input(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
"""Test not allowed values for select inputs."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
old_value = hass.states.get(entity_id).state
|
||||
|
||||
# Test
|
||||
with pytest.raises(
|
||||
ServiceValidationError,
|
||||
match=f"Option {value} is not valid for entity {entity_id}",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
"select",
|
||||
"select_option",
|
||||
service_data={"option": value},
|
||||
blocking=True,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
assert hass.states.get(entity_id).state == old_value
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.parametrize(
|
||||
("raised", "expected", "exc_translation"),
|
||||
[
|
||||
(
|
||||
MyBMWRemoteServiceError(REMOTE_SERVICE_EXC_REASON),
|
||||
HomeAssistantError,
|
||||
REMOTE_SERVICE_EXC_TRANSLATION,
|
||||
),
|
||||
(
|
||||
MyBMWAPIError(REMOTE_SERVICE_EXC_REASON),
|
||||
HomeAssistantError,
|
||||
REMOTE_SERVICE_EXC_TRANSLATION,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_service_call_fail(
|
||||
hass: HomeAssistant,
|
||||
raised: Exception,
|
||||
expected: Exception,
|
||||
exc_translation: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test exception handling."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
entity_id = "select.i4_edrive40_ac_charging_limit"
|
||||
old_value = hass.states.get(entity_id).state
|
||||
|
||||
# Setup exception
|
||||
monkeypatch.setattr(
|
||||
RemoteServices,
|
||||
"trigger_remote_service",
|
||||
AsyncMock(side_effect=raised),
|
||||
)
|
||||
|
||||
# Test
|
||||
with pytest.raises(expected, match=exc_translation):
|
||||
await hass.services.async_call(
|
||||
"select",
|
||||
"select_option",
|
||||
service_data={"option": "16"},
|
||||
blocking=True,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
assert hass.states.get(entity_id).state == old_value
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
async def test_entity_option_translations(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Ensure all enum sensor values are translated."""
|
||||
|
||||
# Setup component to load translations
|
||||
assert await setup_mocked_integration(hass)
|
||||
|
||||
prefix = f"component.{DOMAIN}.entity.{Platform.SELECT.value}"
|
||||
|
||||
translations = await async_get_translations(hass, "en", "entity", [DOMAIN])
|
||||
translation_states = {
|
||||
k for k in translations if k.startswith(prefix) and ".state." in k
|
||||
}
|
||||
|
||||
sensor_options = {
|
||||
f"{prefix}.{entity_description.translation_key}.state.{option}"
|
||||
for entity_description in SELECT_TYPES
|
||||
if entity_description.options
|
||||
for option in entity_description.options
|
||||
}
|
||||
|
||||
assert sensor_options == translation_states
|
||||
@@ -1,149 +0,0 @@
|
||||
"""Test BMW sensors."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from bimmer_connected.models import StrEnum
|
||||
from bimmer_connected.vehicle import fuel_and_battery
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.bmw_connected_drive import DOMAIN
|
||||
from homeassistant.components.bmw_connected_drive.const import SCAN_INTERVALS
|
||||
from homeassistant.components.bmw_connected_drive.sensor import SENSOR_TYPES
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
from homeassistant.util.unit_system import (
|
||||
METRIC_SYSTEM as METRIC,
|
||||
US_CUSTOMARY_SYSTEM as IMPERIAL,
|
||||
UnitSystem,
|
||||
)
|
||||
|
||||
from . import setup_mocked_integration
|
||||
|
||||
from tests.common import async_fire_time_changed, snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-06-22 10:30:00+00:00")
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_entity_state_attrs(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test sensor options and values.."""
|
||||
|
||||
# Setup component
|
||||
with patch(
|
||||
"homeassistant.components.bmw_connected_drive.PLATFORMS", [Platform.SENSOR]
|
||||
):
|
||||
mock_config_entry = await setup_mocked_integration(hass)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "unit_system", "value", "unit_of_measurement"),
|
||||
[
|
||||
("sensor.i3_rex_remaining_range_total", METRIC, "279", "km"),
|
||||
("sensor.i3_rex_remaining_range_total", IMPERIAL, "173.362562634216", "mi"),
|
||||
("sensor.i3_rex_mileage", METRIC, "137009", "km"),
|
||||
("sensor.i3_rex_mileage", IMPERIAL, "85133.4456772449", "mi"),
|
||||
("sensor.i3_rex_remaining_battery_percent", METRIC, "82", "%"),
|
||||
("sensor.i3_rex_remaining_battery_percent", IMPERIAL, "82", "%"),
|
||||
("sensor.i3_rex_remaining_range_electric", METRIC, "174", "km"),
|
||||
("sensor.i3_rex_remaining_range_electric", IMPERIAL, "108.118587449296", "mi"),
|
||||
("sensor.i3_rex_remaining_fuel", METRIC, "6", "L"),
|
||||
("sensor.i3_rex_remaining_fuel", IMPERIAL, "1.58503231414889", "gal"),
|
||||
("sensor.i3_rex_remaining_range_fuel", METRIC, "105", "km"),
|
||||
("sensor.i3_rex_remaining_range_fuel", IMPERIAL, "65.2439751849201", "mi"),
|
||||
("sensor.m340i_xdrive_remaining_fuel_percent", METRIC, "80", "%"),
|
||||
("sensor.m340i_xdrive_remaining_fuel_percent", IMPERIAL, "80", "%"),
|
||||
],
|
||||
)
|
||||
async def test_unit_conversion(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
unit_system: UnitSystem,
|
||||
value: str,
|
||||
unit_of_measurement: str,
|
||||
) -> None:
|
||||
"""Test conversion between metric and imperial units for sensors."""
|
||||
|
||||
# Set unit system
|
||||
hass.config.units = unit_system
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
|
||||
# Test
|
||||
entity = hass.states.get(entity_id)
|
||||
assert entity.state == value
|
||||
assert entity.attributes.get("unit_of_measurement") == unit_of_measurement
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
async def test_entity_option_translations(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Ensure all enum sensor values are translated."""
|
||||
|
||||
# Setup component to load translations
|
||||
assert await setup_mocked_integration(hass)
|
||||
|
||||
prefix = f"component.{DOMAIN}.entity.{Platform.SENSOR.value}"
|
||||
|
||||
translations = await async_get_translations(hass, "en", "entity", [DOMAIN])
|
||||
translation_states = {
|
||||
k for k in translations if k.startswith(prefix) and ".state." in k
|
||||
}
|
||||
|
||||
sensor_options = {
|
||||
f"{prefix}.{entity_description.translation_key}.state.{option}"
|
||||
for entity_description in SENSOR_TYPES
|
||||
if entity_description.device_class == SensorDeviceClass.ENUM
|
||||
for option in entity_description.options
|
||||
}
|
||||
|
||||
assert sensor_options == translation_states
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
async def test_enum_sensor_unknown(
|
||||
hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, freezer: FrozenDateTimeFactory
|
||||
) -> None:
|
||||
"""Test conversion handling of enum sensors."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
|
||||
entity_id = "sensor.i4_edrive40_charging_status"
|
||||
|
||||
# Check normal state
|
||||
entity = hass.states.get(entity_id)
|
||||
assert entity.state == "not_charging"
|
||||
|
||||
class ChargingStateUnkown(StrEnum):
|
||||
"""Charging state of electric vehicle."""
|
||||
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
# Setup enum returning only UNKNOWN
|
||||
monkeypatch.setattr(
|
||||
fuel_and_battery,
|
||||
"ChargingState",
|
||||
ChargingStateUnkown,
|
||||
)
|
||||
|
||||
freezer.tick(SCAN_INTERVALS["rest_of_world"])
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check normal state
|
||||
entity = hass.states.get("sensor.i4_edrive40_charging_status")
|
||||
assert entity.state == STATE_UNAVAILABLE
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Test BMW switches."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError, MyBMWRemoteServiceError
|
||||
from bimmer_connected.vehicle.remote_services import RemoteServices
|
||||
import pytest
|
||||
import respx
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import (
|
||||
REMOTE_SERVICE_EXC_REASON,
|
||||
REMOTE_SERVICE_EXC_TRANSLATION,
|
||||
check_remote_service_call,
|
||||
setup_mocked_integration,
|
||||
)
|
||||
|
||||
from tests.common import snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_entity_state_attrs(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test switch options and values.."""
|
||||
|
||||
# Setup component
|
||||
with patch(
|
||||
"homeassistant.components.bmw_connected_drive.PLATFORMS",
|
||||
[Platform.SWITCH],
|
||||
):
|
||||
mock_config_entry = await setup_mocked_integration(hass)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "new_value", "old_value", "remote_service", "remote_service_params"),
|
||||
[
|
||||
("switch.i4_edrive40_climate", "on", "off", "climate-now", {"action": "START"}),
|
||||
("switch.i4_edrive40_climate", "off", "on", "climate-now", {"action": "STOP"}),
|
||||
("switch.iX_xdrive50_charging", "on", "off", "start-charging", {}),
|
||||
("switch.iX_xdrive50_charging", "off", "on", "stop-charging", {}),
|
||||
],
|
||||
)
|
||||
async def test_service_call_success(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
new_value: str,
|
||||
old_value: str,
|
||||
remote_service: str,
|
||||
remote_service_params: dict,
|
||||
bmw_fixture: respx.Router,
|
||||
) -> None:
|
||||
"""Test successful switch change."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
hass.states.async_set(entity_id, old_value)
|
||||
assert hass.states.get(entity_id).state == old_value
|
||||
|
||||
# Test
|
||||
await hass.services.async_call(
|
||||
"switch",
|
||||
f"turn_{new_value}",
|
||||
blocking=True,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
check_remote_service_call(bmw_fixture, remote_service, remote_service_params)
|
||||
assert hass.states.get(entity_id).state == new_value
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
@pytest.mark.parametrize(
|
||||
("raised", "expected", "exc_translation"),
|
||||
[
|
||||
(
|
||||
MyBMWRemoteServiceError(REMOTE_SERVICE_EXC_REASON),
|
||||
HomeAssistantError,
|
||||
REMOTE_SERVICE_EXC_TRANSLATION,
|
||||
),
|
||||
(
|
||||
MyBMWAPIError(REMOTE_SERVICE_EXC_REASON),
|
||||
HomeAssistantError,
|
||||
REMOTE_SERVICE_EXC_TRANSLATION,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_service_call_fail(
|
||||
hass: HomeAssistant,
|
||||
raised: Exception,
|
||||
expected: Exception,
|
||||
exc_translation: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test exception handling."""
|
||||
|
||||
# Setup component
|
||||
assert await setup_mocked_integration(hass)
|
||||
entity_id = "switch.i4_edrive40_climate"
|
||||
|
||||
# Setup exception
|
||||
monkeypatch.setattr(
|
||||
RemoteServices,
|
||||
"trigger_remote_service",
|
||||
AsyncMock(side_effect=raised),
|
||||
)
|
||||
|
||||
# Turning switch to ON
|
||||
old_value = "off"
|
||||
hass.states.async_set(entity_id, old_value)
|
||||
assert hass.states.get(entity_id).state == old_value
|
||||
|
||||
# Test
|
||||
with pytest.raises(expected, match=exc_translation):
|
||||
await hass.services.async_call(
|
||||
"switch",
|
||||
"turn_on",
|
||||
blocking=True,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
assert hass.states.get(entity_id).state == old_value
|
||||
|
||||
# Turning switch to OFF
|
||||
old_value = "on"
|
||||
hass.states.async_set(entity_id, old_value)
|
||||
assert hass.states.get(entity_id).state == old_value
|
||||
|
||||
# Test
|
||||
with pytest.raises(expected, match=exc_translation):
|
||||
await hass.services.async_call(
|
||||
"switch",
|
||||
"turn_off",
|
||||
blocking=True,
|
||||
target={"entity_id": entity_id},
|
||||
)
|
||||
assert hass.states.get(entity_id).state == old_value
|
||||
Reference in New Issue
Block a user