mirror of
https://github.com/home-assistant/core.git
synced 2025-07-17 10:17:09 +00:00
Add Opower integration for getting electricity/gas usage and cost for many utilities (#90489)
* Create Opower integration * fix tests * Update config_flow.py * Update coordinator.py * Update sensor.py * Update sensor.py * Update coordinator.py * Bump opower==0.0.4 * Ignore errors for "recent" PGE accounts * Add type for forecasts * Bump opower to 0.0.5 * Bump opower to 0.0.6 * Bump opower to 0.0.7 * Update requirements_all.txt * Update requirements_test_all.txt * Update coordinator Fix exception caused by https://github.com/home-assistant/core/pull/92095 {} is dict but the function expects a set so change it to set() * Improve exceptions handling * Bump opower==0.0.9 * Bump opower to 0.0.10 * Bump opower to 0.0.11 * fix issue when integration hasn't run for 30 days use last stat time instead of now when fetching recent usage/cost * Allow username to be changed in reauth * Don't allow changing username in reauth flow
This commit is contained in:
parent
1ead95f5ea
commit
caaeb28cbb
@ -859,6 +859,9 @@ omit =
|
|||||||
homeassistant/components/openweathermap/sensor.py
|
homeassistant/components/openweathermap/sensor.py
|
||||||
homeassistant/components/openweathermap/weather_update_coordinator.py
|
homeassistant/components/openweathermap/weather_update_coordinator.py
|
||||||
homeassistant/components/opnsense/__init__.py
|
homeassistant/components/opnsense/__init__.py
|
||||||
|
homeassistant/components/opower/__init__.py
|
||||||
|
homeassistant/components/opower/coordinator.py
|
||||||
|
homeassistant/components/opower/sensor.py
|
||||||
homeassistant/components/opnsense/device_tracker.py
|
homeassistant/components/opnsense/device_tracker.py
|
||||||
homeassistant/components/opple/light.py
|
homeassistant/components/opple/light.py
|
||||||
homeassistant/components/oru/*
|
homeassistant/components/oru/*
|
||||||
|
@ -897,6 +897,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/openweathermap/ @fabaff @freekode @nzapponi
|
/tests/components/openweathermap/ @fabaff @freekode @nzapponi
|
||||||
/homeassistant/components/opnsense/ @mtreinish
|
/homeassistant/components/opnsense/ @mtreinish
|
||||||
/tests/components/opnsense/ @mtreinish
|
/tests/components/opnsense/ @mtreinish
|
||||||
|
/homeassistant/components/opower/ @tronikos
|
||||||
|
/tests/components/opower/ @tronikos
|
||||||
/homeassistant/components/oralb/ @bdraco @Lash-L
|
/homeassistant/components/oralb/ @bdraco @Lash-L
|
||||||
/tests/components/oralb/ @bdraco @Lash-L
|
/tests/components/oralb/ @bdraco @Lash-L
|
||||||
/homeassistant/components/oru/ @bvlaicu
|
/homeassistant/components/oru/ @bvlaicu
|
||||||
|
31
homeassistant/components/opower/__init__.py
Normal file
31
homeassistant/components/opower/__init__.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"""The Opower integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import OpowerCoordinator
|
||||||
|
|
||||||
|
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up Opower from a config entry."""
|
||||||
|
|
||||||
|
coordinator = OpowerCoordinator(hass, entry.data)
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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
|
112
homeassistant/components/opower/config_flow.py
Normal file
112
homeassistant/components/opower/config_flow.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
"""Config flow for Opower integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from opower import CannotConnect, InvalidAuth, Opower, get_supported_utility_names
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||||
|
|
||||||
|
from .const import CONF_UTILITY, DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names()),
|
||||||
|
vol.Required(CONF_USERNAME): str,
|
||||||
|
vol.Required(CONF_PASSWORD): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_login(
|
||||||
|
hass: HomeAssistant, login_data: dict[str, str]
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Validate login data and return any errors."""
|
||||||
|
api = Opower(
|
||||||
|
async_create_clientsession(hass),
|
||||||
|
login_data[CONF_UTILITY],
|
||||||
|
login_data[CONF_USERNAME],
|
||||||
|
login_data[CONF_PASSWORD],
|
||||||
|
)
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
try:
|
||||||
|
await api.async_login()
|
||||||
|
except InvalidAuth:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
except CannotConnect:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
class OpowerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Opower."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize a new OpowerConfigFlow."""
|
||||||
|
self.reauth_entry: config_entries.ConfigEntry | None = None
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
if user_input is not None:
|
||||||
|
self._async_abort_entries_match(
|
||||||
|
{
|
||||||
|
CONF_UTILITY: user_input[CONF_UTILITY],
|
||||||
|
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
errors = await _validate_login(self.hass, user_input)
|
||||||
|
if not errors:
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=f"{user_input[CONF_UTILITY]} ({user_input[CONF_USERNAME]})",
|
||||||
|
data=user_input,
|
||||||
|
)
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
|
||||||
|
"""Handle configuration by re-auth."""
|
||||||
|
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."""
|
||||||
|
assert self.reauth_entry
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
if user_input is not None:
|
||||||
|
data = {**self.reauth_entry.data, **user_input}
|
||||||
|
errors = await _validate_login(self.hass, data)
|
||||||
|
if not errors:
|
||||||
|
self.hass.config_entries.async_update_entry(
|
||||||
|
self.reauth_entry, data=data
|
||||||
|
)
|
||||||
|
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
|
||||||
|
return self.async_abort(reason="reauth_successful")
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="reauth_confirm",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_USERNAME): self.reauth_entry.data[CONF_USERNAME],
|
||||||
|
vol.Required(CONF_PASSWORD): str,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
errors=errors,
|
||||||
|
)
|
5
homeassistant/components/opower/const.py
Normal file
5
homeassistant/components/opower/const.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""Constants for the Opower integration."""
|
||||||
|
|
||||||
|
DOMAIN = "opower"
|
||||||
|
|
||||||
|
CONF_UTILITY = "utility"
|
220
homeassistant/components/opower/coordinator.py
Normal file
220
homeassistant/components/opower/coordinator.py
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
"""Coordinator to handle Opower connections."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
from types import MappingProxyType
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from opower import (
|
||||||
|
Account,
|
||||||
|
AggregateType,
|
||||||
|
CostRead,
|
||||||
|
Forecast,
|
||||||
|
InvalidAuth,
|
||||||
|
MeterType,
|
||||||
|
Opower,
|
||||||
|
)
|
||||||
|
|
||||||
|
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.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
|
from homeassistant.helpers import aiohttp_client
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
|
from .const import CONF_UTILITY, DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OpowerCoordinator(DataUpdateCoordinator):
|
||||||
|
"""Handle fetching Opower data, updating sensors and inserting statistics."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry_data: MappingProxyType[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the data handler."""
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
name="Opower",
|
||||||
|
# Data is updated daily on Opower.
|
||||||
|
# Refresh every 12h to be at most 12h behind.
|
||||||
|
update_interval=timedelta(hours=12),
|
||||||
|
)
|
||||||
|
self.api = Opower(
|
||||||
|
aiohttp_client.async_get_clientsession(hass),
|
||||||
|
entry_data[CONF_UTILITY],
|
||||||
|
entry_data[CONF_USERNAME],
|
||||||
|
entry_data[CONF_PASSWORD],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_update_data(
|
||||||
|
self,
|
||||||
|
) -> dict[str, Forecast]:
|
||||||
|
"""Fetch data from API endpoint."""
|
||||||
|
try:
|
||||||
|
# Login expires after a few minutes.
|
||||||
|
# Given the infrequent updating (every 12h)
|
||||||
|
# assume previous session has expired and re-login.
|
||||||
|
await self.api.async_login()
|
||||||
|
except InvalidAuth as err:
|
||||||
|
raise ConfigEntryAuthFailed from err
|
||||||
|
forecasts: list[Forecast] = await self.api.async_get_forecast()
|
||||||
|
_LOGGER.debug("Updating sensor data with: %s", forecasts)
|
||||||
|
await self._insert_statistics([forecast.account for forecast in forecasts])
|
||||||
|
return {forecast.account.utility_account_id: forecast for forecast in forecasts}
|
||||||
|
|
||||||
|
async def _insert_statistics(self, accounts: list[Account]) -> None:
|
||||||
|
"""Insert Opower statistics."""
|
||||||
|
for account in accounts:
|
||||||
|
id_prefix = "_".join(
|
||||||
|
(
|
||||||
|
self.api.utility.subdomain(),
|
||||||
|
account.meter_type.name.lower(),
|
||||||
|
account.utility_account_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cost_statistic_id = f"{DOMAIN}:{id_prefix}_energy_cost"
|
||||||
|
consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption"
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Updating Statistics for %s and %s",
|
||||||
|
cost_statistic_id,
|
||||||
|
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")
|
||||||
|
cost_reads = await self._async_get_all_cost_reads(account)
|
||||||
|
cost_sum = 0.0
|
||||||
|
consumption_sum = 0.0
|
||||||
|
last_stats_time = None
|
||||||
|
else:
|
||||||
|
cost_reads = await self._async_get_recent_cost_reads(
|
||||||
|
account, last_stat[consumption_statistic_id][0]["start"]
|
||||||
|
)
|
||||||
|
if not cost_reads:
|
||||||
|
_LOGGER.debug("No recent usage/cost data. Skipping update")
|
||||||
|
continue
|
||||||
|
stats = await get_instance(self.hass).async_add_executor_job(
|
||||||
|
statistics_during_period,
|
||||||
|
self.hass,
|
||||||
|
cost_reads[0].start_time,
|
||||||
|
None,
|
||||||
|
{cost_statistic_id, consumption_statistic_id},
|
||||||
|
"hour" if account.meter_type == MeterType.ELEC else "day",
|
||||||
|
None,
|
||||||
|
{"sum"},
|
||||||
|
)
|
||||||
|
cost_sum = cast(float, stats[cost_statistic_id][0]["sum"])
|
||||||
|
consumption_sum = cast(float, stats[consumption_statistic_id][0]["sum"])
|
||||||
|
last_stats_time = stats[cost_statistic_id][0]["start"]
|
||||||
|
|
||||||
|
cost_statistics = []
|
||||||
|
consumption_statistics = []
|
||||||
|
|
||||||
|
for cost_read in cost_reads:
|
||||||
|
start = cost_read.start_time
|
||||||
|
if last_stats_time is not None and start.timestamp() <= last_stats_time:
|
||||||
|
continue
|
||||||
|
cost_sum += cost_read.provided_cost
|
||||||
|
consumption_sum += cost_read.consumption
|
||||||
|
|
||||||
|
cost_statistics.append(
|
||||||
|
StatisticData(
|
||||||
|
start=start, state=cost_read.provided_cost, sum=cost_sum
|
||||||
|
)
|
||||||
|
)
|
||||||
|
consumption_statistics.append(
|
||||||
|
StatisticData(
|
||||||
|
start=start, state=cost_read.consumption, sum=consumption_sum
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
name_prefix = " ".join(
|
||||||
|
(
|
||||||
|
"Opower",
|
||||||
|
self.api.utility.subdomain(),
|
||||||
|
account.meter_type.name.lower(),
|
||||||
|
account.utility_account_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cost_metadata = StatisticMetaData(
|
||||||
|
has_mean=False,
|
||||||
|
has_sum=True,
|
||||||
|
name=f"{name_prefix} cost",
|
||||||
|
source=DOMAIN,
|
||||||
|
statistic_id=cost_statistic_id,
|
||||||
|
unit_of_measurement=None,
|
||||||
|
)
|
||||||
|
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 account.meter_type == MeterType.ELEC
|
||||||
|
else UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||||
|
)
|
||||||
|
|
||||||
|
async_add_external_statistics(self.hass, cost_metadata, cost_statistics)
|
||||||
|
async_add_external_statistics(
|
||||||
|
self.hass, consumption_metadata, consumption_statistics
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_get_all_cost_reads(self, account: Account) -> list[CostRead]:
|
||||||
|
"""Get all cost reads since account activation but at different resolutions depending on age.
|
||||||
|
|
||||||
|
- month resolution for all years (since account activation)
|
||||||
|
- day resolution for past 3 years
|
||||||
|
- hour resolution for past 2 months, only for electricity, not gas
|
||||||
|
"""
|
||||||
|
cost_reads = []
|
||||||
|
start = None
|
||||||
|
end = datetime.now() - timedelta(days=3 * 365)
|
||||||
|
cost_reads += await self.api.async_get_cost_reads(
|
||||||
|
account, AggregateType.BILL, start, end
|
||||||
|
)
|
||||||
|
start = end if not cost_reads else cost_reads[-1].end_time
|
||||||
|
end = (
|
||||||
|
datetime.now() - timedelta(days=2 * 30)
|
||||||
|
if account.meter_type == MeterType.ELEC
|
||||||
|
else datetime.now()
|
||||||
|
)
|
||||||
|
cost_reads += await self.api.async_get_cost_reads(
|
||||||
|
account, AggregateType.DAY, start, end
|
||||||
|
)
|
||||||
|
if account.meter_type == MeterType.ELEC:
|
||||||
|
start = end if not cost_reads else cost_reads[-1].end_time
|
||||||
|
end = datetime.now()
|
||||||
|
cost_reads += await self.api.async_get_cost_reads(
|
||||||
|
account, AggregateType.HOUR, start, end
|
||||||
|
)
|
||||||
|
return cost_reads
|
||||||
|
|
||||||
|
async def _async_get_recent_cost_reads(
|
||||||
|
self, account: Account, last_stat_time: float
|
||||||
|
) -> list[CostRead]:
|
||||||
|
"""Get cost reads within the past 30 days to allow corrections in data from utilities.
|
||||||
|
|
||||||
|
Hourly for electricity, daily for gas.
|
||||||
|
"""
|
||||||
|
return await self.api.async_get_cost_reads(
|
||||||
|
account,
|
||||||
|
AggregateType.HOUR
|
||||||
|
if account.meter_type == MeterType.ELEC
|
||||||
|
else AggregateType.DAY,
|
||||||
|
datetime.fromtimestamp(last_stat_time) - timedelta(days=30),
|
||||||
|
datetime.now(),
|
||||||
|
)
|
10
homeassistant/components/opower/manifest.json
Normal file
10
homeassistant/components/opower/manifest.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"domain": "opower",
|
||||||
|
"name": "Opower",
|
||||||
|
"codeowners": ["@tronikos"],
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": ["recorder"],
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/opower",
|
||||||
|
"iot_class": "cloud_polling",
|
||||||
|
"requirements": ["opower==0.0.11"]
|
||||||
|
}
|
219
homeassistant/components/opower/sensor.py
Normal file
219
homeassistant/components/opower/sensor.py
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
"""Support for Opower sensors."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from opower import Forecast, MeterType, UnitOfMeasure
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import (
|
||||||
|
SensorDeviceClass,
|
||||||
|
SensorEntity,
|
||||||
|
SensorEntityDescription,
|
||||||
|
SensorStateClass,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import UnitOfEnergy, UnitOfVolume
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.typing import StateType
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import OpowerCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OpowerEntityDescriptionMixin:
|
||||||
|
"""Mixin values for required keys."""
|
||||||
|
|
||||||
|
value_fn: Callable[[Forecast], str | float]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OpowerEntityDescription(SensorEntityDescription, OpowerEntityDescriptionMixin):
|
||||||
|
"""Class describing Opower sensors entities."""
|
||||||
|
|
||||||
|
|
||||||
|
# suggested_display_precision=0 for all sensors since
|
||||||
|
# Opower provides 0 decimal points for all these.
|
||||||
|
# (for the statistics in the energy dashboard Opower does provide decimal points)
|
||||||
|
ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||||
|
OpowerEntityDescription(
|
||||||
|
key="elec_usage_to_date",
|
||||||
|
name="Current bill electric usage to date",
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
suggested_display_precision=0,
|
||||||
|
value_fn=lambda data: data.usage_to_date,
|
||||||
|
),
|
||||||
|
OpowerEntityDescription(
|
||||||
|
key="elec_forecasted_usage",
|
||||||
|
name="Current bill electric forecasted usage",
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
suggested_display_precision=0,
|
||||||
|
value_fn=lambda data: data.forecasted_usage,
|
||||||
|
),
|
||||||
|
OpowerEntityDescription(
|
||||||
|
key="elec_typical_usage",
|
||||||
|
name="Typical monthly electric usage",
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
suggested_display_precision=0,
|
||||||
|
value_fn=lambda data: data.typical_usage,
|
||||||
|
),
|
||||||
|
OpowerEntityDescription(
|
||||||
|
key="elec_cost_to_date",
|
||||||
|
name="Current bill electric cost to date",
|
||||||
|
device_class=SensorDeviceClass.MONETARY,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
suggested_display_precision=0,
|
||||||
|
value_fn=lambda data: data.cost_to_date,
|
||||||
|
),
|
||||||
|
OpowerEntityDescription(
|
||||||
|
key="elec_forecasted_cost",
|
||||||
|
name="Current bill electric forecasted cost",
|
||||||
|
device_class=SensorDeviceClass.MONETARY,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
suggested_display_precision=0,
|
||||||
|
value_fn=lambda data: data.forecasted_cost,
|
||||||
|
),
|
||||||
|
OpowerEntityDescription(
|
||||||
|
key="elec_typical_cost",
|
||||||
|
name="Typical monthly electric cost",
|
||||||
|
device_class=SensorDeviceClass.MONETARY,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
suggested_display_precision=0,
|
||||||
|
value_fn=lambda data: data.typical_cost,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||||
|
OpowerEntityDescription(
|
||||||
|
key="gas_usage_to_date",
|
||||||
|
name="Current bill gas usage to date",
|
||||||
|
device_class=SensorDeviceClass.GAS,
|
||||||
|
native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||||
|
suggested_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
suggested_display_precision=0,
|
||||||
|
value_fn=lambda data: data.usage_to_date,
|
||||||
|
),
|
||||||
|
OpowerEntityDescription(
|
||||||
|
key="gas_forecasted_usage",
|
||||||
|
name="Current bill gas forecasted usage",
|
||||||
|
device_class=SensorDeviceClass.GAS,
|
||||||
|
native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||||
|
suggested_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
suggested_display_precision=0,
|
||||||
|
value_fn=lambda data: data.forecasted_usage,
|
||||||
|
),
|
||||||
|
OpowerEntityDescription(
|
||||||
|
key="gas_typical_usage",
|
||||||
|
name="Typical monthly gas usage",
|
||||||
|
device_class=SensorDeviceClass.GAS,
|
||||||
|
native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||||
|
suggested_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
suggested_display_precision=0,
|
||||||
|
value_fn=lambda data: data.typical_usage,
|
||||||
|
),
|
||||||
|
OpowerEntityDescription(
|
||||||
|
key="gas_cost_to_date",
|
||||||
|
name="Current bill gas cost to date",
|
||||||
|
device_class=SensorDeviceClass.MONETARY,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
suggested_display_precision=0,
|
||||||
|
value_fn=lambda data: data.cost_to_date,
|
||||||
|
),
|
||||||
|
OpowerEntityDescription(
|
||||||
|
key="gas_forecasted_cost",
|
||||||
|
name="Current bill gas forecasted cost",
|
||||||
|
device_class=SensorDeviceClass.MONETARY,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
suggested_display_precision=0,
|
||||||
|
value_fn=lambda data: data.forecasted_cost,
|
||||||
|
),
|
||||||
|
OpowerEntityDescription(
|
||||||
|
key="gas_typical_cost",
|
||||||
|
name="Typical monthly gas cost",
|
||||||
|
device_class=SensorDeviceClass.MONETARY,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
suggested_display_precision=0,
|
||||||
|
value_fn=lambda data: data.typical_cost,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
|
) -> None:
|
||||||
|
"""Set up the Opower sensor."""
|
||||||
|
|
||||||
|
coordinator: OpowerCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
entities: list[OpowerSensor] = []
|
||||||
|
forecasts: list[Forecast] = coordinator.data.values()
|
||||||
|
for forecast in forecasts:
|
||||||
|
device_id = f"{coordinator.api.utility.subdomain()}_{forecast.account.utility_account_id}"
|
||||||
|
device = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, device_id)},
|
||||||
|
name=f"{forecast.account.meter_type.name} account {forecast.account.utility_account_id}",
|
||||||
|
manufacturer="Opower",
|
||||||
|
model=coordinator.api.utility.name(),
|
||||||
|
)
|
||||||
|
sensors: tuple[OpowerEntityDescription, ...] = ()
|
||||||
|
if (
|
||||||
|
forecast.account.meter_type == MeterType.ELEC
|
||||||
|
and forecast.unit_of_measure == UnitOfMeasure.KWH
|
||||||
|
):
|
||||||
|
sensors = ELEC_SENSORS
|
||||||
|
elif (
|
||||||
|
forecast.account.meter_type == MeterType.GAS
|
||||||
|
and forecast.unit_of_measure == UnitOfMeasure.THERM
|
||||||
|
):
|
||||||
|
sensors = GAS_SENSORS
|
||||||
|
for sensor in sensors:
|
||||||
|
entities.append(
|
||||||
|
OpowerSensor(
|
||||||
|
coordinator,
|
||||||
|
sensor,
|
||||||
|
forecast.account.utility_account_id,
|
||||||
|
device,
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
class OpowerSensor(SensorEntity, CoordinatorEntity[OpowerCoordinator]):
|
||||||
|
"""Representation of an Opower sensor."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: OpowerCoordinator,
|
||||||
|
description: OpowerEntityDescription,
|
||||||
|
utility_account_id: str,
|
||||||
|
device: DeviceInfo,
|
||||||
|
device_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the sensor."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.entity_description: OpowerEntityDescription = description
|
||||||
|
self._attr_unique_id = f"{device_id}_{description.key}"
|
||||||
|
self._attr_device_info = device
|
||||||
|
self.utility_account_id = utility_account_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> StateType:
|
||||||
|
"""Return the state."""
|
||||||
|
if self.coordinator.data is not None:
|
||||||
|
return self.entity_description.value_fn(
|
||||||
|
self.coordinator.data[self.utility_account_id]
|
||||||
|
)
|
||||||
|
return None
|
28
homeassistant/components/opower/strings.json
Normal file
28
homeassistant/components/opower/strings.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"utility": "Utility name",
|
||||||
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reauth_confirm": {
|
||||||
|
"title": "[%key:common::config_flow::title::reauth%]",
|
||||||
|
"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%]"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||||
|
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -325,6 +325,7 @@ FLOWS = {
|
|||||||
"opentherm_gw",
|
"opentherm_gw",
|
||||||
"openuv",
|
"openuv",
|
||||||
"openweathermap",
|
"openweathermap",
|
||||||
|
"opower",
|
||||||
"oralb",
|
"oralb",
|
||||||
"otbr",
|
"otbr",
|
||||||
"overkiz",
|
"overkiz",
|
||||||
|
@ -4016,6 +4016,12 @@
|
|||||||
"config_flow": false,
|
"config_flow": false,
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
},
|
},
|
||||||
|
"opower": {
|
||||||
|
"name": "Opower",
|
||||||
|
"integration_type": "hub",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "cloud_polling"
|
||||||
|
},
|
||||||
"opple": {
|
"opple": {
|
||||||
"name": "Opple",
|
"name": "Opple",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
@ -1362,6 +1362,9 @@ openwrt-luci-rpc==1.1.16
|
|||||||
# homeassistant.components.ubus
|
# homeassistant.components.ubus
|
||||||
openwrt-ubus-rpc==0.0.2
|
openwrt-ubus-rpc==0.0.2
|
||||||
|
|
||||||
|
# homeassistant.components.opower
|
||||||
|
opower==0.0.11
|
||||||
|
|
||||||
# homeassistant.components.oralb
|
# homeassistant.components.oralb
|
||||||
oralb-ble==0.17.6
|
oralb-ble==0.17.6
|
||||||
|
|
||||||
|
@ -1028,6 +1028,9 @@ openerz-api==0.2.0
|
|||||||
# homeassistant.components.openhome
|
# homeassistant.components.openhome
|
||||||
openhomedevice==2.2.0
|
openhomedevice==2.2.0
|
||||||
|
|
||||||
|
# homeassistant.components.opower
|
||||||
|
opower==0.0.11
|
||||||
|
|
||||||
# homeassistant.components.oralb
|
# homeassistant.components.oralb
|
||||||
oralb-ble==0.17.6
|
oralb-ble==0.17.6
|
||||||
|
|
||||||
|
1
tests/components/opower/__init__.py
Normal file
1
tests/components/opower/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for the Opower integration."""
|
25
tests/components/opower/conftest.py
Normal file
25
tests/components/opower/conftest.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
"""Fixtures for the Opower integration tests."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.opower.const import DOMAIN
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||||
|
"""Return the default mocked config entry."""
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
title="Pacific Gas & Electric (test-username)",
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
"utility": "Pacific Gas and Electric Company (PG&E)",
|
||||||
|
"username": "test-username",
|
||||||
|
"password": "test-password",
|
||||||
|
},
|
||||||
|
state=ConfigEntryState.LOADED,
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
return config_entry
|
204
tests/components/opower/test_config_flow.py
Normal file
204
tests/components/opower/test_config_flow.py
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
"""Test the Opower config flow."""
|
||||||
|
from collections.abc import Generator
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from opower import CannotConnect, InvalidAuth
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components.opower.const import DOMAIN
|
||||||
|
from homeassistant.components.recorder import Recorder
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True, name="mock_setup_entry")
|
||||||
|
def override_async_setup_entry() -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Override async_setup_entry."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.opower.async_setup_entry", return_value=True
|
||||||
|
) as mock_setup_entry:
|
||||||
|
yield mock_setup_entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_unload_entry() -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Mock unloading a config entry."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.opower.async_unload_entry",
|
||||||
|
return_value=True,
|
||||||
|
) as mock_unload_entry:
|
||||||
|
yield mock_unload_entry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form(
|
||||||
|
recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock
|
||||||
|
) -> None:
|
||||||
|
"""Test we get the form."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert not result["errors"]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.opower.config_flow.Opower.async_login",
|
||||||
|
) as mock_login:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
"utility": "Pacific Gas and Electric Company (PG&E)",
|
||||||
|
"username": "test-username",
|
||||||
|
"password": "test-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result2["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result2["title"] == "Pacific Gas and Electric Company (PG&E) (test-username)"
|
||||||
|
assert result2["data"] == {
|
||||||
|
"utility": "Pacific Gas and Electric Company (PG&E)",
|
||||||
|
"username": "test-username",
|
||||||
|
"password": "test-password",
|
||||||
|
}
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
assert mock_login.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("api_exception", "expected_error"),
|
||||||
|
[
|
||||||
|
(InvalidAuth(), "invalid_auth"),
|
||||||
|
(CannotConnect(), "cannot_connect"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_form_exceptions(
|
||||||
|
recorder_mock: Recorder, hass: HomeAssistant, api_exception, expected_error
|
||||||
|
) -> None:
|
||||||
|
"""Test we handle exceptions."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.opower.config_flow.Opower.async_login",
|
||||||
|
side_effect=api_exception,
|
||||||
|
) as mock_login:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
"utility": "Pacific Gas and Electric Company (PG&E)",
|
||||||
|
"username": "test-username",
|
||||||
|
"password": "test-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2["type"] == FlowResultType.FORM
|
||||||
|
assert result2["errors"] == {"base": expected_error}
|
||||||
|
assert mock_login.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_already_configured(
|
||||||
|
recorder_mock: Recorder,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test user input for config_entry that already exists."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.opower.config_flow.Opower.async_login",
|
||||||
|
) as mock_login:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
"utility": "Pacific Gas and Electric Company (PG&E)",
|
||||||
|
"username": "test-username",
|
||||||
|
"password": "test-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2["type"] == FlowResultType.ABORT
|
||||||
|
assert result2["reason"] == "already_configured"
|
||||||
|
assert mock_login.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_not_already_configured(
|
||||||
|
recorder_mock: Recorder,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_setup_entry: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test user input for config_entry different than the existing one."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.opower.config_flow.Opower.async_login",
|
||||||
|
) as mock_login:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
"utility": "Pacific Gas and Electric Company (PG&E)",
|
||||||
|
"username": "test-username2",
|
||||||
|
"password": "test-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result2["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert (
|
||||||
|
result2["title"] == "Pacific Gas and Electric Company (PG&E) (test-username2)"
|
||||||
|
)
|
||||||
|
assert result2["data"] == {
|
||||||
|
"utility": "Pacific Gas and Electric Company (PG&E)",
|
||||||
|
"username": "test-username2",
|
||||||
|
"password": "test-password",
|
||||||
|
}
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 2
|
||||||
|
assert mock_login.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_valid_reauth(
|
||||||
|
recorder_mock: Recorder,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_setup_entry: AsyncMock,
|
||||||
|
mock_unload_entry: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test that we can handle a valid reauth."""
|
||||||
|
mock_config_entry.async_start_reauth(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
flows = hass.config_entries.flow.async_progress()
|
||||||
|
assert len(flows) == 1
|
||||||
|
result = flows[0]
|
||||||
|
assert result["step_id"] == "reauth_confirm"
|
||||||
|
assert result["context"]["source"] == "reauth"
|
||||||
|
assert result["context"]["title_placeholders"] == {"name": mock_config_entry.title}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.opower.config_flow.Opower.async_login",
|
||||||
|
) as mock_login:
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{"username": "test-username", "password": "test-password2"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "reauth_successful"
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert hass.config_entries.async_entries(DOMAIN)[0].data == {
|
||||||
|
"utility": "Pacific Gas and Electric Company (PG&E)",
|
||||||
|
"username": "test-username",
|
||||||
|
"password": "test-password2",
|
||||||
|
}
|
||||||
|
assert len(mock_unload_entry.mock_calls) == 1
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
assert mock_login.call_count == 1
|
Loading…
x
Reference in New Issue
Block a user