From f72c1a8cb6d57a94d55a838452952d5756c29c70 Mon Sep 17 00:00:00 2001 From: Garrett <7310260+G-Two@users.noreply.github.com> Date: Sat, 29 Oct 2022 23:34:37 -0400 Subject: [PATCH] v0.6.5 (#57) * Incorporate changes from HA Core * Remove deprecated unit conversions * Migrate sensor unique_id to VIN_JSON_KEY * Refactor platforms to use description objects * Refactor other platforms and add type hints * Add diagnostics support * Update casing of button names --- custom_components/subaru/__init__.py | 49 +++-- custom_components/subaru/binary_sensor.py | 187 +++++++++---------- custom_components/subaru/button.py | 114 ++++++----- custom_components/subaru/config_flow.py | 35 +++- custom_components/subaru/const.py | 13 -- custom_components/subaru/device.py | 23 +++ custom_components/subaru/device_tracker.py | 87 ++++++--- custom_components/subaru/diagnostics.py | 56 ++++++ custom_components/subaru/lock.py | 37 ++-- custom_components/subaru/manifest.json | 2 +- custom_components/subaru/migrate.py | 75 ++++++++ custom_components/subaru/options.py | 5 +- custom_components/subaru/remote_service.py | 28 ++- custom_components/subaru/select.py | 68 ++++--- custom_components/subaru/sensor.py | 39 ++-- hacs.json | 2 +- tests/conftest.py | 181 +++++++++++------- tests/fixtures/diagnostics_config_entry.json | 173 +++++++++++++++++ tests/fixtures/diagnostics_device.json | 171 +++++++++++++++++ tests/test_binary_sensor.py | 77 +++++++- tests/test_button.py | 60 +++++- tests/test_diagnostics.py | 70 +++++++ tests/test_init.py | 54 ++++-- tests/test_select.py | 52 ++++++ tests/test_sensor.py | 60 +++++- 25 files changed, 1338 insertions(+), 380 deletions(-) create mode 100644 custom_components/subaru/device.py create mode 100644 custom_components/subaru/diagnostics.py create mode 100644 custom_components/subaru/migrate.py create mode 100644 tests/fixtures/diagnostics_config_entry.json create mode 100644 tests/fixtures/diagnostics_device.json create mode 100644 tests/test_diagnostics.py diff --git a/custom_components/subaru/__init__.py b/custom_components/subaru/__init__.py index 7985d93..39b8aa2 100644 --- a/custom_components/subaru/__init__.py +++ b/custom_components/subaru/__init__.py @@ -1,4 +1,6 @@ """The Subaru integration.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging @@ -21,15 +23,15 @@ STATE_ON, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import ( aiohttp_client, config_validation as cv, device_registry, - entity_registry, + entity_registry as er, ) -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -42,7 +44,6 @@ ENTRY_COORDINATOR, ENTRY_VEHICLES, FETCH_INTERVAL, - MANUFACTURER, REMOTE_CLIMATE_PRESET_NAME, REMOTE_SERVICE_REMOTE_START, SUPPORTED_PLATFORMS, @@ -60,6 +61,7 @@ VEHICLE_NAME, VEHICLE_VIN, ) +from .migrate import async_migrate_entries from .options import PollingOptions from .remote_service import ( async_call_remote_service, @@ -71,13 +73,13 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup(hass, base_config): +async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool: """Do nothing since this integration does not support configuration.yml setup.""" hass.data.setdefault(DOMAIN, {}) return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Subaru from a config entry.""" config = entry.data websession = aiohttp_client.async_create_clientsession(hass) @@ -109,12 +111,12 @@ async def async_setup_entry(hass, entry): vehicles = {} for vin in controller.get_vehicles(): - vehicles[vin] = get_vehicle_info(controller, vin) + vehicles[vin] = _get_vehicle_info(controller, vin) - async def async_update_data(): + async def async_update_data() -> dict: """Fetch data from API endpoint.""" try: - return await refresh_subaru_data(hass, entry, vehicles, controller) + return await _refresh_subaru_data(hass, entry, vehicles, controller) except SubaruException as err: raise UpdateFailed(err.message) from err @@ -134,12 +136,14 @@ async def async_update_data(): ENTRY_VEHICLES: vehicles, } + await async_migrate_entries(hass, entry) + for component in SUPPORTED_PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) - async def async_call_service(call): + async def async_call_service(call: ServiceCall) -> None: """Execute subaru service.""" _LOGGER.warning( "This Subaru-specific service is deprecated and will be removed in v0.7.0. Use button or lock entities (or their respective services) to actuate remove vehicle services." @@ -161,7 +165,7 @@ async def async_call_service(call): raise HomeAssistantError(f"Invalid VIN provided while calling {call.service}") - async def async_remote_start(call): + async def async_remote_start(call: ServiceCall) -> None: """Start the vehicle engine.""" dev_reg = device_registry.async_get(hass) device_entry = dev_reg.async_get(call.data[ATTR_DEVICE_ID]) @@ -209,7 +213,7 @@ async def async_remote_start(call): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = all( await asyncio.gather( @@ -225,7 +229,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): return unload_ok -async def refresh_subaru_data(hass, config_entry, vehicle_info, controller): +async def _refresh_subaru_data( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_info: dict, + controller: SubaruAPI, +) -> dict: """ Refresh local data with data fetched via Subaru API. @@ -247,7 +256,7 @@ async def refresh_subaru_data(hass, config_entry, vehicle_info, controller): ) if polling_option == PollingOptions.CHARGING: # Is there a better way to check if the subaru is charging? - e_registry = entity_registry.async_get(hass) + e_registry = er.async_get(hass) battery_charging = e_registry.async_get_device_class_lookup( {(Platform.BINARY_SENSOR, BinarySensorDeviceClass.BATTERY_CHARGING)} ) @@ -278,7 +287,7 @@ async def refresh_subaru_data(hass, config_entry, vehicle_info, controller): return data -def get_vehicle_info(controller, vin): +def _get_vehicle_info(controller: SubaruAPI, vin: str) -> dict: """Obtain vehicle identifiers and capabilities.""" info = { VEHICLE_VIN: vin, @@ -294,13 +303,3 @@ def get_vehicle_info(controller, vin): VEHICLE_LAST_FETCH: 0, } return info - - -def get_device_info(vehicle_info): - """Return DeviceInfo object based on vehicle info.""" - return DeviceInfo( - identifiers={(DOMAIN, vehicle_info[VEHICLE_VIN])}, - manufacturer=MANUFACTURER, - model=f"{vehicle_info[VEHICLE_MODEL_YEAR]} {vehicle_info[VEHICLE_MODEL_NAME]}", - name=vehicle_info[VEHICLE_NAME], - ) diff --git a/custom_components/subaru/binary_sensor.py b/custom_components/subaru/binary_sensor.py index 8331168..b772e2e 100644 --- a/custom_components/subaru/binary_sensor.py +++ b/custom_components/subaru/binary_sensor.py @@ -1,6 +1,7 @@ """Support for Subaru binary sensors.""" -from dataclasses import dataclass -from typing import List +from __future__ import annotations + +from typing import Any import subarulink.const as sc @@ -9,9 +10,14 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) -from . import get_device_info from .const import ( API_GEN_2, DOMAIN, @@ -19,10 +25,10 @@ ENTRY_VEHICLES, VEHICLE_API_GEN, VEHICLE_HAS_EV, - VEHICLE_NAME, VEHICLE_STATUS, VEHICLE_VIN, ) +from .device import get_device_info BINARY_SENSOR_ICONS = { BinarySensorDeviceClass.POWER: {True: "mdi:engine", False: "mdi:engine-off"}, @@ -38,172 +44,161 @@ }, } - -@dataclass -class SubaruBinarySensorFieldsMixin: - """Additional fields needed for Subaru binary sensors.""" - - suffix: str - on_values: List - - -@dataclass -class SubaruBinarySensorEntityDescription( - BinarySensorEntityDescription, SubaruBinarySensorFieldsMixin -): - """Describes Subaru binary sensor entity.""" - +ON_VALUES = { + BinarySensorDeviceClass.DOOR: [sc.DOOR_OPEN], + BinarySensorDeviceClass.POWER: [sc.IGNITION_ON], + BinarySensorDeviceClass.WINDOW: [sc.WINDOW_OPEN], + BinarySensorDeviceClass.PLUG: [sc.LOCKED_CONNECTED, sc.UNLOCKED_CONNECTED], + BinarySensorDeviceClass.BATTERY_CHARGING: [sc.CHARGING], +} # Binary Sensors available to "Subaru Safety Plus" subscribers with Gen2 vehicles -API_GEN_2_SENSORS = [ - SubaruBinarySensorEntityDescription( - suffix="Ignition", +API_GEN_2_BINARY_SENSORS = [ + BinarySensorEntityDescription( + name="Ignition", key=sc.VEHICLE_STATE, device_class=BinarySensorDeviceClass.POWER, - on_values=[sc.IGNITION_ON], ), - SubaruBinarySensorEntityDescription( - suffix="Trunk", + BinarySensorEntityDescription( + name="Trunk", key=sc.DOOR_BOOT_POSITION, device_class=BinarySensorDeviceClass.DOOR, - on_values=[sc.DOOR_OPEN], ), - SubaruBinarySensorEntityDescription( - suffix="Hood", + BinarySensorEntityDescription( + name="Hood", key=sc.DOOR_ENGINE_HOOD_POSITION, device_class=BinarySensorDeviceClass.DOOR, - on_values=[sc.DOOR_OPEN], ), - SubaruBinarySensorEntityDescription( - suffix="Front Left Door", + BinarySensorEntityDescription( + name="Front left door", key=sc.DOOR_FRONT_LEFT_POSITION, device_class=BinarySensorDeviceClass.DOOR, - on_values=[sc.DOOR_OPEN], ), - SubaruBinarySensorEntityDescription( - suffix="Front Right Door", + BinarySensorEntityDescription( + name="Front right door", key=sc.DOOR_FRONT_RIGHT_POSITION, device_class=BinarySensorDeviceClass.DOOR, - on_values=[sc.DOOR_OPEN], ), - SubaruBinarySensorEntityDescription( - suffix="Rear Left Door", + BinarySensorEntityDescription( + name="Rear left door", key=sc.DOOR_REAR_LEFT_POSITION, device_class=BinarySensorDeviceClass.DOOR, - on_values=[sc.DOOR_OPEN], ), - SubaruBinarySensorEntityDescription( - suffix="Rear Right Door", + BinarySensorEntityDescription( + name="Rear right door", key=sc.DOOR_REAR_RIGHT_POSITION, device_class=BinarySensorDeviceClass.DOOR, - on_values=[sc.DOOR_OPEN], ), - SubaruBinarySensorEntityDescription( - suffix="Front Left Window", + BinarySensorEntityDescription( + name="Front left window", key=sc.WINDOW_FRONT_LEFT_STATUS, device_class=BinarySensorDeviceClass.WINDOW, - on_values=[sc.WINDOW_OPEN], ), - SubaruBinarySensorEntityDescription( - suffix="Front Right Window", + BinarySensorEntityDescription( + name="Front right window", key=sc.WINDOW_FRONT_RIGHT_STATUS, device_class=BinarySensorDeviceClass.WINDOW, - on_values=[sc.WINDOW_OPEN], ), - SubaruBinarySensorEntityDescription( - suffix="Rear Left Window", + BinarySensorEntityDescription( + name="Rear left window", key=sc.WINDOW_REAR_LEFT_STATUS, device_class=BinarySensorDeviceClass.WINDOW, - on_values=[sc.WINDOW_OPEN], ), - SubaruBinarySensorEntityDescription( - suffix="Rear Right Window", + BinarySensorEntityDescription( + name="Rear right window", key=sc.WINDOW_REAR_RIGHT_STATUS, device_class=BinarySensorDeviceClass.WINDOW, - on_values=[sc.WINDOW_OPEN], ), - SubaruBinarySensorEntityDescription( - suffix="Sunroof", + BinarySensorEntityDescription( + name="Sunroof", key=sc.WINDOW_SUNROOF_STATUS, device_class=BinarySensorDeviceClass.WINDOW, - on_values=[sc.WINDOW_OPEN], ), ] # Binary Sensors available to "Subaru Safety Plus" subscribers with PHEV vehicles -EV_SENSORS = [ - SubaruBinarySensorEntityDescription( - suffix="EV Charge Port", +EV_BINARY_SENSORS = [ + BinarySensorEntityDescription( + name="EV charge port", key=sc.EV_IS_PLUGGED_IN, device_class=BinarySensorDeviceClass.PLUG, - on_values=[sc.LOCKED_CONNECTED, sc.UNLOCKED_CONNECTED], ), - SubaruBinarySensorEntityDescription( - suffix="EV Battery Charging", + BinarySensorEntityDescription( + name="EV battery charging", key=sc.EV_CHARGER_STATE_TYPE, device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - on_values=[sc.CHARGING], ), ] -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Subaru binary sensors by config_entry.""" entry = hass.data[DOMAIN][config_entry.entry_id] coordinator = entry[ENTRY_COORDINATOR] vehicle_info = entry[ENTRY_VEHICLES] entities = [] - for vin in vehicle_info: - _create_sensor_entities(entities, vehicle_info[vin], coordinator) + for info in vehicle_info.values(): + entities.extend(create_vehicle_binary_sensors(info, coordinator)) async_add_entities(entities) -def _create_sensor_entities(entities, vehicle_info, coordinator): - sensors_to_add = [] +def create_vehicle_binary_sensors( + vehicle_info: dict, coordinator: DataUpdateCoordinator +) -> list[SubaruBinarySensor]: + """Instantiate all available binary sensors for the vehicle.""" + potential_sensors = [] if vehicle_info[VEHICLE_API_GEN] == API_GEN_2: - sensors_to_add.extend(API_GEN_2_SENSORS) + potential_sensors.extend(API_GEN_2_BINARY_SENSORS) if vehicle_info[VEHICLE_HAS_EV]: - sensors_to_add.extend(EV_SENSORS) + potential_sensors.extend(EV_BINARY_SENSORS) - for sensor_description in sensors_to_add: + binary_sensors_to_add = [] + for sensor in potential_sensors: if ( - coordinator.data[vehicle_info[VEHICLE_VIN]][VEHICLE_STATUS].get( - sensor_description.key - ) + coordinator.data[vehicle_info[VEHICLE_VIN]][VEHICLE_STATUS].get(sensor.key) not in sc.BAD_BINARY_SENSOR_VALUES ): - entities.append( - SubaruBinarySensor( - vehicle_info, - coordinator, - sensor_description, - ) - ) + binary_sensors_to_add.append(sensor) + return [ + SubaruBinarySensor(vehicle_info, coordinator, description) + for description in binary_sensors_to_add + ] -class SubaruBinarySensor(CoordinatorEntity, BinarySensorEntity): + +class SubaruBinarySensor( + CoordinatorEntity[DataUpdateCoordinator[dict[str, Any]]], BinarySensorEntity +): """Class for Subaru binary sensors.""" - entity_description: SubaruBinarySensorEntityDescription + _attr_has_entity_name = True - def __init__(self, vehicle_info, coordinator, description): + def __init__( + self, + vehicle_info: dict, + coordinator: DataUpdateCoordinator, + description: BinarySensorEntityDescription, + ) -> None: """Initialize the binary sensor.""" super().__init__(coordinator) self.vin = vehicle_info[VEHICLE_VIN] self.entity_description = description self._attr_device_info = get_device_info(vehicle_info) - self._attr_name = f"{vehicle_info[VEHICLE_NAME]} {description.suffix}" - self._attr_unique_id = f"{self.vin}_{description.suffix}" + self._attr_unique_id = f"{self.vin}_{description.key}" @property - def icon(self): + def icon(self) -> str: """Return icon for sensor.""" return BINARY_SENSOR_ICONS[self.device_class][self.is_on] @property - def available(self): + def available(self) -> bool: """Return if entity is available.""" last_update_success = super().available if last_update_success and self.vin not in self.coordinator.data: @@ -213,13 +208,13 @@ def available(self): return last_update_success @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self.get_current_value() in self.entity_description.on_values + return self.get_current_value() in ON_VALUES[self.device_class] - def get_current_value(self): + def get_current_value(self) -> str | None: """Get raw value from the coordinator.""" - if isinstance(data := self.coordinator.data, dict): - if data.get(self.vin): - return data[self.vin][VEHICLE_STATUS].get(self.entity_description.key) - return None + value = None + if data := self.coordinator.data.get(self.vin): + value = data[VEHICLE_STATUS].get(self.entity_description.key) + return value diff --git a/custom_components/subaru/button.py b/custom_components/subaru/button.py index fa7f605..ba451a6 100644 --- a/custom_components/subaru/button.py +++ b/custom_components/subaru/button.py @@ -1,15 +1,20 @@ """Support for Subaru buttons.""" +from __future__ import annotations + import logging -from homeassistant.components.button import ButtonEntity +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import DOMAIN as SUBARU_DOMAIN, get_device_info from .const import ( CONF_NOTIFICATION_OPTION, + DOMAIN as SUBARU_DOMAIN, ENTRY_CONTROLLER, ENTRY_COORDINATOR, ENTRY_VEHICLES, - ICONS, REMOTE_SERVICE_CHARGE_START, REMOTE_SERVICE_FETCH, REMOTE_SERVICE_HORN, @@ -23,49 +28,70 @@ VEHICLE_HAS_EV, VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_HAS_REMOTE_START, - VEHICLE_NAME, VEHICLE_VIN, ) +from .device import get_device_info from .remote_service import async_call_remote_service _LOGGER = logging.getLogger(__name__) -BUTTON_TYPE = "type" -BUTTON_SERVICE = "service" - G1_REMOTE_BUTTONS = [ - {BUTTON_TYPE: "Horn Start", BUTTON_SERVICE: REMOTE_SERVICE_HORN}, - {BUTTON_TYPE: "Horn Stop", BUTTON_SERVICE: REMOTE_SERVICE_HORN_STOP}, - {BUTTON_TYPE: "Lights Start", BUTTON_SERVICE: REMOTE_SERVICE_LIGHTS}, - {BUTTON_TYPE: "Lights Stop", BUTTON_SERVICE: REMOTE_SERVICE_LIGHTS_STOP}, - {BUTTON_TYPE: "Locate", BUTTON_SERVICE: REMOTE_SERVICE_UPDATE}, - {BUTTON_TYPE: "Refresh", BUTTON_SERVICE: REMOTE_SERVICE_FETCH}, + ButtonEntityDescription( + key=REMOTE_SERVICE_HORN, icon="mdi:volume-high", name="Horn start" + ), + ButtonEntityDescription( + key=REMOTE_SERVICE_HORN_STOP, icon="mdi:volume-off", name="Horn stop" + ), + ButtonEntityDescription( + key=REMOTE_SERVICE_LIGHTS, icon="mdi:lightbulb-on", name="Lights start" + ), + ButtonEntityDescription( + key=REMOTE_SERVICE_LIGHTS_STOP, icon="mdi:lightbulb-off", name="Lights stop" + ), + ButtonEntityDescription( + key=REMOTE_SERVICE_UPDATE, icon="mdi:car-connected", name="Locate" + ), + ButtonEntityDescription( + key=REMOTE_SERVICE_FETCH, icon="mdi:refresh", name="Refresh" + ), ] RES_REMOTE_BUTTONS = [ - {BUTTON_TYPE: "Remote Start", BUTTON_SERVICE: REMOTE_SERVICE_REMOTE_START}, - {BUTTON_TYPE: "Remote Stop", BUTTON_SERVICE: REMOTE_SERVICE_REMOTE_STOP}, + ButtonEntityDescription( + key=REMOTE_SERVICE_REMOTE_START, icon="mdi:power", name="Remote start" + ), + ButtonEntityDescription( + key=REMOTE_SERVICE_REMOTE_STOP, + icon="mdi:stop-circle-outline", + name="Remote Stop", + ), ] EV_REMOTE_BUTTONS = [ - {BUTTON_TYPE: "Charge EV", BUTTON_SERVICE: REMOTE_SERVICE_CHARGE_START} + ButtonEntityDescription( + key=REMOTE_SERVICE_CHARGE_START, icon="mdi:ev-station", name="Charge EV" + ) ] -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the Subaru button by config_entry.""" +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Subaru buttons by config_entry.""" entry = hass.data[SUBARU_DOMAIN][config_entry.entry_id] coordinator = entry[ENTRY_COORDINATOR] vehicle_info = entry[ENTRY_VEHICLES] entities = [] - for vin in vehicle_info: - entities.extend( - create_vehicle_buttons(vehicle_info[vin], coordinator, config_entry) - ) + for info in vehicle_info.values(): + entities.extend(create_vehicle_buttons(info, coordinator, config_entry)) async_add_entities(entities) -def create_vehicle_buttons(vehicle_info, coordinator, config_entry): +def create_vehicle_buttons( + vehicle_info: dict, coordinator: DataUpdateCoordinator, config_entry: ConfigEntry +) -> list[SubaruButton]: """Instantiate all available buttons for the vehicle.""" buttons_to_add = [] if vehicle_info[VEHICLE_HAS_REMOTE_SERVICE]: @@ -78,44 +104,38 @@ def create_vehicle_buttons(vehicle_info, coordinator, config_entry): buttons_to_add.extend(EV_REMOTE_BUTTONS) return [ - SubaruButton( - vehicle_info, - config_entry, - coordinator, - b[BUTTON_TYPE], - b[BUTTON_SERVICE], - ) - for b in buttons_to_add + SubaruButton(vehicle_info, config_entry, coordinator, description) + for description in buttons_to_add ] class SubaruButton(ButtonEntity): - """Representation of a Subaru button.""" + """Class for a Subaru buttons.""" + + _attr_has_entity_name = True - def __init__(self, vehicle_info, config_entry, coordinator, entity_type, service): + def __init__( + self, + vehicle_info: dict, + config_entry: ConfigEntry, + coordinator: DataUpdateCoordinator, + description: ButtonEntityDescription, + ) -> None: """Initialize the button for the vehicle.""" self.vin = vehicle_info[VEHICLE_VIN] self.vehicle_info = vehicle_info - self.entity_type = entity_type + self.entity_description = description self.config_entry = config_entry - self.service = service self.arg = None self.coordinator = coordinator self._attr_device_info = get_device_info(vehicle_info) - self._attr_name = f"{vehicle_info[VEHICLE_NAME]} {entity_type}" - self._attr_unique_id = f"{self.vin}_{entity_type}" - - @property - def icon(self): - """Return the icon of the sensor.""" - if not self.device_class: - return ICONS.get(self.entity_type) + self._attr_unique_id = f"{self.vin}_{description.key}" - async def async_press(self): + async def async_press(self) -> None: """Press the button.""" - _LOGGER.info("%s button pressed", self._attr_name) + _LOGGER.info("%s button pressed", self.name) arg = None - if self.service == REMOTE_SERVICE_REMOTE_START: + if self.entity_description.key == REMOTE_SERVICE_REMOTE_START: arg = self.coordinator.data.get(self.vin).get( VEHICLE_CLIMATE_SELECTED_PRESET ) @@ -125,7 +145,7 @@ async def async_press(self): await async_call_remote_service( self.hass, controller, - self.service, + self.entity_description.key, self.vehicle_info, arg, self.config_entry.options.get(CONF_NOTIFICATION_OPTION), diff --git a/custom_components/subaru/config_flow.py b/custom_components/subaru/config_flow.py index 545a006..89485f2 100644 --- a/custom_components/subaru/config_flow.py +++ b/custom_components/subaru/config_flow.py @@ -1,6 +1,9 @@ """Config flow for Subaru integration.""" +from __future__ import annotations + from datetime import datetime import logging +from typing import Any from subarulink import ( Controller as SubaruAPI, @@ -12,8 +15,10 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import CONF_COUNTRY, CONF_NOTIFICATION_OPTION, CONF_POLLING_OPTION, DOMAIN @@ -31,12 +36,16 @@ class SubaruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - def __init__(self): + controller: SubaruAPI + + def __init__(self) -> None: """Initialize config flow.""" self.config_data = {CONF_PIN: None} self.controller = None - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the start of the config flow.""" error = None @@ -88,11 +97,11 @@ async def async_step_user(self, user_input=None): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) - async def validate_login_creds(self, data): + async def validate_login_creds(self, data: dict[str, Any]) -> None: """Validate the user input allows us to connect. data: contains values provided by the user. @@ -118,7 +127,9 @@ async def validate_login_creds(self, data): _LOGGER.debug("Successfully authenticated with Subaru API") self.config_data.update(data) - async def async_step_two_factor(self, user_input=None): + async def async_step_two_factor( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Select contact method and request 2FA code from Subaru.""" error = None if user_input: @@ -142,7 +153,9 @@ async def async_step_two_factor(self, user_input=None): step_id="two_factor", data_schema=data_schema, errors=error ) - async def async_step_two_factor_validate(self, user_input=None): + async def async_step_two_factor_validate( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Validate received 2FA code with Subaru.""" error = None if user_input: @@ -165,7 +178,9 @@ async def async_step_two_factor_validate(self, user_input=None): step_id="two_factor_validate", data_schema=data_schema, errors=error ) - async def async_step_pin(self, user_input=None): + async def async_step_pin( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle second part of config flow, if required.""" error = None if user_input: @@ -189,11 +204,13 @@ async def async_step_pin(self, user_input=None): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle an option flow for Subaru.""" - def __init__(self, config_entry: config_entries.ConfigEntry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle options flow.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/custom_components/subaru/const.py b/custom_components/subaru/const.py index d72d710..523320d 100644 --- a/custom_components/subaru/const.py +++ b/custom_components/subaru/const.py @@ -74,16 +74,3 @@ Platform.BUTTON, Platform.SELECT, ] - -ICONS = { - "Horn Start": "mdi:volume-high", - "Horn Stop": "mdi:volume-off", - "Lights Start": "mdi:lightbulb-on", - "Lights Stop": "mdi:lightbulb-off", - "Locate": "mdi:car-connected", - "Refresh": "mdi:refresh", - "Remote Start": "mdi:power", - "Remote Stop": "mdi:stop-circle-outline", - "Charge EV": "mdi:ev-station", - "Climate Preset": "mdi:thermometer-lines", -} diff --git a/custom_components/subaru/device.py b/custom_components/subaru/device.py new file mode 100644 index 0000000..1986f27 --- /dev/null +++ b/custom_components/subaru/device.py @@ -0,0 +1,23 @@ +"""Common device information for a vehicle.""" +from __future__ import annotations + +from homeassistant.helpers.entity import DeviceInfo + +from .const import ( + DOMAIN, + MANUFACTURER, + VEHICLE_MODEL_NAME, + VEHICLE_MODEL_YEAR, + VEHICLE_NAME, + VEHICLE_VIN, +) + + +def get_device_info(vehicle_info: dict) -> DeviceInfo: + """Return DeviceInfo object based on vehicle info.""" + return DeviceInfo( + identifiers={(DOMAIN, vehicle_info[VEHICLE_VIN])}, + manufacturer=MANUFACTURER, + model=f"{vehicle_info[VEHICLE_MODEL_YEAR]} {vehicle_info[VEHICLE_MODEL_NAME]}", + name=vehicle_info[VEHICLE_NAME], + ) diff --git a/custom_components/subaru/device_tracker.py b/custom_components/subaru/device_tracker.py index b436ab2..3130a7f 100644 --- a/custom_components/subaru/device_tracker.py +++ b/custom_components/subaru/device_tracker.py @@ -1,63 +1,94 @@ """Support for Subaru device tracker.""" +from __future__ import annotations + +from typing import Any + import subarulink.const as sc -from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker import SourceType from homeassistant.components.device_tracker.config_entry import TrackerEntity -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) -from . import get_device_info from .const import ( DOMAIN, ENTRY_COORDINATOR, ENTRY_VEHICLES, VEHICLE_HAS_REMOTE_SERVICE, - VEHICLE_NAME, VEHICLE_STATUS, VEHICLE_VIN, ) +from .device import get_device_info -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Subaru device tracker by config_entry.""" entry = hass.data[DOMAIN][config_entry.entry_id] coordinator = entry[ENTRY_COORDINATOR] vehicle_info = entry[ENTRY_VEHICLES] entities = [] - for vin in vehicle_info: - if vehicle_info[vin][VEHICLE_HAS_REMOTE_SERVICE]: - entities.append(SubaruDeviceTracker(vehicle_info[vin], coordinator)) + for info in vehicle_info.values(): + if info[VEHICLE_HAS_REMOTE_SERVICE]: + entities.append(SubaruDeviceTracker(info, coordinator)) async_add_entities(entities) -class SubaruDeviceTracker(CoordinatorEntity, TrackerEntity): +class SubaruDeviceTracker( + CoordinatorEntity[DataUpdateCoordinator[dict[str, Any]]], TrackerEntity +): """Class for Subaru device tracker.""" - def __init__(self, vehicle_info, coordinator): + _attr_icon = "mdi:car" + _attr_has_entity_name = True + name = "Location" + + def __init__(self, vehicle_info: dict, coordinator: DataUpdateCoordinator) -> None: """Initialize the device tracker.""" super().__init__(coordinator) self.vin = vehicle_info[VEHICLE_VIN] - self._attr_name = f"{vehicle_info[VEHICLE_NAME]} Location" - self._attr_unique_id = f"{self.vin}_location" - self._attr_should_poll = False self._attr_device_info = get_device_info(vehicle_info) + self._attr_unique_id = f"{self.vin}_location" + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return entity specific state attributes.""" + extra_attributes = None + if self.vin in self.coordinator.data: + extra_attributes = { + "Position timestamp": self.coordinator.data[self.vin][ + VEHICLE_STATUS + ].get(sc.POSITION_TIMESTAMP) + } + return extra_attributes @property - def source_type(self): - """Return the source type, eg gps or router, of the device.""" - return SOURCE_TYPE_GPS + def latitude(self) -> float | None: + """Return latitude value of the vehicle.""" + latitude = None + if self.vin in self.coordinator.data: + latitude = self.coordinator.data[self.vin][VEHICLE_STATUS].get(sc.LATITUDE) + return latitude @property - def latitude(self): - """Return latitude value of the device.""" - value = None - if isinstance(data := self.coordinator.data.get(self.vin), dict): - value = data[VEHICLE_STATUS].get(sc.LATITUDE) - return value + def longitude(self) -> float | None: + """Return longitude value of the vehicle.""" + longitude = None + if self.vin in self.coordinator.data: + longitude = self.coordinator.data[self.vin][VEHICLE_STATUS].get( + sc.LONGITUDE + ) + return longitude @property - def longitude(self): - """Return longitude value of the device.""" - value = None - if isinstance(data := self.coordinator.data.get(self.vin), dict): - value = data[VEHICLE_STATUS].get(sc.LONGITUDE) - return value + def source_type(self) -> SourceType: + """Return the source type of the vehicle.""" + return SourceType.GPS diff --git a/custom_components/subaru/diagnostics.py b/custom_components/subaru/diagnostics.py new file mode 100644 index 0000000..79ffcbe --- /dev/null +++ b/custom_components/subaru/diagnostics.py @@ -0,0 +1,56 @@ +"""Diagnostics for the Subaru integration.""" +from __future__ import annotations + +from typing import Any + +from subarulink.const import LATITUDE, LONGITUDE, ODOMETER, VEHICLE_NAME + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import DOMAIN, ENTRY_COORDINATOR, VEHICLE_VIN + +CONFIG_FIELDS_TO_REDACT = [CONF_USERNAME, CONF_PASSWORD, CONF_PIN, CONF_DEVICE_ID] +DATA_FIELDS_TO_REDACT = [VEHICLE_VIN, VEHICLE_NAME, LATITUDE, LONGITUDE, ODOMETER] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id][ENTRY_COORDINATOR] + + diagnostics_data = { + "config_entry": async_redact_data(config_entry.data, CONFIG_FIELDS_TO_REDACT), + "options": async_redact_data(config_entry.options, []), + "data": [ + async_redact_data(info, DATA_FIELDS_TO_REDACT) + for info in coordinator.data.values() + ], + } + + return diagnostics_data + + +async def async_get_device_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id][ENTRY_COORDINATOR] + + vin = next(iter(device.identifiers))[1] + + if info := coordinator.data.get(vin): + return { + "config_entry": async_redact_data( + config_entry.data, CONFIG_FIELDS_TO_REDACT + ), + "options": async_redact_data(config_entry.options, []), + "data": async_redact_data(info, DATA_FIELDS_TO_REDACT), + } + + raise HomeAssistantError("Device not found") diff --git a/custom_components/subaru/lock.py b/custom_components/subaru/lock.py index 59c36a6..1820e28 100644 --- a/custom_components/subaru/lock.py +++ b/custom_components/subaru/lock.py @@ -1,13 +1,20 @@ """Support for Subaru door locks.""" +from __future__ import annotations + import logging +from typing import Any +from subarulink.controller import Controller import voluptuous as vol from homeassistant.components.lock import LockEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import SERVICE_LOCK, SERVICE_UNLOCK +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN as SUBARU_DOMAIN, get_device_info +from . import DOMAIN from .const import ( ATTR_DOOR, CONF_NOTIFICATION_OPTION, @@ -20,17 +27,21 @@ VEHICLE_NAME, VEHICLE_VIN, ) +from .device import get_device_info from .remote_service import async_call_remote_service _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Subaru locks by config_entry.""" - entry = hass.data[SUBARU_DOMAIN][config_entry.entry_id] + entry = hass.data[DOMAIN][config_entry.entry_id] controller = entry[ENTRY_CONTROLLER] vehicle_info = entry[ENTRY_VEHICLES] - async_add_entities( SubaruLock(vehicle, controller, config_entry) for vehicle in vehicle_info.values() @@ -50,21 +61,23 @@ class SubaruLock(LockEntity): """ Representation of a Subaru door lock. - Note that the Subaru API currently does not support returning the status of the locks. - Lock status is always unknown. + Note that the Subaru API currently does not support returning the status of the locks. Lock status is always unknown. """ - def __init__(self, vehicle_info, controller, config_entry): + def __init__( + self, vehicle_info: dict, controller: Controller, config_entry: ConfigEntry + ) -> None: """Initialize the locks for the vehicle.""" self.controller = controller + self.config_entry = config_entry self.vehicle_info = vehicle_info + vin = vehicle_info[VEHICLE_VIN] self.car_name = vehicle_info[VEHICLE_NAME] - self.config_entry = config_entry self._attr_name = f"{self.car_name} Door Locks" - self._attr_unique_id = f"{vehicle_info[VEHICLE_VIN]}_door_locks" + self._attr_unique_id = f"{vin}_door_locks" self._attr_device_info = get_device_info(vehicle_info) - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Send the lock command.""" _LOGGER.debug("Locking doors for: %s", self.car_name) await async_call_remote_service( @@ -76,7 +89,7 @@ async def async_lock(self, **kwargs): self.config_entry.options.get(CONF_NOTIFICATION_OPTION), ) - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Send the unlock command.""" _LOGGER.debug("Unlocking doors for: %s", self.car_name) await async_call_remote_service( @@ -88,7 +101,7 @@ async def async_unlock(self, **kwargs): self.config_entry.options.get(CONF_NOTIFICATION_OPTION), ) - async def async_unlock_specific_door(self, door): + async def async_unlock_specific_door(self, door: str) -> None: """Send the unlock command for a specified door.""" _LOGGER.debug("Unlocking %s door for: %s", self, self.car_name) await async_call_remote_service( diff --git a/custom_components/subaru/manifest.json b/custom_components/subaru/manifest.json index 0deeafa..3ce0c14 100644 --- a/custom_components/subaru/manifest.json +++ b/custom_components/subaru/manifest.json @@ -6,6 +6,6 @@ "issue_tracker": "https://github.com/G-Two/homeassistant-subaru/issues", "requirements": ["subarulink==0.6.1"], "codeowners": ["@G-Two"], - "version": "0.6.4", + "version": "0.6.5", "iot_class": "cloud_polling" } diff --git a/custom_components/subaru/migrate.py b/custom_components/subaru/migrate.py new file mode 100644 index 0000000..efb2517 --- /dev/null +++ b/custom_components/subaru/migrate.py @@ -0,0 +1,75 @@ +"""Migrate entity unique_ids.""" +from __future__ import annotations + +import logging +from typing import Any + +from custom_components.subaru.binary_sensor import ( + API_GEN_2_BINARY_SENSORS, + EV_BINARY_SENSORS, +) +from custom_components.subaru.button import ( + EV_REMOTE_BUTTONS, + G1_REMOTE_BUTTONS, + RES_REMOTE_BUTTONS, +) +from custom_components.subaru.select import OLD_CLIMATE_SELECT +from custom_components.subaru.sensor import ( + API_GEN_2_SENSORS, + EV_SENSORS, + SAFETY_SENSORS, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er + +_LOGGER = logging.getLogger(__name__) + + +async def async_migrate_entries(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Migrate entities from versions prior to 0.6.5 to use preferred unique_id.""" + entity_registry = er.async_get(hass) + + all_entities = [] + all_entities.extend(API_GEN_2_BINARY_SENSORS) + all_entities.extend(EV_BINARY_SENSORS) + all_entities.extend(SAFETY_SENSORS) + all_entities.extend(API_GEN_2_SENSORS) + all_entities.extend(EV_SENSORS) + all_entities.extend(G1_REMOTE_BUTTONS) + all_entities.extend(RES_REMOTE_BUTTONS) + all_entities.extend(EV_REMOTE_BUTTONS) + all_entities.extend([OLD_CLIMATE_SELECT]) + + # Old unique_id is (previously title-cased) sensor name (e.g. "VIN_Avg Fuel Consumption") + replacements = {str(s.name).upper(): s.key for s in all_entities} + + @callback + def update_unique_id(entry: er.RegistryEntry) -> dict[str, Any] | None: + id_split = entry.unique_id.split("_", maxsplit=1) + key = id_split[1].upper() if len(id_split) == 2 else None + + if key not in replacements or id_split[1] == replacements[key]: + return None + + new_unique_id = entry.unique_id.replace(id_split[1], replacements[key]) + _LOGGER.debug( + "Migrating entity '%s' unique_id from '%s' to '%s'", + entry.entity_id, + entry.unique_id, + new_unique_id, + ) + if existing_entity_id := entity_registry.async_get_entity_id( + entry.domain, entry.platform, new_unique_id + ): + _LOGGER.warning( + "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, + } + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) diff --git a/custom_components/subaru/options.py b/custom_components/subaru/options.py index f7e6353..7c07e0a 100644 --- a/custom_components/subaru/options.py +++ b/custom_components/subaru/options.py @@ -1,4 +1,5 @@ """Enums for Subaru integration config options.""" +from __future__ import annotations from enum import Enum @@ -7,12 +8,12 @@ class ConfigOptionsEnum(Enum): """Base class for Config UI options enums.""" @classmethod - def list(cls): + def list(cls) -> list[str]: """List values.""" return [item.value for item in cls] @classmethod - def get_by_value(cls, value): + def get_by_value(cls, value: str) -> ConfigOptionsEnum | None: """Get enum instance by value.""" result = None for item in cls: diff --git a/custom_components/subaru/remote_service.py b/custom_components/subaru/remote_service.py index 13739f8..026b7f9 100644 --- a/custom_components/subaru/remote_service.py +++ b/custom_components/subaru/remote_service.py @@ -1,9 +1,14 @@ """Remote vehicle services for Subaru integration.""" +from __future__ import annotations + import logging import time +from typing import Any +from subarulink.controller import Controller from subarulink.exceptions import SubaruException +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from .const import ( @@ -43,8 +48,13 @@ async def async_call_remote_service( - hass, controller, cmd, vehicle_info, arg, notify_option -): + hass: HomeAssistant, + controller: Controller, + cmd: str, + vehicle_info: dict, + arg: Any | None, + notify_option: str, +) -> None: """Execute subarulink remote command with optional start/end notification.""" car_name = vehicle_info[VEHICLE_NAME] vin = vehicle_info[VEHICLE_VIN] @@ -93,7 +103,7 @@ async def async_call_remote_service( raise HomeAssistantError(f"Service {cmd} failed for {car_name}: {err_msg}") -def get_supported_services(vehicle_info): +def get_supported_services(vehicle_info: dict) -> set[str]: """Return a list of supported services.""" remote_services = set() for vin in vehicle_info: @@ -116,11 +126,13 @@ def get_supported_services(vehicle_info): return remote_services -async def poll_subaru(vehicle, controller, update_interval=UPDATE_INTERVAL): +async def poll_subaru( + vehicle: dict, controller: Controller, update_interval: int = UPDATE_INTERVAL +) -> bool: """Commands remote vehicle update (polls the vehicle to update subaru API cache).""" cur_time = time.time() last_update = vehicle[VEHICLE_LAST_UPDATE] - success = None + success = False if (cur_time - last_update) > update_interval: success = await controller.update(vehicle[VEHICLE_VIN], force=True) @@ -129,12 +141,14 @@ async def poll_subaru(vehicle, controller, update_interval=UPDATE_INTERVAL): return success -async def refresh_subaru(vehicle, controller, refresh_interval=FETCH_INTERVAL): +async def refresh_subaru( + vehicle: dict, controller: Controller, refresh_interval: int = FETCH_INTERVAL +) -> bool: """Refresh data from Subaru servers.""" cur_time = time.time() last_fetch = vehicle[VEHICLE_LAST_FETCH] vin = vehicle[VEHICLE_VIN] - success = None + success = False if (cur_time - last_fetch) > refresh_interval: success = await controller.fetch(vin, force=True) diff --git a/custom_components/subaru/select.py b/custom_components/subaru/select.py index a7994fe..c39c2a9 100644 --- a/custom_components/subaru/select.py +++ b/custom_components/subaru/select.py @@ -1,11 +1,17 @@ """Support for Subaru selectors.""" +from __future__ import annotations + import logging -from homeassistant.components.select import SelectEntity +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import DOMAIN as SUBARU_DOMAIN, get_device_info from .const import ( + DOMAIN as SUBARU_DOMAIN, ENTRY_COORDINATOR, ENTRY_VEHICLES, VEHICLE_CLIMATE, @@ -13,68 +19,84 @@ VEHICLE_CLIMATE_SELECTED_PRESET, VEHICLE_HAS_EV, VEHICLE_HAS_REMOTE_START, - VEHICLE_NAME, VEHICLE_VIN, ) +from .device import get_device_info _LOGGER = logging.getLogger(__name__) +CLIMATE_SELECT = SelectEntityDescription( + name="Climate preset", key=VEHICLE_CLIMATE, icon="mdi:thermometer-lines" +) +# Select naming scheme was inconsistent. Description below reflects previous naming for migration purposes. +OLD_CLIMATE_SELECT = SelectEntityDescription(name="climate_preset", key=VEHICLE_CLIMATE) + -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Subaru selectors by config_entry.""" entry = hass.data[SUBARU_DOMAIN][config_entry.entry_id] coordinator = entry[ENTRY_COORDINATOR] vehicle_info = entry[ENTRY_VEHICLES] climate_select = [] - for vin in vehicle_info: - if ( - vehicle_info[vin][VEHICLE_HAS_REMOTE_START] - or vehicle_info[vin][VEHICLE_HAS_EV] - ): - climate_select.append( - SubaruClimateSelect(vehicle_info[vin], coordinator, config_entry) - ) + for info in vehicle_info.values(): + if info[VEHICLE_HAS_REMOTE_START] or info[VEHICLE_HAS_EV]: + climate_select.append(SubaruClimateSelect(info, config_entry, coordinator)) async_add_entities(climate_select) class SubaruClimateSelect(SelectEntity, RestoreEntity): """Representation of a Subaru climate preset selector entity.""" - def __init__(self, vehicle_info, coordinator, config_entry): + _attr_has_entity_name = True + + def __init__( + self, + vehicle_info: dict, + config_entry: ConfigEntry, + coordinator: DataUpdateCoordinator, + ) -> None: """Initialize the selector for the vehicle.""" self.coordinator = coordinator self.vin = vehicle_info[VEHICLE_VIN] - self.car_name = vehicle_info[VEHICLE_NAME] self.config_entry = config_entry - self._attr_current_option = None - self._attr_name = f"{vehicle_info[VEHICLE_NAME]} Climate Preset" - self._attr_unique_id = f"{self.vin}_climate_preset" + self.entity_description = CLIMATE_SELECT + self._attr_current_option = "" self._attr_device_info = get_device_info(vehicle_info) + self._attr_unique_id = f"{self.vin}_{self.entity_description.key}" @property - def options(self): + def options(self) -> list: """Return a set of selectable options.""" vehicle_data = None if self.coordinator.data: vehicle_data = self.coordinator.data.get(self.vin) if vehicle_data: - if isinstance(preset_data := vehicle_data.get(VEHICLE_CLIMATE), list): + if isinstance( + preset_data := vehicle_data.get(self.entity_description.key), list + ): return [preset[VEHICLE_CLIMATE_PRESET_NAME] for preset in preset_data] + return [] - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Restore previous state of this selector.""" await super().async_added_to_hass() state = await self.async_get_last_state() - if state and state.state in self.options: + if state and (state.state in self.options): self._attr_current_option = state.state self.coordinator.data.get(self.vin)[ VEHICLE_CLIMATE_SELECTED_PRESET ] = state.state self.async_write_ha_state() - async def async_select_option(self, option): + async def async_select_option(self, option: str) -> None: """Change the selected option.""" - _LOGGER.debug("Selecting %s climate preset for %s", option, self.car_name) + _LOGGER.debug( + "Selecting %s climate preset for %s", option, self.device_info["name"] + ) if option in self.options: self._attr_current_option = option self.coordinator.data.get(self.vin)[ diff --git a/custom_components/subaru/sensor.py b/custom_components/subaru/sensor.py index 5f3c147..c822965 100644 --- a/custom_components/subaru/sensor.py +++ b/custom_components/subaru/sensor.py @@ -1,7 +1,6 @@ """Support for Subaru sensors.""" from __future__ import annotations -import logging from typing import Any import subarulink.const as sc @@ -29,11 +28,9 @@ CoordinatorEntity, DataUpdateCoordinator, ) -from homeassistant.util.distance import convert as dist_convert +from homeassistant.util.unit_conversion import DistanceConverter, VolumeConverter from homeassistant.util.unit_system import IMPERIAL_SYSTEM, LENGTH_UNITS, PRESSURE_UNITS -from homeassistant.util.volume import convert as vol_convert -from . import get_device_info from .const import ( API_GEN_2, DOMAIN, @@ -45,20 +42,20 @@ VEHICLE_STATUS, VEHICLE_VIN, ) - -_LOGGER = logging.getLogger(__name__) +from .device import get_device_info # Fuel consumption units FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS = "L/100km" FUEL_CONSUMPTION_MILES_PER_GALLON = "mi/gal" -L_PER_GAL = vol_convert(1, VOLUME_GALLONS, VOLUME_LITERS) -KM_PER_MI = dist_convert(1, LENGTH_MILES, LENGTH_KILOMETERS) +L_PER_GAL = VolumeConverter.convert(1, VOLUME_GALLONS, VOLUME_LITERS) +KM_PER_MI = DistanceConverter.convert(1, LENGTH_MILES, LENGTH_KILOMETERS) # Sensor available to "Subaru Safety Plus" subscribers with Gen1 or Gen2 vehicles SAFETY_SENSORS = [ SensorEntityDescription( key=sc.ODOMETER, + device_class=SensorDeviceClass.DISTANCE, icon="mdi:road-variant", name="Odometer", native_unit_of_measurement=LENGTH_KILOMETERS, @@ -71,12 +68,13 @@ SensorEntityDescription( key=sc.AVG_FUEL_CONSUMPTION, icon="mdi:leaf", - name="Avg Fuel Consumption", + name="Avg fuel consumption", native_unit_of_measurement=FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.DIST_TO_EMPTY, + device_class=SensorDeviceClass.DISTANCE, icon="mdi:gas-station", name="Range", native_unit_of_measurement=LENGTH_KILOMETERS, @@ -85,42 +83,42 @@ SensorEntityDescription( key=sc.TIRE_PRESSURE_FL, device_class=SensorDeviceClass.PRESSURE, - name="Tire Pressure FL", + name="Tire pressure FL", native_unit_of_measurement=PRESSURE_HPA, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_FR, device_class=SensorDeviceClass.PRESSURE, - name="Tire Pressure FR", + name="Tire pressure FR", native_unit_of_measurement=PRESSURE_HPA, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_RL, device_class=SensorDeviceClass.PRESSURE, - name="Tire Pressure RL", + name="Tire pressure RL", native_unit_of_measurement=PRESSURE_HPA, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_RR, device_class=SensorDeviceClass.PRESSURE, - name="Tire Pressure RR", + name="Tire pressure RR", native_unit_of_measurement=PRESSURE_HPA, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.EXTERNAL_TEMP, device_class=SensorDeviceClass.TEMPERATURE, - name="External Temp", + name="External temp", native_unit_of_measurement=TEMP_CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.BATTERY_VOLTAGE, device_class=SensorDeviceClass.VOLTAGE, - name="12V Battery Voltage", + name="12V battery voltage", native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, state_class=SensorStateClass.MEASUREMENT, ), @@ -130,22 +128,23 @@ EV_SENSORS = [ SensorEntityDescription( key=sc.EV_DISTANCE_TO_EMPTY, + device_class=SensorDeviceClass.DISTANCE, icon="mdi:ev-station", - name="EV Range", + name="EV range", native_unit_of_measurement=LENGTH_MILES, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.EV_STATE_OF_CHARGE_PERCENT, device_class=SensorDeviceClass.BATTERY, - name="EV Battery Level", + name="EV battery level", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.EV_TIME_TO_FULLY_CHARGED_UTC, device_class=SensorDeviceClass.TIMESTAMP, - name="EV Time to Full Charge", + name="EV time to full charge", state_class=SensorStateClass.MEASUREMENT, ), ] @@ -167,7 +166,7 @@ async def async_setup_entry( def create_vehicle_sensors( - vehicle_info, coordinator: DataUpdateCoordinator + vehicle_info: dict, coordinator: DataUpdateCoordinator ) -> list[SubaruSensor]: """Instantiate all available sensors for the vehicle.""" sensor_descriptions_to_add = [] @@ -208,7 +207,7 @@ def __init__( self.vin = vehicle_info[VEHICLE_VIN] self.entity_description = description self._attr_device_info = get_device_info(vehicle_info) - self._attr_unique_id = f"{self.vin}_{description.name}" + self._attr_unique_id = f"{self.vin}_{description.key}" @property def native_value(self) -> None | int | float: diff --git a/hacs.json b/hacs.json index 317c663..d0c7e2e 100644 --- a/hacs.json +++ b/hacs.json @@ -2,6 +2,6 @@ "name": "Subaru (HACS)", "content_in_root": false, "render_readme": true, - "homeassistant": "2022.2", + "homeassistant": "2022.10", "iot_class": "Cloud Polling" } diff --git a/tests/conftest.py b/tests/conftest.py index 5d7755b..02f4d4f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,10 +23,11 @@ VEHICLE_NAME, ) from custom_components.subaru.options import NotificationOptions, PollingOptions +from homeassistant import config_entries from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN -from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME from homeassistant.core import State +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -74,6 +75,20 @@ CONF_DEVICE_ID: TEST_DEVICE_ID, } +TEST_OPTIONS = { + CONF_POLLING_OPTION: PollingOptions.ENABLE.value, + CONF_NOTIFICATION_OPTION: NotificationOptions.SUCCESS.value, +} + +TEST_CONFIG_ENTRY = { + "entry_id": "1", + "domain": DOMAIN, + "title": TEST_CONFIG[CONF_USERNAME], + "data": TEST_CONFIG, + "options": TEST_OPTIONS, + "source": config_entries.SOURCE_USER, +} + TEST_DEVICE_NAME = "test_vehicle_2" TEST_ENTITY_ID = f"sensor.{TEST_DEVICE_NAME}_odometer" @@ -84,41 +99,16 @@ def advance_time(hass, seconds): async_fire_time_changed(hass, future) -async def setup_subaru_integration( +async def setup_subaru_config_entry( hass, + config_entry, vehicle_list=None, vehicle_data=None, vehicle_status=None, connect_effect=None, fetch_effect=None, - saved_cache=None, - charge_polling=None, ): - """Create Subaru entry.""" - if saved_cache: - mock_restore_cache( - hass, - (State("select.test_vehicle_2_climate_preset", "Full Heat"),), - ) - - test_options = { - CONF_POLLING_OPTION: PollingOptions.CHARGING.value - if charge_polling - else PollingOptions.ENABLE.value, - CONF_NOTIFICATION_OPTION: NotificationOptions.SUCCESS.value, - } - - assert await async_setup_component(hass, HA_DOMAIN, {}) - assert await async_setup_component(hass, DOMAIN, {}) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data=TEST_CONFIG, - options=test_options, - entry_id=1, - ) - config_entry.add_to_hass(hass) - + """Run async_setup with API mocks in place.""" with patch( MOCK_API_CONNECT, return_value=connect_effect is None, @@ -152,54 +142,117 @@ async def setup_subaru_integration( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - return config_entry - -@pytest.fixture -async def ev_entry(hass, enable_custom_integrations): - """Create a Subaru entry representing an EV vehicle with full STARLINK subscription.""" - entry = await setup_subaru_integration( +async def setup_default_ev_entry(hass, config_entry): + """Run async_setup with API mocks in place and EV subscription responses.""" + await setup_subaru_config_entry( hass, + config_entry, vehicle_list=[TEST_VIN_2_EV], vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], vehicle_status=VEHICLE_STATUS_EV, ) - assert DOMAIN in hass.config_entries.async_domains() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert hass.config_entries.async_get_entry(entry.entry_id) - assert entry.state is ConfigEntryState.LOADED - return entry + + +@pytest.fixture(name="subaru_config_entry", scope="function") +async def fixture_subaru_config_entry(hass, enable_custom_integrations): + """Create a Subaru config entry prior to setup.""" + await async_setup_component(hass, HA_DOMAIN, {}) + config_entry = MockConfigEntry(**TEST_CONFIG_ENTRY) + config_entry.add_to_hass(hass) + return config_entry @pytest.fixture -async def ev_entry_with_saved_climate(hass, enable_custom_integrations): - """Create Subaru EV entity but with saved climate preset.""" - entry = await setup_subaru_integration( +async def ev_entry(hass, subaru_config_entry, enable_custom_integrations): + """Create a Subaru entry representing an EV vehicle with full STARLINK subscription.""" + await setup_default_ev_entry(hass, subaru_config_entry) + return subaru_config_entry + + +@pytest.fixture +async def ev_entry_with_saved_climate( + hass, subaru_config_entry, enable_custom_integrations +): + """Create Subaru EV entity with saved climate preset.""" + mock_restore_cache( hass, - vehicle_list=[TEST_VIN_2_EV], - vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], - vehicle_status=VEHICLE_STATUS_EV, - saved_cache=True, + (State("select.test_vehicle_2_climate_preset", "Full Heat"),), ) - assert DOMAIN in hass.config_entries.async_domains() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert hass.config_entries.async_get_entry(entry.entry_id) - assert entry.state is ConfigEntryState.LOADED - return entry + await setup_default_ev_entry( + hass, + subaru_config_entry, + ) + return subaru_config_entry @pytest.fixture -async def ev_entry_charge_polling(hass, enable_custom_integrations): - """Create a Subaru EV entity but with charge polling option.""" - entry = await setup_subaru_integration( +async def ev_entry_charge_polling( + hass, subaru_config_entry, enable_custom_integrations +): + """Create a Subaru EV entity with charge polling option enabled.""" + options_form = await hass.config_entries.options.async_init( + subaru_config_entry.entry_id + ) + await hass.config_entries.options.async_configure( + options_form["flow_id"], + user_input={ + CONF_NOTIFICATION_OPTION: NotificationOptions.SUCCESS.value, + CONF_POLLING_OPTION: PollingOptions.CHARGING.value, + }, + ) + await setup_default_ev_entry( hass, - vehicle_list=[TEST_VIN_2_EV], - vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], - vehicle_status=VEHICLE_STATUS_EV, - charge_polling=True, + subaru_config_entry, + ) + return subaru_config_entry + + +async def migrate_unique_ids( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry +) -> None: + """Test successful migration of entity unique_ids.""" + entity_registry = er.async_get(hass) + entity: er.RegistryEntry = entity_registry.async_get_or_create( + **entitydata, + config_entry=subaru_config_entry, ) - assert DOMAIN in hass.config_entries.async_domains() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert hass.config_entries.async_get_entry(entry.entry_id) - assert entry.state is ConfigEntryState.LOADED - return entry + assert entity.unique_id == old_unique_id + + await setup_default_ev_entry(hass, subaru_config_entry) + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == new_unique_id + + +async def migrate_unique_ids_duplicate( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry +) -> None: + """Test unsuccessful migration of entity unique_ids due to duplicate.""" + entity_registry = er.async_get(hass) + entity: er.RegistryEntry = entity_registry.async_get_or_create( + **entitydata, + config_entry=subaru_config_entry, + ) + assert entity.unique_id == old_unique_id + + # create existing entry with new_unique_id that conflicts with migrate + existing_entity = entity_registry.async_get_or_create( + entitydata["domain"], + entitydata["platform"], + unique_id=new_unique_id, + config_entry=subaru_config_entry, + ) + + await setup_default_ev_entry(hass, subaru_config_entry) + + 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 diff --git a/tests/fixtures/diagnostics_config_entry.json b/tests/fixtures/diagnostics_config_entry.json new file mode 100644 index 0000000..a533e2e --- /dev/null +++ b/tests/fixtures/diagnostics_config_entry.json @@ -0,0 +1,173 @@ +{ + "config_entry": { + "username": "**REDACTED**", + "password": "**REDACTED**", + "country": "USA", + "pin": "**REDACTED**", + "device_id": "**REDACTED**" + }, + "options": { + "polling_option": "Enable \u2014 Poll vehicle every 2 hours", + "notification_option": "Success \u2014 Persistent notification of completed remote command" + }, + "data": [ + { + "preset_name": null, + "climate": [ + { + "name": "Auto", + "runTimeMinutes": "10", + "climateZoneFrontTemp": "74", + "climateZoneFrontAirMode": "AUTO", + "climateZoneFrontAirVolume": "AUTO", + "outerAirCirculation": "auto", + "heatedRearWindowActive": "false", + "airConditionOn": "false", + "heatedSeatFrontLeft": "off", + "heatedSeatFrontRight": "off", + "startConfiguration": "START_ENGINE_ALLOW_KEY_IN_IGNITION", + "canEdit": "true", + "disabled": "false", + "vehicleType": "gas", + "presetType": "subaruPreset" + }, + { + "name": "Full Cool", + "runTimeMinutes": "10", + "climateZoneFrontTemp": "60", + "climateZoneFrontAirMode": "feet_face_balanced", + "climateZoneFrontAirVolume": "7", + "airConditionOn": "true", + "heatedSeatFrontLeft": "OFF", + "heatedSeatFrontRight": "OFF", + "heatedRearWindowActive": "false", + "outerAirCirculation": "outsideAir", + "startConfiguration": "START_ENGINE_ALLOW_KEY_IN_IGNITION", + "canEdit": "true", + "disabled": "true", + "vehicleType": "gas", + "presetType": "subaruPreset" + }, + { + "name": "Full Heat", + "runTimeMinutes": "10", + "climateZoneFrontTemp": "85", + "climateZoneFrontAirMode": "feet_window", + "climateZoneFrontAirVolume": "7", + "airConditionOn": "false", + "heatedSeatFrontLeft": "high_heat", + "heatedSeatFrontRight": "high_heat", + "heatedRearWindowActive": "true", + "outerAirCirculation": "outsideAir", + "startConfiguration": "START_ENGINE_ALLOW_KEY_IN_IGNITION", + "canEdit": "true", + "disabled": "true", + "vehicleType": "gas", + "presetType": "subaruPreset" + }, + { + "name": "Full Cool", + "runTimeMinutes": "10", + "climateZoneFrontTemp": "60", + "climateZoneFrontAirMode": "feet_face_balanced", + "climateZoneFrontAirVolume": "7", + "airConditionOn": "true", + "heatedSeatFrontLeft": "OFF", + "heatedSeatFrontRight": "OFF", + "heatedRearWindowActive": "false", + "outerAirCirculation": "outsideAir", + "startConfiguration": "START_CLIMATE_CONTROL_ONLY_ALLOW_KEY_IN_IGNITION", + "canEdit": "true", + "disabled": "true", + "vehicleType": "phev", + "presetType": "subaruPreset" + }, + { + "name": "Full Heat", + "runTimeMinutes": "10", + "climateZoneFrontTemp": "85", + "climateZoneFrontAirMode": "feet_window", + "climateZoneFrontAirVolume": "7", + "airConditionOn": "false", + "heatedSeatFrontLeft": "high_heat", + "heatedSeatFrontRight": "high_heat", + "heatedRearWindowActive": "true", + "outerAirCirculation": "outsideAir", + "startConfiguration": "START_CLIMATE_CONTROL_ONLY_ALLOW_KEY_IN_IGNITION", + "canEdit": "true", + "disabled": "true", + "vehicleType": "phev", + "presetType": "subaruPreset" + } + ], + "status": { + "AVG_FUEL_CONSUMPTION": 2.3, + "BATTERY_VOLTAGE": 12.0, + "DISTANCE_TO_EMPTY_FUEL": 707, + "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", + "DOOR_BOOT_POSITION": "CLOSED", + "DOOR_ENGINE_HOOD_LOCK_STATUS": "UNKNOWN", + "DOOR_ENGINE_HOOD_POSITION": "CLOSED", + "DOOR_FRONT_LEFT_LOCK_STATUS": "UNKNOWN", + "DOOR_FRONT_LEFT_POSITION": "CLOSED", + "DOOR_FRONT_RIGHT_LOCK_STATUS": "UNKNOWN", + "DOOR_FRONT_RIGHT_POSITION": "CLOSED", + "DOOR_REAR_LEFT_LOCK_STATUS": "UNKNOWN", + "DOOR_REAR_LEFT_POSITION": "CLOSED", + "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", + "DOOR_REAR_RIGHT_POSITION": "CLOSED", + "EV_CHARGER_STATE_TYPE": "CHARGING", + "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", + "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", + "EV_DISTANCE_TO_EMPTY": 1, + "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", + "EV_STATE_OF_CHARGE_MODE": "EV_MODE", + "EV_STATE_OF_CHARGE_PERCENT": 20, + "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24 03:06:40+00:00", + "EXT_EXTERNAL_TEMP": 21.5, + "ODOMETER": "**REDACTED**", + "POSITION_HEADING_DEGREE": 150, + "POSITION_SPEED_KMPH": "0", + "POSITION_TIMESTAMP": 1595560000.0, + "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", + "SEAT_BELT_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", + "SEAT_BELT_STATUS_FRONT_RIGHT": "BELTED", + "SEAT_BELT_STATUS_SECOND_LEFT": "UNKNOWN", + "SEAT_BELT_STATUS_SECOND_MIDDLE": "UNKNOWN", + "SEAT_BELT_STATUS_SECOND_RIGHT": "UNKNOWN", + "SEAT_BELT_STATUS_THIRD_LEFT": "UNKNOWN", + "SEAT_BELT_STATUS_THIRD_MIDDLE": "UNKNOWN", + "SEAT_BELT_STATUS_THIRD_RIGHT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_FRONT_LEFT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", + "SEAT_OCCUPATION_STATUS_FRONT_RIGHT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_SECOND_LEFT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_SECOND_MIDDLE": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_SECOND_RIGHT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_THIRD_LEFT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_THIRD_MIDDLE": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_THIRD_RIGHT": "UNKNOWN", + "TIMESTAMP": 1595560000.0, + "TRANSMISSION_MODE": "UNKNOWN", + "TYRE_PRESSURE_FRONT_LEFT": 0, + "TYRE_PRESSURE_FRONT_RIGHT": 2550, + "TYRE_PRESSURE_REAR_LEFT": 2450, + "TYRE_PRESSURE_REAR_RIGHT": 2350, + "TYRE_STATUS_FRONT_LEFT": "UNKNOWN", + "TYRE_STATUS_FRONT_RIGHT": "UNKNOWN", + "TYRE_STATUS_REAR_LEFT": "UNKNOWN", + "TYRE_STATUS_REAR_RIGHT": "UNKNOWN", + "VEHICLE_STATE_TYPE": "IGNITION_OFF", + "WINDOW_BACK_STATUS": "UNKNOWN", + "WINDOW_FRONT_LEFT_STATUS": "VENTED", + "WINDOW_FRONT_RIGHT_STATUS": "VENTED", + "WINDOW_REAR_LEFT_STATUS": "UNKNOWN", + "WINDOW_REAR_RIGHT_STATUS": "UNKNOWN", + "WINDOW_SUNROOF_STATUS": "UNKNOWN", + "heading": 170, + "latitude": "**REDACTED**", + "longitude": "**REDACTED**" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/diagnostics_device.json b/tests/fixtures/diagnostics_device.json new file mode 100644 index 0000000..800e55c --- /dev/null +++ b/tests/fixtures/diagnostics_device.json @@ -0,0 +1,171 @@ +{ + "config_entry": { + "username": "**REDACTED**", + "password": "**REDACTED**", + "country": "USA", + "pin": "**REDACTED**", + "device_id": "**REDACTED**" + }, + "options": { + "polling_option": "Enable \u2014 Poll vehicle every 2 hours", + "notification_option": "Success \u2014 Persistent notification of completed remote command" + }, + "data": { + "preset_name": null, + "climate": [ + { + "name": "Auto", + "runTimeMinutes": "10", + "climateZoneFrontTemp": "74", + "climateZoneFrontAirMode": "AUTO", + "climateZoneFrontAirVolume": "AUTO", + "outerAirCirculation": "auto", + "heatedRearWindowActive": "false", + "airConditionOn": "false", + "heatedSeatFrontLeft": "off", + "heatedSeatFrontRight": "off", + "startConfiguration": "START_ENGINE_ALLOW_KEY_IN_IGNITION", + "canEdit": "true", + "disabled": "false", + "vehicleType": "gas", + "presetType": "subaruPreset" + }, + { + "name": "Full Cool", + "runTimeMinutes": "10", + "climateZoneFrontTemp": "60", + "climateZoneFrontAirMode": "feet_face_balanced", + "climateZoneFrontAirVolume": "7", + "airConditionOn": "true", + "heatedSeatFrontLeft": "OFF", + "heatedSeatFrontRight": "OFF", + "heatedRearWindowActive": "false", + "outerAirCirculation": "outsideAir", + "startConfiguration": "START_ENGINE_ALLOW_KEY_IN_IGNITION", + "canEdit": "true", + "disabled": "true", + "vehicleType": "gas", + "presetType": "subaruPreset" + }, + { + "name": "Full Heat", + "runTimeMinutes": "10", + "climateZoneFrontTemp": "85", + "climateZoneFrontAirMode": "feet_window", + "climateZoneFrontAirVolume": "7", + "airConditionOn": "false", + "heatedSeatFrontLeft": "high_heat", + "heatedSeatFrontRight": "high_heat", + "heatedRearWindowActive": "true", + "outerAirCirculation": "outsideAir", + "startConfiguration": "START_ENGINE_ALLOW_KEY_IN_IGNITION", + "canEdit": "true", + "disabled": "true", + "vehicleType": "gas", + "presetType": "subaruPreset" + }, + { + "name": "Full Cool", + "runTimeMinutes": "10", + "climateZoneFrontTemp": "60", + "climateZoneFrontAirMode": "feet_face_balanced", + "climateZoneFrontAirVolume": "7", + "airConditionOn": "true", + "heatedSeatFrontLeft": "OFF", + "heatedSeatFrontRight": "OFF", + "heatedRearWindowActive": "false", + "outerAirCirculation": "outsideAir", + "startConfiguration": "START_CLIMATE_CONTROL_ONLY_ALLOW_KEY_IN_IGNITION", + "canEdit": "true", + "disabled": "true", + "vehicleType": "phev", + "presetType": "subaruPreset" + }, + { + "name": "Full Heat", + "runTimeMinutes": "10", + "climateZoneFrontTemp": "85", + "climateZoneFrontAirMode": "feet_window", + "climateZoneFrontAirVolume": "7", + "airConditionOn": "false", + "heatedSeatFrontLeft": "high_heat", + "heatedSeatFrontRight": "high_heat", + "heatedRearWindowActive": "true", + "outerAirCirculation": "outsideAir", + "startConfiguration": "START_CLIMATE_CONTROL_ONLY_ALLOW_KEY_IN_IGNITION", + "canEdit": "true", + "disabled": "true", + "vehicleType": "phev", + "presetType": "subaruPreset" + } + ], + "status": { + "AVG_FUEL_CONSUMPTION": 2.3, + "BATTERY_VOLTAGE": 12.0, + "DISTANCE_TO_EMPTY_FUEL": 707, + "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", + "DOOR_BOOT_POSITION": "CLOSED", + "DOOR_ENGINE_HOOD_LOCK_STATUS": "UNKNOWN", + "DOOR_ENGINE_HOOD_POSITION": "CLOSED", + "DOOR_FRONT_LEFT_LOCK_STATUS": "UNKNOWN", + "DOOR_FRONT_LEFT_POSITION": "CLOSED", + "DOOR_FRONT_RIGHT_LOCK_STATUS": "UNKNOWN", + "DOOR_FRONT_RIGHT_POSITION": "CLOSED", + "DOOR_REAR_LEFT_LOCK_STATUS": "UNKNOWN", + "DOOR_REAR_LEFT_POSITION": "CLOSED", + "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", + "DOOR_REAR_RIGHT_POSITION": "CLOSED", + "EV_CHARGER_STATE_TYPE": "CHARGING", + "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", + "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", + "EV_DISTANCE_TO_EMPTY": 1, + "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", + "EV_STATE_OF_CHARGE_MODE": "EV_MODE", + "EV_STATE_OF_CHARGE_PERCENT": 20, + "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24 03:06:40+00:00", + "EXT_EXTERNAL_TEMP": 21.5, + "ODOMETER": "**REDACTED**", + "POSITION_HEADING_DEGREE": 150, + "POSITION_SPEED_KMPH": "0", + "POSITION_TIMESTAMP": 1595560000.0, + "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", + "SEAT_BELT_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", + "SEAT_BELT_STATUS_FRONT_RIGHT": "BELTED", + "SEAT_BELT_STATUS_SECOND_LEFT": "UNKNOWN", + "SEAT_BELT_STATUS_SECOND_MIDDLE": "UNKNOWN", + "SEAT_BELT_STATUS_SECOND_RIGHT": "UNKNOWN", + "SEAT_BELT_STATUS_THIRD_LEFT": "UNKNOWN", + "SEAT_BELT_STATUS_THIRD_MIDDLE": "UNKNOWN", + "SEAT_BELT_STATUS_THIRD_RIGHT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_FRONT_LEFT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", + "SEAT_OCCUPATION_STATUS_FRONT_RIGHT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_SECOND_LEFT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_SECOND_MIDDLE": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_SECOND_RIGHT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_THIRD_LEFT": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_THIRD_MIDDLE": "UNKNOWN", + "SEAT_OCCUPATION_STATUS_THIRD_RIGHT": "UNKNOWN", + "TIMESTAMP": 1595560000.0, + "TRANSMISSION_MODE": "UNKNOWN", + "TYRE_PRESSURE_FRONT_LEFT": 0, + "TYRE_PRESSURE_FRONT_RIGHT": 2550, + "TYRE_PRESSURE_REAR_LEFT": 2450, + "TYRE_PRESSURE_REAR_RIGHT": 2350, + "TYRE_STATUS_FRONT_LEFT": "UNKNOWN", + "TYRE_STATUS_FRONT_RIGHT": "UNKNOWN", + "TYRE_STATUS_REAR_LEFT": "UNKNOWN", + "TYRE_STATUS_REAR_RIGHT": "UNKNOWN", + "VEHICLE_STATE_TYPE": "IGNITION_OFF", + "WINDOW_BACK_STATUS": "UNKNOWN", + "WINDOW_FRONT_LEFT_STATUS": "VENTED", + "WINDOW_FRONT_RIGHT_STATUS": "VENTED", + "WINDOW_REAR_LEFT_STATUS": "UNKNOWN", + "WINDOW_REAR_RIGHT_STATUS": "UNKNOWN", + "WINDOW_SUNROOF_STATUS": "UNKNOWN", + "heading": 170, + "latitude": "**REDACTED**", + "longitude": "**REDACTED**" + } + } +} \ No newline at end of file diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py index f813201..07caf6d 100644 --- a/tests/test_binary_sensor.py +++ b/tests/test_binary_sensor.py @@ -2,10 +2,19 @@ from copy import deepcopy from unittest.mock import patch +import pytest from subarulink.const import DOOR_ENGINE_HOOD_POSITION, VEHICLE_STATUS -from custom_components.subaru.binary_sensor import API_GEN_2_SENSORS, EV_SENSORS -from custom_components.subaru.const import FETCH_INTERVAL, VEHICLE_NAME +from custom_components.subaru.binary_sensor import ( + API_GEN_2_BINARY_SENSORS, + DOMAIN as BINARY_SENSOR_DOMAIN, + EV_BINARY_SENSORS, +) +from custom_components.subaru.const import ( + DOMAIN as SUBARU_DOMAIN, + FETCH_INTERVAL, + VEHICLE_NAME, +) from homeassistant.const import STATE_UNAVAILABLE from homeassistant.util import slugify @@ -13,13 +22,15 @@ EXPECTED_STATE_EV_BINARY_SENSORS, EXPECTED_STATE_EV_UNAVAILABLE, TEST_VIN_2_EV, - VEHICLE_DATA, VEHICLE_STATUS_EV, ) - -from tests.conftest import MOCK_API_FETCH, MOCK_API_GET_DATA, advance_time - -VEHICLE_NAME = VEHICLE_DATA[TEST_VIN_2_EV][VEHICLE_NAME] +from .conftest import ( + MOCK_API_FETCH, + MOCK_API_GET_DATA, + advance_time, + migrate_unique_ids, + migrate_unique_ids_duplicate, +) async def test_binary_sensors_ev(hass, ev_entry): @@ -54,13 +65,59 @@ async def test_binary_sensors_missing_field(hass, ev_entry): _assert_data(hass, expected_state_missing_field) +@pytest.mark.parametrize( + "entitydata,old_unique_id,new_unique_id", + [ + ( + { + "domain": BINARY_SENSOR_DOMAIN, + "platform": SUBARU_DOMAIN, + "unique_id": f"{TEST_VIN_2_EV}_{API_GEN_2_BINARY_SENSORS[3].name}", + }, + f"{TEST_VIN_2_EV}_{API_GEN_2_BINARY_SENSORS[3].name}", + f"{TEST_VIN_2_EV}_{API_GEN_2_BINARY_SENSORS[3].key}", + ), + ], +) +async def test_binary_sensor_migrate_unique_ids( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry +) -> None: + """Test successful migration of entity unique_ids.""" + await migrate_unique_ids( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry + ) + + +@pytest.mark.parametrize( + "entitydata,old_unique_id,new_unique_id", + [ + ( + { + "domain": BINARY_SENSOR_DOMAIN, + "platform": SUBARU_DOMAIN, + "unique_id": f"{TEST_VIN_2_EV}_{API_GEN_2_BINARY_SENSORS[3].name}", + }, + f"{TEST_VIN_2_EV}_{API_GEN_2_BINARY_SENSORS[3].name}", + f"{TEST_VIN_2_EV}_{API_GEN_2_BINARY_SENSORS[3].key}", + ) + ], +) +async def test_binary_sensor_migrate_unique_ids_duplicate( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry +) -> None: + """Test unsuccessful migration of entity unique_ids due to duplicate.""" + await migrate_unique_ids_duplicate( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry + ) + + def _assert_data(hass, expected_state): - sensor_list = EV_SENSORS - sensor_list.extend(API_GEN_2_SENSORS) + sensor_list = EV_BINARY_SENSORS + sensor_list.extend(API_GEN_2_BINARY_SENSORS) expected_states = {} for item in sensor_list: expected_states[ - f"binary_sensor.{slugify(f'{VEHICLE_NAME} {item.suffix}')}" + f"binary_sensor.{slugify(f'{VEHICLE_NAME} {item.name}')}" ] = expected_state[item.key] for sensor, state in expected_states.items(): diff --git a/tests/test_button.py b/tests/test_button.py index 85165b5..f97bd42 100644 --- a/tests/test_button.py +++ b/tests/test_button.py @@ -1,11 +1,21 @@ """Test Subaru buttons.""" - from unittest.mock import patch +import pytest + +from custom_components.subaru.button import G1_REMOTE_BUTTONS +from custom_components.subaru.const import DOMAIN as SUBARU_DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.const import ATTR_ENTITY_ID -from .conftest import MOCK_API_FETCH, MOCK_API_LIGHTS, MOCK_API_REMOTE_START +from .api_responses import TEST_VIN_2_EV +from .conftest import ( + MOCK_API_FETCH, + MOCK_API_LIGHTS, + MOCK_API_REMOTE_START, + migrate_unique_ids, + migrate_unique_ids_duplicate, +) REMOTE_START_BUTTON = "button.test_vehicle_2_remote_start" REMOTE_LIGHTS_BUTTON = "button.test_vehicle_2_lights_start" @@ -57,3 +67,49 @@ async def test_button_fetch(hass, ev_entry): ) await hass.async_block_till_done() mock_fetch.assert_called_once() + + +@pytest.mark.parametrize( + "entitydata,old_unique_id,new_unique_id", + [ + ( + { + "domain": BUTTON_DOMAIN, + "platform": SUBARU_DOMAIN, + "unique_id": f"{TEST_VIN_2_EV}_{G1_REMOTE_BUTTONS[0].name}", + }, + f"{TEST_VIN_2_EV}_{G1_REMOTE_BUTTONS[0].name}", + f"{TEST_VIN_2_EV}_{G1_REMOTE_BUTTONS[0].key}", + ), + ], +) +async def test_binary_sensor_migrate_unique_ids( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry +) -> None: + """Test successful migration of entity unique_ids.""" + await migrate_unique_ids( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry + ) + + +@pytest.mark.parametrize( + "entitydata,old_unique_id,new_unique_id", + [ + ( + { + "domain": BUTTON_DOMAIN, + "platform": SUBARU_DOMAIN, + "unique_id": f"{TEST_VIN_2_EV}_{G1_REMOTE_BUTTONS[0].name}", + }, + f"{TEST_VIN_2_EV}_{G1_REMOTE_BUTTONS[0].name}", + f"{TEST_VIN_2_EV}_{G1_REMOTE_BUTTONS[0].key}", + ) + ], +) +async def test_binary_sensor_migrate_unique_ids_duplicate( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry +) -> None: + """Test unsuccessful migration of entity unique_ids due to duplicate.""" + await migrate_unique_ids_duplicate( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry + ) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..8fedb14 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,70 @@ +"""Test Subaru diagnostics.""" +import json +from unittest.mock import patch + +import pytest +from pytest_homeassistant_custom_component.common import load_fixture + +from custom_components.subaru.const import DOMAIN, FETCH_INTERVAL +from custom_components.subaru.diagnostics import ( + async_get_config_entry_diagnostics, + async_get_device_diagnostics, +) +from homeassistant.core import HomeAssistant, HomeAssistantError +from homeassistant.helpers import device_registry as dr + +from .api_responses import TEST_VIN_2_EV +from .conftest import MOCK_API_FETCH, MOCK_API_GET_DATA, advance_time + + +async def test_config_entry_diagnostics(hass: HomeAssistant, ev_entry): + """Test config entry diagnostics.""" + assert hass.data[DOMAIN] + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + expected = json.loads(load_fixture("diagnostics_config_entry.json")) + + result = await async_get_config_entry_diagnostics(hass, config_entry) + assert json.dumps(expected) == json.dumps(result, default=str) + + +async def test_device_diagnostics(hass: HomeAssistant, hass_client, ev_entry): + """Test device diagnostics.""" + assert hass.data[DOMAIN] + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + device_registry = dr.async_get(hass) + reg_device = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_VIN_2_EV)}, + ) + assert reg_device is not None + + expected = json.loads(load_fixture("diagnostics_device.json")) + + result = await async_get_device_diagnostics(hass, config_entry, reg_device) + assert json.dumps(expected) == json.dumps(result, default=str) + + +async def test_device_diagnostics_vehicle_not_found( + hass: HomeAssistant, hass_client, ev_entry +): + """Test device diagnostics when the vehicle cannot be found.""" + assert hass.data[DOMAIN] + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + device_registry = dr.async_get(hass) + reg_device = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_VIN_2_EV)}, + ) + assert reg_device is not None + + # Simulate case where Subaru API does not return vehicle data + with patch(MOCK_API_FETCH), patch(MOCK_API_GET_DATA, return_value=None): + advance_time(hass, FETCH_INTERVAL) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError): + await async_get_device_diagnostics(hass, config_entry, reg_device) diff --git a/tests/test_init.py b/tests/test_init.py index 12584c3..9804282 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -26,7 +26,7 @@ MOCK_API_UPDATE, TEST_ENTITY_ID, advance_time, - setup_subaru_integration, + setup_subaru_config_entry, ) @@ -44,61 +44,76 @@ async def test_setup_ev(hass, ev_entry): assert check_entry.state is ConfigEntryState.LOADED -async def test_setup_g2(hass, enable_custom_integrations): +async def test_setup_g2(hass, subaru_config_entry, enable_custom_integrations): """Test setup with a G2 vehicle .""" - entry = await setup_subaru_integration( + await setup_subaru_config_entry( hass, + subaru_config_entry, vehicle_list=[TEST_VIN_3_G2], vehicle_data=VEHICLE_DATA[TEST_VIN_3_G2], vehicle_status=VEHICLE_STATUS_G2, ) - check_entry = hass.config_entries.async_get_entry(entry.entry_id) + check_entry = hass.config_entries.async_get_entry(subaru_config_entry.entry_id) assert check_entry assert check_entry.state is ConfigEntryState.LOADED -async def test_setup_g1(hass, enable_custom_integrations): +async def test_setup_g1(hass, subaru_config_entry, enable_custom_integrations): """Test setup with a G1 vehicle.""" - entry = await setup_subaru_integration( - hass, vehicle_list=[TEST_VIN_1_G1], vehicle_data=VEHICLE_DATA[TEST_VIN_1_G1] + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[TEST_VIN_1_G1], + vehicle_data=VEHICLE_DATA[TEST_VIN_1_G1], ) - check_entry = hass.config_entries.async_get_entry(entry.entry_id) + check_entry = hass.config_entries.async_get_entry(subaru_config_entry.entry_id) assert check_entry assert check_entry.state is ConfigEntryState.LOADED -async def test_unsuccessful_connect(hass, enable_custom_integrations): +async def test_unsuccessful_connect( + hass, subaru_config_entry, enable_custom_integrations +): """Test unsuccessful connect due to connectivity.""" - entry = await setup_subaru_integration( + await setup_subaru_config_entry( hass, + subaru_config_entry, connect_effect=SubaruException("Service Unavailable"), vehicle_list=[TEST_VIN_2_EV], vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], vehicle_status=VEHICLE_STATUS_EV, ) - check_entry = hass.config_entries.async_get_entry(entry.entry_id) + check_entry = hass.config_entries.async_get_entry(subaru_config_entry.entry_id) assert check_entry assert check_entry.state is ConfigEntryState.SETUP_RETRY -async def test_invalid_credentials(hass, enable_custom_integrations): +async def test_invalid_credentials( + hass, subaru_config_entry, enable_custom_integrations +): """Test invalid credentials.""" - entry = await setup_subaru_integration( + await setup_subaru_config_entry( hass, + subaru_config_entry, connect_effect=InvalidCredentials("Invalid Credentials"), vehicle_list=[TEST_VIN_2_EV], vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], vehicle_status=VEHICLE_STATUS_EV, ) - check_entry = hass.config_entries.async_get_entry(entry.entry_id) + check_entry = hass.config_entries.async_get_entry(subaru_config_entry.entry_id) assert check_entry assert check_entry.state is ConfigEntryState.SETUP_ERROR -async def test_update_skip_unsubscribed(hass, enable_custom_integrations): +async def test_update_skip_unsubscribed( + hass, subaru_config_entry, enable_custom_integrations +): """Test update function skips vehicles without subscription.""" - await setup_subaru_integration( - hass, vehicle_list=[TEST_VIN_1_G1], vehicle_data=VEHICLE_DATA[TEST_VIN_1_G1] + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[TEST_VIN_1_G1], + vehicle_data=VEHICLE_DATA[TEST_VIN_1_G1], ) with patch(MOCK_API_FETCH) as mock_fetch: await hass.services.async_call( @@ -112,10 +127,11 @@ async def test_update_skip_unsubscribed(hass, enable_custom_integrations): mock_fetch.assert_not_called() -async def test_fetch_failed(hass, enable_custom_integrations): +async def test_fetch_failed(hass, subaru_config_entry, enable_custom_integrations): """Tests when fetch fails.""" - await setup_subaru_integration( + await setup_subaru_config_entry( hass, + subaru_config_entry, vehicle_list=[TEST_VIN_2_EV], vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], vehicle_status=VEHICLE_STATUS_EV, diff --git a/tests/test_select.py b/tests/test_select.py index ec9ff7f..cb8e8c9 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -1,8 +1,14 @@ """Test Subaru select.""" +import pytest +from custom_components.subaru.const import DOMAIN as SUBARU_DOMAIN +from custom_components.subaru.select import CLIMATE_SELECT, OLD_CLIMATE_SELECT from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, SERVICE_SELECT_OPTION +from .api_responses import TEST_VIN_2_EV +from .conftest import migrate_unique_ids, migrate_unique_ids_duplicate + DEVICE_ID = "select.test_vehicle_2_climate_preset" @@ -24,3 +30,49 @@ async def test_select(hass, ev_entry_with_saved_climate): ) await hass.async_block_till_done() assert hass.states.get(DEVICE_ID).state == "Full Heat" + + +@pytest.mark.parametrize( + "entitydata,old_unique_id,new_unique_id", + [ + ( + { + "domain": SELECT_DOMAIN, + "platform": SUBARU_DOMAIN, + "unique_id": f"{TEST_VIN_2_EV}_{OLD_CLIMATE_SELECT.name}", + }, + f"{TEST_VIN_2_EV}_{OLD_CLIMATE_SELECT.name}", + f"{TEST_VIN_2_EV}_{CLIMATE_SELECT.key}", + ), + ], +) +async def test_binary_sensor_migrate_unique_ids( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry +) -> None: + """Test successful migration of entity unique_ids.""" + await migrate_unique_ids( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry + ) + + +@pytest.mark.parametrize( + "entitydata,old_unique_id,new_unique_id", + [ + ( + { + "domain": SELECT_DOMAIN, + "platform": SUBARU_DOMAIN, + "unique_id": f"{TEST_VIN_2_EV}_{OLD_CLIMATE_SELECT.name}", + }, + f"{TEST_VIN_2_EV}_{OLD_CLIMATE_SELECT.name}", + f"{TEST_VIN_2_EV}_{CLIMATE_SELECT.key}", + ), + ], +) +async def test_binary_sensor_migrate_unique_ids_duplicate( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry +) -> None: + """Test unsuccessful migration of entity unique_ids due to duplicate.""" + await migrate_unique_ids_duplicate( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry + ) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index b526984..21da619 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,12 +1,16 @@ """Test Subaru sensors.""" from unittest.mock import patch +import pytest + from custom_components.subaru.const import FETCH_INTERVAL from custom_components.subaru.sensor import ( API_GEN_2_SENSORS, + DOMAIN as SUBARU_DOMAIN, EV_SENSORS, SAFETY_SENSORS, ) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.util import slugify from homeassistant.util.unit_system import IMPERIAL_SYSTEM @@ -14,9 +18,17 @@ EXPECTED_STATE_EV_IMPERIAL, EXPECTED_STATE_EV_METRIC, EXPECTED_STATE_EV_UNAVAILABLE, + TEST_VIN_2_EV, VEHICLE_STATUS_EV, ) -from .conftest import MOCK_API_FETCH, MOCK_API_GET_DATA, TEST_DEVICE_NAME, advance_time +from .conftest import ( + MOCK_API_FETCH, + MOCK_API_GET_DATA, + TEST_DEVICE_NAME, + advance_time, + migrate_unique_ids, + migrate_unique_ids_duplicate, +) async def test_sensors_ev_imperial(hass, ev_entry): @@ -46,6 +58,52 @@ async def test_sensors_missing_vin_data(hass, ev_entry): _assert_data(hass, EXPECTED_STATE_EV_UNAVAILABLE) +@pytest.mark.parametrize( + "entitydata,old_unique_id,new_unique_id", + [ + ( + { + "domain": SENSOR_DOMAIN, + "platform": SUBARU_DOMAIN, + "unique_id": f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].name}", + }, + f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].name}", + f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].key}", + ), + ], +) +async def test_sensor_migrate_unique_ids( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry +) -> None: + """Test successful migration of entity unique_ids.""" + await migrate_unique_ids( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry + ) + + +@pytest.mark.parametrize( + "entitydata,old_unique_id,new_unique_id", + [ + ( + { + "domain": SENSOR_DOMAIN, + "platform": SUBARU_DOMAIN, + "unique_id": f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].name}", + }, + f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].name}", + f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].key}", + ) + ], +) +async def test_sensor_migrate_unique_ids_duplicate( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry +) -> None: + """Test unsuccessful migration of entity unique_ids due to duplicate.""" + await migrate_unique_ids_duplicate( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry + ) + + def _assert_data(hass, expected_state): sensor_list = EV_SENSORS sensor_list.extend(API_GEN_2_SENSORS)