mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
Add electric kiwi integration (#81149)
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
This commit is contained in:
parent
04f6d1848b
commit
6ef7c5ece6
@ -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
|
||||
|
@ -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.*
|
||||
|
@ -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
|
||||
|
65
homeassistant/components/electric_kiwi/__init__.py
Normal file
65
homeassistant/components/electric_kiwi/__init__.py
Normal 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
|
33
homeassistant/components/electric_kiwi/api.py
Normal file
33
homeassistant/components/electric_kiwi/api.py
Normal 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"])
|
@ -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/"
|
||||
}
|
59
homeassistant/components/electric_kiwi/config_flow.py
Normal file
59
homeassistant/components/electric_kiwi/config_flow.py
Normal 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)
|
11
homeassistant/components/electric_kiwi/const.py
Normal file
11
homeassistant/components/electric_kiwi/const.py
Normal 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"
|
81
homeassistant/components/electric_kiwi/coordinator.py
Normal file
81
homeassistant/components/electric_kiwi/coordinator.py
Normal 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
|
11
homeassistant/components/electric_kiwi/manifest.json
Normal file
11
homeassistant/components/electric_kiwi/manifest.json
Normal 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"]
|
||||
}
|
76
homeassistant/components/electric_kiwi/oauth2.py
Normal file
76
homeassistant/components/electric_kiwi/oauth2.py
Normal 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
|
113
homeassistant/components/electric_kiwi/sensor.py
Normal file
113
homeassistant/components/electric_kiwi/sensor.py
Normal 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)
|
36
homeassistant/components/electric_kiwi/strings.json
Normal file
36
homeassistant/components/electric_kiwi/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -4,6 +4,7 @@ To update, run python3 -m script.hassfest
|
||||
"""
|
||||
|
||||
APPLICATION_CREDENTIALS = [
|
||||
"electric_kiwi",
|
||||
"geocaching",
|
||||
"google",
|
||||
"google_assistant_sdk",
|
||||
|
@ -117,6 +117,7 @@ FLOWS = {
|
||||
"efergy",
|
||||
"eight_sleep",
|
||||
"electrasmart",
|
||||
"electric_kiwi",
|
||||
"elgato",
|
||||
"elkm1",
|
||||
"elmax",
|
||||
|
@ -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": {
|
||||
|
10
mypy.ini
10
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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
1
tests/components/electric_kiwi/__init__.py
Normal file
1
tests/components/electric_kiwi/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the Electric Kiwi integration."""
|
63
tests/components/electric_kiwi/conftest.py
Normal file
63
tests/components/electric_kiwi/conftest.py
Normal 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
|
187
tests/components/electric_kiwi/test_config_flow.py
Normal file
187
tests/components/electric_kiwi/test_config_flow.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user