From 6ef7c5ece6e317737901c482ccac8f4875bb3c73 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Tue, 25 Jul 2023 20:46:53 +1200 Subject: [PATCH] Add electric kiwi integration (#81149) Co-authored-by: Franck Nijhof --- .coveragerc | 5 + .strict-typing | 1 + CODEOWNERS | 2 + .../components/electric_kiwi/__init__.py | 65 ++++++ homeassistant/components/electric_kiwi/api.py | 33 ++++ .../electric_kiwi/application_credentials.py | 38 ++++ .../components/electric_kiwi/config_flow.py | 59 ++++++ .../components/electric_kiwi/const.py | 11 ++ .../components/electric_kiwi/coordinator.py | 81 ++++++++ .../components/electric_kiwi/manifest.json | 11 ++ .../components/electric_kiwi/oauth2.py | 76 +++++++ .../components/electric_kiwi/sensor.py | 113 +++++++++++ .../components/electric_kiwi/strings.json | 36 ++++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/electric_kiwi/__init__.py | 1 + tests/components/electric_kiwi/conftest.py | 63 ++++++ .../electric_kiwi/test_config_flow.py | 187 ++++++++++++++++++ 22 files changed, 806 insertions(+) create mode 100644 homeassistant/components/electric_kiwi/__init__.py create mode 100644 homeassistant/components/electric_kiwi/api.py create mode 100644 homeassistant/components/electric_kiwi/application_credentials.py create mode 100644 homeassistant/components/electric_kiwi/config_flow.py create mode 100644 homeassistant/components/electric_kiwi/const.py create mode 100644 homeassistant/components/electric_kiwi/coordinator.py create mode 100644 homeassistant/components/electric_kiwi/manifest.json create mode 100644 homeassistant/components/electric_kiwi/oauth2.py create mode 100644 homeassistant/components/electric_kiwi/sensor.py create mode 100644 homeassistant/components/electric_kiwi/strings.json create mode 100644 tests/components/electric_kiwi/__init__.py create mode 100644 tests/components/electric_kiwi/conftest.py create mode 100644 tests/components/electric_kiwi/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 05a86ddebd1..30f768e01a4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/.strict-typing b/.strict-typing index 9818e3d3197..dffeb08e014 100644 --- a/.strict-typing +++ b/.strict-typing @@ -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.* diff --git a/CODEOWNERS b/CODEOWNERS index 918ad4c2343..ef9634e1527 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/electric_kiwi/__init__.py b/homeassistant/components/electric_kiwi/__init__.py new file mode 100644 index 00000000000..3ae6b1c70cf --- /dev/null +++ b/homeassistant/components/electric_kiwi/__init__.py @@ -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 diff --git a/homeassistant/components/electric_kiwi/api.py b/homeassistant/components/electric_kiwi/api.py new file mode 100644 index 00000000000..89109f01948 --- /dev/null +++ b/homeassistant/components/electric_kiwi/api.py @@ -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"]) diff --git a/homeassistant/components/electric_kiwi/application_credentials.py b/homeassistant/components/electric_kiwi/application_credentials.py new file mode 100644 index 00000000000..4a3ef8aa1c5 --- /dev/null +++ b/homeassistant/components/electric_kiwi/application_credentials.py @@ -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/" + } diff --git a/homeassistant/components/electric_kiwi/config_flow.py b/homeassistant/components/electric_kiwi/config_flow.py new file mode 100644 index 00000000000..c2c80aaa402 --- /dev/null +++ b/homeassistant/components/electric_kiwi/config_flow.py @@ -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) diff --git a/homeassistant/components/electric_kiwi/const.py b/homeassistant/components/electric_kiwi/const.py new file mode 100644 index 00000000000..907b6247172 --- /dev/null +++ b/homeassistant/components/electric_kiwi/const.py @@ -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" diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py new file mode 100644 index 00000000000..3e0ba997cd4 --- /dev/null +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -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 diff --git a/homeassistant/components/electric_kiwi/manifest.json b/homeassistant/components/electric_kiwi/manifest.json new file mode 100644 index 00000000000..8ddb4c1af7c --- /dev/null +++ b/homeassistant/components/electric_kiwi/manifest.json @@ -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"] +} diff --git a/homeassistant/components/electric_kiwi/oauth2.py b/homeassistant/components/electric_kiwi/oauth2.py new file mode 100644 index 00000000000..ce3e473159a --- /dev/null +++ b/homeassistant/components/electric_kiwi/oauth2.py @@ -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 diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py new file mode 100644 index 00000000000..4f32f237c00 --- /dev/null +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -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) diff --git a/homeassistant/components/electric_kiwi/strings.json b/homeassistant/components/electric_kiwi/strings.json new file mode 100644 index 00000000000..19056180f17 --- /dev/null +++ b/homeassistant/components/electric_kiwi/strings.json @@ -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" + } + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index d1b330b5dbe..78c98bcc03d 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -4,6 +4,7 @@ To update, run python3 -m script.hassfest """ APPLICATION_CREDENTIALS = [ + "electric_kiwi", "geocaching", "google", "google_assistant_sdk", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6d9a132a29a..7283b187ba0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -117,6 +117,7 @@ FLOWS = { "efergy", "eight_sleep", "electrasmart", + "electric_kiwi", "elgato", "elkm1", "elmax", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 99566340ccd..18e7f1c22e1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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": { diff --git a/mypy.ini b/mypy.ini index 66568cf5400..7d1ec19c4d5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index d4836ea1522..f64d8f8e66b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d3a9d3819f..440d4a22c0c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/electric_kiwi/__init__.py b/tests/components/electric_kiwi/__init__.py new file mode 100644 index 00000000000..7f5e08a56b5 --- /dev/null +++ b/tests/components/electric_kiwi/__init__.py @@ -0,0 +1 @@ +"""Tests for the Electric Kiwi integration.""" diff --git a/tests/components/electric_kiwi/conftest.py b/tests/components/electric_kiwi/conftest.py new file mode 100644 index 00000000000..525f5742382 --- /dev/null +++ b/tests/components/electric_kiwi/conftest.py @@ -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 diff --git a/tests/components/electric_kiwi/test_config_flow.py b/tests/components/electric_kiwi/test_config_flow.py new file mode 100644 index 00000000000..51d00722341 --- /dev/null +++ b/tests/components/electric_kiwi/test_config_flow.py @@ -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