Add pvpc hourly pricing optional API Token support (#85767)

* 🍱 Add new fixture for PVPC data from authenticated API call

and update mocked server responses when data is not available
(now returns a 200 OK with empty data)

*  Implement optional API token in config-flow + options

to make the data download from an authenticated path in ESIOS server

As this is an *alternative* access, and current public path works for the PVPC,
no user (current or new) is compelled to obtain a token,
and it can be enabled anytime in options, or doing the setup again

When enabling the token, it is verified (or "invalid_auth" error),
and a 'reauth' flow is implemented, which can change or disable the token if
it starts failing.

The 1st step of config/options flow adds a bool to enable this private access,
- if unchecked (default), entry is set for public access (like before)
- if checked, a 2nd step opens to input the token, with instructions
  of how to get one (with a direct link to create a 'request email').
  If the token is valid, the entry is set for authenticated access

The 'reauth' flow shows the boolean flag so the user could disable a bad token
by unchecking the boolean flag 'use_api_token'

* 🌐 Update strings for config/options flows

*  Adapt tests to check API token option and add test_reauth

* 🎨 Link new strings to those in config-flow

* 🐛 tests: Fix mocked date-change with async_fire_time_changed

* ♻️ Remove storage of flag 'use_api_token' in config entry

leaving it only to enable/disable the optional token in the config-flow

* ♻️ Adjust async_update_options
This commit is contained in:
Eugenio Panadero 2023-11-23 08:35:30 +01:00 committed by GitHub
parent 0b213c6732
commit 32aa1aaec2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 1377 additions and 51 deletions

View File

@ -2,38 +2,21 @@
from datetime import timedelta
import logging
from aiopvpc import DEFAULT_POWER_KW, TARIFFS, EsiosApiData, PVPCData
import voluptuous as vol
from aiopvpc import BadApiTokenAuthError, EsiosApiData, PVPCData
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import (
ATTR_POWER,
ATTR_POWER_P3,
ATTR_TARIFF,
DEFAULT_NAME,
DOMAIN,
PLATFORMS,
)
from .const import ATTR_POWER, ATTR_POWER_P3, ATTR_TARIFF, DOMAIN
_LOGGER = logging.getLogger(__name__)
_DEFAULT_TARIFF = TARIFFS[0]
VALID_POWER = vol.All(vol.Coerce(float), vol.Range(min=1.0, max=15.0))
VALID_TARIFF = vol.In(TARIFFS)
UI_CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME, default=DEFAULT_NAME): str,
vol.Required(ATTR_TARIFF, default=_DEFAULT_TARIFF): VALID_TARIFF,
vol.Required(ATTR_POWER, default=DEFAULT_POWER_KW): VALID_POWER,
vol.Required(ATTR_POWER_P3, default=DEFAULT_POWER_KW): VALID_POWER,
}
)
PLATFORMS: list[Platform] = [Platform.SENSOR]
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
@ -52,7 +35,7 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
if any(
entry.data.get(attrib) != entry.options.get(attrib)
for attrib in (ATTR_POWER, ATTR_POWER_P3)
for attrib in (ATTR_POWER, ATTR_POWER_P3, CONF_API_TOKEN)
):
# update entry replacing data with new options
hass.config_entries.async_update_entry(
@ -80,6 +63,7 @@ class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]):
local_timezone=hass.config.time_zone,
power=entry.data[ATTR_POWER],
power_valley=entry.data[ATTR_POWER_P3],
api_token=entry.data.get(CONF_API_TOKEN),
)
super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30)
@ -93,7 +77,10 @@ class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]):
async def _async_update_data(self) -> EsiosApiData:
"""Update electricity prices from the ESIOS API."""
api_data = await self.api.async_update_all(self.data, dt_util.utcnow())
try:
api_data = await self.api.async_update_all(self.data, dt_util.utcnow())
except BadApiTokenAuthError as exc:
raise ConfigEntryAuthFailed from exc
if (
not api_data
or not api_data.sensors

View File

@ -1,22 +1,49 @@
"""Config flow for pvpc_hourly_pricing."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from aiopvpc import DEFAULT_POWER_KW, PVPCData
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_API_TOKEN, CONF_NAME
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import dt as dt_util
from . import CONF_NAME, UI_CONFIG_SCHEMA, VALID_POWER
from .const import ATTR_POWER, ATTR_POWER_P3, ATTR_TARIFF, DOMAIN
from .const import (
ATTR_POWER,
ATTR_POWER_P3,
ATTR_TARIFF,
CONF_USE_API_TOKEN,
DEFAULT_NAME,
DEFAULT_TARIFF,
DOMAIN,
VALID_POWER,
VALID_TARIFF,
)
_MAIL_TO_LINK = (
"[consultasios@ree.es]"
"(mailto:consultasios@ree.es?subject=Personal%20token%20request)"
)
class TariffSelectorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle config flow for `pvpc_hourly_pricing`."""
VERSION = 1
_name: str | None = None
_tariff: str | None = None
_power: float | None = None
_power_p3: float | None = None
_use_api_token: bool = False
_api_token: str | None = None
_api: PVPCData | None = None
_reauth_entry: config_entries.ConfigEntry | None = None
@staticmethod
@callback
@ -33,36 +60,180 @@ class TariffSelectorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None:
await self.async_set_unique_id(user_input[ATTR_TARIFF])
self._abort_if_unique_id_configured()
return self.async_create_entry(title=user_input[CONF_NAME], data=user_input)
if not user_input[CONF_USE_API_TOKEN]:
return self.async_create_entry(
title=user_input[CONF_NAME],
data={
CONF_NAME: user_input[CONF_NAME],
ATTR_TARIFF: user_input[ATTR_TARIFF],
ATTR_POWER: user_input[ATTR_POWER],
ATTR_POWER_P3: user_input[ATTR_POWER_P3],
CONF_API_TOKEN: None,
},
)
return self.async_show_form(step_id="user", data_schema=UI_CONFIG_SCHEMA)
self._name = user_input[CONF_NAME]
self._tariff = user_input[ATTR_TARIFF]
self._power = user_input[ATTR_POWER]
self._power_p3 = user_input[ATTR_POWER_P3]
self._use_api_token = user_input[CONF_USE_API_TOKEN]
return self.async_show_form(
step_id="api_token",
data_schema=vol.Schema(
{vol.Required(CONF_API_TOKEN, default=self._api_token): str}
),
description_placeholders={"mail_to_link": _MAIL_TO_LINK},
)
data_schema = vol.Schema(
{
vol.Required(CONF_NAME, default=DEFAULT_NAME): str,
vol.Required(ATTR_TARIFF, default=DEFAULT_TARIFF): VALID_TARIFF,
vol.Required(ATTR_POWER, default=DEFAULT_POWER_KW): VALID_POWER,
vol.Required(ATTR_POWER_P3, default=DEFAULT_POWER_KW): VALID_POWER,
vol.Required(CONF_USE_API_TOKEN, default=False): bool,
}
)
return self.async_show_form(step_id="user", data_schema=data_schema)
async def async_step_api_token(self, user_input: dict[str, Any]) -> FlowResult:
"""Handle optional step to define API token for extra sensors."""
self._api_token = user_input[CONF_API_TOKEN]
return await self._async_verify(
"api_token",
data_schema=vol.Schema(
{vol.Required(CONF_API_TOKEN, default=self._api_token): str}
),
)
async def _async_verify(self, step_id: str, data_schema: vol.Schema) -> FlowResult:
"""Attempt to verify the provided configuration."""
errors: dict[str, str] = {}
auth_ok = True
if self._use_api_token:
if not self._api:
self._api = PVPCData(session=async_get_clientsession(self.hass))
auth_ok = await self._api.check_api_token(dt_util.utcnow(), self._api_token)
if not auth_ok:
errors["base"] = "invalid_auth"
return self.async_show_form(
step_id=step_id,
data_schema=data_schema,
errors=errors,
description_placeholders={"mail_to_link": _MAIL_TO_LINK},
)
data = {
CONF_NAME: self._name,
ATTR_TARIFF: self._tariff,
ATTR_POWER: self._power,
ATTR_POWER_P3: self._power_p3,
CONF_API_TOKEN: self._api_token if self._use_api_token else None,
}
if self._reauth_entry:
self.hass.config_entries.async_update_entry(self._reauth_entry, data=data)
self.hass.async_create_task(
self.hass.config_entries.async_reload(self._reauth_entry.entry_id)
)
return self.async_abort(reason="reauth_successful")
assert self._name is not None
return self.async_create_entry(title=self._name, data=data)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Handle re-authentication with ESIOS Token."""
self._reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
self._api_token = entry_data.get(CONF_API_TOKEN)
self._use_api_token = self._api_token is not None
self._name = entry_data[CONF_NAME]
self._tariff = entry_data[ATTR_TARIFF]
self._power = entry_data[ATTR_POWER]
self._power_p3 = entry_data[ATTR_POWER_P3]
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm reauth dialog."""
data_schema = vol.Schema(
{
vol.Required(CONF_USE_API_TOKEN, default=self._use_api_token): bool,
vol.Optional(CONF_API_TOKEN, default=self._api_token): str,
}
)
if user_input:
self._api_token = user_input[CONF_API_TOKEN]
self._use_api_token = user_input[CONF_USE_API_TOKEN]
return await self._async_verify("reauth_confirm", data_schema)
return self.async_show_form(step_id="reauth_confirm", data_schema=data_schema)
class PVPCOptionsFlowHandler(config_entries.OptionsFlow):
class PVPCOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry):
"""Handle PVPC options."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
_power: float | None = None
_power_p3: float | None = None
async def async_step_api_token(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle optional step to define API token for extra sensors."""
if user_input is not None and user_input.get(CONF_API_TOKEN):
return self.async_create_entry(
title="",
data={
ATTR_POWER: self._power,
ATTR_POWER_P3: self._power_p3,
CONF_API_TOKEN: user_input[CONF_API_TOKEN],
},
)
# Fill options with entry data
api_token = self.options.get(
CONF_API_TOKEN, self.config_entry.data.get(CONF_API_TOKEN)
)
return self.async_show_form(
step_id="api_token",
data_schema=vol.Schema(
{vol.Required(CONF_API_TOKEN, default=api_token): str}
),
description_placeholders={"mail_to_link": _MAIL_TO_LINK},
)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
if user_input[CONF_USE_API_TOKEN]:
self._power = user_input[ATTR_POWER]
self._power_p3 = user_input[ATTR_POWER_P3]
return await self.async_step_api_token(user_input)
return self.async_create_entry(
title="",
data={
ATTR_POWER: user_input[ATTR_POWER],
ATTR_POWER_P3: user_input[ATTR_POWER_P3],
CONF_API_TOKEN: None,
},
)
# Fill options with entry data
power = self.config_entry.options.get(
ATTR_POWER, self.config_entry.data[ATTR_POWER]
)
power_valley = self.config_entry.options.get(
power = self.options.get(ATTR_POWER, self.config_entry.data[ATTR_POWER])
power_valley = self.options.get(
ATTR_POWER_P3, self.config_entry.data[ATTR_POWER_P3]
)
api_token = self.options.get(
CONF_API_TOKEN, self.config_entry.data.get(CONF_API_TOKEN)
)
use_api_token = api_token is not None
schema = vol.Schema(
{
vol.Required(ATTR_POWER, default=power): VALID_POWER,
vol.Required(ATTR_POWER_P3, default=power_valley): VALID_POWER,
vol.Required(CONF_USE_API_TOKEN, default=use_api_token): bool,
}
)
return self.async_show_form(step_id="init", data_schema=schema)

View File

@ -1,9 +1,15 @@
"""Constant values for pvpc_hourly_pricing."""
from homeassistant.const import Platform
from aiopvpc import TARIFFS
import voluptuous as vol
DOMAIN = "pvpc_hourly_pricing"
PLATFORMS = [Platform.SENSOR]
ATTR_POWER = "power"
ATTR_POWER_P3 = "power_p3"
ATTR_TARIFF = "tariff"
DEFAULT_NAME = "PVPC"
CONF_USE_API_TOKEN = "use_api_token"
VALID_POWER = vol.All(vol.Coerce(float), vol.Range(min=1.0, max=15.0))
VALID_TARIFF = vol.In(TARIFFS)
DEFAULT_TARIFF = TARIFFS[0]

View File

@ -6,12 +6,31 @@
"name": "Sensor Name",
"tariff": "Applicable tariff by geographic zone",
"power": "Contracted power (kW)",
"power_p3": "Contracted power for valley period P3 (kW)"
"power_p3": "Contracted power for valley period P3 (kW)",
"use_api_token": "Enable ESIOS Personal API token for private access"
}
},
"api_token": {
"title": "ESIOS API token",
"description": "To use the extended API you must request a personal token by mailing to {mail_to_link}.",
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]"
}
},
"reauth_confirm": {
"data": {
"description": "Re-authenticate with a valid token or disable it",
"use_api_token": "[%key:component::pvpc_hourly_pricing::config::step::user::data::use_api_token%]",
"api_token": "[%key:common::config_flow::data::api_token%]"
}
}
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"options": {
@ -19,7 +38,15 @@
"init": {
"data": {
"power": "[%key:component::pvpc_hourly_pricing::config::step::user::data::power%]",
"power_p3": "[%key:component::pvpc_hourly_pricing::config::step::user::data::power_p3%]"
"power_p3": "[%key:component::pvpc_hourly_pricing::config::step::user::data::power_p3%]",
"use_api_token": "[%key:component::pvpc_hourly_pricing::config::step::user::data::use_api_token%]"
}
},
"api_token": {
"title": "[%key:component::pvpc_hourly_pricing::config::step::api_token::title%]",
"description": "[%key:component::pvpc_hourly_pricing::config::step::api_token::description%]",
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]"
}
}
}

View File

@ -10,6 +10,7 @@ from tests.common import load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
FIXTURE_JSON_PUBLIC_DATA_2023_01_06 = "PVPC_DATA_2023_01_06.json"
FIXTURE_JSON_ESIOS_DATA_PVPC_2023_01_06 = "PRICES_ESIOS_1001_2023_01_06.json"
def check_valid_state(state, tariff: str, value=None, key_attr=None):
@ -21,7 +22,7 @@ def check_valid_state(state, tariff: str, value=None, key_attr=None):
)
try:
_ = float(state.state)
# safety margins for current electricity price (it shouldn't be out of [0, 0.2])
# safety margins for current electricity price (it shouldn't be out of [0, 0.5])
assert -0.1 < float(state.state) < 0.5
assert state.attributes[ATTR_TARIFF] == tariff
except ValueError:
@ -41,20 +42,42 @@ def pvpc_aioclient_mock(aioclient_mock: AiohttpClientMocker):
mask_url_public = (
"https://api.esios.ree.es/archives/70/download_json?locale=es&date={0}"
)
# new format for prices >= 2021-06-01
mask_url_esios = (
"https://api.esios.ree.es/indicators/1001"
"?start_date={0}T00:00&end_date={0}T23:59"
)
example_day = "2023-01-06"
aioclient_mock.get(
mask_url_public.format(example_day),
text=load_fixture(f"{DOMAIN}/{FIXTURE_JSON_PUBLIC_DATA_2023_01_06}"),
)
aioclient_mock.get(
mask_url_esios.format(example_day),
text=load_fixture(f"{DOMAIN}/{FIXTURE_JSON_ESIOS_DATA_PVPC_2023_01_06}"),
)
# simulate missing days
aioclient_mock.get(
mask_url_public.format("2023-01-07"),
status=HTTPStatus.BAD_GATEWAY,
status=HTTPStatus.OK,
text='{"message":"No values for specified archive"}',
)
aioclient_mock.get(
mask_url_esios.format("2023-01-07"),
status=HTTPStatus.OK,
text=(
'{"errors":[{"code":502,"status":"502","title":"Bad response from ESIOS",'
'"detail":"There are no data for the selected filters."}]}'
'{"indicator":{"name":"Término de facturación de energía activa del '
'PVPC 2.0TD","short_name":"PVPC T. 2.0TD","id":1001,"composited":false,'
'"step_type":"linear","disaggregated":true,"magnitud":'
'[{"name":"Precio","id":23}],"tiempo":[{"name":"Hora","id":4}],"geos":[],'
'"values_updated_at":null,"values":[]}}'
),
)
# simulate bad authentication
aioclient_mock.get(
mask_url_esios.format("2023-01-08"),
status=HTTPStatus.UNAUTHORIZED,
text="HTTP Token: Access denied.",
)
return aioclient_mock

File diff suppressed because it is too large Load Diff

View File

@ -4,14 +4,15 @@ from datetime import datetime, timedelta
from freezegun.api import FrozenDateTimeFactory
from homeassistant import config_entries, data_entry_flow
from homeassistant.components.pvpc_hourly_pricing import (
from homeassistant.components.pvpc_hourly_pricing.const import (
ATTR_POWER,
ATTR_POWER_P3,
ATTR_TARIFF,
CONF_USE_API_TOKEN,
DOMAIN,
TARIFFS,
)
from homeassistant.const import CONF_NAME
from homeassistant.const import CONF_API_TOKEN, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
@ -22,6 +23,7 @@ from tests.common import async_fire_time_changed
from tests.test_util.aiohttp import AiohttpClientMocker
_MOCK_TIME_VALID_RESPONSES = datetime(2023, 1, 6, 12, 0, tzinfo=dt_util.UTC)
_MOCK_TIME_BAD_AUTH_RESPONSES = datetime(2023, 1, 8, 12, 0, tzinfo=dt_util.UTC)
async def test_config_flow(
@ -35,7 +37,7 @@ async def test_config_flow(
- Check state and attributes
- Check abort when trying to config another with same tariff
- Check removal and add again to check state restoration
- Configure options to change power and tariff to "2.0TD"
- Configure options to introduce API Token, with bad auth and good one
"""
freezer.move_to(_MOCK_TIME_VALID_RESPONSES)
hass.config.set_time_zone("Europe/Madrid")
@ -44,6 +46,7 @@ async def test_config_flow(
ATTR_TARIFF: TARIFFS[1],
ATTR_POWER: 4.6,
ATTR_POWER_P3: 5.75,
CONF_USE_API_TOKEN: False,
}
result = await hass.config_entries.flow.async_init(
@ -107,8 +110,17 @@ async def test_config_flow(
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={ATTR_POWER: 3.0, ATTR_POWER_P3: 4.6},
user_input={ATTR_POWER: 3.0, ATTR_POWER_P3: 4.6, CONF_USE_API_TOKEN: True},
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "api_token"
assert pvpc_aioclient_mock.call_count == 2
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={CONF_API_TOKEN: "good-token"}
)
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert pvpc_aioclient_mock.call_count == 2
await hass.async_block_till_done()
state = hass.states.get("sensor.esios_pvpc")
check_valid_state(state, tariff=TARIFFS[1])
@ -125,3 +137,96 @@ async def test_config_flow(
check_valid_state(state, tariff=TARIFFS[0], value="unavailable")
assert "period" not in state.attributes
assert pvpc_aioclient_mock.call_count == 4
# disable api token in options
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={ATTR_POWER: 3.0, ATTR_POWER_P3: 4.6, CONF_USE_API_TOKEN: False},
)
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert pvpc_aioclient_mock.call_count == 4
await hass.async_block_till_done()
async def test_reauth(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
pvpc_aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test reauth flow for API-token mode."""
freezer.move_to(_MOCK_TIME_BAD_AUTH_RESPONSES)
hass.config.set_time_zone("Europe/Madrid")
tst_config = {
CONF_NAME: "test",
ATTR_TARIFF: TARIFFS[1],
ATTR_POWER: 4.6,
ATTR_POWER_P3: 5.75,
CONF_USE_API_TOKEN: True,
}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"], tst_config
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "api_token"
assert pvpc_aioclient_mock.call_count == 0
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_API_TOKEN: "bad-token"}
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "api_token"
assert result["errors"]["base"] == "invalid_auth"
assert pvpc_aioclient_mock.call_count == 1
freezer.move_to(_MOCK_TIME_VALID_RESPONSES)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_API_TOKEN: "good-token"}
)
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
config_entry = result["result"]
assert pvpc_aioclient_mock.call_count == 3
# check reauth trigger with bad-auth responses
freezer.move_to(_MOCK_TIME_BAD_AUTH_RESPONSES)
async_fire_time_changed(hass, _MOCK_TIME_BAD_AUTH_RESPONSES)
await hass.async_block_till_done()
assert pvpc_aioclient_mock.call_count == 4
result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0]
assert result["context"]["entry_id"] == config_entry.entry_id
assert result["context"]["source"] == config_entries.SOURCE_REAUTH
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_API_TOKEN: "bad-token"}
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert pvpc_aioclient_mock.call_count == 5
result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0]
assert result["context"]["entry_id"] == config_entry.entry_id
assert result["context"]["source"] == config_entries.SOURCE_REAUTH
assert result["step_id"] == "reauth_confirm"
freezer.move_to(_MOCK_TIME_VALID_RESPONSES)
async_fire_time_changed(hass, _MOCK_TIME_VALID_RESPONSES)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_API_TOKEN: "good-token"}
)
assert result["type"] == data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert pvpc_aioclient_mock.call_count == 6
await hass.async_block_till_done()
assert pvpc_aioclient_mock.call_count == 7