diff --git a/CODEOWNERS b/CODEOWNERS index f3c7487a520..788f3636143 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -450,6 +450,7 @@ homeassistant/components/stiebel_eltron/* @fucm homeassistant/components/stookalert/* @fwestenberg homeassistant/components/stream/* @hunterjm @uvjustin homeassistant/components/stt/* @pvizeli +homeassistant/components/subaru/* @G-Two homeassistant/components/suez_water/* @ooii homeassistant/components/sun/* @Swamp-Ig homeassistant/components/supla/* @mwegrzynek diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py new file mode 100644 index 00000000000..63bc644b50a --- /dev/null +++ b/homeassistant/components/subaru/__init__.py @@ -0,0 +1,173 @@ +"""The Subaru integration.""" +import asyncio +from datetime import timedelta +import logging +import time + +from subarulink import Controller as SubaruAPI, InvalidCredentials, SubaruException + +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 ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_COUNTRY, + CONF_UPDATE_ENABLED, + COORDINATOR_NAME, + DOMAIN, + ENTRY_CONTROLLER, + ENTRY_COORDINATOR, + ENTRY_VEHICLES, + FETCH_INTERVAL, + SUPPORTED_PLATFORMS, + UPDATE_INTERVAL, + VEHICLE_API_GEN, + VEHICLE_HAS_EV, + VEHICLE_HAS_REMOTE_SERVICE, + VEHICLE_HAS_REMOTE_START, + VEHICLE_HAS_SAFETY_SERVICE, + VEHICLE_LAST_UPDATE, + VEHICLE_NAME, + VEHICLE_VIN, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, base_config): + """Do nothing since this integration does not support configuration.yml setup.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass, entry): + """Set up Subaru from a config entry.""" + config = entry.data + websession = aiohttp_client.async_get_clientsession(hass) + try: + controller = SubaruAPI( + websession, + config[CONF_USERNAME], + config[CONF_PASSWORD], + config[CONF_DEVICE_ID], + config[CONF_PIN], + None, + config[CONF_COUNTRY], + update_interval=UPDATE_INTERVAL, + fetch_interval=FETCH_INTERVAL, + ) + _LOGGER.debug("Using subarulink %s", controller.version) + await controller.connect() + except InvalidCredentials: + _LOGGER.error("Invalid account") + return False + except SubaruException as err: + raise ConfigEntryNotReady(err.message) from err + + vehicle_info = {} + for vin in controller.get_vehicles(): + vehicle_info[vin] = get_vehicle_info(controller, vin) + + async def async_update_data(): + """Fetch data from API endpoint.""" + try: + return await refresh_subaru_data(entry, vehicle_info, controller) + except SubaruException as err: + raise UpdateFailed(err.message) from err + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=COORDINATOR_NAME, + update_method=async_update_data, + update_interval=timedelta(seconds=FETCH_INTERVAL), + ) + + await coordinator.async_refresh() + + hass.data[DOMAIN][entry.entry_id] = { + ENTRY_CONTROLLER: controller, + ENTRY_COORDINATOR: coordinator, + ENTRY_VEHICLES: vehicle_info, + } + + for component in SUPPORTED_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in SUPPORTED_PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +async def refresh_subaru_data(config_entry, vehicle_info, controller): + """ + Refresh local data with data fetched via Subaru API. + + Subaru API calls assume a server side vehicle context + Data fetch/update must be done for each vehicle + """ + data = {} + + for vehicle in vehicle_info.values(): + vin = vehicle[VEHICLE_VIN] + + # Active subscription required + if not vehicle[VEHICLE_HAS_SAFETY_SERVICE]: + continue + + # Optionally send an "update" remote command to vehicle (throttled with update_interval) + if config_entry.options.get(CONF_UPDATE_ENABLED, False): + await update_subaru(vehicle, controller) + + # Fetch data from Subaru servers + await controller.fetch(vin, force=True) + + # Update our local data that will go to entity states + received_data = await controller.get_data(vin) + if received_data: + data[vin] = received_data + + return data + + +async def update_subaru(vehicle, controller): + """Commands remote vehicle update (polls the vehicle to update subaru API cache).""" + cur_time = time.time() + last_update = vehicle[VEHICLE_LAST_UPDATE] + + if cur_time - last_update > controller.get_update_interval(): + await controller.update(vehicle[VEHICLE_VIN], force=True) + vehicle[VEHICLE_LAST_UPDATE] = cur_time + + +def get_vehicle_info(controller, vin): + """Obtain vehicle identifiers and capabilities.""" + info = { + VEHICLE_VIN: vin, + VEHICLE_NAME: controller.vin_to_name(vin), + VEHICLE_HAS_EV: controller.get_ev_status(vin), + VEHICLE_API_GEN: controller.get_api_gen(vin), + VEHICLE_HAS_REMOTE_START: controller.get_res_status(vin), + VEHICLE_HAS_REMOTE_SERVICE: controller.get_remote_status(vin), + VEHICLE_HAS_SAFETY_SERVICE: controller.get_safety_status(vin), + VEHICLE_LAST_UPDATE: 0, + } + return info diff --git a/homeassistant/components/subaru/config_flow.py b/homeassistant/components/subaru/config_flow.py new file mode 100644 index 00000000000..4c5c476a402 --- /dev/null +++ b/homeassistant/components/subaru/config_flow.py @@ -0,0 +1,157 @@ +"""Config flow for Subaru integration.""" +from datetime import datetime +import logging + +from subarulink import ( + Controller as SubaruAPI, + InvalidCredentials, + InvalidPIN, + SubaruException, +) +from subarulink.const import COUNTRY_CAN, COUNTRY_USA +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv + +# pylint: disable=unused-import +from .const import CONF_COUNTRY, CONF_UPDATE_ENABLED, DOMAIN + +_LOGGER = logging.getLogger(__name__) +PIN_SCHEMA = vol.Schema({vol.Required(CONF_PIN): str}) + + +class SubaruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Subaru.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + config_data = {CONF_PIN: None} + controller = None + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + error = None + + if user_input: + if user_input[CONF_USERNAME] in [ + entry.data[CONF_USERNAME] for entry in self._async_current_entries() + ]: + return self.async_abort(reason="already_configured") + + try: + await self.validate_login_creds(user_input) + except InvalidCredentials: + error = {"base": "invalid_auth"} + except SubaruException as ex: + _LOGGER.error("Unable to communicate with Subaru API: %s", ex.message) + return self.async_abort(reason="cannot_connect") + else: + if self.controller.is_pin_required(): + return await self.async_step_pin() + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=self.config_data + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_USERNAME, + default=user_input.get(CONF_USERNAME) if user_input else "", + ): str, + vol.Required( + CONF_PASSWORD, + default=user_input.get(CONF_PASSWORD) if user_input else "", + ): str, + vol.Required( + CONF_COUNTRY, + default=user_input.get(CONF_COUNTRY) + if user_input + else COUNTRY_USA, + ): vol.In([COUNTRY_CAN, COUNTRY_USA]), + } + ), + errors=error, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + async def validate_login_creds(self, data): + """Validate the user input allows us to connect. + + data: contains values provided by the user. + """ + websession = aiohttp_client.async_get_clientsession(self.hass) + now = datetime.now() + if not data.get(CONF_DEVICE_ID): + data[CONF_DEVICE_ID] = int(now.timestamp()) + date = now.strftime("%Y-%m-%d") + device_name = "Home Assistant: Added " + date + + self.controller = SubaruAPI( + websession, + username=data[CONF_USERNAME], + password=data[CONF_PASSWORD], + device_id=data[CONF_DEVICE_ID], + pin=None, + device_name=device_name, + country=data[CONF_COUNTRY], + ) + _LOGGER.debug("Using subarulink %s", self.controller.version) + _LOGGER.debug( + "Setting up first time connection to Subuaru API. This may take up to 20 seconds." + ) + if await self.controller.connect(): + _LOGGER.debug("Successfully authenticated and authorized with Subaru API") + self.config_data.update(data) + + async def async_step_pin(self, user_input=None): + """Handle second part of config flow, if required.""" + error = None + if user_input: + if self.controller.update_saved_pin(user_input[CONF_PIN]): + try: + vol.Match(r"[0-9]{4}")(user_input[CONF_PIN]) + await self.controller.test_pin() + except vol.Invalid: + error = {"base": "bad_pin_format"} + except InvalidPIN: + error = {"base": "incorrect_pin"} + else: + _LOGGER.debug("PIN successfully tested") + self.config_data.update(user_input) + return self.async_create_entry( + title=self.config_data[CONF_USERNAME], data=self.config_data + ) + return self.async_show_form(step_id="pin", data_schema=PIN_SCHEMA, errors=error) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for Subaru.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Required( + CONF_UPDATE_ENABLED, + default=self.config_entry.options.get(CONF_UPDATE_ENABLED, False), + ): cv.boolean, + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py new file mode 100644 index 00000000000..7349f9c32d6 --- /dev/null +++ b/homeassistant/components/subaru/const.py @@ -0,0 +1,47 @@ +"""Constants for the Subaru integration.""" + +DOMAIN = "subaru" +FETCH_INTERVAL = 300 +UPDATE_INTERVAL = 7200 +CONF_UPDATE_ENABLED = "update_enabled" +CONF_COUNTRY = "country" + +# entry fields +ENTRY_CONTROLLER = "controller" +ENTRY_COORDINATOR = "coordinator" +ENTRY_VEHICLES = "vehicles" + +# update coordinator name +COORDINATOR_NAME = "subaru_data" + +# info fields +VEHICLE_VIN = "vin" +VEHICLE_NAME = "display_name" +VEHICLE_HAS_EV = "is_ev" +VEHICLE_API_GEN = "api_gen" +VEHICLE_HAS_REMOTE_START = "has_res" +VEHICLE_HAS_REMOTE_SERVICE = "has_remote" +VEHICLE_HAS_SAFETY_SERVICE = "has_safety" +VEHICLE_LAST_UPDATE = "last_update" +VEHICLE_STATUS = "status" + + +API_GEN_1 = "g1" +API_GEN_2 = "g2" +MANUFACTURER = "Subaru Corp." + +SUPPORTED_PLATFORMS = [ + "sensor", +] + +ICONS = { + "Avg Fuel Consumption": "mdi:leaf", + "EV Time to Full Charge": "mdi:car-electric", + "EV Range": "mdi:ev-station", + "Odometer": "mdi:road-variant", + "Range": "mdi:gas-station", + "Tire Pressure FL": "mdi:gauge", + "Tire Pressure FR": "mdi:gauge", + "Tire Pressure RL": "mdi:gauge", + "Tire Pressure RR": "mdi:gauge", +} diff --git a/homeassistant/components/subaru/entity.py b/homeassistant/components/subaru/entity.py new file mode 100644 index 00000000000..4fdeca4e484 --- /dev/null +++ b/homeassistant/components/subaru/entity.py @@ -0,0 +1,39 @@ +"""Base class for all Subaru Entities.""" +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, ICONS, MANUFACTURER, VEHICLE_NAME, VEHICLE_VIN + + +class SubaruEntity(CoordinatorEntity): + """Representation of a Subaru Entity.""" + + def __init__(self, vehicle_info, coordinator): + """Initialize the Subaru Entity.""" + super().__init__(coordinator) + self.car_name = vehicle_info[VEHICLE_NAME] + self.vin = vehicle_info[VEHICLE_VIN] + self.entity_type = "entity" + + @property + def name(self): + """Return name.""" + return f"{self.car_name} {self.entity_type}" + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self.vin}_{self.entity_type}" + + @property + def icon(self): + """Return the icon of the sensor.""" + return ICONS.get(self.entity_type) + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self.vin)}, + "name": self.car_name, + "manufacturer": MANUFACTURER, + } diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json new file mode 100644 index 00000000000..7a918c59f74 --- /dev/null +++ b/homeassistant/components/subaru/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "subaru", + "name": "Subaru", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/subaru", + "requirements": ["subarulink==0.3.12"], + "codeowners": ["@G-Two"] +} diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py new file mode 100644 index 00000000000..594d18028e6 --- /dev/null +++ b/homeassistant/components/subaru/sensor.py @@ -0,0 +1,265 @@ +"""Support for Subaru sensors.""" +import subarulink.const as sc + +from homeassistant.components.sensor import DEVICE_CLASSES +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + LENGTH_KILOMETERS, + LENGTH_MILES, + PERCENTAGE, + PRESSURE_HPA, + TEMP_CELSIUS, + TIME_MINUTES, + VOLT, + VOLUME_GALLONS, + VOLUME_LITERS, +) +from homeassistant.util.distance import convert as dist_convert +from homeassistant.util.unit_system import ( + IMPERIAL_SYSTEM, + LENGTH_UNITS, + PRESSURE_UNITS, + TEMPERATURE_UNITS, +) +from homeassistant.util.volume import convert as vol_convert + +from .const import ( + API_GEN_2, + DOMAIN, + ENTRY_COORDINATOR, + ENTRY_VEHICLES, + VEHICLE_API_GEN, + VEHICLE_HAS_EV, + VEHICLE_HAS_SAFETY_SERVICE, + VEHICLE_STATUS, +) +from .entity import SubaruEntity + +L_PER_GAL = vol_convert(1, VOLUME_GALLONS, VOLUME_LITERS) +KM_PER_MI = dist_convert(1, LENGTH_MILES, LENGTH_KILOMETERS) + +# Fuel Economy Constants +FUEL_CONSUMPTION_L_PER_100KM = "L/100km" +FUEL_CONSUMPTION_MPG = "mi/gal" +FUEL_CONSUMPTION_UNITS = [FUEL_CONSUMPTION_L_PER_100KM, FUEL_CONSUMPTION_MPG] + +SENSOR_TYPE = "type" +SENSOR_CLASS = "class" +SENSOR_FIELD = "field" +SENSOR_UNITS = "units" + +# Sensor data available to "Subaru Safety Plus" subscribers with Gen1 or Gen2 vehicles +SAFETY_SENSORS = [ + { + SENSOR_TYPE: "Odometer", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.ODOMETER, + SENSOR_UNITS: LENGTH_KILOMETERS, + }, +] + +# Sensor data available to "Subaru Safety Plus" subscribers with Gen2 vehicles +API_GEN_2_SENSORS = [ + { + SENSOR_TYPE: "Avg Fuel Consumption", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.AVG_FUEL_CONSUMPTION, + SENSOR_UNITS: FUEL_CONSUMPTION_L_PER_100KM, + }, + { + SENSOR_TYPE: "Range", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.DIST_TO_EMPTY, + SENSOR_UNITS: LENGTH_KILOMETERS, + }, + { + SENSOR_TYPE: "Tire Pressure FL", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.TIRE_PRESSURE_FL, + SENSOR_UNITS: PRESSURE_HPA, + }, + { + SENSOR_TYPE: "Tire Pressure FR", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.TIRE_PRESSURE_FR, + SENSOR_UNITS: PRESSURE_HPA, + }, + { + SENSOR_TYPE: "Tire Pressure RL", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.TIRE_PRESSURE_RL, + SENSOR_UNITS: PRESSURE_HPA, + }, + { + SENSOR_TYPE: "Tire Pressure RR", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.TIRE_PRESSURE_RR, + SENSOR_UNITS: PRESSURE_HPA, + }, + { + SENSOR_TYPE: "External Temp", + SENSOR_CLASS: DEVICE_CLASS_TEMPERATURE, + SENSOR_FIELD: sc.EXTERNAL_TEMP, + SENSOR_UNITS: TEMP_CELSIUS, + }, + { + SENSOR_TYPE: "12V Battery Voltage", + SENSOR_CLASS: DEVICE_CLASS_VOLTAGE, + SENSOR_FIELD: sc.BATTERY_VOLTAGE, + SENSOR_UNITS: VOLT, + }, +] + +# Sensor data available to "Subaru Safety Plus" subscribers with PHEV vehicles +EV_SENSORS = [ + { + SENSOR_TYPE: "EV Range", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.EV_DISTANCE_TO_EMPTY, + SENSOR_UNITS: LENGTH_MILES, + }, + { + SENSOR_TYPE: "EV Battery Level", + SENSOR_CLASS: DEVICE_CLASS_BATTERY, + SENSOR_FIELD: sc.EV_STATE_OF_CHARGE_PERCENT, + SENSOR_UNITS: PERCENTAGE, + }, + { + SENSOR_TYPE: "EV Time to Full Charge", + SENSOR_CLASS: None, + SENSOR_FIELD: sc.EV_TIME_TO_FULLY_CHARGED, + SENSOR_UNITS: TIME_MINUTES, + }, +] + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Subaru sensors by config_entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id][ENTRY_COORDINATOR] + vehicle_info = hass.data[DOMAIN][config_entry.entry_id][ENTRY_VEHICLES] + entities = [] + for vin in vehicle_info.keys(): + entities.extend(create_vehicle_sensors(vehicle_info[vin], coordinator)) + async_add_entities(entities, True) + + +def create_vehicle_sensors(vehicle_info, coordinator): + """Instantiate all available sensors for the vehicle.""" + sensors_to_add = [] + if vehicle_info[VEHICLE_HAS_SAFETY_SERVICE]: + sensors_to_add.extend(SAFETY_SENSORS) + + if vehicle_info[VEHICLE_API_GEN] == API_GEN_2: + sensors_to_add.extend(API_GEN_2_SENSORS) + + if vehicle_info[VEHICLE_HAS_EV]: + sensors_to_add.extend(EV_SENSORS) + + return [ + SubaruSensor( + vehicle_info, + coordinator, + s[SENSOR_TYPE], + s[SENSOR_CLASS], + s[SENSOR_FIELD], + s[SENSOR_UNITS], + ) + for s in sensors_to_add + ] + + +class SubaruSensor(SubaruEntity): + """Class for Subaru sensors.""" + + def __init__( + self, vehicle_info, coordinator, entity_type, sensor_class, data_field, api_unit + ): + """Initialize the sensor.""" + super().__init__(vehicle_info, coordinator) + self.hass_type = "sensor" + self.current_value = None + self.entity_type = entity_type + self.sensor_class = sensor_class + self.data_field = data_field + self.api_unit = api_unit + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + if self.sensor_class in DEVICE_CLASSES: + return self.sensor_class + return super().device_class + + @property + def state(self): + """Return the state of the sensor.""" + self.current_value = self.get_current_value() + + if self.current_value is None: + return None + + if self.api_unit in TEMPERATURE_UNITS: + return round( + self.hass.config.units.temperature(self.current_value, self.api_unit), 1 + ) + + if self.api_unit in LENGTH_UNITS: + return round( + self.hass.config.units.length(self.current_value, self.api_unit), 1 + ) + + if self.api_unit in PRESSURE_UNITS: + if self.hass.config.units == IMPERIAL_SYSTEM: + return round( + self.hass.config.units.pressure(self.current_value, self.api_unit), + 1, + ) + + if self.api_unit in FUEL_CONSUMPTION_UNITS: + if self.hass.config.units == IMPERIAL_SYSTEM: + return round((100.0 * L_PER_GAL) / (KM_PER_MI * self.current_value), 1) + + return self.current_value + + @property + def unit_of_measurement(self): + """Return the unit_of_measurement of the device.""" + if self.api_unit in TEMPERATURE_UNITS: + return self.hass.config.units.temperature_unit + + if self.api_unit in LENGTH_UNITS: + return self.hass.config.units.length_unit + + if self.api_unit in PRESSURE_UNITS: + if self.hass.config.units == IMPERIAL_SYSTEM: + return self.hass.config.units.pressure_unit + return PRESSURE_HPA + + if self.api_unit in FUEL_CONSUMPTION_UNITS: + if self.hass.config.units == IMPERIAL_SYSTEM: + return FUEL_CONSUMPTION_MPG + return FUEL_CONSUMPTION_L_PER_100KM + + return self.api_unit + + @property + def available(self): + """Return if entity is available.""" + last_update_success = super().available + if last_update_success and self.vin not in self.coordinator.data: + return False + return last_update_success + + def get_current_value(self): + """Get raw value from the coordinator.""" + value = self.coordinator.data[self.vin][VEHICLE_STATUS].get(self.data_field) + if value in sc.BAD_SENSOR_VALUES: + value = None + if isinstance(value, str): + if "." in value: + value = float(value) + else: + value = int(value) + return value diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json new file mode 100644 index 00000000000..064245e0732 --- /dev/null +++ b/homeassistant/components/subaru/strings.json @@ -0,0 +1,45 @@ +{ + "config": { + "step": { + "user": { + "title": "Subaru Starlink Configuration", + "description": "Please enter your MySubaru credentials\nNOTE: Initial setup may take up to 30 seconds", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "country": "Select country" + } + }, + "pin": { + "title": "Subaru Starlink Configuration", + "description": "Please enter your MySubaru PIN\nNOTE: All vehicles in account must have the same PIN", + "data": { + "pin": "PIN" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "incorrect_pin": "Incorrect PIN", + "bad_pin_format": "PIN should be 4 digits", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + + "options": { + "step": { + "init": { + "title": "Subaru Starlink Options", + "description": "When enabled, vehicle polling will send a remote command to your vehicle every 2 hours to obtain new sensor data. Without vehicle polling, new sensor data is only received when the vehicle automatically pushes data (normally after engine shutdown).", + "data": { + "update_enabled": "Enable vehicle polling" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index dfb2f56b29e..7e17a839068 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -216,6 +216,7 @@ FLOWS = [ "squeezebox", "srp_energy", "starline", + "subaru", "syncthru", "synology_dsm", "tado", diff --git a/requirements_all.txt b/requirements_all.txt index 4d3d7d5bebf..9ceb668bc58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2142,6 +2142,9 @@ streamlabswater==1.0.1 # homeassistant.components.traccar stringcase==1.2.0 +# homeassistant.components.subaru +subarulink==0.3.12 + # homeassistant.components.ecovacs sucks==0.9.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80d325100c8..d37ee25cbbd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1105,6 +1105,9 @@ statsd==3.2.1 # homeassistant.components.traccar stringcase==1.2.0 +# homeassistant.components.subaru +subarulink==0.3.12 + # homeassistant.components.solarlog sunwatcher==0.2.1 diff --git a/tests/components/subaru/__init__.py b/tests/components/subaru/__init__.py new file mode 100644 index 00000000000..26b81c84a1e --- /dev/null +++ b/tests/components/subaru/__init__.py @@ -0,0 +1 @@ +"""Tests for the Subaru integration.""" diff --git a/tests/components/subaru/api_responses.py b/tests/components/subaru/api_responses.py new file mode 100644 index 00000000000..b6a79ab8829 --- /dev/null +++ b/tests/components/subaru/api_responses.py @@ -0,0 +1,284 @@ +"""Sample API response data for tests.""" + +from homeassistant.components.subaru.const import ( + API_GEN_1, + API_GEN_2, + VEHICLE_API_GEN, + VEHICLE_HAS_EV, + VEHICLE_HAS_REMOTE_SERVICE, + VEHICLE_HAS_REMOTE_START, + VEHICLE_HAS_SAFETY_SERVICE, + VEHICLE_NAME, + VEHICLE_VIN, +) + +TEST_VIN_1_G1 = "JF2ABCDE6L0000001" +TEST_VIN_2_EV = "JF2ABCDE6L0000002" +TEST_VIN_3_G2 = "JF2ABCDE6L0000003" + +VEHICLE_DATA = { + TEST_VIN_1_G1: { + VEHICLE_VIN: TEST_VIN_1_G1, + VEHICLE_NAME: "test_vehicle_1", + VEHICLE_HAS_EV: False, + VEHICLE_API_GEN: API_GEN_1, + VEHICLE_HAS_REMOTE_START: True, + VEHICLE_HAS_REMOTE_SERVICE: True, + VEHICLE_HAS_SAFETY_SERVICE: False, + }, + TEST_VIN_2_EV: { + VEHICLE_VIN: TEST_VIN_2_EV, + VEHICLE_NAME: "test_vehicle_2", + VEHICLE_HAS_EV: True, + VEHICLE_API_GEN: API_GEN_2, + VEHICLE_HAS_REMOTE_START: True, + VEHICLE_HAS_REMOTE_SERVICE: True, + VEHICLE_HAS_SAFETY_SERVICE: True, + }, + TEST_VIN_3_G2: { + VEHICLE_VIN: TEST_VIN_3_G2, + VEHICLE_NAME: "test_vehicle_3", + VEHICLE_HAS_EV: False, + VEHICLE_API_GEN: API_GEN_2, + VEHICLE_HAS_REMOTE_START: True, + VEHICLE_HAS_REMOTE_SERVICE: True, + VEHICLE_HAS_SAFETY_SERVICE: True, + }, +} + +VEHICLE_STATUS_EV = { + "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_STOPPED", + "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", + "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", + "EV_DISTANCE_TO_EMPTY": 17, + "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", + "EV_STATE_OF_CHARGE_MODE": "EV_MODE", + "EV_STATE_OF_CHARGE_PERCENT": "100", + "EV_TIME_TO_FULLY_CHARGED": "65535", + "EV_VEHICLE_TIME_DAYOFWEEK": "6", + "EV_VEHICLE_TIME_HOUR": "14", + "EV_VEHICLE_TIME_MINUTE": "20", + "EV_VEHICLE_TIME_SECOND": "39", + "EXT_EXTERNAL_TEMP": "21.5", + "ODOMETER": 1234, + "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": 2550, + "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": 40.0, + "longitude": -100.0, + } +} + +VEHICLE_STATUS_G2 = { + "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", + "EXT_EXTERNAL_TEMP": "21.5", + "ODOMETER": 1234, + "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": 2550, + "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": 40.0, + "longitude": -100.0, + } +} + +EXPECTED_STATE_EV_IMPERIAL = { + "AVG_FUEL_CONSUMPTION": "102.3", + "BATTERY_VOLTAGE": "12.0", + "DISTANCE_TO_EMPTY_FUEL": "439.3", + "EV_CHARGER_STATE_TYPE": "CHARGING_STOPPED", + "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", + "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", + "EV_DISTANCE_TO_EMPTY": "17", + "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", + "EV_STATE_OF_CHARGE_MODE": "EV_MODE", + "EV_STATE_OF_CHARGE_PERCENT": "100", + "EV_TIME_TO_FULLY_CHARGED": "unknown", + "EV_VEHICLE_TIME_DAYOFWEEK": "6", + "EV_VEHICLE_TIME_HOUR": "14", + "EV_VEHICLE_TIME_MINUTE": "20", + "EV_VEHICLE_TIME_SECOND": "39", + "EXT_EXTERNAL_TEMP": "70.7", + "ODOMETER": "766.8", + "POSITION_HEADING_DEGREE": "150", + "POSITION_SPEED_KMPH": "0", + "POSITION_TIMESTAMP": 1595560000.0, + "TIMESTAMP": 1595560000.0, + "TRANSMISSION_MODE": "UNKNOWN", + "TYRE_PRESSURE_FRONT_LEFT": "37.0", + "TYRE_PRESSURE_FRONT_RIGHT": "37.0", + "TYRE_PRESSURE_REAR_LEFT": "35.5", + "TYRE_PRESSURE_REAR_RIGHT": "34.1", + "VEHICLE_STATE_TYPE": "IGNITION_OFF", + "heading": 170, + "latitude": 40.0, + "longitude": -100.0, +} + +EXPECTED_STATE_EV_METRIC = { + "AVG_FUEL_CONSUMPTION": "2.3", + "BATTERY_VOLTAGE": "12.0", + "DISTANCE_TO_EMPTY_FUEL": "707", + "EV_CHARGER_STATE_TYPE": "CHARGING_STOPPED", + "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", + "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", + "EV_DISTANCE_TO_EMPTY": "27.4", + "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", + "EV_STATE_OF_CHARGE_MODE": "EV_MODE", + "EV_STATE_OF_CHARGE_PERCENT": "100", + "EV_TIME_TO_FULLY_CHARGED": "unknown", + "EV_VEHICLE_TIME_DAYOFWEEK": "6", + "EV_VEHICLE_TIME_HOUR": "14", + "EV_VEHICLE_TIME_MINUTE": "20", + "EV_VEHICLE_TIME_SECOND": "39", + "EXT_EXTERNAL_TEMP": "21.5", + "ODOMETER": "1234", + "POSITION_HEADING_DEGREE": "150", + "POSITION_SPEED_KMPH": "0", + "POSITION_TIMESTAMP": 1595560000.0, + "TIMESTAMP": 1595560000.0, + "TRANSMISSION_MODE": "UNKNOWN", + "TYRE_PRESSURE_FRONT_LEFT": "2550", + "TYRE_PRESSURE_FRONT_RIGHT": "2550", + "TYRE_PRESSURE_REAR_LEFT": "2450", + "TYRE_PRESSURE_REAR_RIGHT": "2350", + "VEHICLE_STATE_TYPE": "IGNITION_OFF", + "heading": 170, + "latitude": 40.0, + "longitude": -100.0, +} + +EXPECTED_STATE_EV_UNAVAILABLE = { + "AVG_FUEL_CONSUMPTION": "unavailable", + "BATTERY_VOLTAGE": "unavailable", + "DISTANCE_TO_EMPTY_FUEL": "unavailable", + "EV_CHARGER_STATE_TYPE": "unavailable", + "EV_CHARGE_SETTING_AMPERE_TYPE": "unavailable", + "EV_CHARGE_VOLT_TYPE": "unavailable", + "EV_DISTANCE_TO_EMPTY": "unavailable", + "EV_IS_PLUGGED_IN": "unavailable", + "EV_STATE_OF_CHARGE_MODE": "unavailable", + "EV_STATE_OF_CHARGE_PERCENT": "unavailable", + "EV_TIME_TO_FULLY_CHARGED": "unavailable", + "EV_VEHICLE_TIME_DAYOFWEEK": "unavailable", + "EV_VEHICLE_TIME_HOUR": "unavailable", + "EV_VEHICLE_TIME_MINUTE": "unavailable", + "EV_VEHICLE_TIME_SECOND": "unavailable", + "EXT_EXTERNAL_TEMP": "unavailable", + "ODOMETER": "unavailable", + "POSITION_HEADING_DEGREE": "unavailable", + "POSITION_SPEED_KMPH": "unavailable", + "POSITION_TIMESTAMP": "unavailable", + "TIMESTAMP": "unavailable", + "TRANSMISSION_MODE": "unavailable", + "TYRE_PRESSURE_FRONT_LEFT": "unavailable", + "TYRE_PRESSURE_FRONT_RIGHT": "unavailable", + "TYRE_PRESSURE_REAR_LEFT": "unavailable", + "TYRE_PRESSURE_REAR_RIGHT": "unavailable", + "VEHICLE_STATE_TYPE": "unavailable", + "heading": "unavailable", + "latitude": "unavailable", + "longitude": "unavailable", +} diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py new file mode 100644 index 00000000000..8216ca2d2c2 --- /dev/null +++ b/tests/components/subaru/conftest.py @@ -0,0 +1,139 @@ +"""Common functions needed to setup tests for Subaru component.""" +from unittest.mock import patch + +import pytest +from subarulink.const import COUNTRY_USA + +from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN +from homeassistant.components.subaru.const import ( + CONF_COUNTRY, + CONF_UPDATE_ENABLED, + DOMAIN, + VEHICLE_API_GEN, + VEHICLE_HAS_EV, + VEHICLE_HAS_REMOTE_SERVICE, + VEHICLE_HAS_REMOTE_START, + VEHICLE_HAS_SAFETY_SERVICE, + VEHICLE_NAME, +) +from homeassistant.config_entries import ENTRY_STATE_LOADED +from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME +from homeassistant.setup import async_setup_component + +from .api_responses import TEST_VIN_2_EV, VEHICLE_DATA, VEHICLE_STATUS_EV + +from tests.common import MockConfigEntry + +MOCK_API = "homeassistant.components.subaru.SubaruAPI." +MOCK_API_CONNECT = f"{MOCK_API}connect" +MOCK_API_IS_PIN_REQUIRED = f"{MOCK_API}is_pin_required" +MOCK_API_TEST_PIN = f"{MOCK_API}test_pin" +MOCK_API_UPDATE_SAVED_PIN = f"{MOCK_API}update_saved_pin" +MOCK_API_GET_VEHICLES = f"{MOCK_API}get_vehicles" +MOCK_API_VIN_TO_NAME = f"{MOCK_API}vin_to_name" +MOCK_API_GET_API_GEN = f"{MOCK_API}get_api_gen" +MOCK_API_GET_EV_STATUS = f"{MOCK_API}get_ev_status" +MOCK_API_GET_RES_STATUS = f"{MOCK_API}get_res_status" +MOCK_API_GET_REMOTE_STATUS = f"{MOCK_API}get_remote_status" +MOCK_API_GET_SAFETY_STATUS = f"{MOCK_API}get_safety_status" +MOCK_API_GET_GET_DATA = f"{MOCK_API}get_data" +MOCK_API_UPDATE = f"{MOCK_API}update" +MOCK_API_FETCH = f"{MOCK_API}fetch" + +TEST_USERNAME = "user@email.com" +TEST_PASSWORD = "password" +TEST_PIN = "1234" +TEST_DEVICE_ID = 1613183362 +TEST_COUNTRY = COUNTRY_USA + +TEST_CREDS = { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_COUNTRY: TEST_COUNTRY, +} + +TEST_CONFIG = { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_COUNTRY: TEST_COUNTRY, + CONF_PIN: TEST_PIN, + CONF_DEVICE_ID: TEST_DEVICE_ID, +} + +TEST_OPTIONS = { + CONF_UPDATE_ENABLED: True, +} + +TEST_ENTITY_ID = "sensor.test_vehicle_2_odometer" + + +async def setup_subaru_integration( + hass, + vehicle_list=None, + vehicle_data=None, + vehicle_status=None, + connect_effect=None, + fetch_effect=None, +): + """Create Subaru entry.""" + 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) + + with patch( + MOCK_API_CONNECT, + return_value=connect_effect is None, + side_effect=connect_effect, + ), patch(MOCK_API_GET_VEHICLES, return_value=vehicle_list,), patch( + MOCK_API_VIN_TO_NAME, + return_value=vehicle_data[VEHICLE_NAME], + ), patch( + MOCK_API_GET_API_GEN, + return_value=vehicle_data[VEHICLE_API_GEN], + ), patch( + MOCK_API_GET_EV_STATUS, + return_value=vehicle_data[VEHICLE_HAS_EV], + ), patch( + MOCK_API_GET_RES_STATUS, + return_value=vehicle_data[VEHICLE_HAS_REMOTE_START], + ), patch( + MOCK_API_GET_REMOTE_STATUS, + return_value=vehicle_data[VEHICLE_HAS_REMOTE_SERVICE], + ), patch( + MOCK_API_GET_SAFETY_STATUS, + return_value=vehicle_data[VEHICLE_HAS_SAFETY_SERVICE], + ), patch( + MOCK_API_GET_GET_DATA, + return_value=vehicle_status, + ), patch( + MOCK_API_UPDATE, + ), patch( + MOCK_API_FETCH, side_effect=fetch_effect + ): + 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): + """Create a Subaru entry representing an EV vehicle with full STARLINK subscription.""" + entry = await setup_subaru_integration( + hass, + 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 == ENTRY_STATE_LOADED + return entry diff --git a/tests/components/subaru/test_config_flow.py b/tests/components/subaru/test_config_flow.py new file mode 100644 index 00000000000..676b876652b --- /dev/null +++ b/tests/components/subaru/test_config_flow.py @@ -0,0 +1,250 @@ +"""Tests for the Subaru component config flow.""" +# pylint: disable=redefined-outer-name +from copy import deepcopy +from unittest import mock +from unittest.mock import patch + +import pytest +from subarulink.exceptions import InvalidCredentials, InvalidPIN, SubaruException + +from homeassistant import config_entries +from homeassistant.components.subaru import config_flow +from homeassistant.components.subaru.const import CONF_UPDATE_ENABLED, DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_PIN + +from .conftest import ( + MOCK_API_CONNECT, + MOCK_API_IS_PIN_REQUIRED, + MOCK_API_TEST_PIN, + MOCK_API_UPDATE_SAVED_PIN, + TEST_CONFIG, + TEST_CREDS, + TEST_DEVICE_ID, + TEST_PIN, + TEST_USERNAME, +) + +from tests.common import MockConfigEntry + + +async def test_user_form_init(user_form): + """Test the initial user form for first step of the config flow.""" + expected = { + "data_schema": mock.ANY, + "description_placeholders": None, + "errors": None, + "flow_id": mock.ANY, + "handler": DOMAIN, + "step_id": "user", + "type": "form", + } + assert expected == user_form + + +async def test_user_form_repeat_identifier(hass, user_form): + """Test we handle repeat identifiers.""" + entry = MockConfigEntry( + domain=DOMAIN, title=TEST_USERNAME, data=TEST_CREDS, options=None + ) + entry.add_to_hass(hass) + + with patch( + MOCK_API_CONNECT, + return_value=True, + ) as mock_connect: + result = await hass.config_entries.flow.async_configure( + user_form["flow_id"], + TEST_CREDS, + ) + assert len(mock_connect.mock_calls) == 0 + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_user_form_cannot_connect(hass, user_form): + """Test we handle cannot connect error.""" + with patch( + MOCK_API_CONNECT, + side_effect=SubaruException(None), + ) as mock_connect: + result = await hass.config_entries.flow.async_configure( + user_form["flow_id"], + TEST_CREDS, + ) + assert len(mock_connect.mock_calls) == 1 + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + +async def test_user_form_invalid_auth(hass, user_form): + """Test we handle invalid auth.""" + with patch( + MOCK_API_CONNECT, + side_effect=InvalidCredentials("invalidAccount"), + ) as mock_connect: + result = await hass.config_entries.flow.async_configure( + user_form["flow_id"], + TEST_CREDS, + ) + assert len(mock_connect.mock_calls) == 1 + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_user_form_pin_not_required(hass, user_form): + """Test successful login when no PIN is required.""" + with patch(MOCK_API_CONNECT, return_value=True,) as mock_connect, patch( + MOCK_API_IS_PIN_REQUIRED, + return_value=False, + ) as mock_is_pin_required: + result = await hass.config_entries.flow.async_configure( + user_form["flow_id"], + TEST_CREDS, + ) + assert len(mock_connect.mock_calls) == 2 + assert len(mock_is_pin_required.mock_calls) == 1 + + expected = { + "title": TEST_USERNAME, + "description": None, + "description_placeholders": None, + "flow_id": mock.ANY, + "result": mock.ANY, + "handler": DOMAIN, + "type": "create_entry", + "version": 1, + "data": deepcopy(TEST_CONFIG), + } + expected["data"][CONF_PIN] = None + result["data"][CONF_DEVICE_ID] = TEST_DEVICE_ID + assert expected == result + + +async def test_pin_form_init(pin_form): + """Test the pin entry form for second step of the config flow.""" + expected = { + "data_schema": config_flow.PIN_SCHEMA, + "description_placeholders": None, + "errors": None, + "flow_id": mock.ANY, + "handler": DOMAIN, + "step_id": "pin", + "type": "form", + } + assert expected == pin_form + + +async def test_pin_form_bad_pin_format(hass, pin_form): + """Test we handle invalid pin.""" + with patch(MOCK_API_TEST_PIN,) as mock_test_pin, patch( + MOCK_API_UPDATE_SAVED_PIN, + return_value=True, + ) as mock_update_saved_pin: + result = await hass.config_entries.flow.async_configure( + pin_form["flow_id"], user_input={CONF_PIN: "abcd"} + ) + assert len(mock_test_pin.mock_calls) == 0 + assert len(mock_update_saved_pin.mock_calls) == 1 + assert result["type"] == "form" + assert result["errors"] == {"base": "bad_pin_format"} + + +async def test_pin_form_success(hass, pin_form): + """Test successful PIN entry.""" + with patch(MOCK_API_TEST_PIN, return_value=True,) as mock_test_pin, patch( + MOCK_API_UPDATE_SAVED_PIN, + return_value=True, + ) as mock_update_saved_pin: + result = await hass.config_entries.flow.async_configure( + pin_form["flow_id"], user_input={CONF_PIN: TEST_PIN} + ) + + assert len(mock_test_pin.mock_calls) == 1 + assert len(mock_update_saved_pin.mock_calls) == 1 + expected = { + "title": TEST_USERNAME, + "description": None, + "description_placeholders": None, + "flow_id": mock.ANY, + "result": mock.ANY, + "handler": DOMAIN, + "type": "create_entry", + "version": 1, + "data": TEST_CONFIG, + } + result["data"][CONF_DEVICE_ID] = TEST_DEVICE_ID + assert result == expected + + +async def test_pin_form_incorrect_pin(hass, pin_form): + """Test we handle invalid pin.""" + with patch( + MOCK_API_TEST_PIN, + side_effect=InvalidPIN("invalidPin"), + ) as mock_test_pin, patch( + MOCK_API_UPDATE_SAVED_PIN, + return_value=True, + ) as mock_update_saved_pin: + result = await hass.config_entries.flow.async_configure( + pin_form["flow_id"], user_input={CONF_PIN: TEST_PIN} + ) + assert len(mock_test_pin.mock_calls) == 1 + assert len(mock_update_saved_pin.mock_calls) == 1 + assert result["type"] == "form" + assert result["errors"] == {"base": "incorrect_pin"} + + +async def test_option_flow_form(options_form): + """Test config flow options form.""" + expected = { + "data_schema": mock.ANY, + "description_placeholders": None, + "errors": None, + "flow_id": mock.ANY, + "handler": mock.ANY, + "step_id": "init", + "type": "form", + } + assert expected == options_form + + +async def test_option_flow(hass, options_form): + """Test config flow options.""" + result = await hass.config_entries.options.async_configure( + options_form["flow_id"], + user_input={ + CONF_UPDATE_ENABLED: False, + }, + ) + assert result["type"] == "create_entry" + assert result["data"] == { + CONF_UPDATE_ENABLED: False, + } + + +@pytest.fixture +async def user_form(hass): + """Return initial form for Subaru config flow.""" + return await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + +@pytest.fixture +async def pin_form(hass, user_form): + """Return second form (PIN input) for Subaru config flow.""" + with patch(MOCK_API_CONNECT, return_value=True,), patch( + MOCK_API_IS_PIN_REQUIRED, + return_value=True, + ): + return await hass.config_entries.flow.async_configure( + user_form["flow_id"], user_input=TEST_CREDS + ) + + +@pytest.fixture +async def options_form(hass): + """Return options form for Subaru config flow.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) + entry.add_to_hass(hass) + return await hass.config_entries.options.async_init(entry.entry_id) diff --git a/tests/components/subaru/test_init.py b/tests/components/subaru/test_init.py new file mode 100644 index 00000000000..13b510e8c40 --- /dev/null +++ b/tests/components/subaru/test_init.py @@ -0,0 +1,153 @@ +"""Test Subaru component setup and updates.""" +from unittest.mock import patch + +from subarulink import InvalidCredentials, SubaruException + +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.components.subaru.const import DOMAIN +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_ERROR, + ENTRY_STATE_SETUP_RETRY, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.setup import async_setup_component + +from .api_responses import ( + TEST_VIN_1_G1, + TEST_VIN_2_EV, + TEST_VIN_3_G2, + VEHICLE_DATA, + VEHICLE_STATUS_EV, + VEHICLE_STATUS_G2, +) +from .conftest import ( + MOCK_API_FETCH, + MOCK_API_UPDATE, + TEST_ENTITY_ID, + setup_subaru_integration, +) + + +async def test_setup_with_no_config(hass): + """Test DOMAIN is empty if there is no config.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert DOMAIN not in hass.config_entries.async_domains() + + +async def test_setup_ev(hass, ev_entry): + """Test setup with an EV vehicle.""" + check_entry = hass.config_entries.async_get_entry(ev_entry.entry_id) + assert check_entry + assert check_entry.state == ENTRY_STATE_LOADED + + +async def test_setup_g2(hass): + """Test setup with a G2 vehcile .""" + entry = await setup_subaru_integration( + hass, + 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) + assert check_entry + assert check_entry.state == ENTRY_STATE_LOADED + + +async def test_setup_g1(hass): + """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] + ) + check_entry = hass.config_entries.async_get_entry(entry.entry_id) + assert check_entry + assert check_entry.state == ENTRY_STATE_LOADED + + +async def test_unsuccessful_connect(hass): + """Test unsuccessful connect due to connectivity.""" + entry = await setup_subaru_integration( + hass, + 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) + assert check_entry + assert check_entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_invalid_credentials(hass): + """Test invalid credentials.""" + entry = await setup_subaru_integration( + hass, + 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) + assert check_entry + assert check_entry.state == ENTRY_STATE_SETUP_ERROR + + +async def test_update_skip_unsubscribed(hass): + """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] + ) + + with patch(MOCK_API_FETCH) as mock_fetch: + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + mock_fetch.assert_not_called() + + +async def test_update_disabled(hass, ev_entry): + """Test update function disable option.""" + with patch(MOCK_API_FETCH, side_effect=SubaruException("403 Error"),), patch( + MOCK_API_UPDATE, + ) as mock_update: + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + mock_update.assert_not_called() + + +async def test_fetch_failed(hass): + """Tests when fetch fails.""" + await setup_subaru_integration( + hass, + vehicle_list=[TEST_VIN_2_EV], + vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], + vehicle_status=VEHICLE_STATUS_EV, + fetch_effect=SubaruException("403 Error"), + ) + + test_entity = hass.states.get(TEST_ENTITY_ID) + assert test_entity.state == "unavailable" + + +async def test_unload_entry(hass, ev_entry): + """Test that entry is unloaded.""" + assert ev_entry.state == ENTRY_STATE_LOADED + assert await hass.config_entries.async_unload(ev_entry.entry_id) + await hass.async_block_till_done() + assert ev_entry.state == ENTRY_STATE_NOT_LOADED diff --git a/tests/components/subaru/test_sensor.py b/tests/components/subaru/test_sensor.py new file mode 100644 index 00000000000..4344c147f22 --- /dev/null +++ b/tests/components/subaru/test_sensor.py @@ -0,0 +1,67 @@ +"""Test Subaru sensors.""" +from homeassistant.components.subaru.const import VEHICLE_NAME +from homeassistant.components.subaru.sensor import ( + API_GEN_2_SENSORS, + EV_SENSORS, + SAFETY_SENSORS, + SENSOR_FIELD, + SENSOR_TYPE, +) +from homeassistant.util import slugify +from homeassistant.util.unit_system import IMPERIAL_SYSTEM + +from .api_responses import ( + EXPECTED_STATE_EV_IMPERIAL, + EXPECTED_STATE_EV_METRIC, + EXPECTED_STATE_EV_UNAVAILABLE, + TEST_VIN_2_EV, + VEHICLE_DATA, + VEHICLE_STATUS_EV, +) + +from tests.components.subaru.conftest import setup_subaru_integration + +VEHICLE_NAME = VEHICLE_DATA[TEST_VIN_2_EV][VEHICLE_NAME] + + +async def test_sensors_ev_imperial(hass): + """Test sensors supporting imperial units.""" + hass.config.units = IMPERIAL_SYSTEM + await setup_subaru_integration( + hass, + vehicle_list=[TEST_VIN_2_EV], + vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], + vehicle_status=VEHICLE_STATUS_EV, + ) + _assert_data(hass, EXPECTED_STATE_EV_IMPERIAL) + + +async def test_sensors_ev_metric(hass, ev_entry): + """Test sensors supporting metric units.""" + _assert_data(hass, EXPECTED_STATE_EV_METRIC) + + +async def test_sensors_missing_vin_data(hass): + """Test for missing VIN dataset.""" + await setup_subaru_integration( + hass, + vehicle_list=[TEST_VIN_2_EV], + vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], + vehicle_status=None, + ) + _assert_data(hass, EXPECTED_STATE_EV_UNAVAILABLE) + + +def _assert_data(hass, expected_state): + sensor_list = EV_SENSORS + sensor_list.extend(API_GEN_2_SENSORS) + sensor_list.extend(SAFETY_SENSORS) + expected_states = {} + for item in sensor_list: + expected_states[ + f"sensor.{slugify(f'{VEHICLE_NAME} {item[SENSOR_TYPE]}')}" + ] = expected_state[item[SENSOR_FIELD]] + + for sensor in expected_states: + actual = hass.states.get(sensor) + assert actual.state == expected_states[sensor]