From 5105c6c50f6e6998da6f1902a208860d8ad83bcb Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 10 Feb 2026 09:08:58 -0800 Subject: [PATCH] Add last_changed and last_updated for the Opower statistics (#159101) --- .../components/opower/coordinator.py | 64 +++++++++++--- homeassistant/components/opower/sensor.py | 85 ++++++++++++------- homeassistant/components/opower/strings.json | 6 ++ tests/components/opower/test_sensor.py | 50 ++++++++++- 4 files changed, 157 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index beac8971cd2..ed6376b14fa 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -1,5 +1,6 @@ """Coordinator to handle Opower connections.""" +from dataclasses import dataclass from datetime import datetime, timedelta import logging from typing import Any, cast @@ -44,7 +45,17 @@ _LOGGER = logging.getLogger(__name__) type OpowerConfigEntry = ConfigEntry[OpowerCoordinator] -class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): +@dataclass +class OpowerData: + """Class to hold Opower data.""" + + account: Account + forecast: Forecast | None + last_changed: datetime | None + last_updated: datetime + + +class OpowerCoordinator(DataUpdateCoordinator[dict[str, OpowerData]]): """Handle fetching Opower data, updating sensors and inserting statistics.""" config_entry: OpowerConfigEntry @@ -85,7 +96,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): async def _async_update_data( self, - ) -> dict[str, Forecast]: + ) -> dict[str, OpowerData]: """Fetch data from API endpoint.""" try: # Login expires after a few minutes. @@ -98,24 +109,38 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): except CannotConnect as err: _LOGGER.error("Error during login: %s", err) raise UpdateFailed(f"Error during login: {err}") from err - try: - forecasts: list[Forecast] = await self.api.async_get_forecast() - except ApiException as err: - _LOGGER.error("Error getting forecasts: %s", err) - raise - _LOGGER.debug("Updating sensor data with: %s", forecasts) - # Because Opower provides historical usage/cost with a delay of a couple of days - # we need to insert data into statistics. - await self._insert_statistics() - return {forecast.account.utility_account_id: forecast for forecast in forecasts} - async def _insert_statistics(self) -> None: - """Insert Opower statistics.""" try: accounts = await self.api.async_get_accounts() except ApiException as err: _LOGGER.error("Error getting accounts: %s", err) raise + + try: + forecasts_list = await self.api.async_get_forecast() + except ApiException as err: + _LOGGER.error("Error getting forecasts: %s", err) + raise + + forecasts = {f.account.utility_account_id: f for f in forecasts_list} + _LOGGER.debug("Updating sensor data with: %s", forecasts) + + # Because Opower provides historical usage/cost with a delay of a couple of days + # we need to insert data into statistics. + last_changed_per_account = await self._insert_statistics(accounts) + return { + account.utility_account_id: OpowerData( + account=account, + forecast=forecasts.get(account.utility_account_id), + last_changed=last_changed_per_account.get(account.utility_account_id), + last_updated=dt_util.utcnow(), + ) + for account in accounts + } + + async def _insert_statistics(self, accounts: list[Account]) -> dict[str, datetime]: + """Insert Opower statistics.""" + last_changed_per_account: dict[str, datetime] = {} for account in accounts: id_prefix = ( ( @@ -277,6 +302,15 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): return_sum = _safe_get_sum(stats.get(return_statistic_id, [])) last_stats_time = stats[consumption_statistic_id][0]["start"] + if cost_reads: + last_changed_per_account[account.utility_account_id] = cost_reads[ + -1 + ].start_time + elif last_stats_time is not None: + last_changed_per_account[account.utility_account_id] = ( + dt_util.utc_from_timestamp(last_stats_time) + ) + cost_statistics = [] compensation_statistics = [] consumption_statistics = [] @@ -343,6 +377,8 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): ) async_add_external_statistics(self.hass, return_metadata, return_statistics) + return last_changed_per_account + async def _async_maybe_migrate_statistics( self, utility_account_id: str, diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 9fc4d7e536a..72dccb1eebf 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -4,9 +4,9 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import date +from datetime import date, datetime -from opower import Forecast, MeterType, UnitOfMeasure +from opower import MeterType, UnitOfMeasure from homeassistant.components.sensor import ( SensorDeviceClass, @@ -22,7 +22,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import OpowerConfigEntry, OpowerCoordinator +from .coordinator import OpowerConfigEntry, OpowerCoordinator, OpowerData PARALLEL_UPDATES = 0 @@ -31,9 +31,26 @@ PARALLEL_UPDATES = 0 class OpowerEntityDescription(SensorEntityDescription): """Class describing Opower sensors entities.""" - value_fn: Callable[[Forecast], str | float | date] + value_fn: Callable[[OpowerData], str | float | date | datetime | None] +COMMON_SENSORS: tuple[OpowerEntityDescription, ...] = ( + OpowerEntityDescription( + key="last_changed", + translation_key="last_changed", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.last_changed, + ), + OpowerEntityDescription( + key="last_updated", + translation_key="last_updated", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.last_updated, + ), +) + # 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) @@ -46,7 +63,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( # Not TOTAL_INCREASING because it can decrease for accounts with solar state_class=SensorStateClass.TOTAL, suggested_display_precision=0, - value_fn=lambda data: data.usage_to_date, + value_fn=lambda data: data.forecast.usage_to_date if data.forecast else None, ), OpowerEntityDescription( key="elec_forecasted_usage", @@ -55,7 +72,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, suggested_display_precision=0, - value_fn=lambda data: data.forecasted_usage, + value_fn=lambda data: data.forecast.forecasted_usage if data.forecast else None, ), OpowerEntityDescription( key="elec_typical_usage", @@ -64,7 +81,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, suggested_display_precision=0, - value_fn=lambda data: data.typical_usage, + value_fn=lambda data: data.forecast.typical_usage if data.forecast else None, ), OpowerEntityDescription( key="elec_cost_to_date", @@ -73,7 +90,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, suggested_display_precision=0, - value_fn=lambda data: data.cost_to_date, + value_fn=lambda data: data.forecast.cost_to_date if data.forecast else None, ), OpowerEntityDescription( key="elec_forecasted_cost", @@ -82,7 +99,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, suggested_display_precision=0, - value_fn=lambda data: data.forecasted_cost, + value_fn=lambda data: data.forecast.forecasted_cost if data.forecast else None, ), OpowerEntityDescription( key="elec_typical_cost", @@ -91,7 +108,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, suggested_display_precision=0, - value_fn=lambda data: data.typical_cost, + value_fn=lambda data: data.forecast.typical_cost if data.forecast else None, ), OpowerEntityDescription( key="elec_start_date", @@ -99,7 +116,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.start_date, + value_fn=lambda data: data.forecast.start_date if data.forecast else None, ), OpowerEntityDescription( key="elec_end_date", @@ -107,7 +124,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.end_date, + value_fn=lambda data: data.forecast.end_date if data.forecast else None, ), ) GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( @@ -118,7 +135,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, state_class=SensorStateClass.TOTAL, suggested_display_precision=0, - value_fn=lambda data: data.usage_to_date, + value_fn=lambda data: data.forecast.usage_to_date if data.forecast else None, ), OpowerEntityDescription( key="gas_forecasted_usage", @@ -127,7 +144,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, state_class=SensorStateClass.TOTAL, suggested_display_precision=0, - value_fn=lambda data: data.forecasted_usage, + value_fn=lambda data: data.forecast.forecasted_usage if data.forecast else None, ), OpowerEntityDescription( key="gas_typical_usage", @@ -136,7 +153,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, state_class=SensorStateClass.TOTAL, suggested_display_precision=0, - value_fn=lambda data: data.typical_usage, + value_fn=lambda data: data.forecast.typical_usage if data.forecast else None, ), OpowerEntityDescription( key="gas_cost_to_date", @@ -145,7 +162,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, suggested_display_precision=0, - value_fn=lambda data: data.cost_to_date, + value_fn=lambda data: data.forecast.cost_to_date if data.forecast else None, ), OpowerEntityDescription( key="gas_forecasted_cost", @@ -154,7 +171,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, suggested_display_precision=0, - value_fn=lambda data: data.forecasted_cost, + value_fn=lambda data: data.forecast.forecasted_cost if data.forecast else None, ), OpowerEntityDescription( key="gas_typical_cost", @@ -163,7 +180,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, suggested_display_precision=0, - value_fn=lambda data: data.typical_cost, + value_fn=lambda data: data.forecast.typical_cost if data.forecast else None, ), OpowerEntityDescription( key="gas_start_date", @@ -171,7 +188,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.start_date, + value_fn=lambda data: data.forecast.start_date if data.forecast else None, ), OpowerEntityDescription( key="gas_end_date", @@ -179,7 +196,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.end_date, + value_fn=lambda data: data.forecast.end_date if data.forecast else None, ), ) @@ -193,32 +210,38 @@ async def async_setup_entry( coordinator = entry.runtime_data entities: list[OpowerSensor] = [] - forecasts = coordinator.data.values() - for forecast in forecasts: - device_id = f"{coordinator.api.utility.subdomain()}_{forecast.account.utility_account_id}" + opower_data_list = coordinator.data.values() + for opower_data in opower_data_list: + account = opower_data.account + forecast = opower_data.forecast + device_id = ( + f"{coordinator.api.utility.subdomain()}_{account.utility_account_id}" + ) device = DeviceInfo( identifiers={(DOMAIN, device_id)}, - name=f"{forecast.account.meter_type.name} account {forecast.account.utility_account_id}", + name=f"{account.meter_type.name} account {account.utility_account_id}", manufacturer="Opower", model=coordinator.api.utility.name(), entry_type=DeviceEntryType.SERVICE, ) - sensors: tuple[OpowerEntityDescription, ...] = () + sensors: tuple[OpowerEntityDescription, ...] = COMMON_SENSORS if ( - forecast.account.meter_type == MeterType.ELEC + account.meter_type == MeterType.ELEC + and forecast is not None and forecast.unit_of_measure == UnitOfMeasure.KWH ): - sensors = ELEC_SENSORS + sensors += ELEC_SENSORS elif ( - forecast.account.meter_type == MeterType.GAS + account.meter_type == MeterType.GAS + and forecast is not None and forecast.unit_of_measure in [UnitOfMeasure.THERM, UnitOfMeasure.CCF] ): - sensors = GAS_SENSORS + sensors += GAS_SENSORS entities.extend( OpowerSensor( coordinator, sensor, - forecast.account.utility_account_id, + account.utility_account_id, device, device_id, ) @@ -250,7 +273,7 @@ class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity): self.utility_account_id = utility_account_id @property - def native_value(self) -> StateType | date: + def native_value(self) -> StateType | date | datetime: """Return the state.""" return self.entity_description.value_fn( self.coordinator.data[self.utility_account_id] diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index 0ac866574da..6c81f0a334d 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -115,6 +115,12 @@ }, "gas_usage_to_date": { "name": "Current bill gas usage to date" + }, + "last_changed": { + "name": "Last changed" + }, + "last_updated": { + "name": "Last updated" } } }, diff --git a/tests/components/opower/test_sensor.py b/tests/components/opower/test_sensor.py index 883bf86f883..25df9cbcdb6 100644 --- a/tests/components/opower/test_sensor.py +++ b/tests/components/opower/test_sensor.py @@ -1,13 +1,16 @@ """Tests for the Opower sensor platform.""" -from unittest.mock import AsyncMock +from datetime import datetime +from unittest.mock import AsyncMock, patch +from opower import CostRead import pytest from homeassistant.components.recorder import Recorder from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfEnergy, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -19,8 +22,21 @@ async def test_sensors( mock_opower_api: AsyncMock, ) -> None: """Test the creation and values of Opower sensors.""" - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + mock_opower_api.async_get_cost_reads.return_value = [ + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 8)), + end_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), + consumption=1.5, + provided_cost=0.5, + ), + ] + + with patch( + "homeassistant.components.opower.coordinator.dt_util.utcnow" + ) as mock_utcnow: + mock_utcnow.return_value = datetime(2023, 1, 2, 8, 0, 0, tzinfo=dt_util.UTC) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() entity_registry = er.async_get(hass) @@ -49,6 +65,20 @@ async def test_sensors( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "USD" assert state.state == "20.0" + entry = entity_registry.async_get("sensor.elec_account_111111_last_changed") + assert entry + assert entry.unique_id == "pge_111111_last_changed" + state = hass.states.get("sensor.elec_account_111111_last_changed") + assert state + assert state.state == "2023-01-01T16:00:00+00:00" + + entry = entity_registry.async_get("sensor.elec_account_111111_last_updated") + assert entry + assert entry.unique_id == "pge_111111_last_updated" + state = hass.states.get("sensor.elec_account_111111_last_updated") + assert state + assert state.state == "2023-01-02T08:00:00+00:00" + # Check gas sensors entry = entity_registry.async_get( "sensor.gas_account_222222_current_bill_gas_usage_to_date" @@ -70,3 +100,17 @@ async def test_sensors( assert state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "USD" assert state.state == "15.0" + + entry = entity_registry.async_get("sensor.gas_account_222222_last_changed") + assert entry + assert entry.unique_id == "pge_222222_last_changed" + state = hass.states.get("sensor.gas_account_222222_last_changed") + assert state + assert state.state == "2023-01-01T16:00:00+00:00" + + entry = entity_registry.async_get("sensor.gas_account_222222_last_updated") + assert entry + assert entry.unique_id == "pge_222222_last_updated" + state = hass.states.get("sensor.gas_account_222222_last_updated") + assert state + assert state.state == "2023-01-02T08:00:00+00:00"