Add WattTime integration (#56149)

This commit is contained in:
Aaron Bach 2021-09-20 22:10:24 -06:00 committed by GitHub
parent 9059ee6604
commit aabc8cd2d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 747 additions and 0 deletions

View File

@ -1178,6 +1178,8 @@ omit =
homeassistant/components/waterfurnace/*
homeassistant/components/watson_iot/*
homeassistant/components/watson_tts/tts.py
homeassistant/components/watttime/__init__.py
homeassistant/components/watttime/sensor.py
homeassistant/components/waze_travel_time/__init__.py
homeassistant/components/waze_travel_time/helpers.py
homeassistant/components/waze_travel_time/sensor.py

View File

@ -574,6 +574,7 @@ homeassistant/components/wake_on_lan/* @ntilley905
homeassistant/components/wallbox/* @hesselonline
homeassistant/components/waqi/* @andrey-git
homeassistant/components/watson_tts/* @rutkai
homeassistant/components/watttime/* @bachya
homeassistant/components/weather/* @fabaff
homeassistant/components/webostv/* @bendavid @thecode
homeassistant/components/websocket_api/* @home-assistant/core

View File

@ -0,0 +1,78 @@
"""The WattTime integration."""
from __future__ import annotations
from datetime import timedelta
from aiowatttime import Client
from aiowatttime.emissions import RealTimeEmissionsResponseType
from aiowatttime.errors import WattTimeError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_PASSWORD,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DATA_COORDINATOR, DOMAIN, LOGGER
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5)
PLATFORMS: list[str] = ["sensor"]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up WattTime from a config entry."""
hass.data.setdefault(DOMAIN, {DATA_COORDINATOR: {}})
session = aiohttp_client.async_get_clientsession(hass)
try:
client = await Client.async_login(
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
session=session,
logger=LOGGER,
)
except WattTimeError as err:
LOGGER.error("Error while authenticating with WattTime: %s", err)
return False
async def async_update_data() -> RealTimeEmissionsResponseType:
"""Get the latest realtime emissions data."""
try:
return await client.emissions.async_get_realtime_emissions(
entry.data[CONF_LATITUDE], entry.data[CONF_LONGITUDE]
)
except WattTimeError as err:
raise UpdateFailed(
f"Error while requesting data from WattTime: {err}"
) from err
coordinator = DataUpdateCoordinator(
hass,
LOGGER,
name=entry.title,
update_interval=DEFAULT_UPDATE_INTERVAL,
update_method=async_update_data,
)
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] = coordinator
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN][DATA_COORDINATOR].pop(entry.entry_id)
return unload_ok

View File

@ -0,0 +1,165 @@
"""Config flow for WattTime integration."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from aiowatttime import Client
from aiowatttime.errors import CoordinatesNotFoundError, InvalidCredentialsError
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import (
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_PASSWORD,
CONF_USERNAME,
)
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import aiohttp_client, config_validation as cv
from .const import (
CONF_BALANCING_AUTHORITY,
CONF_BALANCING_AUTHORITY_ABBREV,
DOMAIN,
LOGGER,
)
CONF_LOCATION_TYPE = "location_type"
LOCATION_TYPE_COORDINATES = "Specify coordinates"
LOCATION_TYPE_HOME = "Use home location"
STEP_COORDINATES_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_LATITUDE): cv.latitude,
vol.Required(CONF_LONGITUDE): cv.longitude,
}
)
STEP_LOCATION_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_LOCATION_TYPE): vol.In(
[LOCATION_TYPE_HOME, LOCATION_TYPE_COORDINATES]
),
}
)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for WattTime."""
VERSION = 1
def __init__(self) -> None:
"""Initialize."""
self._client: Client | None = None
self._password: str | None = None
self._username: str | None = None
async def async_step_coordinates(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the coordinates step."""
if not user_input:
return self.async_show_form(
step_id="coordinates", data_schema=STEP_COORDINATES_DATA_SCHEMA
)
if TYPE_CHECKING:
assert self._client
unique_id = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}"
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
try:
grid_region = await self._client.emissions.async_get_grid_region(
user_input[CONF_LATITUDE], user_input[CONF_LONGITUDE]
)
except CoordinatesNotFoundError:
return self.async_show_form(
step_id="coordinates",
data_schema=STEP_COORDINATES_DATA_SCHEMA,
errors={CONF_LATITUDE: "unknown_coordinates"},
)
except Exception as err: # pylint: disable=broad-except
LOGGER.exception("Unexpected exception while getting region: %s", err)
return self.async_show_form(
step_id="coordinates",
data_schema=STEP_COORDINATES_DATA_SCHEMA,
errors={"base": "unknown"},
)
return self.async_create_entry(
title=unique_id,
data={
CONF_USERNAME: self._username,
CONF_PASSWORD: self._password,
CONF_LATITUDE: user_input[CONF_LATITUDE],
CONF_LONGITUDE: user_input[CONF_LONGITUDE],
CONF_BALANCING_AUTHORITY: grid_region["name"],
CONF_BALANCING_AUTHORITY_ABBREV: grid_region["abbrev"],
},
)
async def async_step_location(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the "pick a location" step."""
if not user_input:
return self.async_show_form(
step_id="location", data_schema=STEP_LOCATION_DATA_SCHEMA
)
if user_input[CONF_LOCATION_TYPE] == LOCATION_TYPE_COORDINATES:
return self.async_show_form(
step_id="coordinates", data_schema=STEP_COORDINATES_DATA_SCHEMA
)
return await self.async_step_coordinates(
{
CONF_LATITUDE: self.hass.config.latitude,
CONF_LONGITUDE: self.hass.config.longitude,
}
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
if not user_input:
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
)
session = aiohttp_client.async_get_clientsession(self.hass)
try:
self._client = await Client.async_login(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
session=session,
)
except InvalidCredentialsError:
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors={CONF_USERNAME: "invalid_auth"},
)
except Exception as err: # pylint: disable=broad-except
LOGGER.exception("Unexpected exception while logging in: %s", err)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors={"base": "unknown"},
)
self._username = user_input[CONF_USERNAME]
self._password = user_input[CONF_PASSWORD]
return await self.async_step_location()

View File

@ -0,0 +1,11 @@
"""Constants for the WattTime integration."""
import logging
DOMAIN = "watttime"
LOGGER = logging.getLogger(__package__)
CONF_BALANCING_AUTHORITY = "balancing_authority"
CONF_BALANCING_AUTHORITY_ABBREV = "balancing_authority_abbreviation"
DATA_COORDINATOR = "coordinator"

View File

@ -0,0 +1,17 @@
{
"domain": "watttime",
"name": "WattTime",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/watttime",
"requirements": [
"aiowatttime==0.1.1"
],
"ssdp": [],
"zeroconf": [],
"homekit": {},
"dependencies": [],
"codeowners": [
"@bachya"
],
"iot_class": "cloud_polling"
}

View File

@ -0,0 +1,134 @@
"""Support for WattTime sensors."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from homeassistant.components.sensor import (
STATE_CLASS_MEASUREMENT,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ATTRIBUTION,
ATTR_LATITUDE,
ATTR_LONGITUDE,
MASS_POUNDS,
PERCENTAGE,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import (
CONF_BALANCING_AUTHORITY,
CONF_BALANCING_AUTHORITY_ABBREV,
DATA_COORDINATOR,
DOMAIN,
)
ATTR_BALANCING_AUTHORITY = "balancing_authority"
DEFAULT_ATTRIBUTION = "Pickup data provided by WattTime"
SENSOR_TYPE_REALTIME_EMISSIONS_MOER = "realtime_emissions_moer"
SENSOR_TYPE_REALTIME_EMISSIONS_PERCENT = "realtime_emissions_percent"
@dataclass
class RealtimeEmissionsSensorDescriptionMixin:
"""Define an entity description mixin for realtime emissions sensors."""
data_key: str
@dataclass
class RealtimeEmissionsSensorEntityDescription(
SensorEntityDescription, RealtimeEmissionsSensorDescriptionMixin
):
"""Describe a realtime emissions sensor."""
REALTIME_EMISSIONS_SENSOR_DESCRIPTIONS = (
RealtimeEmissionsSensorEntityDescription(
key=SENSOR_TYPE_REALTIME_EMISSIONS_MOER,
name="Marginal Operating Emissions Rate",
icon="mdi:blur",
native_unit_of_measurement=f"{MASS_POUNDS} CO2/MWh",
state_class=STATE_CLASS_MEASUREMENT,
data_key="moer",
),
RealtimeEmissionsSensorEntityDescription(
key=SENSOR_TYPE_REALTIME_EMISSIONS_PERCENT,
name="Relative Marginal Emissions Intensity",
icon="mdi:blur",
native_unit_of_measurement=PERCENTAGE,
state_class=STATE_CLASS_MEASUREMENT,
data_key="percent",
),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up WattTime sensors based on a config entry."""
coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id]
async_add_entities(
[
RealtimeEmissionsSensor(coordinator, description)
for description in REALTIME_EMISSIONS_SENSOR_DESCRIPTIONS
if description.data_key in coordinator.data
]
)
class RealtimeEmissionsSensor(CoordinatorEntity, SensorEntity):
"""Define a realtime emissions sensor."""
entity_description: RealtimeEmissionsSensorEntityDescription
def __init__(
self,
coordinator: DataUpdateCoordinator,
description: RealtimeEmissionsSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
if TYPE_CHECKING:
assert coordinator.config_entry
self._attr_extra_state_attributes = {
ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION,
ATTR_BALANCING_AUTHORITY: coordinator.config_entry.data[
CONF_BALANCING_AUTHORITY
],
ATTR_LATITUDE: coordinator.config_entry.data[ATTR_LATITUDE],
ATTR_LONGITUDE: coordinator.config_entry.data[ATTR_LONGITUDE],
}
self._attr_name = f"{description.name} ({coordinator.config_entry.data[CONF_BALANCING_AUTHORITY_ABBREV]})"
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
self.entity_description = description
@callback
def _handle_coordinator_update(self) -> None:
"""Respond to a DataUpdateCoordinator update."""
self.update_from_latest_data()
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
await super().async_added_to_hass()
self.update_from_latest_data()
@callback
def update_from_latest_data(self) -> None:
"""Update the state."""
self._attr_native_value = self.coordinator.data[
self.entity_description.data_key
]

View File

@ -0,0 +1,34 @@
{
"config": {
"step": {
"coordinates": {
"description": "Input the latitude and longitude to monitor:",
"data": {
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]"
}
},
"location": {
"description": "Pick a location to monitor:",
"data": {
"location_type": "[%key:common::config_flow::data::location%]"
}
},
"user": {
"description": "Input your username and password:",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"unknown_coordinates": "No data for latitude/longitude"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}

View File

@ -0,0 +1,34 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured"
},
"error": {
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error",
"unknown_coordinates": "No data for latitude/longitude"
},
"step": {
"coordinates": {
"data": {
"latitude": "Latitude",
"longitude": "Longitude"
},
"description": "Input the latitude and longitude to monitor:"
},
"location": {
"data": {
"location_type": "Location"
},
"description": "Pick a location to monitor:"
},
"user": {
"data": {
"password": "Password",
"username": "Username"
},
"description": "Input your username and password:"
}
}
}
}

View File

@ -303,6 +303,7 @@ FLOWS = [
"vizio",
"volumio",
"wallbox",
"watttime",
"waze_travel_time",
"wemo",
"whirlpool",

View File

@ -254,6 +254,9 @@ aiotractive==0.5.2
# homeassistant.components.unifi
aiounifi==26
# homeassistant.components.watttime
aiowatttime==0.1.1
# homeassistant.components.yandex_transport
aioymaps==1.1.0

View File

@ -178,6 +178,9 @@ aiotractive==0.5.2
# homeassistant.components.unifi
aiounifi==26
# homeassistant.components.watttime
aiowatttime==0.1.1
# homeassistant.components.yandex_transport
aioymaps==1.1.0

View File

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

View File

@ -0,0 +1,263 @@
"""Test the WattTime config flow."""
from unittest.mock import AsyncMock, patch
from aiowatttime.errors import CoordinatesNotFoundError, InvalidCredentialsError
import pytest
from homeassistant import config_entries, setup
from homeassistant.components.watttime.config_flow import (
CONF_LOCATION_TYPE,
LOCATION_TYPE_COORDINATES,
LOCATION_TYPE_HOME,
)
from homeassistant.components.watttime.const import (
CONF_BALANCING_AUTHORITY,
CONF_BALANCING_AUTHORITY_ABBREV,
DOMAIN,
)
from homeassistant.const import (
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_PASSWORD,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT,
RESULT_TYPE_CREATE_ENTRY,
RESULT_TYPE_FORM,
)
from tests.common import MockConfigEntry
@pytest.fixture(name="client")
def client_fixture(get_grid_region):
"""Define a fixture for an aiowatttime client."""
client = AsyncMock(return_value=None)
client.emissions.async_get_grid_region = get_grid_region
return client
@pytest.fixture(name="client_login")
def client_login_fixture(client):
"""Define a fixture for patching the aiowatttime coroutine to get a client."""
with patch("homeassistant.components.watttime.config_flow.Client.async_login") as m:
m.return_value = client
yield m
@pytest.fixture(name="get_grid_region")
def get_grid_region_fixture():
"""Define a fixture for getting grid region data."""
return AsyncMock(return_value={"abbrev": "AUTH_1", "id": 1, "name": "Authority 1"})
async def test_duplicate_error(hass: HomeAssistant, client_login):
"""Test that errors are shown when duplicate entries are added."""
MockConfigEntry(
domain=DOMAIN,
unique_id="32.87336, -117.22743",
data={
CONF_USERNAME: "user",
CONF_PASSWORD: "password",
CONF_LATITUDE: 32.87336,
CONF_LONGITUDE: -117.22743,
},
).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data={CONF_USERNAME: "user", CONF_PASSWORD: "password"},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_HOME},
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
async def test_show_form_coordinates(hass: HomeAssistant) -> None:
"""Test showing the form to input custom latitude/longitude."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_USERNAME: "user", CONF_PASSWORD: "password"},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_COORDINATES},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "coordinates"
assert result["errors"] is None
async def test_show_form_user(hass: HomeAssistant) -> None:
"""Test showing the form to select the authentication type."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] is None
@pytest.mark.parametrize(
"get_grid_region", [AsyncMock(side_effect=CoordinatesNotFoundError)]
)
async def test_step_coordinates_unknown_coordinates(
hass: HomeAssistant, client_login
) -> None:
"""Test that providing coordinates with no data is handled."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data={CONF_USERNAME: "user", CONF_PASSWORD: "password"},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_COORDINATES},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_LATITUDE: "0", CONF_LONGITUDE: "0"},
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] == {"latitude": "unknown_coordinates"}
@pytest.mark.parametrize("get_grid_region", [AsyncMock(side_effect=Exception)])
async def test_step_coordinates_unknown_error(
hass: HomeAssistant, client_login
) -> None:
"""Test that providing coordinates with no data is handled."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data={CONF_USERNAME: "user", CONF_PASSWORD: "password"},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_HOME},
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] == {"base": "unknown"}
async def test_step_login_coordinates(hass: HomeAssistant, client_login) -> None:
"""Test a full login flow (inputting custom coordinates)."""
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
"homeassistant.components.watttime.async_setup_entry",
return_value=True,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data={CONF_USERNAME: "user", CONF_PASSWORD: "password"},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_COORDINATES},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_LATITUDE: "51.528308", CONF_LONGITUDE: "-0.3817765"},
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "51.528308, -0.3817765"
assert result["data"] == {
CONF_USERNAME: "user",
CONF_PASSWORD: "password",
CONF_LATITUDE: 51.528308,
CONF_LONGITUDE: -0.3817765,
CONF_BALANCING_AUTHORITY: "Authority 1",
CONF_BALANCING_AUTHORITY_ABBREV: "AUTH_1",
}
async def test_step_user_home(hass: HomeAssistant, client_login) -> None:
"""Test a full login flow (selecting the home location)."""
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
"homeassistant.components.watttime.async_setup_entry",
return_value=True,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data={CONF_USERNAME: "user", CONF_PASSWORD: "password"},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_HOME},
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "32.87336, -117.22743"
assert result["data"] == {
CONF_USERNAME: "user",
CONF_PASSWORD: "password",
CONF_LATITUDE: 32.87336,
CONF_LONGITUDE: -117.22743,
CONF_BALANCING_AUTHORITY: "Authority 1",
CONF_BALANCING_AUTHORITY_ABBREV: "AUTH_1",
}
async def test_step_user_invalid_credentials(hass: HomeAssistant) -> None:
"""Test that invalid credentials are handled."""
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
"homeassistant.components.watttime.config_flow.Client.async_login",
AsyncMock(side_effect=InvalidCredentialsError),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data={CONF_USERNAME: "user", CONF_PASSWORD: "password"},
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] == {"username": "invalid_auth"}
@pytest.mark.parametrize("get_grid_region", [AsyncMock(side_effect=Exception)])
async def test_step_user_unknown_error(hass: HomeAssistant, client_login) -> None:
"""Test that an unknown error during the login step is handled."""
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
"homeassistant.components.watttime.config_flow.Client.async_login",
AsyncMock(side_effect=Exception),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data={CONF_USERNAME: "user", CONF_PASSWORD: "password"},
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] == {"base": "unknown"}