Compare commits

...

23 Commits

Author SHA1 Message Date
Erik
b763da120e Remove triggers binary_sensor.occupancy_cleared and occupancy_detected 2026-03-09 09:09:47 +01:00
J. Nick Koston
a35c3d5de5 Bump yalexs-ble to 3.3.0 (#165168) 2026-03-08 16:39:30 -10:00
J. Nick Koston
e9c3634cb6 Bump habluetooth to 5.9.1 and bleak-retry-connector to 4.6.0 (#165022) 2026-03-08 16:16:53 -10:00
J. Nick Koston
2ba4544180 Bump yalexs-ble to 3.2.8 (#165018) 2026-03-09 03:07:49 +01:00
Artur Pragacz
5235ce7ae4 Lower ssdp discovery timeout log severity in Onkyo (#165156) 2026-03-09 02:19:42 +01:00
Oscar
56b601e577 Add basic auth support to remote_calendar (#158075) 2026-03-08 16:52:58 -07:00
Justin Boyd
f01a0586cb Bump airtouch5py to 0.4.0 (#161640)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-03-08 21:47:06 +01:00
Erwin Douna
ca641a097b Fix forced VERIFY_SSL in Portainer (#165079) 2026-03-08 13:19:45 +01:00
Åke Strandberg
df2f9d9ef8 Add missing code for Miele dryer (#165122) 2026-03-08 13:18:54 +01:00
Bouwe Westerdijk
501301f4e0 Bump plugwise to v1.11.3 (#165053) 2026-03-08 13:15:44 +01:00
Joakim Plate
89231a1a29 Update pychromecast to 14.0.10 (#165069) 2026-03-08 13:14:34 +01:00
John O'Nolan
fe11a6d38f Add diagnostics to Ghost integration (#165130) 2026-03-08 13:03:57 +01:00
Artur Pragacz
3154c3c962 Make restore state resilient to extra_restore_state_data errors (#165086) 2026-03-08 10:39:53 +01:00
mettolen
5031323dea Add description strings to Huum integration (#165094) 2026-03-08 10:24:15 +01:00
Henning Kerstan
017a9e6938 Bump enocean-async to 0.4.2 (#165084) 2026-03-08 09:02:51 +00:00
tronikos
9e974ab30e Add diagnostics in Opower (#165113) 2026-03-08 09:14:15 +01:00
Norbert Rittel
30c0d6792a Make spelling of "auto-empty dock" consistent in roborock (#165117) 2026-03-08 09:12:56 +01:00
Erwin Douna
9ffb9aa824 Bump pyportainer to 1.0.33 (#165080) 2026-03-08 08:33:33 +01:00
A. Gideonse
9ad71711da Add diagnostics to Indevolt integration (#165096) 2026-03-08 08:32:18 +01:00
Steve Easley
ef83165159 Bump jvc_projector dependency to 2.0.2 (#165099) 2026-03-08 08:29:53 +01:00
Jordan Harvey
f0108c1175 Bump pyanglianwater to 3.1.1 (#165097) 2026-03-08 08:28:06 +01:00
Richard Kroegel
802aa991a9 Remove broken BMW & Mini integrations (#165075) 2026-03-08 00:00:03 +00:00
Sab44
f055c6c7fd Add quality scale exemptions for discovery in Libre Hardware Monitor (#165085) 2026-03-07 23:29:07 +01:00
100 changed files with 1115 additions and 21315 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["airtouch5py"],
"requirements": ["airtouch5py==0.3.0"]
"requirements": ["airtouch5py==0.4.0"]
}

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["pyanglianwater"],
"quality_scale": "bronze",
"requirements": ["pyanglianwater==3.1.0"]
"requirements": ["pyanglianwater==3.1.1"]
}

View File

@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.7"]
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"]
}

View File

@@ -137,7 +137,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
"alarm_control_panel",
"assist_satellite",
"binary_sensor",
"button",
"climate",
"cover",

View File

@@ -174,13 +174,5 @@
"on": "mdi:window-open"
}
}
},
"triggers": {
"occupancy_cleared": {
"trigger": "mdi:home-outline"
},
"occupancy_detected": {
"trigger": "mdi:home"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description_occupancy": "The behavior of the targeted occupancy sensors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"condition_type": {
"is_bat_low": "{entity_name} battery is low",
@@ -321,36 +317,5 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Binary sensor",
"triggers": {
"occupancy_cleared": {
"description": "Triggers after one or more occupancy sensors stop detecting occupancy.",
"fields": {
"behavior": {
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
}
},
"name": "Occupancy cleared"
},
"occupancy_detected": {
"description": "Triggers after one or more occupancy sensors start detecting occupancy.",
"fields": {
"behavior": {
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
}
},
"name": "Occupancy detected"
}
}
"title": "Binary sensor"
}

View File

@@ -1,67 +0,0 @@
"""Provides triggers for binary sensors."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.trigger import EntityTargetStateTriggerBase, Trigger
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from . import DOMAIN, BinarySensorDeviceClass
def get_device_class_or_undefined(
hass: HomeAssistant, entity_id: str
) -> str | None | UndefinedType:
"""Get the device class of an entity or UNDEFINED if not found."""
try:
return get_device_class(hass, entity_id)
except HomeAssistantError:
return UNDEFINED
class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase):
"""Class for binary sensor on/off triggers."""
_device_class: BinarySensorDeviceClass | None
_domains = {DOMAIN}
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if get_device_class_or_undefined(self._hass, entity_id)
== self._device_class
}
def make_binary_sensor_trigger(
device_class: BinarySensorDeviceClass | None,
to_state: str,
) -> type[BinarySensorOnOffTrigger]:
"""Create an entity state trigger class."""
class CustomTrigger(BinarySensorOnOffTrigger):
"""Trigger for entity state changes."""
_device_class = device_class
_to_states = {to_state}
return CustomTrigger
TRIGGERS: dict[str, type[Trigger]] = {
"occupancy_detected": make_binary_sensor_trigger(
BinarySensorDeviceClass.OCCUPANCY, STATE_ON
),
"occupancy_cleared": make_binary_sensor_trigger(
BinarySensorDeviceClass.OCCUPANCY, STATE_OFF
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for binary sensors."""
return TRIGGERS

View File

@@ -1,25 +0,0 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
occupancy_cleared:
fields: *trigger_common_fields
target:
entity:
domain: binary_sensor
device_class: occupancy
occupancy_detected:
fields: *trigger_common_fields
target:
entity:
domain: binary_sensor
device_class: occupancy

View File

@@ -16,11 +16,11 @@
"quality_scale": "internal",
"requirements": [
"bleak==2.1.1",
"bleak-retry-connector==4.4.3",
"bleak-retry-connector==4.6.0",
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.4",
"dbus-fast==3.1.2",
"habluetooth==5.8.0"
"habluetooth==5.9.1"
]
}

View File

@@ -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]
)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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."""

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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

View File

@@ -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()

View File

@@ -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"
}
}
}
}

View File

@@ -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()

View File

@@ -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"]
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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"
}
}
}
}

View File

@@ -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()

View File

@@ -15,7 +15,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["casttube", "pychromecast"],
"requirements": ["PyChromecast==14.0.9"],
"requirements": ["PyChromecast==14.0.10"],
"single_config_entry": true,
"zeroconf": ["_googlecast._tcp.local."]
}

View File

@@ -8,7 +8,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["enocean_async"],
"requirements": ["enocean-async==0.4.1"],
"requirements": ["enocean-async==0.4.2"],
"single_config_entry": true,
"usb": [
{

View File

@@ -17,6 +17,8 @@ from .const import CONF_ADMIN_API_KEY, CONF_API_URL, DOMAIN
_LOGGER = logging.getLogger(__name__)
GHOST_INTEGRATION_SETUP_URL = "https://account.ghost.org/?r=settings/integrations/new"
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_URL): str,
@@ -78,7 +80,7 @@ class GhostConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
description_placeholders={
"title": reauth_entry.title,
"docs_url": "https://account.ghost.org/?r=settings/integrations/new",
"setup_url": GHOST_INTEGRATION_SETUP_URL,
},
)
@@ -103,9 +105,7 @@ class GhostConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders={
"docs_url": "https://account.ghost.org/?r=settings/integrations/new"
},
description_placeholders={"setup_url": GHOST_INTEGRATION_SETUP_URL},
)
async def _validate_credentials(

View File

@@ -0,0 +1,27 @@
"""Diagnostics support for Ghost."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from . import GhostConfigEntry
from .const import CONF_ADMIN_API_KEY
TO_REDACT = {CONF_ADMIN_API_KEY}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: GhostConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return async_redact_data(
{
"entry_data": dict(config_entry.data),
"coordinator_data": asdict(config_entry.runtime_data.coordinator.data),
},
TO_REDACT,
)

View File

@@ -43,7 +43,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: Cloud service integration, not discoverable.

View File

@@ -18,7 +18,7 @@
"data_description": {
"admin_api_key": "[%key:component::ghost::config::step::user::data_description::admin_api_key%]"
},
"description": "Your API key for {title} is invalid. [Create a new integration key]({docs_url}) to reauthenticate.",
"description": "Your API key for {title} is invalid. [Create a new integration key]({setup_url}) to reauthenticate.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
@@ -30,7 +30,7 @@
"admin_api_key": "The Admin API key for your Ghost integration",
"api_url": "The API URL for your Ghost integration"
},
"description": "[Create a custom integration]({docs_url}) to get your API URL and Admin API key.",
"description": "[Create a custom integration]({setup_url}) to get your API URL and Admin API key.",
"title": "Connect to Ghost"
}
}

View File

@@ -14,6 +14,10 @@
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "The password used in the Huum mobile app.",
"username": "The username (email) used in the Huum mobile app."
},
"description": "Log in with the same username and password that is used in the Huum mobile app.",
"title": "Connect to the Huum"
}

View File

@@ -0,0 +1,46 @@
"""Diagnostics support for Indevolt integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from .const import CONF_SERIAL_NUMBER
from .coordinator import IndevoltConfigEntry
# Redact sensitive information from diagnostics (host and serial numbers)
TO_REDACT = {
CONF_HOST,
CONF_SERIAL_NUMBER,
"0",
"9008",
"9032",
"9051",
"9070",
"9218",
"9165",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: IndevoltConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
device_info = {
"model": coordinator.device_model,
"generation": coordinator.generation,
"serial_number": coordinator.serial_number,
"firmware_version": coordinator.firmware_version,
}
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"device": async_redact_data(device_info, TO_REDACT),
"coordinator_data": async_redact_data(coordinator.data, TO_REDACT),
"last_update_success": coordinator.last_update_success,
}

View File

@@ -45,8 +45,7 @@ rules:
# Gold
devices: done
diagnostics:
status: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: Integration does not support network discovery

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["jvcprojector"],
"requirements": ["pyjvcprojector==2.0.1"]
"requirements": ["pyjvcprojector==2.0.2"]
}

View File

@@ -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

View File

@@ -188,6 +188,7 @@ class ProgramPhaseTumbleDryer(MieleEnum, missing_to_none=True):
finished = 522, 11012
extra_dry = 523
hand_iron = 524
hygiene_drying = 525
moisten = 526
thermo_spin = 527
timed_drying = 528

View File

@@ -1006,6 +1006,7 @@
"heating_up_phase": "Heating up phase",
"hot_milk": "Hot milk",
"hygiene": "Hygiene",
"hygiene_drying": "Hygiene drying",
"interim_rinse": "Interim rinse",
"keep_warm": "Keep warm",
"keeping_warm": "Keeping warm",

View File

@@ -1 +0,0 @@
"""Virtual integration: MINI Connected."""

View File

@@ -1,6 +0,0 @@
{
"domain": "mini_connected",
"name": "MINI Connected",
"integration_type": "virtual",
"supported_by": "bmw_connected_drive"
}

View File

@@ -213,7 +213,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
try:
info = await async_interview(host)
except TimeoutError:
_LOGGER.warning("Timed out interviewing: %s", host)
_LOGGER.info("Timed out interviewing: %s", host)
return self.async_abort(reason="cannot_connect")
except OSError:
_LOGGER.exception("Unexpected exception interviewing: %s", host)

View File

@@ -0,0 +1,69 @@
"""Diagnostics support for Opower."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from .const import CONF_LOGIN_DATA, CONF_TOTP_SECRET
from .coordinator import OpowerConfigEntry
TO_REDACT = {
CONF_PASSWORD,
CONF_USERNAME,
CONF_LOGIN_DATA,
CONF_TOTP_SECRET,
# Title contains the username/email
"title",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: OpowerConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
"data": {
account_id: {
"account": {
"utility_account_id": account.utility_account_id,
"meter_type": account.meter_type.name,
"read_resolution": (
account.read_resolution.name
if account.read_resolution
else None
),
},
"forecast": (
{
"usage_to_date": forecast.usage_to_date,
"cost_to_date": forecast.cost_to_date,
"forecasted_usage": forecast.forecasted_usage,
"forecasted_cost": forecast.forecasted_cost,
"typical_usage": forecast.typical_usage,
"typical_cost": forecast.typical_cost,
"unit_of_measure": forecast.unit_of_measure.name,
"start_date": forecast.start_date.isoformat(),
"end_date": forecast.end_date.isoformat(),
"current_date": forecast.current_date.isoformat(),
}
if (forecast := data.forecast)
else None
),
"last_changed": (
data.last_changed.isoformat() if data.last_changed else None
),
"last_updated": (
data.last_updated.isoformat() if data.last_updated else None
),
}
for account_id, data in coordinator.data.items()
for account in (data.account,)
},
}

View File

@@ -44,7 +44,7 @@ rules:
# Gold
devices:
status: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: The integration does not support discovery.

View File

@@ -8,6 +8,6 @@
"iot_class": "local_polling",
"loggers": ["plugwise"],
"quality_scale": "platinum",
"requirements": ["plugwise==1.11.2"],
"requirements": ["plugwise==1.11.3"],
"zeroconf": ["_plugwise._tcp.local."]
}

View File

@@ -159,8 +159,12 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_input[CONF_API_TOKEN])
self._abort_if_unique_id_configured()
# Logic that can be reverted back once the new unique ID is in
existing_entry = await self.async_set_unique_id(
user_input[CONF_API_TOKEN]
)
if existing_entry and existing_entry.entry_id != reconf_entry.entry_id:
return self.async_abort(reason="already_configured")
return self.async_update_reload_and_abort(
reconf_entry,
data_updates={

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyportainer==1.0.32"]
"requirements": ["pyportainer==1.0.33"]
}

View File

@@ -1,12 +1,22 @@
"""Specifies the parameter for the httpx download."""
"""HTTP client for fetching remote calendar data."""
from httpx import AsyncClient, Response, Timeout
from httpx import AsyncClient, Auth, BasicAuth, Response, Timeout
async def get_calendar(client: AsyncClient, url: str) -> Response:
async def get_calendar(
client: AsyncClient,
url: str,
username: str | None = None,
password: str | None = None,
) -> Response:
"""Make an HTTP GET request using Home Assistant's async HTTPX client with timeout."""
auth: Auth | None = None
if username is not None and password is not None:
auth = BasicAuth(username, password)
return await client.get(
url,
auth=auth,
follow_redirects=True,
timeout=Timeout(5, read=30, write=5, pool=5),
)

View File

@@ -8,7 +8,7 @@ from httpx import HTTPError, InvalidURL, TimeoutException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_URL, CONF_VERIFY_SSL
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
from homeassistant.helpers.httpx_client import get_async_client
from .client import get_calendar
@@ -25,12 +25,24 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
STEP_AUTH_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Remote Calendar."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self.data: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -39,8 +51,7 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
)
errors: dict = {}
_LOGGER.debug("User input: %s", user_input)
errors: dict[str, str] = {}
self._async_abort_entries_match(
{CONF_CALENDAR_NAME: user_input[CONF_CALENDAR_NAME]}
)
@@ -52,6 +63,11 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
client = get_async_client(self.hass, verify_ssl=user_input[CONF_VERIFY_SSL])
try:
res = await get_calendar(client, user_input[CONF_URL])
if res.status_code == HTTPStatus.UNAUTHORIZED:
www_auth = res.headers.get("www-authenticate", "").lower()
if "basic" in www_auth:
self.data = user_input
return await self.async_step_auth()
if res.status_code == HTTPStatus.FORBIDDEN:
errors["base"] = "forbidden"
return self.async_show_form(
@@ -83,3 +99,60 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the authentication step."""
if user_input is None:
return self.async_show_form(
step_id="auth",
data_schema=STEP_AUTH_DATA_SCHEMA,
)
errors: dict[str, str] = {}
client = get_async_client(self.hass, verify_ssl=self.data[CONF_VERIFY_SSL])
try:
res = await get_calendar(
client,
self.data[CONF_URL],
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
)
if res.status_code == HTTPStatus.UNAUTHORIZED:
errors["base"] = "invalid_auth"
elif res.status_code == HTTPStatus.FORBIDDEN:
return self.async_abort(reason="forbidden")
else:
res.raise_for_status()
except TimeoutException as err:
errors["base"] = "timeout_connect"
_LOGGER.debug(
"A timeout error occurred: %s", str(err) or type(err).__name__
)
except (HTTPError, InvalidURL) as err:
errors["base"] = "cannot_connect"
_LOGGER.debug("An error occurred: %s", str(err) or type(err).__name__)
else:
if not errors:
try:
await parse_calendar(self.hass, res.text)
except InvalidIcsException:
return self.async_abort(reason="invalid_ics_file")
else:
return self.async_create_entry(
title=self.data[CONF_CALENDAR_NAME],
data={
**self.data,
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
return self.async_show_form(
step_id="auth",
data_schema=self.add_suggested_values_to_schema(
STEP_AUTH_DATA_SCHEMA, user_input
),
errors=errors,
)

View File

@@ -7,7 +7,7 @@ from httpx import HTTPError, InvalidURL, TimeoutException
from ical.calendar import Calendar
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL, CONF_VERIFY_SSL
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -46,11 +46,18 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]):
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
)
self._url = config_entry.data[CONF_URL]
self._username: str | None = config_entry.data.get(CONF_USERNAME)
self._password: str | None = config_entry.data.get(CONF_PASSWORD)
async def _async_update_data(self) -> Calendar:
"""Update data from the url."""
try:
res = await get_calendar(self._client, self._url)
res = await get_calendar(
self._client,
self._url,
username=self._username,
password=self._password,
)
res.raise_for_status()
except TimeoutException as err:
_LOGGER.debug("%s: %s", self._url, str(err) or type(err).__name__)

View File

@@ -1,15 +1,29 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"forbidden": "[%key:component::remote_calendar::config::error::forbidden%]",
"invalid_ics_file": "[%key:component::remote_calendar::config::error::invalid_ics_file%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"forbidden": "The server understood the request but refuses to authorize it.",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details.",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]"
},
"step": {
"auth": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "The password for HTTP Basic Authentication.",
"username": "The username for HTTP Basic Authentication."
},
"description": "The calendar requires authentication."
},
"user": {
"data": {
"calendar_name": "Calendar name",

View File

@@ -468,7 +468,7 @@
"clear_water_box_hoare": "Check the clean water tank",
"cliff_sensor_error": "Cliff sensor error",
"collect_dust_error_3": "Clean auto-empty dock",
"collect_dust_error_4": "Auto empty dock voltage error",
"collect_dust_error_4": "Auto-empty dock voltage error",
"compass_error": "Strong magnetic field detected",
"dirty_water_box_hoare": "Check the dirty water tank",
"dock": "Dock not connected to power",

View File

@@ -14,5 +14,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["socketio", "engineio", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.7"]
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"]
}

View File

@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/yalexs_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["yalexs-ble==3.2.7"]
"requirements": ["yalexs-ble==3.3.0"]
}

View File

@@ -100,7 +100,6 @@ FLOWS = {
"bluemaestro",
"bluesound",
"bluetooth",
"bmw_connected_drive",
"bond",
"bosch_alarm",
"bosch_shc",

View File

@@ -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",

View File

@@ -181,15 +181,24 @@ class RestoreStateData:
}
# Start with the currently registered states
stored_states = [
StoredState(
current_states_by_entity_id[entity_id],
entity.extra_restore_state_data,
now,
stored_states: list[StoredState] = []
for entity_id, entity in self.entities.items():
if entity_id not in current_states_by_entity_id:
continue
try:
extra_data = entity.extra_restore_state_data
except Exception:
_LOGGER.exception(
"Error getting extra restore state data for %s", entity_id
)
continue
stored_states.append(
StoredState(
current_states_by_entity_id[entity_id],
extra_data,
now,
)
)
for entity_id, entity in self.entities.items()
if entity_id in current_states_by_entity_id
]
expiration_time = now - STATE_EXPIRATION
for entity_id, stored_state in self.last_states.items():
@@ -219,6 +228,8 @@ class RestoreStateData:
)
except HomeAssistantError as exc:
_LOGGER.error("Error saving current states", exc_info=exc)
except Exception:
_LOGGER.exception("Unexpected error saving current states")
@callback
def async_setup_dump(self, *args: Any) -> None:
@@ -258,13 +269,15 @@ class RestoreStateData:
@callback
def async_restore_entity_removed(
self, entity_id: str, extra_data: ExtraStoredData | None
self,
entity_id: str,
state: State | None,
extra_data: ExtraStoredData | None,
) -> None:
"""Unregister this entity from saving state."""
# When an entity is being removed from hass, store its last state. This
# allows us to support state restoration if the entity is removed, then
# re-added while hass is still running.
state = self.hass.states.get(entity_id)
# To fully mimic all the attribute data types when loaded from storage,
# we're going to serialize it to JSON and then re-load it.
if state is not None:
@@ -287,8 +300,18 @@ class RestoreEntity(Entity):
async def async_internal_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
try:
extra_data = self.extra_restore_state_data
except Exception:
_LOGGER.exception(
"Error getting extra restore state data for %s", self.entity_id
)
state = None
extra_data = None
else:
state = self.hass.states.get(self.entity_id)
async_get(self.hass).async_restore_entity_removed(
self.entity_id, self.extra_restore_state_data
self.entity_id, state, extra_data
)
await super().async_internal_will_remove_from_hass()

View File

@@ -21,7 +21,7 @@ audioop-lts==0.2.1
av==16.0.1
awesomeversion==25.8.0
bcrypt==5.0.0
bleak-retry-connector==4.4.3
bleak-retry-connector==4.6.0
bleak==2.1.1
bluetooth-adapters==2.1.0
bluetooth-auto-recovery==1.5.3
@@ -36,7 +36,7 @@ file-read-backwards==2.0.0
fnv-hash-fast==1.6.0
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==5.8.0
habluetooth==5.9.1
hass-nabucasa==1.15.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1

10
mypy.ini generated
View File

@@ -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

23
requirements_all.txt generated
View File

@@ -47,7 +47,7 @@ PlexAPI==4.15.16
ProgettiHWSW==0.1.3
# homeassistant.components.cast
PyChromecast==14.0.9
PyChromecast==14.0.10
# homeassistant.components.flume
PyFlume==0.6.5
@@ -476,7 +476,7 @@ airthings-cloud==0.2.0
airtouch4pyapi==1.0.5
# homeassistant.components.airtouch5
airtouch5py==0.3.0
airtouch5py==0.4.0
# homeassistant.components.alpha_vantage
alpha-vantage==2.3.1
@@ -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
@@ -641,7 +638,7 @@ bizkaibus==0.1.1
bleak-esphome==3.7.1
# homeassistant.components.bluetooth
bleak-retry-connector==4.4.3
bleak-retry-connector==4.6.0
# homeassistant.components.bluetooth
bleak==2.1.1
@@ -903,7 +900,7 @@ energyid-webhooks==0.0.14
energyzero==4.0.1
# homeassistant.components.enocean
enocean-async==0.4.1
enocean-async==0.4.2
# homeassistant.components.entur_public_transport
enturclient==0.2.4
@@ -1173,7 +1170,7 @@ ha-silabs-firmware-client==0.3.0
habiticalib==0.4.6
# homeassistant.components.bluetooth
habluetooth==5.8.0
habluetooth==5.9.1
# homeassistant.components.hanna
hanna-cloud==0.0.7
@@ -1784,7 +1781,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
plugwise==1.11.2
plugwise==1.11.3
# homeassistant.components.serial_pm
pmsensor==0.4
@@ -1947,7 +1944,7 @@ pyairobotrest==0.3.0
pyairvisual==2023.08.1
# homeassistant.components.anglian_water
pyanglianwater==3.1.0
pyanglianwater==3.1.1
# homeassistant.components.aprilaire
pyaprilaire==0.9.1
@@ -2188,7 +2185,7 @@ pyitachip2ir==0.0.7
pyituran==0.1.5
# homeassistant.components.jvc_projector
pyjvcprojector==2.0.1
pyjvcprojector==2.0.2
# homeassistant.components.kaleidescape
pykaleidescape==1.1.3
@@ -2379,7 +2376,7 @@ pyplaato==0.0.19
pypoint==3.0.0
# homeassistant.components.portainer
pyportainer==1.0.32
pyportainer==1.0.33
# homeassistant.components.probe_plus
pyprobeplus==1.1.2
@@ -3316,7 +3313,7 @@ yalesmartalarmclient==0.4.3
# homeassistant.components.august
# homeassistant.components.yale
# homeassistant.components.yalexs_ble
yalexs-ble==3.2.7
yalexs-ble==3.3.0
# homeassistant.components.august
# homeassistant.components.yale

View File

@@ -47,7 +47,7 @@ PlexAPI==4.15.16
ProgettiHWSW==0.1.3
# homeassistant.components.cast
PyChromecast==14.0.9
PyChromecast==14.0.10
# homeassistant.components.flume
PyFlume==0.6.5
@@ -461,7 +461,7 @@ airthings-cloud==0.2.0
airtouch4pyapi==1.0.5
# homeassistant.components.airtouch5
airtouch5py==0.3.0
airtouch5py==0.4.0
# homeassistant.components.altruist
altruistclient==0.1.1
@@ -571,14 +571,11 @@ 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
# homeassistant.components.bluetooth
bleak-retry-connector==4.4.3
bleak-retry-connector==4.6.0
# homeassistant.components.bluetooth
bleak==2.1.1
@@ -797,7 +794,7 @@ energyid-webhooks==0.0.14
energyzero==4.0.1
# homeassistant.components.enocean
enocean-async==0.4.1
enocean-async==0.4.2
# homeassistant.components.environment_canada
env-canada==0.13.2
@@ -1043,7 +1040,7 @@ ha-silabs-firmware-client==0.3.0
habiticalib==0.4.6
# homeassistant.components.bluetooth
habluetooth==5.8.0
habluetooth==5.9.1
# homeassistant.components.hanna
hanna-cloud==0.0.7
@@ -1545,7 +1542,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
plugwise==1.11.2
plugwise==1.11.3
# homeassistant.components.poolsense
poolsense==0.0.8
@@ -1681,7 +1678,7 @@ pyairobotrest==0.3.0
pyairvisual==2023.08.1
# homeassistant.components.anglian_water
pyanglianwater==3.1.0
pyanglianwater==3.1.1
# homeassistant.components.aprilaire
pyaprilaire==0.9.1
@@ -1868,7 +1865,7 @@ pyisy==3.4.1
pyituran==0.1.5
# homeassistant.components.jvc_projector
pyjvcprojector==2.0.1
pyjvcprojector==2.0.2
# homeassistant.components.kaleidescape
pykaleidescape==1.1.3
@@ -2032,7 +2029,7 @@ pyplaato==0.0.19
pypoint==3.0.0
# homeassistant.components.portainer
pyportainer==1.0.32
pyportainer==1.0.33
# homeassistant.components.probe_plus
pyprobeplus==1.1.2
@@ -2795,7 +2792,7 @@ yalesmartalarmclient==0.4.3
# homeassistant.components.august
# homeassistant.components.yale
# homeassistant.components.yalexs_ble
yalexs-ble==3.2.7
yalexs-ble==3.3.0
# homeassistant.components.august
# homeassistant.components.yale

View File

@@ -1186,7 +1186,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"bluetooth",
"bluetooth_adapters",
"bluetooth_le_tracker",
"bmw_connected_drive",
"bond",
"bosch_shc",
"braviatv",

View File

@@ -1,258 +0,0 @@
"""Test binary sensor trigger."""
from typing import Any
import pytest
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_LABEL_ID,
CONF_ENTITY_ID,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
TriggerStateDescription,
arm_trigger,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture
async def target_binary_sensors(hass: HomeAssistant) -> tuple[list[str], list[str]]:
"""Create multiple binary sensor entities associated with different targets."""
return await target_entities(hass, "binary_sensor")
@pytest.mark.parametrize(
"trigger_key",
[
"binary_sensor.occupancy_detected",
"binary_sensor.occupancy_cleared",
],
)
async def test_binary_sensor_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the binary sensor triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="binary_sensor.occupancy_detected",
target_states=[STATE_ON],
other_states=[STATE_OFF],
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="binary_sensor.occupancy_cleared",
target_states=[STATE_OFF],
other_states=[STATE_ON],
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
trigger_from_none=False,
),
],
)
async def test_binary_sensor_state_attribute_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_binary_sensors: dict[list[str], list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test that the binary sensor state trigger fires when any binary sensor state changes to a specific state."""
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
# Set all binary sensors, including the tested binary sensor, to the initial state
for eid in target_binary_sensors["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
excluded_state = state["excluded"]
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other binary sensors also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="binary_sensor.occupancy_detected",
target_states=[STATE_ON],
other_states=[STATE_OFF],
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="binary_sensor.occupancy_cleared",
target_states=[STATE_OFF],
other_states=[STATE_ON],
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
trigger_from_none=False,
),
],
)
async def test_binary_sensor_state_attribute_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_binary_sensors: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test that the binary sensor state trigger fires when the first binary sensor state changes to a specific state."""
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
# Set all binary sensors, including the tested binary sensor, to the initial state
for eid in target_binary_sensors["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
excluded_state = state["excluded"]
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other binary sensors should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, excluded_state)
await hass.async_block_till_done()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="binary_sensor.occupancy_detected",
target_states=[STATE_ON],
other_states=[STATE_OFF],
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="binary_sensor.occupancy_cleared",
target_states=[STATE_OFF],
other_states=[STATE_ON],
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
trigger_from_none=False,
),
],
)
async def test_binary_sensor_state_attribute_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_binary_sensors: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test that the binary sensor state trigger fires when the last binary sensor state changes to a specific state."""
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
# Set all binary sensors, including the tested binary sensor, to the initial state
for eid in target_binary_sensors["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
excluded_state = state["excluded"]
included_state = state["included"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == 0

View File

@@ -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"}

View File

@@ -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

View File

@@ -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',
})
# ---

View File

@@ -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',
})
# ---

View File

@@ -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',
})
# ---

View File

@@ -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

View File

@@ -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',
})
# ---

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,74 @@
# serializer version: 1
# name: test_diagnostics
dict({
'coordinator_data': dict({
'activitypub': dict({
'followers': 150,
'following': 25,
}),
'arr': dict({
'usd': 60000,
}),
'comments': 156,
'latest_email': dict({
'click_rate': 10,
'clicked_count': 50,
'delivered_count': 490,
'email_count': 500,
'failed_count': 10,
'open_rate': 40,
'opened_count': 200,
'subject': 'Newsletter #1',
'submitted_at': '2026-01-15T10:00:00Z',
'title': 'Newsletter #1',
}),
'latest_post': dict({
'published_at': '2026-01-15T10:00:00Z',
'slug': 'latest-post',
'title': 'Latest Post',
'url': 'https://test.ghost.io/latest-post/',
}),
'members': dict({
'comped': 50,
'free': 850,
'paid': 100,
'total': 1000,
}),
'mrr': dict({
'usd': 5000,
}),
'newsletters': dict({
'nl1': dict({
'count': dict({
'members': 800,
}),
'id': 'nl1',
'name': 'Weekly',
'status': 'active',
}),
'nl2': dict({
'count': dict({
'members': 200,
}),
'id': 'nl2',
'name': 'Archive',
'status': 'archived',
}),
}),
'posts': dict({
'drafts': 5,
'published': 42,
'scheduled': 2,
}),
'site': dict({
'site_uuid': 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'title': 'Test Ghost',
'url': 'https://test.ghost.io',
}),
}),
'entry_data': dict({
'admin_api_key': '**REDACTED**',
'api_url': 'https://test.ghost.io',
}),
})
# ---

View File

@@ -0,0 +1,28 @@
"""Tests for the diagnostics data provided by the Ghost integration."""
from unittest.mock import AsyncMock
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
mock_ghost_api: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
await setup_integration(hass, mock_config_entry)
assert (
await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry)
== snapshot
)

View File

@@ -0,0 +1,134 @@
# serializer version: 1
# name: test_diagnostics[1]
dict({
'coordinator_data': dict({
'0': '**REDACTED**',
'1501': 0,
'1502': 0,
'1505': 553673,
'1664': 0,
'1665': 0,
'2101': 0,
'21028': 0,
'2107': 58.1,
'2108': 0,
'6000': 0,
'6001': 1000,
'6002': 92,
'6004': 0,
'6005': 0,
'6006': 277.16,
'6007': 256.39,
'606': '1000',
'6105': 5,
'7101': 5,
'7120': 1001,
}),
'device': dict({
'firmware_version': '1.2.3',
'generation': 1,
'model': 'BK1600',
'serial_number': '**REDACTED**',
}),
'entry_data': dict({
'generation': 1,
'host': '**REDACTED**',
'model': 'BK1600',
'serial_number': '**REDACTED**',
}),
'last_update_success': True,
})
# ---
# name: test_diagnostics[2]
dict({
'coordinator_data': dict({
'0': '**REDACTED**',
'11009': 50.2,
'11010': 52.3,
'11011': 85,
'11016': 0,
'11034': 100,
'142': 1.79,
'1501': 0,
'1502': 0,
'1532': 150,
'1600': 48.5,
'1601': 48.3,
'1602': 48.7,
'1603': 48.6,
'1632': 10.2,
'1633': 10.1,
'1634': 9.8,
'1635': 9.9,
'1664': 0,
'1665': 0,
'1666': 0,
'1667': 0,
'19173': 14.8,
'19174': 15.0,
'19175': 15.1,
'19176': 15.3,
'19177': 14.9,
'2101': 0,
'2104': 1500,
'2105': 2000,
'2107': 289.97,
'2108': 0,
'2600': 1200,
'2612': 50.0,
'2618': 1001,
'6000': 0,
'6001': 1000,
'6002': 92,
'6004': 0.07,
'6005': 0,
'6006': 380.58,
'6007': 338.07,
'606': '1001',
'6105': 5,
'667': 0,
'680': 0,
'7101': 1,
'7120': 1001,
'7171': 1,
'9000': 92,
'9004': 51.2,
'9008': '**REDACTED**',
'9012': 25.5,
'9013': 15.2,
'9016': 91,
'9020': 51.0,
'9030': 24.8,
'9032': '**REDACTED**',
'9035': 93,
'9039': 51.3,
'9049': 25.2,
'9051': '**REDACTED**',
'9054': 92,
'9058': 51.1,
'9068': 25.0,
'9070': '**REDACTED**',
'9149': 94,
'9153': 51.4,
'9163': 25.7,
'9165': '**REDACTED**',
'9202': 90,
'9206': 50.9,
'9216': 24.9,
'9218': '**REDACTED**',
}),
'device': dict({
'firmware_version': '1.2.3',
'generation': 2,
'model': 'CMS-SF2000',
'serial_number': '**REDACTED**',
}),
'entry_data': dict({
'generation': 2,
'host': '**REDACTED**',
'model': 'CMS-SF2000',
'serial_number': '**REDACTED**',
}),
'last_update_success': True,
})
# ---

View File

@@ -0,0 +1,32 @@
"""Tests for Indevolt diagnostics."""
from unittest.mock import AsyncMock
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
@pytest.mark.parametrize("generation", [1, 2], indirect=True)
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_indevolt: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics for all device generations."""
await setup_integration(hass, mock_config_entry)
# Verify the diagnostics for config entry can be retrieved and matches snapshot
assert (
await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry)
== snapshot
)

View File

@@ -0,0 +1,73 @@
# serializer version: 1
# name: test_diagnostics
dict({
'data': dict({
'111111': dict({
'account': dict({
'meter_type': 'ELEC',
'read_resolution': 'HOUR',
'utility_account_id': '111111',
}),
'forecast': dict({
'cost_to_date': 20.0,
'current_date': '2023-01-15',
'end_date': '2023-01-31',
'forecasted_cost': 40.0,
'forecasted_usage': 200,
'start_date': '2023-01-01',
'typical_cost': 36.0,
'typical_usage': 180,
'unit_of_measure': 'KWH',
'usage_to_date': 100,
}),
'last_changed': None,
'last_updated': '2026-03-07T23:00:00+00:00',
}),
'222222': dict({
'account': dict({
'meter_type': 'GAS',
'read_resolution': 'DAY',
'utility_account_id': '222222',
}),
'forecast': dict({
'cost_to_date': 15.0,
'current_date': '2023-01-15',
'end_date': '2023-01-31',
'forecasted_cost': 30.0,
'forecasted_usage': 100,
'start_date': '2023-01-01',
'typical_cost': 27.0,
'typical_usage': 90,
'unit_of_measure': 'CCF',
'usage_to_date': 50,
}),
'last_changed': None,
'last_updated': '2026-03-07T23:00:00+00:00',
}),
}),
'entry': dict({
'created_at': '2026-03-07T23:00:00+00:00',
'data': dict({
'password': '**REDACTED**',
'username': '**REDACTED**',
'utility': 'Pacific Gas and Electric Company (PG&E)',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'opower',
'minor_version': 1,
'modified_at': '2026-03-07T23:00:00+00:00',
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'user',
'subentries': list([
]),
'title': '**REDACTED**',
'unique_id': None,
'version': 1,
}),
})
# ---

View File

@@ -0,0 +1,32 @@
"""Tests for the diagnostics data provided by the Opower integration."""
from unittest.mock import AsyncMock
import pytest
from syrupy.assertion import SnapshotAssertion
from syrupy.filters import props
from homeassistant.components.recorder import Recorder
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
@pytest.mark.freeze_time("2026-03-07T23:00:00+00:00")
async def test_diagnostics(
recorder_mock: Recorder,
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
mock_opower_api: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
) == snapshot(exclude=props("entry_id"))

View File

@@ -263,20 +263,28 @@ async def test_full_flow_reconfigure_unique_id(
) -> None:
"""Test the full flow of the config flow, this time with a known unique ID."""
mock_config_entry.add_to_hass(hass)
other_entry = MockConfigEntry(
domain=DOMAIN,
title="Portainer other",
data=USER_INPUT_RECONFIGURE,
unique_id=USER_INPUT_RECONFIGURE[CONF_API_TOKEN],
)
other_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_USER_SETUP,
user_input=USER_INPUT_RECONFIGURE,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_config_entry.data[CONF_API_TOKEN] == "test_api_token"
assert mock_config_entry.data[CONF_URL] == "https://127.0.0.1:9000/"
assert mock_config_entry.data[CONF_VERIFY_SSL] is True
assert len(mock_setup_entry.mock_calls) == 0

View File

@@ -6,7 +6,7 @@ import respx
from homeassistant.components.remote_calendar.const import CONF_CALENDAR_NAME, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_URL, CONF_VERIFY_SSL
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@@ -326,3 +326,249 @@ async def test_duplicate_url(
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "already_configured"
@respx.mock
async def test_form_unauthorized_basic_auth(
hass: HomeAssistant, ics_content: str
) -> None:
"""Test 401 with WWW-Authenticate: Basic triggers auth step and succeeds."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=401,
headers={"www-authenticate": 'Basic realm="test"'},
)
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
CONF_VERIFY_SSL: True,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "auth"
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=ics_content,
)
)
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
},
)
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == CALENDAR_NAME
assert result3["data"] == {
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
CONF_VERIFY_SSL: True,
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
}
@respx.mock
async def test_form_auth_invalid_credentials(
hass: HomeAssistant, ics_content: str
) -> None:
"""Test wrong credentials in auth step shows invalid_auth error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=401,
headers={"www-authenticate": 'Basic realm="test"'},
)
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
CONF_VERIFY_SSL: True,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "auth"
# Wrong credentials - server still returns 401
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "wrong",
CONF_PASSWORD: "wrong",
},
)
assert result3["type"] is FlowResultType.FORM
assert result3["step_id"] == "auth"
assert result3["errors"] == {"base": "invalid_auth"}
# Correct credentials
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=ics_content,
)
)
result4 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
},
)
assert result4["type"] is FlowResultType.CREATE_ENTRY
assert result4["title"] == CALENDAR_NAME
@respx.mock
async def test_form_auth_forbidden_aborts(hass: HomeAssistant) -> None:
"""Test 403 in auth step aborts the flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=401,
headers={"www-authenticate": 'Basic realm="test"'},
)
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
CONF_VERIFY_SSL: True,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "auth"
respx.get(CALENDER_URL).mock(return_value=Response(status_code=403))
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
},
)
assert result3["type"] is FlowResultType.ABORT
assert result3["reason"] == "forbidden"
@respx.mock
async def test_form_auth_invalid_ics_aborts(hass: HomeAssistant) -> None:
"""Test invalid ICS in auth step aborts the flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=401,
headers={"www-authenticate": 'Basic realm="test"'},
)
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
CONF_VERIFY_SSL: True,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "auth"
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text="not valid ics",
)
)
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
},
)
assert result3["type"] is FlowResultType.ABORT
assert result3["reason"] == "invalid_ics_file"
@pytest.mark.parametrize(
("side_effect", "base_error"),
[
(TimeoutException("Connection timed out"), "timeout_connect"),
(HTTPError("Connection failed"), "cannot_connect"),
],
)
@respx.mock
async def test_form_auth_connection_errors(
hass: HomeAssistant,
side_effect: Exception,
ics_content: str,
base_error: str,
) -> None:
"""Test connection errors in auth step show retryable errors."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=401,
headers={"www-authenticate": 'Basic realm="test"'},
)
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
CONF_VERIFY_SSL: True,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "auth"
# Connection error during auth
respx.get(CALENDER_URL).mock(side_effect=side_effect)
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
},
)
assert result3["type"] is FlowResultType.FORM
assert result3["step_id"] == "auth"
assert result3["errors"] == {"base": base_error}
# Retry with success
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=ics_content,
)
)
result4 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
},
)
assert result4["type"] is FlowResultType.CREATE_ENTRY

View File

@@ -4,12 +4,19 @@ from httpx import HTTPError, InvalidURL, Response, TimeoutException
import pytest
import respx
from homeassistant.components.remote_calendar.const import CONF_CALENDAR_NAME, DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF
from homeassistant.const import (
CONF_PASSWORD,
CONF_URL,
CONF_USERNAME,
CONF_VERIFY_SSL,
STATE_OFF,
)
from homeassistant.core import HomeAssistant
from . import setup_integration
from .conftest import CALENDER_URL, TEST_ENTITY
from .conftest import CALENDAR_NAME, CALENDER_URL, TEST_ENTITY
from tests.common import MockConfigEntry
@@ -85,3 +92,30 @@ async def test_calendar_parse_error(
)
await setup_integration(hass, config_entry)
assert config_entry.state is ConfigEntryState.SETUP_RETRY
@respx.mock
async def test_load_with_auth(hass: HomeAssistant, ics_content: str) -> None:
"""Test loading a config entry with basic auth credentials."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
CONF_VERIFY_SSL: True,
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
},
)
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=ics_content,
)
)
await setup_integration(hass, config_entry)
assert config_entry.state is ConfigEntryState.LOADED
state = hass.states.get(TEST_ENTITY)
assert state
assert state.state == STATE_OFF

View File

@@ -6,6 +6,8 @@ import logging
from typing import Any
from unittest.mock import Mock, patch
import pytest
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import CoreState, HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
@@ -16,6 +18,7 @@ from homeassistant.helpers.reload import async_get_platform_without_config_entry
from homeassistant.helpers.restore_state import (
DATA_RESTORE_STATE,
STORAGE_KEY,
ExtraStoredData,
RestoreEntity,
RestoreStateData,
StoredState,
@@ -342,8 +345,12 @@ async def test_dump_data(hass: HomeAssistant) -> None:
assert state1["state"]["state"] == "off"
async def test_dump_error(hass: HomeAssistant) -> None:
"""Test that we cache data."""
@pytest.mark.parametrize(
"exception",
[HomeAssistantError, RuntimeError],
)
async def test_dump_error(hass: HomeAssistant, exception: type[Exception]) -> None:
"""Test that errors during save are caught."""
states = [
State("input_boolean.b0", "on"),
State("input_boolean.b1", "on"),
@@ -368,7 +375,7 @@ async def test_dump_error(hass: HomeAssistant) -> None:
with patch(
"homeassistant.helpers.restore_state.Store.async_save",
side_effect=HomeAssistantError,
side_effect=exception,
) as mock_write_data:
await data.async_dump_states()
@@ -534,3 +541,89 @@ async def test_restore_entity_end_to_end(
assert len(storage_data) == 1
assert storage_data[0]["state"]["entity_id"] == entity_id
assert storage_data[0]["state"]["state"] == "stored"
async def test_dump_states_with_failing_extra_data(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that a failing extra_restore_state_data skips only that entity."""
class BadRestoreEntity(RestoreEntity):
"""Entity that raises on extra_restore_state_data."""
@property
def extra_restore_state_data(self) -> ExtraStoredData | None:
raise RuntimeError("Unexpected error")
states = [
State("input_boolean.good", "on"),
State("input_boolean.bad", "on"),
]
platform = MockEntityPlatform(hass, domain="input_boolean")
good_entity = RestoreEntity()
good_entity.hass = hass
good_entity.entity_id = "input_boolean.good"
await platform.async_add_entities([good_entity])
bad_entity = BadRestoreEntity()
bad_entity.hass = hass
bad_entity.entity_id = "input_boolean.bad"
await platform.async_add_entities([bad_entity])
for state in states:
hass.states.async_set(state.entity_id, state.state, state.attributes)
data = async_get(hass)
with patch(
"homeassistant.helpers.restore_state.Store.async_save"
) as mock_write_data:
await data.async_dump_states()
assert mock_write_data.called
written_states = mock_write_data.mock_calls[0][1][0]
# Only the good entity should be saved
assert len(written_states) == 1
state0 = json_round_trip(written_states[0])
assert state0["state"]["entity_id"] == "input_boolean.good"
assert state0["state"]["state"] == "on"
assert "Error getting extra restore state data for input_boolean.bad" in caplog.text
async def test_entity_removal_with_failing_extra_data(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that entity removal succeeds even if extra_restore_state_data raises."""
class BadRestoreEntity(RestoreEntity):
"""Entity that raises on extra_restore_state_data."""
@property
def extra_restore_state_data(self) -> ExtraStoredData | None:
raise RuntimeError("Unexpected error")
platform = MockEntityPlatform(hass, domain="input_boolean")
entity = BadRestoreEntity()
entity.hass = hass
entity.entity_id = "input_boolean.bad"
await platform.async_add_entities([entity])
hass.states.async_set("input_boolean.bad", "on")
data = async_get(hass)
assert "input_boolean.bad" in data.entities
await entity.async_remove()
# Entity should be unregistered
assert "input_boolean.bad" not in data.entities
# No last state should be saved since extra data failed
assert "input_boolean.bad" not in data.last_states
assert "Error getting extra restore state data for input_boolean.bad" in caplog.text

View File

@@ -692,12 +692,6 @@ async def test_platform_backwards_compatibility_for_new_style_configs(
""",
],
)
# Patch out binary sensor triggers, because loading sun triggers also loads
# binary sensor triggers and those are irrelevant for this test
@patch(
"homeassistant.components.binary_sensor.trigger.async_get_triggers",
new=AsyncMock(return_value={}),
)
async def test_async_get_all_descriptions(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,