diff --git a/homeassistant/components/acmeda/cover.py b/homeassistant/components/acmeda/cover.py index 887e26cd7fcef9..3c3a5ba825a00a 100644 --- a/homeassistant/components/acmeda/cover.py +++ b/homeassistant/components/acmeda/cover.py @@ -16,6 +16,7 @@ from .base import AcmedaBase from .const import ACMEDA_HUB_UPDATE, DOMAIN from .helpers import async_add_acmeda_entities +from .hub import PulseHub async def async_setup_entry( @@ -24,7 +25,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Acmeda Rollers from a config entry.""" - hub = hass.data[DOMAIN][config_entry.entry_id] + hub: PulseHub = hass.data[DOMAIN][config_entry.entry_id] current: set[int] = set() @@ -122,6 +123,6 @@ async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the roller.""" await self.roller.move_stop() - async def async_set_cover_tilt(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Tilt the roller shutter to a specific position.""" await self.roller.move_to(100 - kwargs[ATTR_POSITION]) diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index f1046881ed3e07..c46a0133eb64af 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -15,13 +15,7 @@ SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - LENGTH_KILOMETERS, - LENGTH_MILES, - PERCENTAGE, - VOLUME_GALLONS, - VOLUME_LITERS, -) +from homeassistant.const import LENGTH, PERCENTAGE, VOLUME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -39,8 +33,7 @@ class BMWSensorEntityDescription(SensorEntityDescription): """Describes BMW sensor entity.""" key_class: str | None = None - unit_metric: str | None = None - unit_imperial: str | None = None + unit_type: str | None = None value: Callable = lambda x, y: x @@ -86,56 +79,49 @@ def convert_and_round( "remaining_battery_percent": BMWSensorEntityDescription( key="remaining_battery_percent", key_class="fuel_and_battery", - unit_metric=PERCENTAGE, - unit_imperial=PERCENTAGE, + unit_type=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), # --- Specific --- "mileage": BMWSensorEntityDescription( key="mileage", icon="mdi:speedometer", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, + unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), ), "remaining_range_total": BMWSensorEntityDescription( key="remaining_range_total", key_class="fuel_and_battery", icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, + unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), ), "remaining_range_electric": BMWSensorEntityDescription( key="remaining_range_electric", key_class="fuel_and_battery", icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, + unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), ), "remaining_range_fuel": BMWSensorEntityDescription( key="remaining_range_fuel", key_class="fuel_and_battery", icon="mdi:map-marker-distance", - unit_metric=LENGTH_KILOMETERS, - unit_imperial=LENGTH_MILES, + unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), ), "remaining_fuel": BMWSensorEntityDescription( key="remaining_fuel", key_class="fuel_and_battery", icon="mdi:gas-station", - unit_metric=VOLUME_LITERS, - unit_imperial=VOLUME_GALLONS, + unit_type=VOLUME, value=lambda x, hass: convert_and_round(x, hass.config.units.volume, 2), ), "remaining_fuel_percent": BMWSensorEntityDescription( key="remaining_fuel_percent", key_class="fuel_and_battery", icon="mdi:gas-station", - unit_metric=PERCENTAGE, - unit_imperial=PERCENTAGE, + unit_type=PERCENTAGE, ), } @@ -182,8 +168,12 @@ def __init__( self._attr_name = f"{vehicle.name} {description.key}" self._attr_unique_id = f"{vehicle.vin}-{description.key}" - # Force metric system as BMW API apparently only returns metric values now - self._attr_native_unit_of_measurement = description.unit_metric + # Set the correct unit of measurement based on the unit_type + if description.unit_type: + self._attr_native_unit_of_measurement = ( + coordinator.hass.config.units.as_dict().get(description.unit_type) + or description.unit_type + ) @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/discord/__init__.py b/homeassistant/components/discord/__init__.py index ae06447f741955..a52c079ac8ea15 100644 --- a/homeassistant/components/discord/__init__.py +++ b/homeassistant/components/discord/__init__.py @@ -7,12 +7,20 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import discovery +from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import DATA_HASS_CONFIG, DOMAIN PLATFORMS = [Platform.NOTIFY] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Discord component.""" + + hass.data[DATA_HASS_CONFIG] = config + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Discord from a config entry.""" nextcord.VoiceClient.warn_nacl = False @@ -30,11 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.async_create_task( discovery.async_load_platform( - hass, - Platform.NOTIFY, - DOMAIN, - hass.data[DOMAIN][entry.entry_id], - hass.data[DOMAIN], + hass, Platform.NOTIFY, DOMAIN, dict(entry.data), hass.data[DATA_HASS_CONFIG] ) ) diff --git a/homeassistant/components/discord/const.py b/homeassistant/components/discord/const.py index 9f11c3e2d7a9cf..82ddb890685caa 100644 --- a/homeassistant/components/discord/const.py +++ b/homeassistant/components/discord/const.py @@ -8,3 +8,5 @@ DOMAIN: Final = "discord" URL_PLACEHOLDER = {CONF_URL: "https://www.home-assistant.io/integrations/discord"} + +DATA_HASS_CONFIG = "discord_hass_config" diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py index fd8545b1f98b46..69ecb913c98942 100644 --- a/homeassistant/components/mobile_app/binary_sensor.py +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -75,9 +75,8 @@ def is_on(self): """Return the state of the binary sensor.""" return self._config[ATTR_SENSOR_STATE] - @callback - def async_restore_last_state(self, last_state): + async def async_restore_last_state(self, last_state): """Restore previous state.""" - super().async_restore_last_state(last_state) + await super().async_restore_last_state(last_state) self._config[ATTR_SENSOR_STATE] = last_state.state == STATE_ON diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index d4c4374b8d937d..3a2f038a0af007 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -43,10 +43,9 @@ async def async_added_to_hass(self): if (state := await self.async_get_last_state()) is None: return - self.async_restore_last_state(state) + await self.async_restore_last_state(state) - @callback - def async_restore_last_state(self, last_state): + async def async_restore_last_state(self, last_state): """Restore previous state.""" self._config[ATTR_SENSOR_STATE] = last_state.state self._config[ATTR_SENSOR_ATTRIBUTES] = { diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index d7cfc9545f6fcb..ef7dd122496d57 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -3,9 +3,9 @@ from typing import Any -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import RestoreSensor, SensorDeviceClass from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_WEBHOOK_ID, STATE_UNKNOWN +from homeassistant.const import CONF_WEBHOOK_ID, STATE_UNKNOWN, TEMP_CELSIUS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -27,6 +27,7 @@ DOMAIN, ) from .entity import MobileAppEntity +from .webhook import _extract_sensor_unique_id async def async_setup_entry( @@ -73,9 +74,30 @@ def handle_sensor_registration(data): ) -class MobileAppSensor(MobileAppEntity, SensorEntity): +class MobileAppSensor(MobileAppEntity, RestoreSensor): """Representation of an mobile app sensor.""" + async def async_restore_last_state(self, last_state): + """Restore previous state.""" + + await super().async_restore_last_state(last_state) + + if not (last_sensor_data := await self.async_get_last_sensor_data()): + # Workaround to handle migration to RestoreSensor, can be removed + # in HA Core 2023.4 + self._config[ATTR_SENSOR_STATE] = None + webhook_id = self._entry.data[CONF_WEBHOOK_ID] + sensor_unique_id = _extract_sensor_unique_id(webhook_id, self.unique_id) + if ( + self.device_class == SensorDeviceClass.TEMPERATURE + and sensor_unique_id == "battery_temperature" + ): + self._config[ATTR_SENSOR_UOM] = TEMP_CELSIUS + return + + self._config[ATTR_SENSOR_STATE] = last_sensor_data.native_value + self._config[ATTR_SENSOR_UOM] = last_sensor_data.native_unit_of_measurement + @property def native_value(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/netgear/manifest.json b/homeassistant/components/netgear/manifest.json index 5fd59faac83915..69a21e5aacebbf 100644 --- a/homeassistant/components/netgear/manifest.json +++ b/homeassistant/components/netgear/manifest.json @@ -2,7 +2,7 @@ "domain": "netgear", "name": "NETGEAR", "documentation": "https://www.home-assistant.io/integrations/netgear", - "requirements": ["pynetgear==0.10.6"], + "requirements": ["pynetgear==0.10.7"], "codeowners": ["@hacf-fr", "@Quentame", "@starkillerOG"], "iot_class": "local_polling", "config_flow": true, diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 788c698c0cacc2..60d24578593276 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -1,6 +1,8 @@ """Provides functionality to notify people.""" from __future__ import annotations +import asyncio + import voluptuous as vol import homeassistant.components.persistent_notification as pn @@ -40,13 +42,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the notify services.""" + platform_setups = async_setup_legacy(hass, config) + # We need to add the component here break the deadlock # when setting up integrations from config entries as # they would otherwise wait for notify to be # setup and thus the config entries would not be able to - # setup their platforms. + # setup their platforms, but we need to do it after + # the dispatcher is connected so we don't miss integrations + # that are registered before the dispatcher is connected hass.config.components.add(DOMAIN) - await async_setup_legacy(hass, config) + + if platform_setups: + await asyncio.wait([asyncio.create_task(setup) for setup in platform_setups]) async def persistent_notification(service: ServiceCall) -> None: """Send notification via the built-in persistsent_notify integration.""" diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index 50b023248275a9..f9066b7dff99af 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Coroutine from functools import partial from typing import Any, cast @@ -32,7 +33,10 @@ NOTIFY_DISCOVERY_DISPATCHER = "notify_discovery_dispatcher" -async def async_setup_legacy(hass: HomeAssistant, config: ConfigType) -> None: +@callback +def async_setup_legacy( + hass: HomeAssistant, config: ConfigType +) -> list[Coroutine[Any, Any, None]]: """Set up legacy notify services.""" hass.data.setdefault(NOTIFY_SERVICES, {}) hass.data.setdefault(NOTIFY_DISCOVERY_DISPATCHER, None) @@ -101,15 +105,6 @@ async def async_setup_platform( ) hass.config.components.add(f"{DOMAIN}.{integration_name}") - setup_tasks = [ - asyncio.create_task(async_setup_platform(integration_name, p_config)) - for integration_name, p_config in config_per_platform(config, DOMAIN) - if integration_name is not None - ] - - if setup_tasks: - await asyncio.wait(setup_tasks) - async def async_platform_discovered( platform: str, info: DiscoveryInfoType | None ) -> None: @@ -120,6 +115,12 @@ async def async_platform_discovered( hass, DOMAIN, async_platform_discovered ) + return [ + async_setup_platform(integration_name, p_config) + for integration_name, p_config in config_per_platform(config, DOMAIN) + if integration_name is not None + ] + @callback def check_templates_warn(hass: HomeAssistant, tpl: template.Template) -> None: diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 9acdbfb9ec929b..a4240bc0550ec2 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -111,8 +111,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) or OVERKIZ_DEVICE_TO_PLATFORM.get(device.ui_class): platforms[platform].append(device) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - device_registry = dr.async_get(hass) for gateway in setup.gateways: @@ -128,6 +126,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: configuration_url=server.configuration_url, ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py index ae52013621fc93..a89f645e9b6958 100644 --- a/homeassistant/components/slack/__init__.py +++ b/homeassistant/components/slack/__init__.py @@ -12,7 +12,7 @@ from homeassistant.helpers import aiohttp_client, discovery from homeassistant.helpers.typing import ConfigType -from .const import DATA_CLIENT, DOMAIN +from .const import DATA_CLIENT, DATA_HASS_CONFIG, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -21,6 +21,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Slack component.""" + hass.data[DATA_HASS_CONFIG] = config + # Iterate all entries for notify to only get Slack if Platform.NOTIFY in config: for entry in config[Platform.NOTIFY]: @@ -55,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: Platform.NOTIFY, DOMAIN, hass.data[DOMAIN][entry.entry_id], - hass.data[DOMAIN], + hass.data[DATA_HASS_CONFIG], ) ) diff --git a/homeassistant/components/slack/const.py b/homeassistant/components/slack/const.py index b7b5707aeebbbb..83937f4a43e79a 100644 --- a/homeassistant/components/slack/const.py +++ b/homeassistant/components/slack/const.py @@ -14,3 +14,5 @@ DATA_CLIENT = "client" DEFAULT_TIMEOUT_SECONDS = 15 DOMAIN: Final = "slack" + +DATA_HASS_CONFIG = "slack_hass_config" diff --git a/homeassistant/const.py b/homeassistant/const.py index 329c0e483dd4e5..f75c1802375020 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 8 -PATCH_VERSION: Final = "5" +PATCH_VERSION: Final = "6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/pyproject.toml b/pyproject.toml index b42b0b91b58de7..e69bdd747d2e93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2022.8.5" +version = "2022.8.6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" diff --git a/requirements_all.txt b/requirements_all.txt index 300ac05de8466d..01d1c95d878c83 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1683,7 +1683,7 @@ pymyq==3.1.4 pymysensors==0.22.1 # homeassistant.components.netgear -pynetgear==0.10.6 +pynetgear==0.10.7 # homeassistant.components.netio pynetio==0.1.9.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a069c31b87b025..1b5068e81fb115 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1160,7 +1160,7 @@ pymyq==3.1.4 pymysensors==0.22.1 # homeassistant.components.netgear -pynetgear==0.10.6 +pynetgear==0.10.7 # homeassistant.components.nina pynina==0.1.8 diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py index 4774032b409822..c2bb65b3fa7453 100644 --- a/tests/components/bmw_connected_drive/__init__.py +++ b/tests/components/bmw_connected_drive/__init__.py @@ -1,17 +1,29 @@ """Tests for the for the BMW Connected Drive integration.""" +import json +from pathlib import Path + +from bimmer_connected.account import MyBMWAccount +from bimmer_connected.api.utils import log_to_to_file + from homeassistant import config_entries from homeassistant.components.bmw_connected_drive.const import ( CONF_READ_ONLY, + CONF_REFRESH_TOKEN, DOMAIN as BMW_DOMAIN, ) from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from tests.common import MockConfigEntry, get_fixture_path, load_fixture FIXTURE_USER_INPUT = { CONF_USERNAME: "user@domain.com", CONF_PASSWORD: "p4ssw0rd", CONF_REGION: "rest_of_world", } +FIXTURE_REFRESH_TOKEN = "SOME_REFRESH_TOKEN" FIXTURE_CONFIG_ENTRY = { "entry_id": "1", @@ -21,8 +33,82 @@ 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, }, "options": {CONF_READ_ONLY: False}, "source": config_entries.SOURCE_USER, "unique_id": f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_REGION]}", } + + +async def mock_vehicles_from_fixture(account: MyBMWAccount) -> None: + """Load MyBMWVehicle from fixtures and add them to the account.""" + + fixture_path = Path(get_fixture_path("", integration=BMW_DOMAIN)) + + fixture_vehicles_bmw = list(fixture_path.rglob("vehicles_v2_bmw_*.json")) + fixture_vehicles_mini = list(fixture_path.rglob("vehicles_v2_mini_*.json")) + + # Load vehicle base lists as provided by vehicles/v2 API + vehicles = { + "bmw": [ + vehicle + for bmw_file in fixture_vehicles_bmw + for vehicle in json.loads(load_fixture(bmw_file, integration=BMW_DOMAIN)) + ], + "mini": [ + vehicle + for mini_file in fixture_vehicles_mini + for vehicle in json.loads(load_fixture(mini_file, integration=BMW_DOMAIN)) + ], + } + fetched_at = utcnow() + + # simulate storing fingerprints + if account.config.log_response_path: + for brand in ["bmw", "mini"]: + log_to_to_file( + json.dumps(vehicles[brand]), + account.config.log_response_path, + f"vehicles_v2_{brand}", + ) + + # Create a vehicle with base + specific state as provided by state/VIN API + for vehicle_base in [vehicle for brand in vehicles.values() for vehicle in brand]: + vehicle_state_path = ( + Path("vehicles") + / vehicle_base["attributes"]["bodyType"] + / f"state_{vehicle_base['vin']}_0.json" + ) + vehicle_state = json.loads( + load_fixture( + vehicle_state_path, + integration=BMW_DOMAIN, + ) + ) + + account.add_vehicle( + vehicle_base, + vehicle_state, + fetched_at, + ) + + # simulate storing fingerprints + if account.config.log_response_path: + log_to_to_file( + json.dumps(vehicle_state), + account.config.log_response_path, + f"state_{vehicle_base['vin']}", + ) + + +async def setup_mocked_integration(hass: HomeAssistant) -> MockConfigEntry: + """Mock a fully setup config entry and all components based on fixtures.""" + + 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 diff --git a/tests/components/bmw_connected_drive/conftest.py b/tests/components/bmw_connected_drive/conftest.py new file mode 100644 index 00000000000000..bf9d32ed9fa2b7 --- /dev/null +++ b/tests/components/bmw_connected_drive/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures for BMW tests.""" + +from bimmer_connected.account import MyBMWAccount +import pytest + +from . import mock_vehicles_from_fixture + + +@pytest.fixture +async def bmw_fixture(monkeypatch): + """Patch the vehicle fixtures into a MyBMWAccount.""" + monkeypatch.setattr(MyBMWAccount, "get_vehicles", mock_vehicles_from_fixture) diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/I01/state_WBY00000000REXI01_0.json b/tests/components/bmw_connected_drive/fixtures/vehicles/I01/state_WBY00000000REXI01_0.json new file mode 100644 index 00000000000000..adc2bde3650340 --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/I01/state_WBY00000000REXI01_0.json @@ -0,0 +1,206 @@ +{ + "capabilities": { + "climateFunction": "AIR_CONDITIONING", + "climateNow": true, + "climateTimerTrigger": "DEPARTURE_TIMER", + "horn": true, + "isBmwChargingSupported": true, + "isCarSharingSupported": false, + "isChargeNowForBusinessSupported": false, + "isChargingHistorySupported": true, + "isChargingHospitalityEnabled": false, + "isChargingLoudnessEnabled": false, + "isChargingPlanSupported": true, + "isChargingPowerLimitEnabled": false, + "isChargingSettingsEnabled": false, + "isChargingTargetSocEnabled": false, + "isClimateTimerSupported": true, + "isCustomerEsimSupported": false, + "isDCSContractManagementSupported": true, + "isDataPrivacyEnabled": false, + "isEasyChargeEnabled": false, + "isEvGoChargingSupported": false, + "isMiniChargingSupported": false, + "isNonLscFeatureEnabled": false, + "isRemoteEngineStartSupported": false, + "isRemoteHistoryDeletionSupported": false, + "isRemoteHistorySupported": true, + "isRemoteParkingSupported": false, + "isRemoteServicesActivationRequired": false, + "isRemoteServicesBookingRequired": false, + "isScanAndChargeSupported": false, + "isSustainabilitySupported": false, + "isWifiHotspotServiceSupported": false, + "lastStateCallState": "ACTIVATED", + "lights": true, + "lock": true, + "remoteChargingCommands": {}, + "sendPoi": true, + "specialThemeSupport": [], + "unlock": true, + "vehicleFinder": false, + "vehicleStateSource": "LAST_STATE_CALL" + }, + "state": { + "chargingProfile": { + "chargingControlType": "WEEKLY_PLANNER", + "chargingMode": "DELAYED_CHARGING", + "chargingPreference": "CHARGING_WINDOW", + "chargingSettings": { + "hospitality": "NO_ACTION", + "idcc": "NO_ACTION", + "targetSoc": 100 + }, + "climatisationOn": false, + "departureTimes": [ + { + "action": "DEACTIVATE", + "id": 1, + "timeStamp": { + "hour": 7, + "minute": 35 + }, + "timerWeekDays": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ] + }, + { + "action": "DEACTIVATE", + "id": 2, + "timeStamp": { + "hour": 18, + "minute": 0 + }, + "timerWeekDays": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ] + }, + { + "action": "DEACTIVATE", + "id": 3, + "timeStamp": { + "hour": 7, + "minute": 0 + }, + "timerWeekDays": [] + }, + { + "action": "DEACTIVATE", + "id": 4, + "timerWeekDays": [] + } + ], + "reductionOfChargeCurrent": { + "end": { + "hour": 1, + "minute": 30 + }, + "start": { + "hour": 18, + "minute": 1 + } + } + }, + "checkControlMessages": [], + "climateTimers": [ + { + "departureTime": { + "hour": 6, + "minute": 40 + }, + "isWeeklyTimer": true, + "timerAction": "ACTIVATE", + "timerWeekDays": ["THURSDAY", "SUNDAY"] + }, + { + "departureTime": { + "hour": 12, + "minute": 50 + }, + "isWeeklyTimer": false, + "timerAction": "ACTIVATE", + "timerWeekDays": ["MONDAY"] + }, + { + "departureTime": { + "hour": 18, + "minute": 59 + }, + "isWeeklyTimer": true, + "timerAction": "DEACTIVATE", + "timerWeekDays": ["WEDNESDAY"] + } + ], + "combustionFuelLevel": { + "range": 105, + "remainingFuelLiters": 6, + "remainingFuelPercent": 65 + }, + "currentMileage": 137009, + "doorsState": { + "combinedSecurityState": "UNLOCKED", + "combinedState": "CLOSED", + "hood": "CLOSED", + "leftFront": "CLOSED", + "leftRear": "CLOSED", + "rightFront": "CLOSED", + "rightRear": "CLOSED", + "trunk": "CLOSED" + }, + "driverPreferences": { + "lscPrivacyMode": "OFF" + }, + "electricChargingState": { + "chargingConnectionType": "CONDUCTIVE", + "chargingLevelPercent": 82, + "chargingStatus": "WAITING_FOR_CHARGING", + "chargingTarget": 100, + "isChargerConnected": true, + "range": 174 + }, + "isLeftSteering": true, + "isLscSupported": true, + "lastFetched": "2022-06-22T14:24:23.982Z", + "lastUpdatedAt": "2022-06-22T13:58:52Z", + "range": 174, + "requiredServices": [ + { + "dateTime": "2022-10-01T00:00:00.000Z", + "description": "Next service due by the specified date.", + "status": "OK", + "type": "BRAKE_FLUID" + }, + { + "dateTime": "2023-05-01T00:00:00.000Z", + "description": "Next vehicle check due after the specified distance or date.", + "status": "OK", + "type": "VEHICLE_CHECK" + }, + { + "dateTime": "2023-05-01T00:00:00.000Z", + "description": "Next state inspection due by the specified date.", + "status": "OK", + "type": "VEHICLE_TUV" + } + ], + "roofState": { + "roofState": "CLOSED", + "roofStateType": "SUN_ROOF" + }, + "windowsState": { + "combinedState": "CLOSED", + "leftFront": "CLOSED", + "rightFront": "CLOSED" + } + } +} diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/I01/vehicles_v2_bmw_0.json b/tests/components/bmw_connected_drive/fixtures/vehicles/I01/vehicles_v2_bmw_0.json new file mode 100644 index 00000000000000..145bc13378e74c --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/I01/vehicles_v2_bmw_0.json @@ -0,0 +1,47 @@ +[ + { + "appVehicleType": "CONNECTED", + "attributes": { + "a4aType": "USB_ONLY", + "bodyType": "I01", + "brand": "BMW_I", + "color": 4284110934, + "countryOfOrigin": "CZ", + "driveTrain": "ELECTRIC_WITH_RANGE_EXTENDER", + "driverGuideInfo": { + "androidAppScheme": "com.bmwgroup.driversguide.row", + "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", + "iosAppScheme": "bmwdriversguide:///open", + "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" + }, + "headUnitType": "NBT", + "hmiVersion": "ID4", + "lastFetched": "2022-07-10T09:25:53.104Z", + "model": "i3 (+ REX)", + "softwareVersionCurrent": { + "iStep": 510, + "puStep": { + "month": 11, + "year": 21 + }, + "seriesCluster": "I001" + }, + "softwareVersionExFactory": { + "iStep": 502, + "puStep": { + "month": 3, + "year": 15 + }, + "seriesCluster": "I001" + }, + "year": 2015 + }, + "mappingInfo": { + "isAssociated": false, + "isLmmEnabled": false, + "isPrimaryUser": true, + "mappingStatus": "CONFIRMED" + }, + "vin": "WBY00000000REXI01" + } +] diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index 3f22f984a54004..daac0c04f7b01b 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -12,15 +12,11 @@ ) from homeassistant.const import CONF_USERNAME -from . import FIXTURE_CONFIG_ENTRY, FIXTURE_USER_INPUT +from . import FIXTURE_CONFIG_ENTRY, FIXTURE_REFRESH_TOKEN, FIXTURE_USER_INPUT from tests.common import MockConfigEntry -FIXTURE_REFRESH_TOKEN = "SOME_REFRESH_TOKEN" -FIXTURE_COMPLETE_ENTRY = { - **FIXTURE_USER_INPUT, - CONF_REFRESH_TOKEN: FIXTURE_REFRESH_TOKEN, -} +FIXTURE_COMPLETE_ENTRY = FIXTURE_CONFIG_ENTRY["data"] FIXTURE_IMPORT_ENTRY = {**FIXTURE_USER_INPUT, CONF_REFRESH_TOKEN: None} diff --git a/tests/components/bmw_connected_drive/test_sensor.py b/tests/components/bmw_connected_drive/test_sensor.py new file mode 100644 index 00000000000000..cb1299a274b5ba --- /dev/null +++ b/tests/components/bmw_connected_drive/test_sensor.py @@ -0,0 +1,52 @@ +"""Test BMW sensors.""" +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import ( + IMPERIAL_SYSTEM as IMPERIAL, + METRIC_SYSTEM as METRIC, + UnitSystem, +) + +from . import setup_mocked_integration + + +@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.36", "mi"), + ("sensor.i3_rex_mileage", METRIC, "137009", "km"), + ("sensor.i3_rex_mileage", IMPERIAL, "85133.42", "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.12", "mi"), + ("sensor.i3_rex_remaining_fuel", METRIC, "6", "L"), + ("sensor.i3_rex_remaining_fuel", IMPERIAL, "1.59", "gal"), + ("sensor.i3_rex_remaining_range_fuel", METRIC, "105", "km"), + ("sensor.i3_rex_remaining_range_fuel", IMPERIAL, "65.24", "mi"), + ("sensor.i3_rex_remaining_fuel_percent", METRIC, "65", "%"), + ("sensor.i3_rex_remaining_fuel_percent", IMPERIAL, "65", "%"), + ], +) +async def test_unit_conversion( + hass: HomeAssistant, + entity_id: str, + unit_system: UnitSystem, + value: str, + unit_of_measurement: str, + bmw_fixture, +) -> 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 diff --git a/tests/components/mobile_app/test_sensor.py b/tests/components/mobile_app/test_sensor.py index c0f7f126a49f38..930fb522c4c5cd 100644 --- a/tests/components/mobile_app/test_sensor.py +++ b/tests/components/mobile_app/test_sensor.py @@ -1,15 +1,34 @@ """Entity tests for mobile_app.""" from http import HTTPStatus +from unittest.mock import patch import pytest from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import PERCENTAGE, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + PERCENTAGE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM -async def test_sensor(hass, create_registrations, webhook_client): +@pytest.mark.parametrize( + "unit_system, state_unit, state1, state2", + ( + (METRIC_SYSTEM, TEMP_CELSIUS, "100", "123"), + (IMPERIAL_SYSTEM, TEMP_FAHRENHEIT, "212", "253"), + ), +) +async def test_sensor( + hass, create_registrations, webhook_client, unit_system, state_unit, state1, state2 +): """Test that sensors can be registered and updated.""" + hass.config.units = unit_system + webhook_id = create_registrations[1]["webhook_id"] webhook_url = f"/api/webhook/{webhook_id}" @@ -19,15 +38,15 @@ async def test_sensor(hass, create_registrations, webhook_client): "type": "register_sensor", "data": { "attributes": {"foo": "bar"}, - "device_class": "battery", + "device_class": "temperature", "icon": "mdi:battery", - "name": "Battery State", + "name": "Battery Temperature", "state": 100, "type": "sensor", "entity_category": "diagnostic", - "unique_id": "battery_state", + "unique_id": "battery_temp", "state_class": "total", - "unit_of_measurement": PERCENTAGE, + "unit_of_measurement": TEMP_CELSIUS, }, }, ) @@ -38,20 +57,23 @@ async def test_sensor(hass, create_registrations, webhook_client): assert json == {"success": True} await hass.async_block_till_done() - entity = hass.states.get("sensor.test_1_battery_state") + entity = hass.states.get("sensor.test_1_battery_temperature") assert entity is not None - assert entity.attributes["device_class"] == "battery" + assert entity.attributes["device_class"] == "temperature" assert entity.attributes["icon"] == "mdi:battery" - assert entity.attributes["unit_of_measurement"] == PERCENTAGE + # unit of temperature sensor is automatically converted to the system UoM + assert entity.attributes["unit_of_measurement"] == state_unit assert entity.attributes["foo"] == "bar" assert entity.attributes["state_class"] == "total" assert entity.domain == "sensor" - assert entity.name == "Test 1 Battery State" - assert entity.state == "100" + assert entity.name == "Test 1 Battery Temperature" + assert entity.state == state1 assert ( - er.async_get(hass).async_get("sensor.test_1_battery_state").entity_category + er.async_get(hass) + .async_get("sensor.test_1_battery_temperature") + .entity_category == "diagnostic" ) @@ -64,7 +86,7 @@ async def test_sensor(hass, create_registrations, webhook_client): "icon": "mdi:battery-unknown", "state": 123, "type": "sensor", - "unique_id": "battery_state", + "unique_id": "battery_temp", }, # This invalid data should not invalidate whole request {"type": "sensor", "unique_id": "invalid_state", "invalid": "data"}, @@ -77,8 +99,8 @@ async def test_sensor(hass, create_registrations, webhook_client): json = await update_resp.json() assert json["invalid_state"]["success"] is False - updated_entity = hass.states.get("sensor.test_1_battery_state") - assert updated_entity.state == "123" + updated_entity = hass.states.get("sensor.test_1_battery_temperature") + assert updated_entity.state == state2 assert "foo" not in updated_entity.attributes dev_reg = dr.async_get(hass) @@ -88,16 +110,120 @@ async def test_sensor(hass, create_registrations, webhook_client): config_entry = hass.config_entries.async_entries("mobile_app")[1] await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - unloaded_entity = hass.states.get("sensor.test_1_battery_state") + unloaded_entity = hass.states.get("sensor.test_1_battery_temperature") assert unloaded_entity.state == STATE_UNAVAILABLE await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - restored_entity = hass.states.get("sensor.test_1_battery_state") + restored_entity = hass.states.get("sensor.test_1_battery_temperature") assert restored_entity.state == updated_entity.state assert restored_entity.attributes == updated_entity.attributes +@pytest.mark.parametrize( + "unique_id, unit_system, state_unit, state1, state2", + ( + ("battery_temperature", METRIC_SYSTEM, TEMP_CELSIUS, "100", "123"), + ("battery_temperature", IMPERIAL_SYSTEM, TEMP_FAHRENHEIT, "212", "253"), + # The unique_id doesn't match that of the mobile app's battery temperature sensor + ("battery_temp", IMPERIAL_SYSTEM, TEMP_FAHRENHEIT, "212", "123"), + ), +) +async def test_sensor_migration( + hass, + create_registrations, + webhook_client, + unique_id, + unit_system, + state_unit, + state1, + state2, +): + """Test migration to RestoreSensor.""" + hass.config.units = unit_system + + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "attributes": {"foo": "bar"}, + "device_class": "temperature", + "icon": "mdi:battery", + "name": "Battery Temperature", + "state": 100, + "type": "sensor", + "entity_category": "diagnostic", + "unique_id": unique_id, + "state_class": "total", + "unit_of_measurement": TEMP_CELSIUS, + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + + json = await reg_resp.json() + assert json == {"success": True} + await hass.async_block_till_done() + + entity = hass.states.get("sensor.test_1_battery_temperature") + assert entity is not None + + assert entity.attributes["device_class"] == "temperature" + assert entity.attributes["icon"] == "mdi:battery" + # unit of temperature sensor is automatically converted to the system UoM + assert entity.attributes["unit_of_measurement"] == state_unit + assert entity.attributes["foo"] == "bar" + assert entity.attributes["state_class"] == "total" + assert entity.domain == "sensor" + assert entity.name == "Test 1 Battery Temperature" + assert entity.state == state1 + + # Reload to verify state is restored + config_entry = hass.config_entries.async_entries("mobile_app")[1] + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + unloaded_entity = hass.states.get("sensor.test_1_battery_temperature") + assert unloaded_entity.state == STATE_UNAVAILABLE + + # Simulate migration to RestoreSensor + with patch( + "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_extra_data", + return_value=None, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + restored_entity = hass.states.get("sensor.test_1_battery_temperature") + assert restored_entity.state == "unknown" + assert restored_entity.attributes == entity.attributes + + # Test unit conversion is working + update_resp = await webhook_client.post( + webhook_url, + json={ + "type": "update_sensor_states", + "data": [ + { + "icon": "mdi:battery-unknown", + "state": 123, + "type": "sensor", + "unique_id": unique_id, + }, + ], + }, + ) + + assert update_resp.status == HTTPStatus.OK + + updated_entity = hass.states.get("sensor.test_1_battery_temperature") + assert updated_entity.state == state2 + assert "foo" not in updated_entity.attributes + + async def test_sensor_must_register(hass, create_registrations, webhook_client): """Test that sensors must be registered before updating.""" webhook_id = create_registrations[1]["webhook_id"] diff --git a/tests/components/notify/test_init.py b/tests/components/notify/test_init.py index ae32884add7f07..b691ed7a051995 100644 --- a/tests/components/notify/test_init.py +++ b/tests/components/notify/test_init.py @@ -1,12 +1,13 @@ """The tests for notify services that change targets.""" +import asyncio from unittest.mock import Mock, patch import yaml from homeassistant import config as hass_config from homeassistant.components import notify -from homeassistant.const import SERVICE_RELOAD +from homeassistant.const import SERVICE_RELOAD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.reload import async_setup_reload_service @@ -330,3 +331,99 @@ async def async_get_service2(hass, config, discovery_info=None): # Check if the dynamically notify services from setup were removed assert not hass.services.has_service(notify.DOMAIN, "testnotify2_c") assert not hass.services.has_service(notify.DOMAIN, "testnotify2_d") + + +async def test_setup_platform_before_notify_setup(hass, caplog, tmp_path): + """Test trying to setup a platform before notify is setup.""" + get_service_called = Mock() + + async def async_get_service(hass, config, discovery_info=None): + """Get notify service for mocked platform.""" + get_service_called(config, discovery_info) + targetlist = {"a": 1, "b": 2} + return NotificationService(hass, targetlist, "testnotify") + + async def async_get_service2(hass, config, discovery_info=None): + """Get notify service for mocked platform.""" + get_service_called(config, discovery_info) + targetlist = {"c": 3, "d": 4} + return NotificationService(hass, targetlist, "testnotify2") + + # Mock first platform + mock_notify_platform( + hass, tmp_path, "testnotify", async_get_service=async_get_service + ) + + # Initialize a second platform testnotify2 + mock_notify_platform( + hass, tmp_path, "testnotify2", async_get_service=async_get_service2 + ) + + hass_config = {"notify": [{"platform": "testnotify"}]} + + # Setup the second testnotify2 platform from discovery + load_coro = async_load_platform( + hass, Platform.NOTIFY, "testnotify2", {}, hass_config=hass_config + ) + + # Setup the testnotify platform + setup_coro = async_setup_component(hass, "notify", hass_config) + + load_task = asyncio.create_task(load_coro) + setup_task = asyncio.create_task(setup_coro) + + await asyncio.gather(load_task, setup_task) + + await hass.async_block_till_done() + assert hass.services.has_service(notify.DOMAIN, "testnotify_a") + assert hass.services.has_service(notify.DOMAIN, "testnotify_b") + assert hass.services.has_service(notify.DOMAIN, "testnotify2_c") + assert hass.services.has_service(notify.DOMAIN, "testnotify2_d") + + +async def test_setup_platform_after_notify_setup(hass, caplog, tmp_path): + """Test trying to setup a platform after notify is setup.""" + get_service_called = Mock() + + async def async_get_service(hass, config, discovery_info=None): + """Get notify service for mocked platform.""" + get_service_called(config, discovery_info) + targetlist = {"a": 1, "b": 2} + return NotificationService(hass, targetlist, "testnotify") + + async def async_get_service2(hass, config, discovery_info=None): + """Get notify service for mocked platform.""" + get_service_called(config, discovery_info) + targetlist = {"c": 3, "d": 4} + return NotificationService(hass, targetlist, "testnotify2") + + # Mock first platform + mock_notify_platform( + hass, tmp_path, "testnotify", async_get_service=async_get_service + ) + + # Initialize a second platform testnotify2 + mock_notify_platform( + hass, tmp_path, "testnotify2", async_get_service=async_get_service2 + ) + + hass_config = {"notify": [{"platform": "testnotify"}]} + + # Setup the second testnotify2 platform from discovery + load_coro = async_load_platform( + hass, Platform.NOTIFY, "testnotify2", {}, hass_config=hass_config + ) + + # Setup the testnotify platform + setup_coro = async_setup_component(hass, "notify", hass_config) + + setup_task = asyncio.create_task(setup_coro) + load_task = asyncio.create_task(load_coro) + + await asyncio.gather(load_task, setup_task) + + await hass.async_block_till_done() + assert hass.services.has_service(notify.DOMAIN, "testnotify_a") + assert hass.services.has_service(notify.DOMAIN, "testnotify_b") + assert hass.services.has_service(notify.DOMAIN, "testnotify2_c") + assert hass.services.has_service(notify.DOMAIN, "testnotify2_d")