1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-14 23:28:42 +00:00

Add last_changed and last_updated for the Opower statistics (#159101)

This commit is contained in:
tronikos
2026-02-10 09:08:58 -08:00
committed by GitHub
parent af152ebe50
commit 5105c6c50f
4 changed files with 157 additions and 48 deletions

View File

@@ -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,

View File

@@ -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]

View File

@@ -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"
}
}
},

View File

@@ -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"