diff --git a/CODEOWNERS b/CODEOWNERS index 499b7e131f7..cb69b86ed8c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py new file mode 100644 index 00000000000..14b33df66c0 --- /dev/null +++ b/homeassistant/components/mazda/__init__.py @@ -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']}" diff --git a/homeassistant/components/mazda/config_flow.py b/homeassistant/components/mazda/config_flow.py new file mode 100644 index 00000000000..53c08b9bd69 --- /dev/null +++ b/homeassistant/components/mazda/config_flow.py @@ -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 + ) diff --git a/homeassistant/components/mazda/const.py b/homeassistant/components/mazda/const.py new file mode 100644 index 00000000000..c75f6bf3b77 --- /dev/null +++ b/homeassistant/components/mazda/const.py @@ -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"} diff --git a/homeassistant/components/mazda/manifest.json b/homeassistant/components/mazda/manifest.json new file mode 100644 index 00000000000..b3826d42318 --- /dev/null +++ b/homeassistant/components/mazda/manifest.json @@ -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" +} \ No newline at end of file diff --git a/homeassistant/components/mazda/sensor.py b/homeassistant/components/mazda/sensor.py new file mode 100644 index 00000000000..fa03eb7f410 --- /dev/null +++ b/homeassistant/components/mazda/sensor.py @@ -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" + ] + ) diff --git a/homeassistant/components/mazda/strings.json b/homeassistant/components/mazda/strings.json new file mode 100644 index 00000000000..1950260bfcb --- /dev/null +++ b/homeassistant/components/mazda/strings.json @@ -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" +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/en.json b/homeassistant/components/mazda/translations/en.json new file mode 100644 index 00000000000..b9e02fb3a41 --- /dev/null +++ b/homeassistant/components/mazda/translations/en.json @@ -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" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 282d039128d..c2e36d9f846 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -123,6 +123,7 @@ FLOWS = [ "lutron_caseta", "lyric", "mailgun", + "mazda", "melcloud", "met", "meteo_france", diff --git a/requirements_all.txt b/requirements_all.txt index 4315c577283..93922e9f934 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8c7d112fba..9e379612060 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/mazda/__init__.py b/tests/components/mazda/__init__.py new file mode 100644 index 00000000000..f7a267a5110 --- /dev/null +++ b/tests/components/mazda/__init__.py @@ -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 diff --git a/tests/components/mazda/test_config_flow.py b/tests/components/mazda/test_config_flow.py new file mode 100644 index 00000000000..fbdd74bfdfa --- /dev/null +++ b/tests/components/mazda/test_config_flow.py @@ -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"} diff --git a/tests/components/mazda/test_init.py b/tests/components/mazda/test_init.py new file mode 100644 index 00000000000..d0352682f53 --- /dev/null +++ b/tests/components/mazda/test_init.py @@ -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) diff --git a/tests/components/mazda/test_sensor.py b/tests/components/mazda/test_sensor.py new file mode 100644 index 00000000000..1cb9f7ac4b7 --- /dev/null +++ b/tests/components/mazda/test_sensor.py @@ -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" diff --git a/tests/fixtures/mazda/get_vehicle_status.json b/tests/fixtures/mazda/get_vehicle_status.json new file mode 100644 index 00000000000..f170b222b31 --- /dev/null +++ b/tests/fixtures/mazda/get_vehicle_status.json @@ -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 + } +} \ No newline at end of file diff --git a/tests/fixtures/mazda/get_vehicles.json b/tests/fixtures/mazda/get_vehicles.json new file mode 100644 index 00000000000..871eeb9d2ec --- /dev/null +++ b/tests/fixtures/mazda/get_vehicles.json @@ -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" + } +] \ No newline at end of file