Add new Subaru integration (#35760)

Co-authored-by: On Freund <onfreund@gmail.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Garrett 2021-02-20 21:52:44 -05:00 committed by GitHub
parent efa339ca54
commit 3ad207a499
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1636 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -216,6 +216,7 @@ FLOWS = [
"squeezebox",
"srp_energy",
"starline",
"subaru",
"syncthru",
"synology_dsm",
"tado",

View File

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

View File

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

View File

@ -0,0 +1 @@
"""Tests for the Subaru integration."""

View File

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

View File

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

View File

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

View File

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

View File

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