mirror of
https://github.com/home-assistant/core.git
synced 2025-07-08 13:57:10 +00:00
Duke Energy Integration (#125489)
* Duke Energy Integration * add recorder mock fixture to all tests * address PR comments * update tests * add basic coordinator tests * PR comments round 2 * Fix --------- Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
parent
1a21266325
commit
356bca119d
@ -359,6 +359,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/dsmr/ @Robbie1221
|
/tests/components/dsmr/ @Robbie1221
|
||||||
/homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
|
/homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
|
||||||
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
|
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
|
||||||
|
/homeassistant/components/duke_energy/ @hunterjm
|
||||||
|
/tests/components/duke_energy/ @hunterjm
|
||||||
/homeassistant/components/duotecno/ @cereal2nd
|
/homeassistant/components/duotecno/ @cereal2nd
|
||||||
/tests/components/duotecno/ @cereal2nd
|
/tests/components/duotecno/ @cereal2nd
|
||||||
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo
|
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo
|
||||||
|
22
homeassistant/components/duke_energy/__init__.py
Normal file
22
homeassistant/components/duke_energy/__init__.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
"""The Duke Energy integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .coordinator import DukeEnergyConfigEntry, DukeEnergyCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: DukeEnergyConfigEntry) -> bool:
|
||||||
|
"""Set up Duke Energy from a config entry."""
|
||||||
|
|
||||||
|
coordinator = DukeEnergyCoordinator(hass, entry.data)
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
entry.runtime_data = coordinator
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: DukeEnergyConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
return True
|
67
homeassistant/components/duke_energy/config_flow.py
Normal file
67
homeassistant/components/duke_energy/config_flow.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"""Config flow for Duke Energy integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiodukeenergy import DukeEnergy
|
||||||
|
from aiohttp import ClientError, ClientResponseError
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
|
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_USERNAME
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_USERNAME): str,
|
||||||
|
vol.Required(CONF_PASSWORD): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DukeEnergyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Duke Energy."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
if user_input is not None:
|
||||||
|
session = async_get_clientsession(self.hass)
|
||||||
|
api = DukeEnergy(
|
||||||
|
user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
auth = await api.authenticate()
|
||||||
|
except ClientResponseError as e:
|
||||||
|
errors["base"] = "invalid_auth" if e.status == 404 else "cannot_connect"
|
||||||
|
except (ClientError, TimeoutError):
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Unexpected exception")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
else:
|
||||||
|
username = auth["cdp_internal_user_id"].lower()
|
||||||
|
await self.async_set_unique_id(username)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
email = auth["email"].lower()
|
||||||
|
data = {
|
||||||
|
CONF_EMAIL: email,
|
||||||
|
CONF_USERNAME: username,
|
||||||
|
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||||
|
}
|
||||||
|
self._async_abort_entries_match(data)
|
||||||
|
return self.async_create_entry(title=email, data=data)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||||
|
)
|
3
homeassistant/components/duke_energy/const.py
Normal file
3
homeassistant/components/duke_energy/const.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""Constants for the Duke Energy integration."""
|
||||||
|
|
||||||
|
DOMAIN = "duke_energy"
|
222
homeassistant/components/duke_energy/coordinator.py
Normal file
222
homeassistant/components/duke_energy/coordinator.py
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
"""Coordinator to handle Duke Energy connections."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
from types import MappingProxyType
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from aiodukeenergy import DukeEnergy
|
||||||
|
from aiohttp import ClientError
|
||||||
|
|
||||||
|
from homeassistant.components.recorder import get_instance
|
||||||
|
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
|
||||||
|
from homeassistant.components.recorder.statistics import (
|
||||||
|
async_add_external_statistics,
|
||||||
|
get_last_statistics,
|
||||||
|
statistics_during_period,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SUPPORTED_METER_TYPES = ("ELECTRIC",)
|
||||||
|
|
||||||
|
type DukeEnergyConfigEntry = ConfigEntry[DukeEnergyCoordinator]
|
||||||
|
|
||||||
|
|
||||||
|
class DukeEnergyCoordinator(DataUpdateCoordinator[None]):
|
||||||
|
"""Handle inserting statistics."""
|
||||||
|
|
||||||
|
config_entry: DukeEnergyConfigEntry
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry_data: MappingProxyType[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the data handler."""
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
name="Duke Energy",
|
||||||
|
# Data is updated daily on Duke Energy.
|
||||||
|
# Refresh every 12h to be at most 12h behind.
|
||||||
|
update_interval=timedelta(hours=12),
|
||||||
|
)
|
||||||
|
self.api = DukeEnergy(
|
||||||
|
entry_data[CONF_USERNAME],
|
||||||
|
entry_data[CONF_PASSWORD],
|
||||||
|
async_get_clientsession(hass),
|
||||||
|
)
|
||||||
|
self._statistic_ids: set = set()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _dummy_listener() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Force the coordinator to periodically update by registering at least one listener.
|
||||||
|
# Duke Energy does not provide forecast data, so all information is historical.
|
||||||
|
# This makes _async_update_data get periodically called so we can insert statistics.
|
||||||
|
self.async_add_listener(_dummy_listener)
|
||||||
|
|
||||||
|
self.config_entry.async_on_unload(self._clear_statistics)
|
||||||
|
|
||||||
|
def _clear_statistics(self) -> None:
|
||||||
|
"""Clear statistics."""
|
||||||
|
get_instance(self.hass).async_clear_statistics(list(self._statistic_ids))
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> None:
|
||||||
|
"""Insert Duke Energy statistics."""
|
||||||
|
meters: dict[str, dict[str, Any]] = await self.api.get_meters()
|
||||||
|
for serial_number, meter in meters.items():
|
||||||
|
if (
|
||||||
|
not isinstance(meter["serviceType"], str)
|
||||||
|
or meter["serviceType"] not in _SUPPORTED_METER_TYPES
|
||||||
|
):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Skipping unsupported meter type %s", meter["serviceType"]
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
id_prefix = f"{meter["serviceType"].lower()}_{serial_number}"
|
||||||
|
consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption"
|
||||||
|
self._statistic_ids.add(consumption_statistic_id)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Updating Statistics for %s",
|
||||||
|
consumption_statistic_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
last_stat = await get_instance(self.hass).async_add_executor_job(
|
||||||
|
get_last_statistics, self.hass, 1, consumption_statistic_id, True, set()
|
||||||
|
)
|
||||||
|
if not last_stat:
|
||||||
|
_LOGGER.debug("Updating statistic for the first time")
|
||||||
|
usage = await self._async_get_energy_usage(meter)
|
||||||
|
consumption_sum = 0.0
|
||||||
|
last_stats_time = None
|
||||||
|
else:
|
||||||
|
usage = await self._async_get_energy_usage(
|
||||||
|
meter,
|
||||||
|
last_stat[consumption_statistic_id][0]["start"],
|
||||||
|
)
|
||||||
|
if not usage:
|
||||||
|
_LOGGER.debug("No recent usage data. Skipping update")
|
||||||
|
continue
|
||||||
|
stats = await get_instance(self.hass).async_add_executor_job(
|
||||||
|
statistics_during_period,
|
||||||
|
self.hass,
|
||||||
|
min(usage.keys()),
|
||||||
|
None,
|
||||||
|
{consumption_statistic_id},
|
||||||
|
"hour",
|
||||||
|
None,
|
||||||
|
{"sum"},
|
||||||
|
)
|
||||||
|
consumption_sum = cast(float, stats[consumption_statistic_id][0]["sum"])
|
||||||
|
last_stats_time = stats[consumption_statistic_id][0]["start"]
|
||||||
|
|
||||||
|
consumption_statistics = []
|
||||||
|
|
||||||
|
for start, data in usage.items():
|
||||||
|
if last_stats_time is not None and start.timestamp() <= last_stats_time:
|
||||||
|
continue
|
||||||
|
consumption_sum += data["energy"]
|
||||||
|
|
||||||
|
consumption_statistics.append(
|
||||||
|
StatisticData(
|
||||||
|
start=start, state=data["energy"], sum=consumption_sum
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
name_prefix = (
|
||||||
|
f"Duke Energy " f"{meter["serviceType"].capitalize()} {serial_number}"
|
||||||
|
)
|
||||||
|
consumption_metadata = StatisticMetaData(
|
||||||
|
has_mean=False,
|
||||||
|
has_sum=True,
|
||||||
|
name=f"{name_prefix} Consumption",
|
||||||
|
source=DOMAIN,
|
||||||
|
statistic_id=consumption_statistic_id,
|
||||||
|
unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR
|
||||||
|
if meter["serviceType"] == "ELECTRIC"
|
||||||
|
else UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Adding %s statistics for %s",
|
||||||
|
len(consumption_statistics),
|
||||||
|
consumption_statistic_id,
|
||||||
|
)
|
||||||
|
async_add_external_statistics(
|
||||||
|
self.hass, consumption_metadata, consumption_statistics
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_get_energy_usage(
|
||||||
|
self, meter: dict[str, Any], start_time: float | None = None
|
||||||
|
) -> dict[datetime, dict[str, float | int]]:
|
||||||
|
"""Get energy usage.
|
||||||
|
|
||||||
|
If start_time is None, get usage since account activation (or as far back as possible),
|
||||||
|
otherwise since start_time - 30 days to allow corrections in data.
|
||||||
|
|
||||||
|
Duke Energy provides hourly data all the way back to ~3 years.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# All of Duke Energy Service Areas are currently in America/New_York timezone
|
||||||
|
# May need to re-think this if that ever changes and determine timezone based
|
||||||
|
# on the service address somehow.
|
||||||
|
tz = await dt_util.async_get_time_zone("America/New_York")
|
||||||
|
lookback = timedelta(days=30)
|
||||||
|
one = timedelta(days=1)
|
||||||
|
if start_time is None:
|
||||||
|
# Max 3 years of data
|
||||||
|
agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"])
|
||||||
|
if agreement_date is None:
|
||||||
|
start = dt_util.now(tz) - timedelta(days=3 * 365)
|
||||||
|
else:
|
||||||
|
start = max(
|
||||||
|
agreement_date.replace(tzinfo=tz),
|
||||||
|
dt_util.now(tz) - timedelta(days=3 * 365),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
start = datetime.fromtimestamp(start_time, tz=tz) - lookback
|
||||||
|
|
||||||
|
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
end = dt_util.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) - one
|
||||||
|
_LOGGER.debug("Data lookup range: %s - %s", start, end)
|
||||||
|
|
||||||
|
start_step = end - lookback
|
||||||
|
end_step = end
|
||||||
|
usage: dict[datetime, dict[str, float | int]] = {}
|
||||||
|
while True:
|
||||||
|
_LOGGER.debug("Getting hourly usage: %s - %s", start_step, end_step)
|
||||||
|
try:
|
||||||
|
# Get data
|
||||||
|
results = await self.api.get_energy_usage(
|
||||||
|
meter["serialNum"], "HOURLY", "DAY", start_step, end_step
|
||||||
|
)
|
||||||
|
usage = {**results["data"], **usage}
|
||||||
|
|
||||||
|
for missing in results["missing"]:
|
||||||
|
_LOGGER.debug("Missing data: %s", missing)
|
||||||
|
|
||||||
|
# Set next range
|
||||||
|
end_step = start_step - one
|
||||||
|
start_step = max(start_step - lookback, start)
|
||||||
|
|
||||||
|
# Make sure we don't go back too far
|
||||||
|
if end_step < start:
|
||||||
|
break
|
||||||
|
except (TimeoutError, ClientError):
|
||||||
|
# ClientError is raised when there is no more data for the range
|
||||||
|
break
|
||||||
|
|
||||||
|
_LOGGER.debug("Got %s meter usage reads", len(usage))
|
||||||
|
return usage
|
10
homeassistant/components/duke_energy/manifest.json
Normal file
10
homeassistant/components/duke_energy/manifest.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"domain": "duke_energy",
|
||||||
|
"name": "Duke Energy",
|
||||||
|
"codeowners": ["@hunterjm"],
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": ["recorder"],
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/duke_energy",
|
||||||
|
"iot_class": "cloud_polling",
|
||||||
|
"requirements": ["aiodukeenergy==0.2.2"]
|
||||||
|
}
|
20
homeassistant/components/duke_energy/strings.json
Normal file
20
homeassistant/components/duke_energy/strings.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -139,6 +139,7 @@ FLOWS = {
|
|||||||
"drop_connect",
|
"drop_connect",
|
||||||
"dsmr",
|
"dsmr",
|
||||||
"dsmr_reader",
|
"dsmr_reader",
|
||||||
|
"duke_energy",
|
||||||
"dunehd",
|
"dunehd",
|
||||||
"duotecno",
|
"duotecno",
|
||||||
"dwd_weather_warnings",
|
"dwd_weather_warnings",
|
||||||
|
@ -1375,6 +1375,12 @@
|
|||||||
"config_flow": false,
|
"config_flow": false,
|
||||||
"iot_class": "cloud_polling"
|
"iot_class": "cloud_polling"
|
||||||
},
|
},
|
||||||
|
"duke_energy": {
|
||||||
|
"name": "Duke Energy",
|
||||||
|
"integration_type": "hub",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "cloud_polling"
|
||||||
|
},
|
||||||
"dunehd": {
|
"dunehd": {
|
||||||
"name": "Dune HD",
|
"name": "Dune HD",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
@ -221,6 +221,9 @@ aiodiscover==2.1.0
|
|||||||
# homeassistant.components.dnsip
|
# homeassistant.components.dnsip
|
||||||
aiodns==3.2.0
|
aiodns==3.2.0
|
||||||
|
|
||||||
|
# homeassistant.components.duke_energy
|
||||||
|
aiodukeenergy==0.2.2
|
||||||
|
|
||||||
# homeassistant.components.eafm
|
# homeassistant.components.eafm
|
||||||
aioeafm==0.1.2
|
aioeafm==0.1.2
|
||||||
|
|
||||||
|
@ -209,6 +209,9 @@ aiodiscover==2.1.0
|
|||||||
# homeassistant.components.dnsip
|
# homeassistant.components.dnsip
|
||||||
aiodns==3.2.0
|
aiodns==3.2.0
|
||||||
|
|
||||||
|
# homeassistant.components.duke_energy
|
||||||
|
aiodukeenergy==0.2.2
|
||||||
|
|
||||||
# homeassistant.components.eafm
|
# homeassistant.components.eafm
|
||||||
aioeafm==0.1.2
|
aioeafm==0.1.2
|
||||||
|
|
||||||
|
1
tests/components/duke_energy/__init__.py
Normal file
1
tests/components/duke_energy/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for the Duke Energy integration."""
|
90
tests/components/duke_energy/conftest.py
Normal file
90
tests/components/duke_energy/conftest.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"""Common fixtures for the Duke Energy tests."""
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.duke_energy.const import DOMAIN
|
||||||
|
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_USERNAME
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
from tests.typing import RecorderInstanceGenerator
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mock_recorder_before_hass(
|
||||||
|
async_test_recorder: RecorderInstanceGenerator,
|
||||||
|
) -> None:
|
||||||
|
"""Set up recorder."""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||||
|
"""Override async_setup_entry."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.duke_energy.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
) as mock_setup_entry:
|
||||||
|
yield mock_setup_entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config_entry(hass: HomeAssistant) -> Generator[AsyncMock]:
|
||||||
|
"""Return the default mocked config entry."""
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
CONF_EMAIL: "test@example.com",
|
||||||
|
CONF_USERNAME: "test-username",
|
||||||
|
CONF_PASSWORD: "test-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
return config_entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_api() -> Generator[AsyncMock]:
|
||||||
|
"""Mock a successful Duke Energy API."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.duke_energy.config_flow.DukeEnergy",
|
||||||
|
autospec=True,
|
||||||
|
) as mock_api,
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.duke_energy.coordinator.DukeEnergy",
|
||||||
|
new=mock_api,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
api = mock_api.return_value
|
||||||
|
api.authenticate.return_value = {
|
||||||
|
"email": "TEST@EXAMPLE.COM",
|
||||||
|
"cdp_internal_user_id": "test-username",
|
||||||
|
}
|
||||||
|
api.get_meters.return_value = {}
|
||||||
|
yield api
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_api_with_meters(mock_api: AsyncMock) -> AsyncMock:
|
||||||
|
"""Mock a successful Duke Energy API with meters."""
|
||||||
|
mock_api.get_meters.return_value = {
|
||||||
|
"123": {
|
||||||
|
"serialNum": "123",
|
||||||
|
"serviceType": "ELECTRIC",
|
||||||
|
"agreementActiveDate": "2000-01-01",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_api.get_energy_usage.return_value = {
|
||||||
|
"data": {
|
||||||
|
dt_util.now(): {
|
||||||
|
"energy": 1.3,
|
||||||
|
"temperature": 70,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"missing": [],
|
||||||
|
}
|
||||||
|
return mock_api
|
118
tests/components/duke_energy/test_config_flow.py
Normal file
118
tests/components/duke_energy/test_config_flow.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
"""Test the Duke Energy config flow."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
from aiohttp import ClientError, ClientResponseError
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components.duke_energy.const import DOMAIN
|
||||||
|
from homeassistant.components.recorder import Recorder
|
||||||
|
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_USERNAME
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
recorder_mock: Recorder,
|
||||||
|
mock_api: AsyncMock,
|
||||||
|
mock_setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test user config."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result.get("type") is FlowResultType.FORM
|
||||||
|
assert result.get("step_id") == "user"
|
||||||
|
|
||||||
|
# test with all provided
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"},
|
||||||
|
)
|
||||||
|
assert result.get("type") is FlowResultType.CREATE_ENTRY
|
||||||
|
assert result.get("title") == "test@example.com"
|
||||||
|
|
||||||
|
data = result.get("data")
|
||||||
|
assert data
|
||||||
|
assert data[CONF_USERNAME] == "test-username"
|
||||||
|
assert data[CONF_PASSWORD] == "test-password"
|
||||||
|
assert data[CONF_EMAIL] == "test@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_abort_if_already_setup(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
recorder_mock: Recorder,
|
||||||
|
mock_api: AsyncMock,
|
||||||
|
mock_config_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test we abort if the email is already setup."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_USER},
|
||||||
|
data={
|
||||||
|
CONF_USERNAME: "test-username",
|
||||||
|
CONF_PASSWORD: "test-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result
|
||||||
|
assert result.get("type") is FlowResultType.ABORT
|
||||||
|
assert result.get("reason") == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_abort_if_already_setup_alternate_username(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
recorder_mock: Recorder,
|
||||||
|
mock_api: AsyncMock,
|
||||||
|
mock_config_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test we abort if the email is already setup."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_USER},
|
||||||
|
data={
|
||||||
|
CONF_USERNAME: "test@example.com",
|
||||||
|
CONF_PASSWORD: "test-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result
|
||||||
|
assert result.get("type") is FlowResultType.ABORT
|
||||||
|
assert result.get("reason") == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("side_effect", "expected_error"),
|
||||||
|
[
|
||||||
|
(ClientResponseError(None, None, status=404), "invalid_auth"),
|
||||||
|
(ClientResponseError(None, None, status=500), "cannot_connect"),
|
||||||
|
(TimeoutError(), "cannot_connect"),
|
||||||
|
(ClientError(), "cannot_connect"),
|
||||||
|
(Exception(), "unknown"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_api_errors(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
recorder_mock: Recorder,
|
||||||
|
mock_api: Mock,
|
||||||
|
side_effect,
|
||||||
|
expected_error,
|
||||||
|
) -> None:
|
||||||
|
"""Test the failure scenarios."""
|
||||||
|
mock_api.authenticate.side_effect = side_effect
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_USER},
|
||||||
|
data={CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"},
|
||||||
|
)
|
||||||
|
assert result.get("type") is FlowResultType.FORM
|
||||||
|
assert result.get("errors") == {"base": expected_error}
|
||||||
|
|
||||||
|
mock_api.authenticate.side_effect = None
|
||||||
|
|
||||||
|
# test with all provided
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"},
|
||||||
|
)
|
||||||
|
assert result.get("type") is FlowResultType.CREATE_ENTRY
|
44
tests/components/duke_energy/test_coordinator.py
Normal file
44
tests/components/duke_energy/test_coordinator.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"""Tests for the SolarEdge coordinator services."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
|
|
||||||
|
from homeassistant.components.recorder import Recorder
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
mock_api_with_meters: Mock,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
recorder_mock: Recorder,
|
||||||
|
) -> None:
|
||||||
|
"""Test Coordinator."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
|
||||||
|
assert mock_api_with_meters.get_meters.call_count == 1
|
||||||
|
# 3 years of data
|
||||||
|
assert mock_api_with_meters.get_energy_usage.call_count == 37
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.duke_energy.coordinator.get_last_statistics",
|
||||||
|
return_value={
|
||||||
|
"duke_energy:electric_123_energy_consumption": [
|
||||||
|
{"start": dt_util.now().timestamp()}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
):
|
||||||
|
freezer.tick(timedelta(hours=12))
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
|
|
||||||
|
assert mock_api_with_meters.get_meters.call_count == 2
|
||||||
|
# Now have stats, so only one call
|
||||||
|
assert mock_api_with_meters.get_energy_usage.call_count == 38
|
Loading…
x
Reference in New Issue
Block a user