Add Mazda Connected Services integration (#45768)

This commit is contained in:
Brandon Rothweiler 2021-02-03 11:38:12 -05:00 committed by GitHub
parent a584ad5ac3
commit 4b208746e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1325 additions and 0 deletions

View File

@ -263,6 +263,7 @@ homeassistant/components/lutron_caseta/* @swails @bdraco
homeassistant/components/lyric/* @timmo001
homeassistant/components/mastodon/* @fabaff
homeassistant/components/matrix/* @tinloaf
homeassistant/components/mazda/* @bdr99
homeassistant/components/mcp23017/* @jardiamj
homeassistant/components/media_source/* @hunterjm
homeassistant/components/mediaroom/* @dgomes

View File

@ -0,0 +1,173 @@
"""The Mazda Connected Services integration."""
import asyncio
from datetime import timedelta
import logging
import async_timeout
from pymazda import (
Client as MazdaAPI,
MazdaAccountLockedException,
MazdaAPIEncryptionException,
MazdaAuthenticationException,
MazdaException,
MazdaTokenExpiredException,
)
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from homeassistant.util.async_ import gather_with_concurrency
from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = ["sensor"]
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Mazda Connected Services component."""
hass.data[DOMAIN] = {}
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Mazda Connected Services from a config entry."""
email = entry.data[CONF_EMAIL]
password = entry.data[CONF_PASSWORD]
region = entry.data[CONF_REGION]
websession = aiohttp_client.async_get_clientsession(hass)
mazda_client = MazdaAPI(email, password, region, websession)
try:
await mazda_client.validate_credentials()
except MazdaAuthenticationException:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH},
data=entry.data,
)
)
return False
except (
MazdaException,
MazdaAccountLockedException,
MazdaTokenExpiredException,
MazdaAPIEncryptionException,
) as ex:
_LOGGER.error("Error occurred during Mazda login request: %s", ex)
raise ConfigEntryNotReady from ex
async def async_update_data():
"""Fetch data from Mazda API."""
async def with_timeout(task):
async with async_timeout.timeout(10):
return await task
try:
vehicles = await with_timeout(mazda_client.get_vehicles())
vehicle_status_tasks = [
with_timeout(mazda_client.get_vehicle_status(vehicle["id"]))
for vehicle in vehicles
]
statuses = await gather_with_concurrency(5, *vehicle_status_tasks)
for vehicle, status in zip(vehicles, statuses):
vehicle["status"] = status
return vehicles
except MazdaAuthenticationException as ex:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH},
data=entry.data,
)
)
raise UpdateFailed("Not authenticated with Mazda API") from ex
except Exception as ex:
_LOGGER.exception(
"Unknown error occurred during Mazda update request: %s", ex
)
raise UpdateFailed(ex) from ex
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=DOMAIN,
update_method=async_update_data,
update_interval=timedelta(seconds=60),
)
hass.data[DOMAIN][entry.entry_id] = {
DATA_CLIENT: mazda_client,
DATA_COORDINATOR: coordinator,
}
# Fetch initial data so we have data when entities subscribe
await coordinator.async_refresh()
if not coordinator.last_update_success:
raise ConfigEntryNotReady
# Setup components
for component in 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 PLATFORMS
]
)
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class MazdaEntity(CoordinatorEntity):
"""Defines a base Mazda entity."""
def __init__(self, coordinator, index):
"""Initialize the Mazda entity."""
super().__init__(coordinator)
self.index = index
self.vin = self.coordinator.data[self.index]["vin"]
@property
def device_info(self):
"""Return device info for the Mazda entity."""
data = self.coordinator.data[self.index]
return {
"identifiers": {(DOMAIN, self.vin)},
"name": self.get_vehicle_name(),
"manufacturer": "Mazda",
"model": f"{data['modelYear']} {data['carlineName']}",
}
def get_vehicle_name(self):
"""Return the vehicle name, to be used as a prefix for names of other entities."""
data = self.coordinator.data[self.index]
if "nickname" in data and len(data["nickname"]) > 0:
return data["nickname"]
return f"{data['modelYear']} {data['carlineName']}"

View File

@ -0,0 +1,117 @@
"""Config flow for Mazda Connected Services integration."""
import logging
import aiohttp
from pymazda import (
Client as MazdaAPI,
MazdaAccountLockedException,
MazdaAuthenticationException,
)
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION
from homeassistant.helpers import aiohttp_client
# https://github.com/PyCQA/pylint/issues/3202
from .const import DOMAIN # pylint: disable=unused-import
from .const import MAZDA_REGIONS
_LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_REGION): vol.In(MAZDA_REGIONS),
}
)
class MazdaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Mazda Connected Services."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_EMAIL].lower())
try:
websession = aiohttp_client.async_get_clientsession(self.hass)
mazda_client = MazdaAPI(
user_input[CONF_EMAIL],
user_input[CONF_PASSWORD],
user_input[CONF_REGION],
websession,
)
await mazda_client.validate_credentials()
except MazdaAuthenticationException:
errors["base"] = "invalid_auth"
except MazdaAccountLockedException:
errors["base"] = "account_locked"
except aiohttp.ClientError:
errors["base"] = "cannot_connect"
except Exception as ex: # pylint: disable=broad-except
errors["base"] = "unknown"
_LOGGER.exception(
"Unknown error occurred during Mazda login request: %s", ex
)
else:
return self.async_create_entry(
title=user_input[CONF_EMAIL], data=user_input
)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_reauth(self, user_input=None):
"""Perform reauth if the user credentials have changed."""
errors = {}
if user_input is not None:
try:
websession = aiohttp_client.async_get_clientsession(self.hass)
mazda_client = MazdaAPI(
user_input[CONF_EMAIL],
user_input[CONF_PASSWORD],
user_input[CONF_REGION],
websession,
)
await mazda_client.validate_credentials()
except MazdaAuthenticationException:
errors["base"] = "invalid_auth"
except MazdaAccountLockedException:
errors["base"] = "account_locked"
except aiohttp.ClientError:
errors["base"] = "cannot_connect"
except Exception as ex: # pylint: disable=broad-except
errors["base"] = "unknown"
_LOGGER.exception(
"Unknown error occurred during Mazda login request: %s", ex
)
else:
await self.async_set_unique_id(user_input[CONF_EMAIL].lower())
for entry in self._async_current_entries():
if entry.unique_id == self.unique_id:
self.hass.config_entries.async_update_entry(
entry, data=user_input
)
# Reload the config entry otherwise devices will remain unavailable
self.hass.async_create_task(
self.hass.config_entries.async_reload(entry.entry_id)
)
return self.async_abort(reason="reauth_successful")
errors["base"] = "unknown"
return self.async_show_form(
step_id="reauth", data_schema=DATA_SCHEMA, errors=errors
)

View File

@ -0,0 +1,8 @@
"""Constants for the Mazda Connected Services integration."""
DOMAIN = "mazda"
DATA_CLIENT = "mazda_client"
DATA_COORDINATOR = "coordinator"
MAZDA_REGIONS = {"MNAO": "North America", "MME": "Europe", "MJO": "Japan"}

View File

@ -0,0 +1,9 @@
{
"domain": "mazda",
"name": "Mazda Connected Services",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/mazda",
"requirements": ["pymazda==0.0.8"],
"codeowners": ["@bdr99"],
"quality_scale": "platinum"
}

View File

@ -0,0 +1,263 @@
"""Platform for Mazda sensor integration."""
from homeassistant.const import (
CONF_UNIT_SYSTEM_IMPERIAL,
LENGTH_KILOMETERS,
LENGTH_MILES,
PERCENTAGE,
PRESSURE_PSI,
)
from . import MazdaEntity
from .const import DATA_COORDINATOR, DOMAIN
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the sensor platform."""
coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
entities = []
for index, _ in enumerate(coordinator.data):
entities.append(MazdaFuelRemainingSensor(coordinator, index))
entities.append(MazdaFuelDistanceSensor(coordinator, index))
entities.append(MazdaOdometerSensor(coordinator, index))
entities.append(MazdaFrontLeftTirePressureSensor(coordinator, index))
entities.append(MazdaFrontRightTirePressureSensor(coordinator, index))
entities.append(MazdaRearLeftTirePressureSensor(coordinator, index))
entities.append(MazdaRearRightTirePressureSensor(coordinator, index))
async_add_entities(entities)
class MazdaFuelRemainingSensor(MazdaEntity):
"""Class for the fuel remaining sensor."""
@property
def name(self):
"""Return the name of the sensor."""
vehicle_name = self.get_vehicle_name()
return f"{vehicle_name} Fuel Remaining Percentage"
@property
def unique_id(self):
"""Return a unique identifier for this entity."""
return f"{self.vin}_fuel_remaining_percentage"
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
return PERCENTAGE
@property
def icon(self):
"""Return the icon to use in the frontend."""
return "mdi:gas-station"
@property
def state(self):
"""Return the state of the sensor."""
return self.coordinator.data[self.index]["status"]["fuelRemainingPercent"]
class MazdaFuelDistanceSensor(MazdaEntity):
"""Class for the fuel distance sensor."""
@property
def name(self):
"""Return the name of the sensor."""
vehicle_name = self.get_vehicle_name()
return f"{vehicle_name} Fuel Distance Remaining"
@property
def unique_id(self):
"""Return a unique identifier for this entity."""
return f"{self.vin}_fuel_distance_remaining"
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL:
return LENGTH_MILES
return LENGTH_KILOMETERS
@property
def icon(self):
"""Return the icon to use in the frontend."""
return "mdi:gas-station"
@property
def state(self):
"""Return the state of the sensor."""
fuel_distance_km = self.coordinator.data[self.index]["status"][
"fuelDistanceRemainingKm"
]
return round(self.hass.config.units.length(fuel_distance_km, LENGTH_KILOMETERS))
class MazdaOdometerSensor(MazdaEntity):
"""Class for the odometer sensor."""
@property
def name(self):
"""Return the name of the sensor."""
vehicle_name = self.get_vehicle_name()
return f"{vehicle_name} Odometer"
@property
def unique_id(self):
"""Return a unique identifier for this entity."""
return f"{self.vin}_odometer"
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL:
return LENGTH_MILES
return LENGTH_KILOMETERS
@property
def icon(self):
"""Return the icon to use in the frontend."""
return "mdi:speedometer"
@property
def state(self):
"""Return the state of the sensor."""
odometer_km = self.coordinator.data[self.index]["status"]["odometerKm"]
return round(self.hass.config.units.length(odometer_km, LENGTH_KILOMETERS))
class MazdaFrontLeftTirePressureSensor(MazdaEntity):
"""Class for the front left tire pressure sensor."""
@property
def name(self):
"""Return the name of the sensor."""
vehicle_name = self.get_vehicle_name()
return f"{vehicle_name} Front Left Tire Pressure"
@property
def unique_id(self):
"""Return a unique identifier for this entity."""
return f"{self.vin}_front_left_tire_pressure"
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
return PRESSURE_PSI
@property
def icon(self):
"""Return the icon to use in the frontend."""
return "mdi:car-tire-alert"
@property
def state(self):
"""Return the state of the sensor."""
return round(
self.coordinator.data[self.index]["status"]["tirePressure"][
"frontLeftTirePressurePsi"
]
)
class MazdaFrontRightTirePressureSensor(MazdaEntity):
"""Class for the front right tire pressure sensor."""
@property
def name(self):
"""Return the name of the sensor."""
vehicle_name = self.get_vehicle_name()
return f"{vehicle_name} Front Right Tire Pressure"
@property
def unique_id(self):
"""Return a unique identifier for this entity."""
return f"{self.vin}_front_right_tire_pressure"
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
return PRESSURE_PSI
@property
def icon(self):
"""Return the icon to use in the frontend."""
return "mdi:car-tire-alert"
@property
def state(self):
"""Return the state of the sensor."""
return round(
self.coordinator.data[self.index]["status"]["tirePressure"][
"frontRightTirePressurePsi"
]
)
class MazdaRearLeftTirePressureSensor(MazdaEntity):
"""Class for the rear left tire pressure sensor."""
@property
def name(self):
"""Return the name of the sensor."""
vehicle_name = self.get_vehicle_name()
return f"{vehicle_name} Rear Left Tire Pressure"
@property
def unique_id(self):
"""Return a unique identifier for this entity."""
return f"{self.vin}_rear_left_tire_pressure"
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
return PRESSURE_PSI
@property
def icon(self):
"""Return the icon to use in the frontend."""
return "mdi:car-tire-alert"
@property
def state(self):
"""Return the state of the sensor."""
return round(
self.coordinator.data[self.index]["status"]["tirePressure"][
"rearLeftTirePressurePsi"
]
)
class MazdaRearRightTirePressureSensor(MazdaEntity):
"""Class for the rear right tire pressure sensor."""
@property
def name(self):
"""Return the name of the sensor."""
vehicle_name = self.get_vehicle_name()
return f"{vehicle_name} Rear Right Tire Pressure"
@property
def unique_id(self):
"""Return a unique identifier for this entity."""
return f"{self.vin}_rear_right_tire_pressure"
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
return PRESSURE_PSI
@property
def icon(self):
"""Return the icon to use in the frontend."""
return "mdi:car-tire-alert"
@property
def state(self):
"""Return the state of the sensor."""
return round(
self.coordinator.data[self.index]["status"]["tirePressure"][
"rearRightTirePressurePsi"
]
)

View File

@ -0,0 +1,35 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"account_locked": "Account locked. Please try again later.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]",
"region": "Region"
},
"description": "Authentication failed for Mazda Connected Services. Please enter your current credentials.",
"title": "Mazda Connected Services - Authentication Failed"
},
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]",
"region": "Region"
},
"description": "Please enter the email address and password you use to log into the MyMazda mobile app.",
"title": "Mazda Connected Services - Add Account"
}
}
},
"title": "Mazda Connected Services"
}

View File

@ -0,0 +1,35 @@
{
"config": {
"abort": {
"already_configured": "Account is already configured",
"reauth_successful": "Re-authentication was successful"
},
"error": {
"account_locked": "Account locked. Please try again later.",
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"step": {
"reauth": {
"data": {
"email": "Email",
"password": "Password",
"region": "Region"
},
"description": "Authentication failed for Mazda Connected Services. Please enter your current credentials.",
"title": "Mazda Connected Services - Authentication Failed"
},
"user": {
"data": {
"email": "Email",
"password": "Password",
"region": "Region"
},
"description": "Please enter the email address and password you use to log into the MyMazda mobile app.",
"title": "Mazda Connected Services - Add Account"
}
}
},
"title": "Mazda Connected Services"
}

View File

@ -123,6 +123,7 @@ FLOWS = [
"lutron_caseta",
"lyric",
"mailgun",
"mazda",
"melcloud",
"met",
"meteo_france",

View File

@ -1517,6 +1517,9 @@ pymailgunner==1.4
# homeassistant.components.firmata
pymata-express==1.19
# homeassistant.components.mazda
pymazda==0.0.8
# homeassistant.components.mediaroom
pymediaroom==0.6.4.1

View File

@ -789,6 +789,9 @@ pymailgunner==1.4
# homeassistant.components.firmata
pymata-express==1.19
# homeassistant.components.mazda
pymazda==0.0.8
# homeassistant.components.melcloud
pymelcloud==2.5.2

View File

@ -0,0 +1,53 @@
"""Tests for the Mazda Connected Services integration."""
import json
from unittest.mock import AsyncMock, MagicMock, patch
from pymazda import Client as MazdaAPI
from homeassistant.components.mazda.const import DOMAIN
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from tests.common import MockConfigEntry, load_fixture
FIXTURE_USER_INPUT = {
CONF_EMAIL: "example@example.com",
CONF_PASSWORD: "password",
CONF_REGION: "MNAO",
}
async def init_integration(hass: HomeAssistant, use_nickname=True) -> MockConfigEntry:
"""Set up the Mazda Connected Services integration in Home Assistant."""
get_vehicles_fixture = json.loads(load_fixture("mazda/get_vehicles.json"))
if not use_nickname:
get_vehicles_fixture[0].pop("nickname")
get_vehicle_status_fixture = json.loads(
load_fixture("mazda/get_vehicle_status.json")
)
config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT)
config_entry.add_to_hass(hass)
client_mock = MagicMock(
MazdaAPI(
FIXTURE_USER_INPUT[CONF_EMAIL],
FIXTURE_USER_INPUT[CONF_PASSWORD],
FIXTURE_USER_INPUT[CONF_REGION],
aiohttp_client.async_get_clientsession(hass),
)
)
client_mock.get_vehicles = AsyncMock(return_value=get_vehicles_fixture)
client_mock.get_vehicle_status = AsyncMock(return_value=get_vehicle_status_fixture)
with patch(
"homeassistant.components.mazda.config_flow.MazdaAPI",
return_value=client_mock,
), patch("homeassistant.components.mazda.MazdaAPI", return_value=client_mock):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return config_entry

View File

@ -0,0 +1,310 @@
"""Test the Mazda Connected Services config flow."""
from unittest.mock import patch
import aiohttp
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.mazda.config_flow import (
MazdaAccountLockedException,
MazdaAuthenticationException,
)
from homeassistant.components.mazda.const import DOMAIN
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
FIXTURE_USER_INPUT = {
CONF_EMAIL: "example@example.com",
CONF_PASSWORD: "password",
CONF_REGION: "MNAO",
}
FIXTURE_USER_INPUT_REAUTH = {
CONF_EMAIL: "example@example.com",
CONF_PASSWORD: "password_fixed",
CONF_REGION: "MNAO",
}
async def test_form(hass):
"""Test the entire flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
with patch(
"homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials",
return_value=True,
), patch(
"homeassistant.components.mazda.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.mazda.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
FIXTURE_USER_INPUT,
)
await hass.async_block_till_done()
assert result2["type"] == "create_entry"
assert result2["title"] == FIXTURE_USER_INPUT[CONF_EMAIL]
assert result2["data"] == FIXTURE_USER_INPUT
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_invalid_auth(hass: HomeAssistant) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
with patch(
"homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials",
side_effect=MazdaAuthenticationException("Failed to authenticate"),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
FIXTURE_USER_INPUT,
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "user"
assert result2["errors"] == {"base": "invalid_auth"}
async def test_form_account_locked(hass: HomeAssistant) -> None:
"""Test we handle account locked error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
with patch(
"homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials",
side_effect=MazdaAccountLockedException("Account locked"),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
FIXTURE_USER_INPUT,
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "user"
assert result2["errors"] == {"base": "account_locked"}
async def test_form_cannot_connect(hass):
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials",
side_effect=aiohttp.ClientError,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
FIXTURE_USER_INPUT,
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_unknown_error(hass):
"""Test we handle unknown error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials",
side_effect=Exception,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
FIXTURE_USER_INPUT,
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "unknown"}
async def test_reauth_flow(hass: HomeAssistant) -> None:
"""Test reauth works."""
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
"homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials",
side_effect=MazdaAuthenticationException("Failed to authenticate"),
):
mock_config = MockConfigEntry(
domain=DOMAIN,
unique_id=FIXTURE_USER_INPUT[CONF_EMAIL],
data=FIXTURE_USER_INPUT,
)
mock_config.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "reauth"
assert result["errors"] == {"base": "invalid_auth"}
with patch(
"homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials",
return_value=True,
):
result2 = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "reauth", "unique_id": FIXTURE_USER_INPUT[CONF_EMAIL]},
data=FIXTURE_USER_INPUT_REAUTH,
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result2["reason"] == "reauth_successful"
async def test_reauth_authorization_error(hass: HomeAssistant) -> None:
"""Test we show user form on authorization error."""
with patch(
"homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials",
side_effect=MazdaAuthenticationException("Failed to authenticate"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "reauth"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
FIXTURE_USER_INPUT_REAUTH,
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "reauth"
assert result2["errors"] == {"base": "invalid_auth"}
async def test_reauth_account_locked(hass: HomeAssistant) -> None:
"""Test we show user form on account_locked error."""
with patch(
"homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials",
side_effect=MazdaAccountLockedException("Account locked"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "reauth"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
FIXTURE_USER_INPUT_REAUTH,
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "reauth"
assert result2["errors"] == {"base": "account_locked"}
async def test_reauth_connection_error(hass: HomeAssistant) -> None:
"""Test we show user form on connection error."""
with patch(
"homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials",
side_effect=aiohttp.ClientError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "reauth"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
FIXTURE_USER_INPUT_REAUTH,
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "reauth"
assert result2["errors"] == {"base": "cannot_connect"}
async def test_reauth_unknown_error(hass: HomeAssistant) -> None:
"""Test we show user form on unknown error."""
with patch(
"homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials",
side_effect=Exception,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "reauth"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
FIXTURE_USER_INPUT_REAUTH,
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "reauth"
assert result2["errors"] == {"base": "unknown"}
async def test_reauth_unique_id_not_found(hass: HomeAssistant) -> None:
"""Test we show user form when unique id not found during reauth."""
with patch(
"homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials",
return_value=True,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "reauth"
# Change the unique_id of the flow in order to cause a mismatch
flows = hass.config_entries.flow.async_progress()
flows[0]["context"]["unique_id"] = "example2@example.com"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
FIXTURE_USER_INPUT_REAUTH,
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "reauth"
assert result2["errors"] == {"base": "unknown"}

View File

@ -0,0 +1,100 @@
"""Tests for the Mazda Connected Services integration."""
from unittest.mock import patch
from pymazda import MazdaAuthenticationException, MazdaException
from homeassistant.components.mazda.const import DATA_COORDINATOR, DOMAIN
from homeassistant.config_entries import (
ENTRY_STATE_LOADED,
ENTRY_STATE_SETUP_ERROR,
ENTRY_STATE_SETUP_RETRY,
)
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.components.mazda import init_integration
FIXTURE_USER_INPUT = {
CONF_EMAIL: "example@example.com",
CONF_PASSWORD: "password",
CONF_REGION: "MNAO",
}
async def test_config_entry_not_ready(hass: HomeAssistant) -> None:
"""Test the Mazda configuration entry not ready."""
config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.mazda.MazdaAPI.validate_credentials",
side_effect=MazdaException("Unknown error"),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ENTRY_STATE_SETUP_RETRY
async def test_init_auth_failure(hass: HomeAssistant):
"""Test auth failure during setup."""
with patch(
"homeassistant.components.mazda.MazdaAPI.validate_credentials",
side_effect=MazdaAuthenticationException("Login failed"),
):
config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].state == ENTRY_STATE_SETUP_ERROR
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["step_id"] == "reauth"
async def test_update_auth_failure(hass: HomeAssistant):
"""Test auth failure during data update."""
with patch(
"homeassistant.components.mazda.MazdaAPI.validate_credentials",
return_value=True,
), patch("homeassistant.components.mazda.MazdaAPI.get_vehicles", return_value={}):
config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].state == ENTRY_STATE_LOADED
coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
with patch(
"homeassistant.components.mazda.MazdaAPI.validate_credentials",
side_effect=MazdaAuthenticationException("Login failed"),
), patch(
"homeassistant.components.mazda.MazdaAPI.get_vehicles",
side_effect=MazdaAuthenticationException("Login failed"),
):
await coordinator.async_refresh()
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["step_id"] == "reauth"
async def test_unload_config_entry(hass: HomeAssistant) -> None:
"""Test the Mazda configuration entry unloading."""
entry = await init_integration(hass)
assert hass.data[DOMAIN]
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert not hass.data.get(DOMAIN)

View File

@ -0,0 +1,160 @@
"""The sensor tests for the Mazda Connected Services integration."""
from homeassistant.components.mazda.const import DOMAIN
from homeassistant.const import (
ATTR_FRIENDLY_NAME,
ATTR_ICON,
ATTR_UNIT_OF_MEASUREMENT,
LENGTH_KILOMETERS,
LENGTH_MILES,
PERCENTAGE,
PRESSURE_PSI,
)
from homeassistant.util.unit_system import IMPERIAL_SYSTEM
from tests.components.mazda import init_integration
async def test_device_nickname(hass):
"""Test creation of the device when vehicle has a nickname."""
await init_integration(hass, use_nickname=True)
device_registry = await hass.helpers.device_registry.async_get_registry()
reg_device = device_registry.async_get_device(
identifiers={(DOMAIN, "JM000000000000000")},
)
assert reg_device.model == "2021 MAZDA3 2.5 S SE AWD"
assert reg_device.manufacturer == "Mazda"
assert reg_device.name == "My Mazda3"
async def test_device_no_nickname(hass):
"""Test creation of the device when vehicle has no nickname."""
await init_integration(hass, use_nickname=False)
device_registry = await hass.helpers.device_registry.async_get_registry()
reg_device = device_registry.async_get_device(
identifiers={(DOMAIN, "JM000000000000000")},
)
assert reg_device.model == "2021 MAZDA3 2.5 S SE AWD"
assert reg_device.manufacturer == "Mazda"
assert reg_device.name == "2021 MAZDA3 2.5 S SE AWD"
async def test_sensors(hass):
"""Test creation of the sensors."""
await init_integration(hass)
entity_registry = await hass.helpers.entity_registry.async_get_registry()
# Fuel Remaining Percentage
state = hass.states.get("sensor.my_mazda3_fuel_remaining_percentage")
assert state
assert (
state.attributes.get(ATTR_FRIENDLY_NAME)
== "My Mazda3 Fuel Remaining Percentage"
)
assert state.attributes.get(ATTR_ICON) == "mdi:gas-station"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.state == "87.0"
entry = entity_registry.async_get("sensor.my_mazda3_fuel_remaining_percentage")
assert entry
assert entry.unique_id == "JM000000000000000_fuel_remaining_percentage"
# Fuel Distance Remaining
state = hass.states.get("sensor.my_mazda3_fuel_distance_remaining")
assert state
assert (
state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Fuel Distance Remaining"
)
assert state.attributes.get(ATTR_ICON) == "mdi:gas-station"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_KILOMETERS
assert state.state == "381"
entry = entity_registry.async_get("sensor.my_mazda3_fuel_distance_remaining")
assert entry
assert entry.unique_id == "JM000000000000000_fuel_distance_remaining"
# Odometer
state = hass.states.get("sensor.my_mazda3_odometer")
assert state
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Odometer"
assert state.attributes.get(ATTR_ICON) == "mdi:speedometer"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_KILOMETERS
assert state.state == "2796"
entry = entity_registry.async_get("sensor.my_mazda3_odometer")
assert entry
assert entry.unique_id == "JM000000000000000_odometer"
# Front Left Tire Pressure
state = hass.states.get("sensor.my_mazda3_front_left_tire_pressure")
assert state
assert (
state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Front Left Tire Pressure"
)
assert state.attributes.get(ATTR_ICON) == "mdi:car-tire-alert"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_PSI
assert state.state == "35"
entry = entity_registry.async_get("sensor.my_mazda3_front_left_tire_pressure")
assert entry
assert entry.unique_id == "JM000000000000000_front_left_tire_pressure"
# Front Right Tire Pressure
state = hass.states.get("sensor.my_mazda3_front_right_tire_pressure")
assert state
assert (
state.attributes.get(ATTR_FRIENDLY_NAME)
== "My Mazda3 Front Right Tire Pressure"
)
assert state.attributes.get(ATTR_ICON) == "mdi:car-tire-alert"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_PSI
assert state.state == "35"
entry = entity_registry.async_get("sensor.my_mazda3_front_right_tire_pressure")
assert entry
assert entry.unique_id == "JM000000000000000_front_right_tire_pressure"
# Rear Left Tire Pressure
state = hass.states.get("sensor.my_mazda3_rear_left_tire_pressure")
assert state
assert (
state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Rear Left Tire Pressure"
)
assert state.attributes.get(ATTR_ICON) == "mdi:car-tire-alert"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_PSI
assert state.state == "33"
entry = entity_registry.async_get("sensor.my_mazda3_rear_left_tire_pressure")
assert entry
assert entry.unique_id == "JM000000000000000_rear_left_tire_pressure"
# Rear Right Tire Pressure
state = hass.states.get("sensor.my_mazda3_rear_right_tire_pressure")
assert state
assert (
state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Rear Right Tire Pressure"
)
assert state.attributes.get(ATTR_ICON) == "mdi:car-tire-alert"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_PSI
assert state.state == "33"
entry = entity_registry.async_get("sensor.my_mazda3_rear_right_tire_pressure")
assert entry
assert entry.unique_id == "JM000000000000000_rear_right_tire_pressure"
async def test_sensors_imperial_units(hass):
"""Test that the sensors work properly with imperial units."""
hass.config.units = IMPERIAL_SYSTEM
await init_integration(hass)
# Fuel Distance Remaining
state = hass.states.get("sensor.my_mazda3_fuel_distance_remaining")
assert state
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_MILES
assert state.state == "237"
# Odometer
state = hass.states.get("sensor.my_mazda3_odometer")
assert state
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_MILES
assert state.state == "1737"

View File

@ -0,0 +1,37 @@
{
"lastUpdatedTimestamp": "20210123143809",
"latitude": 1.234567,
"longitude": -2.345678,
"positionTimestamp": "20210123143808",
"fuelRemainingPercent": 87.0,
"fuelDistanceRemainingKm": 380.8,
"odometerKm": 2795.8,
"doors": {
"driverDoorOpen": false,
"passengerDoorOpen": false,
"rearLeftDoorOpen": false,
"rearRightDoorOpen": false,
"trunkOpen": false,
"hoodOpen": false,
"fuelLidOpen": false
},
"doorLocks": {
"driverDoorUnlocked": false,
"passengerDoorUnlocked": false,
"rearLeftDoorUnlocked": false,
"rearRightDoorUnlocked": false
},
"windows": {
"driverWindowOpen": false,
"passengerWindowOpen": false,
"rearLeftWindowOpen": false,
"rearRightWindowOpen": false
},
"hazardLightsOn": false,
"tirePressure": {
"frontLeftTirePressurePsi": 35.0,
"frontRightTirePressurePsi": 35.0,
"rearLeftTirePressurePsi": 33.0,
"rearRightTirePressurePsi": 33.0
}
}

17
tests/fixtures/mazda/get_vehicles.json vendored Normal file
View File

@ -0,0 +1,17 @@
[
{
"vin": "JM000000000000000",
"id": 12345,
"nickname": "My Mazda3",
"carlineCode": "M3S",
"carlineName": "MAZDA3 2.5 S SE AWD",
"modelYear": "2021",
"modelCode": "M3S SE XA",
"modelName": "W/ SELECT PKG AWD SDN",
"automaticTransmission": true,
"interiorColorCode": "BY3",
"interiorColorName": "BLACK",
"exteriorColorCode": "42M",
"exteriorColorName": "DEEP CRYSTAL BLUE MICA"
}
]