From 950651f9c61d5e4f95667211646bbcf0391d9225 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Mar 2023 04:13:25 +0000 Subject: [PATCH 01/23] Create Opower integration --- .coveragerc | 3 + CODEOWNERS | 2 + homeassistant/components/opower/__init__.py | 31 +++ .../components/opower/config_flow.py | 119 ++++++++++ homeassistant/components/opower/const.py | 5 + .../components/opower/coordinator.py | 205 +++++++++++++++++ homeassistant/components/opower/manifest.json | 10 + homeassistant/components/opower/sensor.py | 216 ++++++++++++++++++ homeassistant/components/opower/strings.json | 28 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/opower/__init__.py | 1 + tests/components/opower/conftest.py | 25 ++ tests/components/opower/test_config_flow.py | 202 ++++++++++++++++ 16 files changed, 860 insertions(+) create mode 100644 homeassistant/components/opower/__init__.py create mode 100644 homeassistant/components/opower/config_flow.py create mode 100644 homeassistant/components/opower/const.py create mode 100644 homeassistant/components/opower/coordinator.py create mode 100644 homeassistant/components/opower/manifest.json create mode 100644 homeassistant/components/opower/sensor.py create mode 100644 homeassistant/components/opower/strings.json create mode 100644 tests/components/opower/__init__.py create mode 100644 tests/components/opower/conftest.py create mode 100644 tests/components/opower/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 4b831fc3d3c2c..5280ca1c0593d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -858,6 +858,9 @@ omit = homeassistant/components/openweathermap/sensor.py homeassistant/components/openweathermap/weather_update_coordinator.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/opple/light.py homeassistant/components/oru/* diff --git a/CODEOWNERS b/CODEOWNERS index 1acd5f6c9f7bd..1405479fdf25b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -864,6 +864,8 @@ build.json @home-assistant/supervisor /tests/components/openweathermap/ @fabaff @freekode @nzapponi /homeassistant/components/opnsense/ @mtreinish /tests/components/opnsense/ @mtreinish +/homeassistant/components/opower/ @tronikos +/tests/components/opower/ @tronikos /homeassistant/components/oralb/ @bdraco @Lash-L /tests/components/oralb/ @bdraco @Lash-L /homeassistant/components/oru/ @bvlaicu diff --git a/homeassistant/components/opower/__init__.py b/homeassistant/components/opower/__init__.py new file mode 100644 index 0000000000000..f4fca22c9b442 --- /dev/null +++ b/homeassistant/components/opower/__init__.py @@ -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 diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py new file mode 100644 index 0000000000000..08ce4956984e0 --- /dev/null +++ b/homeassistant/components/opower/config_flow.py @@ -0,0 +1,119 @@ +"""Config flow for Opower integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from aiohttp.client_exceptions import ClientResponseError +from opower import 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, + } +) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + 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 = {} + try: + await api.async_login() + except ClientResponseError as err: + _LOGGER.exception("Exception while logging in") + if err.status in (401, 403): + errors["base"] = "invalid_auth" + else: + 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=STEP_REAUTH_DATA_SCHEMA, + description_placeholders={ + CONF_USERNAME: self.reauth_entry.data[CONF_USERNAME] + }, + errors=errors, + ) diff --git a/homeassistant/components/opower/const.py b/homeassistant/components/opower/const.py new file mode 100644 index 0000000000000..b996a214a0591 --- /dev/null +++ b/homeassistant/components/opower/const.py @@ -0,0 +1,5 @@ +"""Constants for the Opower integration.""" + +DOMAIN = "opower" + +CONF_UTILITY = "utility" diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py new file mode 100644 index 0000000000000..79d863200d675 --- /dev/null +++ b/homeassistant/components/opower/coordinator.py @@ -0,0 +1,205 @@ +"""Coordinator to handle Opower connections.""" +from datetime import datetime, timedelta +import logging +from types import MappingProxyType +from typing import Any, cast + +from aiohttp.client_exceptions import ClientResponseError +from opower import Account, AggregateType, CostRead, Forecast, 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", + 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: + await self.api.async_login() + except ClientResponseError as err: + if err.status in (401, 403): + # Cancel future updates and start config flow config async_step_reauth + raise ConfigEntryAuthFailed from err + # Let DataUpdateCoordinator handle ClientError retries + raise err + forecasts = 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.""" + if "recorder" not in self.hass.config.components: + return + 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, + ) + + if not await get_instance(self.hass).async_add_executor_job( + get_last_statistics, self.hass, 1, consumption_statistic_id, True, {} + ): + _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) + 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 + """ + 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() + if account.meter_type == MeterType.GAS + else datetime.now() - timedelta(days=2 * 30) + ) + 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) -> list[CostRead]: + """Get cost reads within the past 30 days.""" + return await self.api.async_get_cost_reads( + account, + AggregateType.HOUR + if account.meter_type == MeterType.ELEC + else AggregateType.DAY, + datetime.now() - timedelta(days=30), + datetime.now(), + ) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json new file mode 100644 index 0000000000000..4822401501814 --- /dev/null +++ b/homeassistant/components/opower/manifest.json @@ -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.3"] +} diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py new file mode 100644 index 0000000000000..97be601e49a68 --- /dev/null +++ b/homeassistant/components/opower/sensor.py @@ -0,0 +1,216 @@ +"""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.""" + + +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 diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json new file mode 100644 index 0000000000000..56c4617eabe2b --- /dev/null +++ b/homeassistant/components/opower/strings.json @@ -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%]", + "description": "Enter password for {username}", + "data": { + "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%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 37480904f9e46..60c1052b6fb09 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -310,6 +310,7 @@ "opentherm_gw", "openuv", "openweathermap", + "opower", "oralb", "otbr", "overkiz", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3e89f9d12d5a1..c281143e120f0 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3964,6 +3964,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "opower": { + "name": "Opower", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "opple": { "name": "Opple", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index ea3c58add01b1..30391d7e0af85 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1295,6 +1295,9 @@ openwrt-luci-rpc==1.1.11 # homeassistant.components.ubus openwrt-ubus-rpc==0.0.2 +# homeassistant.components.opower +opower==0.0.3 + # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3db436ada3af0..bdfe0ed34c833 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -952,6 +952,9 @@ openai==0.27.2 # homeassistant.components.openerz openerz-api==0.2.0 +# homeassistant.components.opower +opower==0.0.3 + # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/tests/components/opower/__init__.py b/tests/components/opower/__init__.py new file mode 100644 index 0000000000000..71aea27a69835 --- /dev/null +++ b/tests/components/opower/__init__.py @@ -0,0 +1 @@ +"""Tests for the Opower integration.""" diff --git a/tests/components/opower/conftest.py b/tests/components/opower/conftest.py new file mode 100644 index 0000000000000..f0ca37f98d94f --- /dev/null +++ b/tests/components/opower/conftest.py @@ -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 & Electric", + "username": "test-username", + "password": "test-password", + }, + state=ConfigEntryState.LOADED, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/opower/test_config_flow.py b/tests/components/opower/test_config_flow.py new file mode 100644 index 0000000000000..d3a85a732aed4 --- /dev/null +++ b/tests/components/opower/test_config_flow.py @@ -0,0 +1,202 @@ +"""Test the Opower config flow.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from aiohttp.client_exceptions import ClientResponseError +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 & Electric", + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Pacific Gas & Electric (test-username)" + assert result2["data"] == { + "utility": "Pacific Gas & Electric", + "username": "test-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + assert mock_login.call_count == 1 + + +@pytest.mark.parametrize( + ("http_status", "expected_error"), + [ + (401, "invalid_auth"), + (403, "invalid_auth"), + (500, "cannot_connect"), + ], +) +async def test_form_exceptions( + recorder_mock: Recorder, hass: HomeAssistant, http_status, 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=ClientResponseError(None, None, status=http_status), + ) as mock_login: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "utility": "Pacific Gas & Electric", + "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 & Electric", + "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 & Electric", + "username": "test-username2", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Pacific Gas & Electric (test-username2)" + assert result2["data"] == { + "utility": "Pacific Gas & Electric", + "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"], {"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 & Electric", + "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 From 2463060f121f576ecf76735eb37e1310d0abace9 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Mar 2023 05:26:02 +0000 Subject: [PATCH 02/23] fix tests --- tests/components/opower/conftest.py | 2 +- tests/components/opower/test_config_flow.py | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/components/opower/conftest.py b/tests/components/opower/conftest.py index f0ca37f98d94f..17c6896593b03 100644 --- a/tests/components/opower/conftest.py +++ b/tests/components/opower/conftest.py @@ -15,7 +15,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: title="Pacific Gas & Electric (test-username)", domain=DOMAIN, data={ - "utility": "Pacific Gas & Electric", + "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", }, diff --git a/tests/components/opower/test_config_flow.py b/tests/components/opower/test_config_flow.py index d3a85a732aed4..f5d90427afa4f 100644 --- a/tests/components/opower/test_config_flow.py +++ b/tests/components/opower/test_config_flow.py @@ -49,7 +49,7 @@ async def test_form( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas & Electric", + "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", }, @@ -57,9 +57,9 @@ async def test_form( await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Pacific Gas & Electric (test-username)" + assert result2["title"] == "Pacific Gas and Electric Company (PG&E) (test-username)" assert result2["data"] == { - "utility": "Pacific Gas & Electric", + "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", } @@ -90,7 +90,7 @@ async def test_form_exceptions( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas & Electric", + "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", }, @@ -117,7 +117,7 @@ async def test_form_already_configured( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas & Electric", + "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", }, @@ -145,7 +145,7 @@ async def test_form_not_already_configured( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas & Electric", + "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username2", "password": "test-password", }, @@ -153,9 +153,11 @@ async def test_form_not_already_configured( await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Pacific Gas & Electric (test-username2)" + assert ( + result2["title"] == "Pacific Gas and Electric Company (PG&E) (test-username2)" + ) assert result2["data"] == { - "utility": "Pacific Gas & Electric", + "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username2", "password": "test-password", } @@ -193,7 +195,7 @@ async def test_form_valid_reauth( await hass.async_block_till_done() assert hass.config_entries.async_entries(DOMAIN)[0].data == { - "utility": "Pacific Gas & Electric", + "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password2", } From c295a4f1af8c66d5f6431b1e4cf86dee9d4fa99e Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Mar 2023 18:19:11 -0700 Subject: [PATCH 03/23] Update config_flow.py --- homeassistant/components/opower/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index 08ce4956984e0..fd897c2ce8a08 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -43,7 +43,7 @@ async def _validate_login( login_data[CONF_USERNAME], login_data[CONF_PASSWORD], ) - errors = {} + errors: dict[str, str] = {} try: await api.async_login() except ClientResponseError as err: From 655dcbfcc940b67507d8aa6dbc233f2bed023591 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Mar 2023 18:22:25 -0700 Subject: [PATCH 04/23] Update coordinator.py --- homeassistant/components/opower/coordinator.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 79d863200d675..266b0221a404d 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -38,6 +38,8 @@ def __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( @@ -52,6 +54,9 @@ async def _async_update_data( ) -> dict[str, Forecast]: """Fetch data from API endpoint.""" try: + # Login expires after a few minutes. + # Given the infrequent updating (every 12h) + # assume previous sessions have expired and re-login. await self.api.async_login() except ClientResponseError as err: if err.status in (401, 403): @@ -66,8 +71,6 @@ async def _async_update_data( async def _insert_statistics(self, accounts: list[Account]) -> None: """Insert Opower statistics.""" - if "recorder" not in self.hass.config.components: - return for account in accounts: id_prefix = "_".join( ( From 8e56ccb5e7fbb3923d3be66ab037af185846bea3 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Mar 2023 18:27:36 -0700 Subject: [PATCH 05/23] Update sensor.py --- homeassistant/components/opower/sensor.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 97be601e49a68..6051b50f71680 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -35,7 +35,9 @@ class OpowerEntityDescriptionMixin: 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", From 5b534ef64572d131b23fb10ba91971b24876d068 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Mar 2023 18:38:15 -0700 Subject: [PATCH 06/23] Update sensor.py --- homeassistant/components/opower/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 6051b50f71680..e28dcbd0661a7 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -35,6 +35,7 @@ class OpowerEntityDescriptionMixin: 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) From e7d6b07d9e7a27b37b09802e8c503a18ed432ea2 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 31 Mar 2023 02:16:47 -0700 Subject: [PATCH 07/23] Update coordinator.py --- homeassistant/components/opower/coordinator.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 266b0221a404d..b724626396de8 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -60,7 +60,7 @@ async def _async_update_data( await self.api.async_login() except ClientResponseError as err: if err.status in (401, 403): - # Cancel future updates and start config flow config async_step_reauth + # Cancel future updates and start reauth config flow raise ConfigEntryAuthFailed from err # Let DataUpdateCoordinator handle ClientError retries raise err @@ -197,7 +197,10 @@ async def _async_get_all_cost_reads(self, account: Account) -> list[CostRead]: return cost_reads async def _async_get_recent_cost_reads(self, account: Account) -> list[CostRead]: - """Get cost reads within the past 30 days.""" + """Get cost reads within the past 30 days to allow corrections in date from utilities. + + Hourly for electricity, daily for gas. + """ return await self.api.async_get_cost_reads( account, AggregateType.HOUR From d688d3c68758da68e75407a270b2a69142cd2ed6 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 5 Apr 2023 07:23:22 +0000 Subject: [PATCH 08/23] Bump opower==0.0.4 --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 4822401501814..6fcde8d272099 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", - "requirements": ["opower==0.0.3"] + "requirements": ["opower==0.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 80c4a2df2973d..da34379a7d493 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1296,7 +1296,7 @@ openwrt-luci-rpc==1.1.11 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.3 +opower==0.0.4 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ce3e94dcd18a..c13809924317d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -953,7 +953,7 @@ openai==0.27.2 openerz-api==0.2.0 # homeassistant.components.opower -opower==0.0.3 +opower==0.0.4 # homeassistant.components.oralb oralb-ble==0.17.6 From 26dcb87b35ddae52af813ac899bf2a642d12c005 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 6 May 2023 05:53:12 +0000 Subject: [PATCH 09/23] Ignore errors for "recent" PGE accounts --- .../components/opower/coordinator.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index b724626396de8..61e2d14537795 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -174,16 +174,22 @@ async def _async_get_all_cost_reads(self, account: Account) -> list[CostRead]: - 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 - ) + try: + cost_reads += await self.api.async_get_cost_reads( + account, AggregateType.BILL, start, end + ) + except ClientResponseError as err: + # Ignore server errors that could happen if end is before account activation + if err.status != 500: + raise err start = end if not cost_reads else cost_reads[-1].end_time end = ( - datetime.now() - if account.meter_type == MeterType.GAS - else datetime.now() - timedelta(days=2 * 30) + 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 @@ -197,7 +203,7 @@ async def _async_get_all_cost_reads(self, account: Account) -> list[CostRead]: return cost_reads async def _async_get_recent_cost_reads(self, account: Account) -> list[CostRead]: - """Get cost reads within the past 30 days to allow corrections in date from utilities. + """Get cost reads within the past 30 days to allow corrections in data from utilities. Hourly for electricity, daily for gas. """ From 727b00cc36d52df5c91ae0ce35fb181a05017fd2 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 11 May 2023 22:33:25 +0000 Subject: [PATCH 10/23] Add type for forecasts --- homeassistant/components/opower/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 61e2d14537795..41f391f216201 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -64,7 +64,7 @@ async def _async_update_data( raise ConfigEntryAuthFailed from err # Let DataUpdateCoordinator handle ClientError retries raise err - forecasts = await self.api.async_get_forecast() + 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} From 42b7e7b657696b0b76a79507aff6d837f98370c5 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 11 May 2023 22:43:06 +0000 Subject: [PATCH 11/23] Bump opower to 0.0.5 --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 6fcde8d272099..68a8e7b3d719b 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", - "requirements": ["opower==0.0.4"] + "requirements": ["opower==0.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index da34379a7d493..9fa4dbcfbbeff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1296,7 +1296,7 @@ openwrt-luci-rpc==1.1.11 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.4 +opower==0.0.5 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c13809924317d..74770a280630d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -953,7 +953,7 @@ openai==0.27.2 openerz-api==0.2.0 # homeassistant.components.opower -opower==0.0.4 +opower==0.0.5 # homeassistant.components.oralb oralb-ble==0.17.6 From bdaa94769ca4a0bb67405dd09bb9f2ddb2d8835c Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 12 May 2023 02:19:40 +0000 Subject: [PATCH 12/23] Bump opower to 0.0.6 --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 68a8e7b3d719b..958867fcecae4 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", - "requirements": ["opower==0.0.5"] + "requirements": ["opower==0.0.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9fa4dbcfbbeff..3930b29138134 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1296,7 +1296,7 @@ openwrt-luci-rpc==1.1.11 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.5 +opower==0.0.6 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74770a280630d..f77bfa26dfe6e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -953,7 +953,7 @@ openai==0.27.2 openerz-api==0.2.0 # homeassistant.components.opower -opower==0.0.5 +opower==0.0.6 # homeassistant.components.oralb oralb-ble==0.17.6 From 2a00cc4d8346cab0ab34c76d02356fa1d5f2f1c6 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 18 May 2023 14:33:52 -0700 Subject: [PATCH 13/23] Bump opower to 0.0.7 --- homeassistant/components/opower/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 958867fcecae4..2318ecdc0b26a 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", - "requirements": ["opower==0.0.6"] + "requirements": ["opower==0.0.7"] } From 764de5069add360d473c14328020a9605f4fc469 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 18 May 2023 14:57:05 -0700 Subject: [PATCH 14/23] Update requirements_all.txt --- requirements_all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_all.txt b/requirements_all.txt index b680eef9711e2..e15b2c4e85bc7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1303,7 +1303,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.6 +opower==0.0.7 # homeassistant.components.oralb oralb-ble==0.17.6 From fd138da60155a485a9b698d6ee991f378d2c51ba Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 18 May 2023 14:57:30 -0700 Subject: [PATCH 15/23] Update requirements_test_all.txt --- requirements_test_all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 59e8aa6f47c94..01c9819fb13d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ openai==0.27.2 openerz-api==0.2.0 # homeassistant.components.opower -opower==0.0.6 +opower==0.0.7 # homeassistant.components.oralb oralb-ble==0.17.6 From 0eb55faba2385c60ce9cc8d0d4da56bde466d0f9 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 3 Jun 2023 10:23:02 +0000 Subject: [PATCH 16/23] 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() --- homeassistant/components/opower/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 41f391f216201..d5d21f4e7aa10 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -88,7 +88,7 @@ async def _insert_statistics(self, accounts: list[Account]) -> None: ) if not await get_instance(self.hass).async_add_executor_job( - get_last_statistics, self.hass, 1, consumption_statistic_id, True, {} + get_last_statistics, self.hass, 1, consumption_statistic_id, True, set() ): _LOGGER.debug("Updating statistic for the first time") cost_reads = await self._async_get_all_cost_reads(account) @@ -105,7 +105,7 @@ async def _insert_statistics(self, accounts: list[Account]) -> None: self.hass, cost_reads[0].start_time, None, - [cost_statistic_id, consumption_statistic_id], + {cost_statistic_id, consumption_statistic_id}, "hour" if account.meter_type == MeterType.ELEC else "day", None, {"sum"}, From 480bf3ee764810b906331015815a3a1b146b63e9 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 4 Jun 2023 06:08:17 +0000 Subject: [PATCH 17/23] Improve exceptions handling --- .../components/opower/config_flow.py | 14 +++----- .../components/opower/coordinator.py | 32 +++++++++---------- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/opower/test_config_flow.py | 13 ++++---- 6 files changed, 29 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index fd897c2ce8a08..d616391dafd4f 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -5,8 +5,7 @@ import logging from typing import Any -from aiohttp.client_exceptions import ClientResponseError -from opower import Opower, get_supported_utility_names +from opower import CannotConnect, InvalidAuth, Opower, get_supported_utility_names import voluptuous as vol from homeassistant import config_entries @@ -46,13 +45,10 @@ async def _validate_login( errors: dict[str, str] = {} try: await api.async_login() - except ClientResponseError as err: - _LOGGER.exception("Exception while logging in") - if err.status in (401, 403): - errors["base"] = "invalid_auth" - else: - errors["base"] = "cannot_connect" - + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" return errors diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index d5d21f4e7aa10..89e03086b83ae 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -4,8 +4,15 @@ from types import MappingProxyType from typing import Any, cast -from aiohttp.client_exceptions import ClientResponseError -from opower import Account, AggregateType, CostRead, Forecast, MeterType, Opower +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 @@ -56,14 +63,10 @@ async def _async_update_data( try: # Login expires after a few minutes. # Given the infrequent updating (every 12h) - # assume previous sessions have expired and re-login. + # assume previous session has expired and re-login. await self.api.async_login() - except ClientResponseError as err: - if err.status in (401, 403): - # Cancel future updates and start reauth config flow - raise ConfigEntryAuthFailed from err - # Let DataUpdateCoordinator handle ClientError retries - raise err + 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]) @@ -177,14 +180,9 @@ async def _async_get_all_cost_reads(self, account: Account) -> list[CostRead]: cost_reads = [] start = None end = datetime.now() - timedelta(days=3 * 365) - try: - cost_reads += await self.api.async_get_cost_reads( - account, AggregateType.BILL, start, end - ) - except ClientResponseError as err: - # Ignore server errors that could happen if end is before account activation - if err.status != 500: - raise err + 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) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 2318ecdc0b26a..d319755c7a74b 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", - "requirements": ["opower==0.0.7"] + "requirements": ["opower==0.0.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 25e75445801e5..cba61715200bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1367,7 +1367,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.7 +opower==0.0.8 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3fc395f9a6100..803495161b900 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1027,7 +1027,7 @@ openai==0.27.2 openerz-api==0.2.0 # homeassistant.components.opower -opower==0.0.7 +opower==0.0.8 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/tests/components/opower/test_config_flow.py b/tests/components/opower/test_config_flow.py index f5d90427afa4f..7c7cecd0dc775 100644 --- a/tests/components/opower/test_config_flow.py +++ b/tests/components/opower/test_config_flow.py @@ -2,7 +2,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from aiohttp.client_exceptions import ClientResponseError +from opower import CannotConnect, InvalidAuth import pytest from homeassistant import config_entries @@ -68,15 +68,14 @@ async def test_form( @pytest.mark.parametrize( - ("http_status", "expected_error"), + ("api_exception", "expected_error"), [ - (401, "invalid_auth"), - (403, "invalid_auth"), - (500, "cannot_connect"), + (InvalidAuth(), "invalid_auth"), + (CannotConnect(), "cannot_connect"), ], ) async def test_form_exceptions( - recorder_mock: Recorder, hass: HomeAssistant, http_status, expected_error + recorder_mock: Recorder, hass: HomeAssistant, api_exception, expected_error ) -> None: """Test we handle exceptions.""" result = await hass.config_entries.flow.async_init( @@ -85,7 +84,7 @@ async def test_form_exceptions( with patch( "homeassistant.components.opower.config_flow.Opower.async_login", - side_effect=ClientResponseError(None, None, status=http_status), + side_effect=api_exception, ) as mock_login: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], From 0a0382008627b4934cf2685a71defabbcff7b060 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 11 Jun 2023 07:59:05 +0000 Subject: [PATCH 18/23] Bump opower==0.0.9 --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index d319755c7a74b..a56eb0d91ed6e 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", - "requirements": ["opower==0.0.8"] + "requirements": ["opower==0.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 866bef6384c84..595f606b51ba8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1364,7 +1364,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.8 +opower==0.0.9 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6619fb3930aa3..64fc57308d229 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1027,7 +1027,7 @@ openai==0.27.2 openerz-api==0.2.0 # homeassistant.components.opower -opower==0.0.8 +opower==0.0.9 # homeassistant.components.oralb oralb-ble==0.17.6 From 2ec280f82a7d767d3b77c7721d64329e3d0f5af3 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 12 Jun 2023 11:08:03 +0000 Subject: [PATCH 19/23] Bump opower to 0.0.10 --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index a56eb0d91ed6e..77f15879cc775 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", - "requirements": ["opower==0.0.9"] + "requirements": ["opower==0.0.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 595f606b51ba8..b21f7b5d4bfcd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1364,7 +1364,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.9 +opower==0.0.10 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 64fc57308d229..ec496a995b3a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1027,7 +1027,7 @@ openai==0.27.2 openerz-api==0.2.0 # homeassistant.components.opower -opower==0.0.9 +opower==0.0.10 # homeassistant.components.oralb oralb-ble==0.17.6 From a28b1294bc915b290636c49105fe955b70656897 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 15 Jun 2023 06:56:16 +0000 Subject: [PATCH 20/23] Bump opower to 0.0.11 --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 77f15879cc775..969583f050a0a 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", - "requirements": ["opower==0.0.10"] + "requirements": ["opower==0.0.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index b21f7b5d4bfcd..da83927f4ea26 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1364,7 +1364,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.10 +opower==0.0.11 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec496a995b3a1..bff05cbdeeb66 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1027,7 +1027,7 @@ openai==0.27.2 openerz-api==0.2.0 # homeassistant.components.opower -opower==0.0.10 +opower==0.0.11 # homeassistant.components.oralb oralb-ble==0.17.6 From ecb90dfc56e4ade42b32391c0fd33b469fef74ae Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 30 Jun 2023 08:44:53 +0000 Subject: [PATCH 21/23] fix issue when integration hasn't run for 30 days use last stat time instead of now when fetching recent usage/cost --- homeassistant/components/opower/coordinator.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 89e03086b83ae..4d40bb3356bfe 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -90,16 +90,19 @@ async def _insert_statistics(self, accounts: list[Account]) -> None: consumption_statistic_id, ) - if not await get_instance(self.hass).async_add_executor_job( + 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) + 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 @@ -200,7 +203,9 @@ async def _async_get_all_cost_reads(self, account: Account) -> list[CostRead]: ) return cost_reads - async def _async_get_recent_cost_reads(self, account: Account) -> list[CostRead]: + 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. @@ -210,6 +215,6 @@ async def _async_get_recent_cost_reads(self, account: Account) -> list[CostRead] AggregateType.HOUR if account.meter_type == MeterType.ELEC else AggregateType.DAY, - datetime.now() - timedelta(days=30), + datetime.fromtimestamp(last_stat_time) - timedelta(days=30), datetime.now(), ) From 046f61038ce30f7d1009e056e83ed9c250d57a95 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 30 Jun 2023 09:13:36 +0000 Subject: [PATCH 22/23] Allow username to be changed in reauth --- .../components/opower/config_flow.py | 21 ++++++++++--------- homeassistant/components/opower/strings.json | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index d616391dafd4f..db5d75b823d81 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -25,11 +25,6 @@ vol.Required(CONF_PASSWORD): str, } ) -STEP_REAUTH_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_PASSWORD): str, - } -) async def _validate_login( @@ -101,15 +96,21 @@ async def async_step_reauth_confirm( errors = await _validate_login(self.hass, data) if not errors: self.hass.config_entries.async_update_entry( - self.reauth_entry, data=data + self.reauth_entry, + title=f"{data[CONF_UTILITY]} ({data[CONF_USERNAME]})", + 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=STEP_REAUTH_DATA_SCHEMA, - description_placeholders={ - CONF_USERNAME: self.reauth_entry.data[CONF_USERNAME] - }, + data_schema=vol.Schema( + { + vol.Required( + CONF_USERNAME, default=self.reauth_entry.data[CONF_USERNAME] + ): str, + vol.Required(CONF_PASSWORD): str, + } + ), errors=errors, ) diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index 56c4617eabe2b..79d8bf80feeb3 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -10,8 +10,8 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "Enter password for {username}", "data": { + "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } } From ac65c6088990340607b6d141991a397a2db3b429 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 30 Jun 2023 18:22:33 +0000 Subject: [PATCH 23/23] Don't allow changing username in reauth flow --- homeassistant/components/opower/config_flow.py | 8 ++------ tests/components/opower/test_config_flow.py | 3 ++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index db5d75b823d81..fdf007c3b681a 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -96,9 +96,7 @@ async def async_step_reauth_confirm( errors = await _validate_login(self.hass, data) if not errors: self.hass.config_entries.async_update_entry( - self.reauth_entry, - title=f"{data[CONF_UTILITY]} ({data[CONF_USERNAME]})", - data=data, + self.reauth_entry, data=data ) await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) return self.async_abort(reason="reauth_successful") @@ -106,9 +104,7 @@ async def async_step_reauth_confirm( step_id="reauth_confirm", data_schema=vol.Schema( { - vol.Required( - CONF_USERNAME, default=self.reauth_entry.data[CONF_USERNAME] - ): str, + vol.Required(CONF_USERNAME): self.reauth_entry.data[CONF_USERNAME], vol.Required(CONF_PASSWORD): str, } ), diff --git a/tests/components/opower/test_config_flow.py b/tests/components/opower/test_config_flow.py index 7c7cecd0dc775..7f6a847f52e3b 100644 --- a/tests/components/opower/test_config_flow.py +++ b/tests/components/opower/test_config_flow.py @@ -186,7 +186,8 @@ async def test_form_valid_reauth( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"password": "test-password2"} + result["flow_id"], + {"username": "test-username", "password": "test-password2"}, ) assert result["type"] == FlowResultType.ABORT