Add electric kiwi integration (#81149)

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
This commit is contained in:
Michael Arthur 2023-07-25 20:46:53 +12:00 committed by GitHub
parent 04f6d1848b
commit 6ef7c5ece6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 806 additions and 0 deletions

View File

@ -261,6 +261,11 @@ omit =
homeassistant/components/eight_sleep/__init__.py
homeassistant/components/eight_sleep/binary_sensor.py
homeassistant/components/eight_sleep/sensor.py
homeassistant/components/electric_kiwi/__init__.py
homeassistant/components/electric_kiwi/api.py
homeassistant/components/electric_kiwi/oauth2.py
homeassistant/components/electric_kiwi/sensor.py
homeassistant/components/electric_kiwi/coordinator.py
homeassistant/components/eliqonline/sensor.py
homeassistant/components/elkm1/__init__.py
homeassistant/components/elkm1/alarm_control_panel.py

View File

@ -108,6 +108,7 @@ homeassistant.components.dsmr.*
homeassistant.components.dunehd.*
homeassistant.components.efergy.*
homeassistant.components.electrasmart.*
homeassistant.components.electric_kiwi.*
homeassistant.components.elgato.*
homeassistant.components.elkm1.*
homeassistant.components.emulated_hue.*

View File

@ -319,6 +319,8 @@ build.json @home-assistant/supervisor
/tests/components/eight_sleep/ @mezz64 @raman325
/homeassistant/components/electrasmart/ @jafar-atili
/tests/components/electrasmart/ @jafar-atili
/homeassistant/components/electric_kiwi/ @mikey0000
/tests/components/electric_kiwi/ @mikey0000
/homeassistant/components/elgato/ @frenck
/tests/components/elgato/ @frenck
/homeassistant/components/elkm1/ @gwww @bdraco

View File

@ -0,0 +1,65 @@
"""The Electric Kiwi integration."""
from __future__ import annotations
import aiohttp
from electrickiwi_api import ElectricKiwiApi
from electrickiwi_api.exceptions import ApiException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from . import api
from .const import DOMAIN
from .coordinator import ElectricKiwiHOPDataCoordinator
PLATFORMS: list[Platform] = [
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Electric Kiwi from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(err) from err
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
ek_api = ElectricKiwiApi(
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
)
hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, ek_api)
try:
await ek_api.set_active_session()
await hop_coordinator.async_config_entry_first_refresh()
except ApiException as err:
raise ConfigEntryNotReady from err
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = hop_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -0,0 +1,33 @@
"""API for Electric Kiwi bound to Home Assistant OAuth."""
from __future__ import annotations
from typing import cast
from aiohttp import ClientSession
from electrickiwi_api import AbstractAuth
from homeassistant.helpers import config_entry_oauth2_flow
from .const import API_BASE_URL
class AsyncConfigEntryAuth(AbstractAuth):
"""Provide Electric Kiwi authentication tied to an OAuth2 based config entry."""
def __init__(
self,
websession: ClientSession,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialize Electric Kiwi auth."""
# add host when ready for production "https://api.electrickiwi.co.nz" defaults to dev
super().__init__(websession, API_BASE_URL)
self._oauth_session = oauth_session
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
if not self._oauth_session.valid_token:
await self._oauth_session.async_ensure_token_valid()
return cast(str, self._oauth_session.token["access_token"])

View File

@ -0,0 +1,38 @@
"""application_credentials platform the Electric Kiwi integration."""
from homeassistant.components.application_credentials import (
AuthorizationServer,
ClientCredential,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
from .oauth2 import ElectricKiwiLocalOAuth2Implementation
async def async_get_auth_implementation(
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
) -> config_entry_oauth2_flow.AbstractOAuth2Implementation:
"""Return auth implementation."""
return ElectricKiwiLocalOAuth2Implementation(
hass,
auth_domain,
credential,
authorization_server=await async_get_authorization_server(hass),
)
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server."""
return AuthorizationServer(
authorize_url=OAUTH2_AUTHORIZE,
token_url=OAUTH2_TOKEN,
)
async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
"""Return description placeholders for the credentials dialog."""
return {
"more_info_url": "https://www.home-assistant.io/integrations/electric_kiwi/"
}

View File

@ -0,0 +1,59 @@
"""Config flow for Electric Kiwi."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN, SCOPE_VALUES
class ElectricKiwiOauth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Config flow to handle Electric Kiwi OAuth2 authentication."""
DOMAIN = DOMAIN
def __init__(self) -> None:
"""Set up instance."""
super().__init__()
self._reauth_entry: ConfigEntry | None = None
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data that needs to be appended to the authorize url."""
return {"scope": SCOPE_VALUES}
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Perform reauth upon an API authentication error."""
self._reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> FlowResult:
"""Create an entry for Electric Kiwi."""
existing_entry = await self.async_set_unique_id(DOMAIN)
if existing_entry:
self.hass.config_entries.async_update_entry(existing_entry, data=data)
await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reauth_successful")
return await super().async_oauth_create_entry(data)

View File

@ -0,0 +1,11 @@
"""Constants for the Electric Kiwi integration."""
NAME = "Electric Kiwi"
DOMAIN = "electric_kiwi"
ATTRIBUTION = "Data provided by the Juice Hacker API"
OAUTH2_AUTHORIZE = "https://welcome.electrickiwi.co.nz/oauth/authorize"
OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token"
API_BASE_URL = "https://api.electrickiwi.co.nz"
SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session"

View File

@ -0,0 +1,81 @@
"""Electric Kiwi coordinators."""
from collections import OrderedDict
from datetime import timedelta
import logging
import async_timeout
from electrickiwi_api import ElectricKiwiApi
from electrickiwi_api.exceptions import ApiException, AuthException
from electrickiwi_api.model import Hop, HopIntervals
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
_LOGGER = logging.getLogger(__name__)
HOP_SCAN_INTERVAL = timedelta(hours=2)
class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
"""ElectricKiwi Data object."""
def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None:
"""Initialize ElectricKiwiAccountDataCoordinator."""
super().__init__(
hass,
_LOGGER,
# Name of the data. For logging purposes.
name="Electric Kiwi HOP Data",
# Polling interval. Will only be polled if there are subscribers.
update_interval=HOP_SCAN_INTERVAL,
)
self._ek_api = ek_api
self.hop_intervals: HopIntervals | None = None
def get_hop_options(self) -> dict[str, int]:
"""Get the hop interval options for selection."""
if self.hop_intervals is not None:
return {
f"{v.start_time} - {v.end_time}": k
for k, v in self.hop_intervals.intervals.items()
}
return {}
async def async_update_hop(self, hop_interval: int) -> Hop:
"""Update selected hop and data."""
try:
self.async_set_updated_data(await self._ek_api.post_hop(hop_interval))
except AuthException as auth_err:
raise ConfigEntryAuthFailed from auth_err
except ApiException as api_err:
raise UpdateFailed(
f"Error communicating with EK API: {api_err}"
) from api_err
return self.data
async def _async_update_data(self) -> Hop:
"""Fetch data from API endpoint.
filters the intervals to remove ones that are not active
"""
try:
async with async_timeout.timeout(60):
if self.hop_intervals is None:
hop_intervals: HopIntervals = await self._ek_api.get_hop_intervals()
hop_intervals.intervals = OrderedDict(
filter(
lambda pair: pair[1].active == 1,
hop_intervals.intervals.items(),
)
)
self.hop_intervals = hop_intervals
return await self._ek_api.get_hop()
except AuthException as auth_err:
raise ConfigEntryAuthFailed from auth_err
except ApiException as api_err:
raise UpdateFailed(
f"Error communicating with EK API: {api_err}"
) from api_err

View File

@ -0,0 +1,11 @@
{
"domain": "electric_kiwi",
"name": "Electric Kiwi",
"codeowners": ["@mikey0000"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/electric_kiwi",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["electrickiwi-api==0.8.5"]
}

View File

@ -0,0 +1,76 @@
"""OAuth2 implementations for Toon."""
from __future__ import annotations
import base64
from typing import Any, cast
from homeassistant.components.application_credentials import (
AuthImplementation,
AuthorizationServer,
ClientCredential,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import SCOPE_VALUES
class ElectricKiwiLocalOAuth2Implementation(AuthImplementation):
"""Local OAuth2 implementation for Electric Kiwi."""
def __init__(
self,
hass: HomeAssistant,
domain: str,
client_credential: ClientCredential,
authorization_server: AuthorizationServer,
) -> None:
"""Set up Electric Kiwi oauth."""
super().__init__(
hass=hass,
auth_domain=domain,
credential=client_credential,
authorization_server=authorization_server,
)
self._name = client_credential.name
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data that needs to be appended to the authorize url."""
return {"scope": SCOPE_VALUES}
async def async_resolve_external_data(self, external_data: Any) -> dict:
"""Initialize local Electric Kiwi auth implementation."""
data = {
"grant_type": "authorization_code",
"code": external_data["code"],
"redirect_uri": external_data["state"]["redirect_uri"],
}
return await self._token_request(data)
async def _async_refresh_token(self, token: dict) -> dict:
"""Refresh tokens."""
data = {
"grant_type": "refresh_token",
"refresh_token": token["refresh_token"],
}
new_token = await self._token_request(data)
return {**token, **new_token}
async def _token_request(self, data: dict) -> dict:
"""Make a token request."""
session = async_get_clientsession(self.hass)
client_str = f"{self.client_id}:{self.client_secret}"
client_string_bytes = client_str.encode("ascii")
base64_bytes = base64.b64encode(client_string_bytes)
base64_client = base64_bytes.decode("ascii")
headers = {"Authorization": f"Basic {base64_client}"}
resp = await session.post(self.token_url, data=data, headers=headers)
resp.raise_for_status()
resp_json = cast(dict, await resp.json())
return resp_json

View File

@ -0,0 +1,113 @@
"""Support for Electric Kiwi sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from electrickiwi_api.model import Hop
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import ATTRIBUTION, DOMAIN
from .coordinator import ElectricKiwiHOPDataCoordinator
_LOGGER = logging.getLogger(DOMAIN)
ATTR_EK_HOP_START = "hop_sensor_start"
ATTR_EK_HOP_END = "hop_sensor_end"
@dataclass
class ElectricKiwiHOPRequiredKeysMixin:
"""Mixin for required HOP keys."""
value_func: Callable[[Hop], datetime]
@dataclass
class ElectricKiwiHOPSensorEntityDescription(
SensorEntityDescription,
ElectricKiwiHOPRequiredKeysMixin,
):
"""Describes Electric Kiwi HOP sensor entity."""
def _check_and_move_time(hop: Hop, time: str) -> datetime:
"""Return the time a day forward if HOP end_time is in the past."""
date_time = datetime.combine(
datetime.today(),
datetime.strptime(time, "%I:%M %p").time(),
).astimezone(dt_util.DEFAULT_TIME_ZONE)
end_time = datetime.combine(
datetime.today(),
datetime.strptime(hop.end.end_time, "%I:%M %p").time(),
).astimezone(dt_util.DEFAULT_TIME_ZONE)
if end_time < datetime.now().astimezone(dt_util.DEFAULT_TIME_ZONE):
return date_time + timedelta(days=1)
return date_time
HOP_SENSOR_TYPE: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = (
ElectricKiwiHOPSensorEntityDescription(
key=ATTR_EK_HOP_START,
translation_key="hopfreepowerstart",
device_class=SensorDeviceClass.TIMESTAMP,
value_func=lambda hop: _check_and_move_time(hop, hop.start.start_time),
),
ElectricKiwiHOPSensorEntityDescription(
key=ATTR_EK_HOP_END,
translation_key="hopfreepowerend",
device_class=SensorDeviceClass.TIMESTAMP,
value_func=lambda hop: _check_and_move_time(hop, hop.end.end_time),
),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Electric Kiwi Sensor Setup."""
hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id]
hop_entities = [
ElectricKiwiHOPEntity(hop_coordinator, description)
for description in HOP_SENSOR_TYPE
]
async_add_entities(hop_entities)
class ElectricKiwiHOPEntity(
CoordinatorEntity[ElectricKiwiHOPDataCoordinator], SensorEntity
):
"""Entity object for Electric Kiwi sensor."""
entity_description: ElectricKiwiHOPSensorEntityDescription
_attr_attribution = ATTRIBUTION
def __init__(
self,
hop_coordinator: ElectricKiwiHOPDataCoordinator,
description: ElectricKiwiHOPSensorEntityDescription,
) -> None:
"""Entity object for Electric Kiwi sensor."""
super().__init__(hop_coordinator)
self._attr_unique_id = f"{self.coordinator._ek_api.customer_number}_{self.coordinator._ek_api.connection_id}_{description.key}"
self.entity_description = description
@property
def native_value(self) -> datetime:
"""Return the state of the sensor."""
return self.entity_description.value_func(self.coordinator.data)

View File

@ -0,0 +1,36 @@
{
"config": {
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Electric Kiwi integration needs to re-authenticate your account"
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
},
"entity": {
"sensor": {
"hopfreepowerstart": {
"name": "Hour of free power start"
},
"hopfreepowerend": {
"name": "Hour of free power end"
}
}
}
}

View File

@ -4,6 +4,7 @@ To update, run python3 -m script.hassfest
"""
APPLICATION_CREDENTIALS = [
"electric_kiwi",
"geocaching",
"google",
"google_assistant_sdk",

View File

@ -117,6 +117,7 @@ FLOWS = {
"efergy",
"eight_sleep",
"electrasmart",
"electric_kiwi",
"elgato",
"elkm1",
"elmax",

View File

@ -1328,6 +1328,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"electric_kiwi": {
"name": "Electric Kiwi",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"elgato": {
"name": "Elgato",
"integrations": {

View File

@ -842,6 +842,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.electric_kiwi.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.elgato.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -702,6 +702,9 @@ ebusdpy==0.0.17
# homeassistant.components.ecoal_boiler
ecoaliface==0.4.0
# homeassistant.components.electric_kiwi
electrickiwi-api==0.8.5
# homeassistant.components.elgato
elgato==4.0.1

View File

@ -564,6 +564,9 @@ eagle100==0.1.1
# homeassistant.components.easyenergy
easyenergy==0.3.0
# homeassistant.components.electric_kiwi
electrickiwi-api==0.8.5
# homeassistant.components.elgato
elgato==4.0.1

View File

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

View File

@ -0,0 +1,63 @@
"""Define fixtures for electric kiwi tests."""
from __future__ import annotations
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.electric_kiwi.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
REDIRECT_URI = "https://example.com/auth/external/callback"
@pytest.fixture(autouse=True)
async def request_setup(current_request_with_host) -> None:
"""Request setup."""
return
@pytest.fixture
async def setup_credentials(hass: HomeAssistant) -> None:
"""Fixture to setup credentials."""
assert await async_setup_component(hass, "application_credentials", {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET),
)
@pytest.fixture(name="config_entry")
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Create mocked config entry."""
entry = MockConfigEntry(
title="Electric Kiwi",
domain=DOMAIN,
data={
"id": "mock_user",
"auth_implementation": DOMAIN,
},
unique_id=DOMAIN,
)
entry.add_to_hass(hass)
return entry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Mock setting up a config entry."""
with patch(
"homeassistant.components.electric_kiwi.async_setup_entry", return_value=True
) as mock_setup:
yield mock_setup

View File

@ -0,0 +1,187 @@
"""Test the Electric Kiwi config flow."""
from __future__ import annotations
from http import HTTPStatus
from unittest.mock import AsyncMock, MagicMock
import pytest
from homeassistant import config_entries
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.electric_kiwi.const import (
DOMAIN,
OAUTH2_AUTHORIZE,
OAUTH2_TOKEN,
SCOPE_VALUES,
)
from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from .conftest import CLIENT_ID, CLIENT_SECRET, REDIRECT_URI
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
async def test_config_flow_no_credentials(hass: HomeAssistant) -> None:
"""Test config flow base case with no credentials registered."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result.get("type") == FlowResultType.ABORT
assert result.get("reason") == "missing_credentials"
async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
current_request_with_host: None,
setup_credentials,
mock_setup_entry: AsyncMock,
) -> None:
"""Check full flow."""
await async_import_client_credential(
hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred"
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT_URI,
},
)
URL_SCOPE = SCOPE_VALUES.replace(" ", "+")
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
f"&redirect_uri={REDIRECT_URI}"
f"&state={state}"
f"&scope={URL_SCOPE}"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == HTTPStatus.OK
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_existing_entry(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
current_request_with_host: None,
setup_credentials: None,
config_entry: MockConfigEntry,
) -> None:
"""Check existing entry."""
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": OAUTH2_AUTHORIZE,
},
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == HTTPStatus.OK
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"access_token": "mock-access-token",
"token_type": "bearer",
"expires_in": 3599,
"refresh_token": "mock-refresh_token",
},
)
await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
async def test_reauthentication(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
current_request_with_host: None,
aioclient_mock: AiohttpClientMocker,
mock_setup_entry: MagicMock,
config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""Test Electric Kiwi reauthentication."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_REAUTH, "entry_id": DOMAIN}
)
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert "flow_id" in flows[0]
result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {})
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT_URI,
},
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == HTTPStatus.OK
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"access_token": "mock-access-token",
"token_type": "bearer",
"expires_in": 3599,
"refresh_token": "mock-refresh_token",
},
)
await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup_entry.mock_calls) == 1