diff --git a/.coveragerc b/.coveragerc index 322c7e1af48..d75eb6aa9d6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/CODEOWNERS b/CODEOWNERS index bc3f6f6f838..76748306cfe 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/watttime/__init__.py b/homeassistant/components/watttime/__init__.py new file mode 100644 index 00000000000..d376dd40db6 --- /dev/null +++ b/homeassistant/components/watttime/__init__.py @@ -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 diff --git a/homeassistant/components/watttime/config_flow.py b/homeassistant/components/watttime/config_flow.py new file mode 100644 index 00000000000..a6c5dd422c2 --- /dev/null +++ b/homeassistant/components/watttime/config_flow.py @@ -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() diff --git a/homeassistant/components/watttime/const.py b/homeassistant/components/watttime/const.py new file mode 100644 index 00000000000..680505c8d43 --- /dev/null +++ b/homeassistant/components/watttime/const.py @@ -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" diff --git a/homeassistant/components/watttime/manifest.json b/homeassistant/components/watttime/manifest.json new file mode 100644 index 00000000000..d4000b6f6b1 --- /dev/null +++ b/homeassistant/components/watttime/manifest.json @@ -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" +} diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py new file mode 100644 index 00000000000..f44249ecde1 --- /dev/null +++ b/homeassistant/components/watttime/sensor.py @@ -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 + ] diff --git a/homeassistant/components/watttime/strings.json b/homeassistant/components/watttime/strings.json new file mode 100644 index 00000000000..34dc253dcde --- /dev/null +++ b/homeassistant/components/watttime/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/components/watttime/translations/en.json b/homeassistant/components/watttime/translations/en.json new file mode 100644 index 00000000000..44ae51fae53 --- /dev/null +++ b/homeassistant/components/watttime/translations/en.json @@ -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:" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 80395e8e3f6..0983da03f98 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -303,6 +303,7 @@ FLOWS = [ "vizio", "volumio", "wallbox", + "watttime", "waze_travel_time", "wemo", "whirlpool", diff --git a/requirements_all.txt b/requirements_all.txt index 13ea083eb12..986b79e278d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3238c57590f..c0dff9c66d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/watttime/__init__.py b/tests/components/watttime/__init__.py new file mode 100644 index 00000000000..6e01f28b518 --- /dev/null +++ b/tests/components/watttime/__init__.py @@ -0,0 +1 @@ +"""Tests for the WattTime integration.""" diff --git a/tests/components/watttime/test_config_flow.py b/tests/components/watttime/test_config_flow.py new file mode 100644 index 00000000000..a3d2867eb2d --- /dev/null +++ b/tests/components/watttime/test_config_flow.py @@ -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"}