mirror of
https://github.com/home-assistant/core.git
synced 2026-04-02 00:20:30 +01:00
Add support for energy statistics in waterfurnace integration (#166707)
Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
@@ -17,7 +17,11 @@ from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN, INTEGRATION_TITLE
|
||||
from .coordinator import WaterFurnaceCoordinator
|
||||
from .coordinator import (
|
||||
WaterFurnaceCoordinator,
|
||||
WaterFurnaceDeviceData,
|
||||
WaterFurnaceEnergyCoordinator,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,7 +38,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
type WaterFurnaceConfigEntry = ConfigEntry[dict[str, WaterFurnaceCoordinator]]
|
||||
type WaterFurnaceConfigEntry = ConfigEntry[dict[str, WaterFurnaceDeviceData]]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
@@ -95,7 +99,7 @@ async def _async_setup_coordinator(
|
||||
password: str,
|
||||
device_index: int,
|
||||
entry: WaterFurnaceConfigEntry,
|
||||
) -> tuple[str, WaterFurnaceCoordinator]:
|
||||
) -> tuple[str, WaterFurnaceDeviceData]:
|
||||
"""Set up a coordinator for a device."""
|
||||
|
||||
device_client = WaterFurnace(username, password, device=device_index)
|
||||
@@ -107,7 +111,18 @@ async def _async_setup_coordinator(
|
||||
raise ConfigEntryNotReady(
|
||||
f"Invalid GWID for device at index {device_index}: {device_client.gwid}"
|
||||
)
|
||||
return device_client.gwid, coordinator
|
||||
|
||||
energy_coordinator = WaterFurnaceEnergyCoordinator(
|
||||
hass, device_client, entry, device_client.gwid
|
||||
)
|
||||
# Use async_refresh() instead of async_config_entry_first_refresh() so that
|
||||
# energy data failures (e.g. WFNoDataError for new accounts) don't block
|
||||
# the integration from loading. Realtime sensor data is the primary concern.
|
||||
await energy_coordinator.async_refresh()
|
||||
|
||||
return device_client.gwid, WaterFurnaceDeviceData(
|
||||
realtime=coordinator, energy=energy_coordinator
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -126,10 +141,12 @@ async def async_setup_entry(
|
||||
"Authentication failed. Please update your credentials."
|
||||
) from err
|
||||
|
||||
device_count = len(client.devices) if client.devices else 0
|
||||
|
||||
results = await asyncio.gather(
|
||||
*[
|
||||
_async_setup_coordinator(hass, username, password, index, entry)
|
||||
for index in range(len(client.devices) if client.devices else 0)
|
||||
for index in range(device_count)
|
||||
]
|
||||
)
|
||||
entry.runtime_data = dict(results)
|
||||
|
||||
@@ -6,3 +6,4 @@ from typing import Final
|
||||
DOMAIN: Final = "waterfurnace"
|
||||
INTEGRATION_TITLE: Final = "WaterFurnace"
|
||||
UPDATE_INTERVAL: Final = timedelta(seconds=10)
|
||||
ENERGY_UPDATE_INTERVAL: Final = timedelta(hours=2)
|
||||
|
||||
@@ -1,14 +1,38 @@
|
||||
"""Data update coordinator for WaterFurnace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from waterfurnace.waterfurnace import WaterFurnace, WFException, WFGateway, WFReading
|
||||
from waterfurnace.waterfurnace import (
|
||||
WaterFurnace,
|
||||
WFCredentialError,
|
||||
WFException,
|
||||
WFGateway,
|
||||
WFNoDataError,
|
||||
WFReading,
|
||||
)
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.components.recorder import get_instance
|
||||
from homeassistant.components.recorder.models import StatisticMeanType
|
||||
from homeassistant.components.recorder.models.statistics import (
|
||||
StatisticData,
|
||||
StatisticMetaData,
|
||||
)
|
||||
from homeassistant.components.recorder.statistics import (
|
||||
async_add_external_statistics,
|
||||
get_last_statistics,
|
||||
)
|
||||
from homeassistant.const import UnitOfEnergy
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.unit_conversion import EnergyConverter
|
||||
|
||||
from .const import UPDATE_INTERVAL
|
||||
from .const import DOMAIN, ENERGY_UPDATE_INTERVAL, UPDATE_INTERVAL
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import WaterFurnaceConfigEntry
|
||||
@@ -16,6 +40,14 @@ if TYPE_CHECKING:
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WaterFurnaceDeviceData:
|
||||
"""Container for per-device coordinators."""
|
||||
|
||||
realtime: WaterFurnaceCoordinator
|
||||
energy: WaterFurnaceEnergyCoordinator
|
||||
|
||||
|
||||
class WaterFurnaceCoordinator(DataUpdateCoordinator[WFReading]):
|
||||
"""WaterFurnace data update coordinator.
|
||||
|
||||
@@ -54,3 +86,164 @@ class WaterFurnaceCoordinator(DataUpdateCoordinator[WFReading]):
|
||||
return await self.hass.async_add_executor_job(self.client.read_with_retry)
|
||||
except WFException as err:
|
||||
raise UpdateFailed(str(err)) from err
|
||||
|
||||
|
||||
class WaterFurnaceEnergyCoordinator(DataUpdateCoordinator[None]):
|
||||
"""WaterFurnace energy data coordinator.
|
||||
|
||||
Periodically fetches energy data and inserts external statistics
|
||||
for the Energy Dashboard.
|
||||
"""
|
||||
|
||||
config_entry: WaterFurnaceConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
client: WaterFurnace,
|
||||
config_entry: WaterFurnaceConfigEntry,
|
||||
gwid: str,
|
||||
) -> None:
|
||||
"""Initialize the energy coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"WaterFurnace Energy {gwid}",
|
||||
update_interval=ENERGY_UPDATE_INTERVAL,
|
||||
config_entry=config_entry,
|
||||
)
|
||||
self.client = client
|
||||
self.gwid = gwid
|
||||
self.statistic_id = f"{DOMAIN}:{gwid.lower()}_energy"
|
||||
self._statistic_metadata = StatisticMetaData(
|
||||
has_sum=True,
|
||||
mean_type=StatisticMeanType.NONE,
|
||||
name=f"WaterFurnace Energy {gwid}",
|
||||
source=DOMAIN,
|
||||
statistic_id=self.statistic_id,
|
||||
unit_class=EnergyConverter.UNIT_CLASS,
|
||||
unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _dummy_listener() -> None:
|
||||
pass
|
||||
|
||||
# Ensure periodic polling even without entity listeners,
|
||||
# since this coordinator only inserts external statistics.
|
||||
self.async_add_listener(_dummy_listener)
|
||||
|
||||
async def _async_get_last_stat(self) -> tuple[float, float] | None:
|
||||
"""Get the last recorded statistic timestamp and sum.
|
||||
|
||||
Returns (timestamp, sum) or None if no statistics exist.
|
||||
"""
|
||||
last_stat = await get_instance(self.hass).async_add_executor_job(
|
||||
get_last_statistics, self.hass, 1, self.statistic_id, True, {"sum"}
|
||||
)
|
||||
if not last_stat:
|
||||
return None
|
||||
entry = last_stat[self.statistic_id][0]
|
||||
if entry["sum"] is None:
|
||||
return None
|
||||
return (entry["start"], entry["sum"])
|
||||
|
||||
def _fetch_energy_data(
|
||||
self, start_date: str, end_date: str
|
||||
) -> list[tuple[datetime, float]]:
|
||||
"""Fetch energy data and return list of (timestamp, kWh) tuples."""
|
||||
# Re-login to refresh the HTTP session token, which expires between
|
||||
# the 2-hour polling intervals.
|
||||
try:
|
||||
self.client.login()
|
||||
except WFCredentialError as err:
|
||||
raise UpdateFailed(
|
||||
"Authentication failed during energy data fetch"
|
||||
) from err
|
||||
data = self.client.get_energy_data(
|
||||
start_date,
|
||||
end_date,
|
||||
frequency="1H",
|
||||
timezone_str=self.hass.config.time_zone,
|
||||
)
|
||||
return [
|
||||
(reading.timestamp, reading.total_power)
|
||||
for reading in data
|
||||
if reading.total_power is not None
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _build_statistics(
|
||||
readings: list[tuple[datetime, float]],
|
||||
last_ts: float,
|
||||
last_sum: float,
|
||||
now: datetime,
|
||||
) -> list[StatisticData]:
|
||||
"""Build hourly statistics from readings, skipping already-recorded ones."""
|
||||
current_hour_ts = now.replace(minute=0, second=0, microsecond=0).timestamp()
|
||||
statistics: list[StatisticData] = []
|
||||
seen_hours: set[float] = set()
|
||||
running_sum = last_sum
|
||||
for timestamp, kwh in sorted(readings, key=lambda x: x[0]):
|
||||
ts = timestamp.timestamp()
|
||||
if ts <= last_ts:
|
||||
continue
|
||||
if ts >= current_hour_ts:
|
||||
continue
|
||||
hour_ts = timestamp.replace(minute=0, second=0, microsecond=0).timestamp()
|
||||
if hour_ts in seen_hours:
|
||||
continue
|
||||
seen_hours.add(hour_ts)
|
||||
running_sum += kwh
|
||||
statistics.append(
|
||||
StatisticData(
|
||||
start=timestamp.replace(minute=0, second=0, microsecond=0),
|
||||
state=kwh,
|
||||
sum=running_sum,
|
||||
)
|
||||
)
|
||||
return statistics
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch energy data and insert statistics."""
|
||||
last = await self._async_get_last_stat()
|
||||
now = dt_util.utcnow()
|
||||
|
||||
if last is None:
|
||||
_LOGGER.info("No prior statistics found, fetching recent energy data")
|
||||
last_ts = 0.0
|
||||
last_sum = 0.0
|
||||
start_dt = now - timedelta(days=1)
|
||||
else:
|
||||
last_ts, last_sum = last
|
||||
start_dt = dt_util.utc_from_timestamp(last_ts)
|
||||
_LOGGER.debug("Last stat: ts=%s, sum=%s", start_dt.isoformat(), last_sum)
|
||||
|
||||
local_tz = dt_util.DEFAULT_TIME_ZONE
|
||||
start_date = start_dt.astimezone(local_tz).strftime("%Y-%m-%d")
|
||||
end_date = (now.astimezone(local_tz) + timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
|
||||
try:
|
||||
readings = await self.hass.async_add_executor_job(
|
||||
self._fetch_energy_data, start_date, end_date
|
||||
)
|
||||
except WFNoDataError:
|
||||
_LOGGER.debug("No energy data available for %s to %s", start_date, end_date)
|
||||
return
|
||||
except WFException as err:
|
||||
raise UpdateFailed(str(err)) from err
|
||||
|
||||
if not readings:
|
||||
_LOGGER.debug("No readings returned for %s to %s", start_date, end_date)
|
||||
return
|
||||
|
||||
_LOGGER.debug("Fetched %s readings", len(readings))
|
||||
|
||||
statistics = self._build_statistics(readings, last_ts, last_sum, now)
|
||||
|
||||
_LOGGER.debug("Built %s statistics to insert", len(statistics))
|
||||
|
||||
if statistics:
|
||||
async_add_external_statistics(
|
||||
self.hass, self._statistic_metadata, statistics
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"domain": "waterfurnace",
|
||||
"name": "WaterFurnace",
|
||||
"after_dependencies": ["recorder"],
|
||||
"codeowners": ["@sdague", "@masterkoppa"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/waterfurnace",
|
||||
|
||||
@@ -156,8 +156,8 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up Waterfurnace sensors from a config entry."""
|
||||
async_add_entities(
|
||||
WaterFurnaceSensor(coordinator, description)
|
||||
for coordinator in config_entry.runtime_data.values()
|
||||
WaterFurnaceSensor(device_data.realtime, description)
|
||||
for device_data in config_entry.runtime_data.values()
|
||||
for description in SENSORS
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from waterfurnace.waterfurnace import WaterFurnace, WFGateway, WFReading
|
||||
from waterfurnace.waterfurnace import WaterFurnace, WFGateway, WFNoDataError, WFReading
|
||||
|
||||
from homeassistant.components.recorder import Recorder
|
||||
from homeassistant.components.waterfurnace.const import DOMAIN
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -49,6 +50,7 @@ def mock_waterfurnace_client() -> Generator[Mock]:
|
||||
device_data = WFReading(load_json_object_fixture("device_data.json", DOMAIN))
|
||||
client.read.return_value = device_data
|
||||
client.read_with_retry.return_value = device_data
|
||||
client.get_energy_data.side_effect = WFNoDataError("No data")
|
||||
|
||||
yield client
|
||||
|
||||
@@ -87,6 +89,7 @@ def mock_waterfurnace_client_multi_device() -> Generator[Mock]:
|
||||
client.devices = [WFGateway(gateway_data_1), WFGateway(gateway_data_2)]
|
||||
client.read.return_value = device_data
|
||||
client.read_with_retry.return_value = device_data
|
||||
client.get_energy_data.side_effect = WFNoDataError("No data")
|
||||
instances.append(client)
|
||||
|
||||
mock_client.side_effect = lambda username, password, device=0: instances[device]
|
||||
@@ -111,6 +114,7 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_waterfurnace_client: Mock,
|
||||
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import Mock
|
||||
import pytest
|
||||
from waterfurnace.waterfurnace import WFCredentialError
|
||||
|
||||
from homeassistant.components.recorder import Recorder
|
||||
from homeassistant.components.waterfurnace.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
@@ -124,11 +125,70 @@ async def test_reload_entry(
|
||||
) -> None:
|
||||
"""Test reloading a config entry."""
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
assert mock_waterfurnace_client.login.call_count == 2
|
||||
assert mock_waterfurnace_client.login.call_count == 3
|
||||
|
||||
await hass.config_entries.async_reload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
assert mock_waterfurnace_client.login.call_count == 4
|
||||
assert "TEST_GWID_12345" in mock_config_entry.runtime_data
|
||||
assert mock_waterfurnace_client.login.call_count == 6
|
||||
|
||||
|
||||
async def test_setup_creates_energy_coordinator(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_waterfurnace_client: Mock,
|
||||
) -> None:
|
||||
"""Test that setup creates both realtime and energy coordinators."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
assert mock_waterfurnace_client.login.call_count == 3
|
||||
assert mock_waterfurnace_client.read_with_retry.call_count == 1
|
||||
assert mock_waterfurnace_client.get_energy_data.call_count == 1
|
||||
|
||||
|
||||
async def test_setup_multi_device_energy_coordinators(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_waterfurnace_client_multi_device: Mock,
|
||||
) -> None:
|
||||
"""Test multi-device setup creates energy coordinators with correct gwids."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
gwids = set()
|
||||
for gwid, device_data in mock_config_entry.runtime_data.items():
|
||||
assert device_data.energy is not None
|
||||
assert device_data.energy.gwid == gwid
|
||||
gwids.add(gwid)
|
||||
|
||||
assert gwids == {"TEST_GWID_12345", "TEST_GWID_67890"}
|
||||
|
||||
|
||||
async def test_setup_energy_statistic_ids_per_device(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_waterfurnace_client_multi_device: Mock,
|
||||
) -> None:
|
||||
"""Test that each device gets a unique statistic_id."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
stat_ids = {
|
||||
device_data.energy.statistic_id
|
||||
for device_data in mock_config_entry.runtime_data.values()
|
||||
}
|
||||
assert stat_ids == {
|
||||
f"{DOMAIN}:test_gwid_12345_energy",
|
||||
f"{DOMAIN}:test_gwid_67890_energy",
|
||||
}
|
||||
|
||||
251
tests/components/waterfurnace/test_statistics.py
Normal file
251
tests/components/waterfurnace/test_statistics.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""Tests for WaterFurnace energy statistics."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import Mock
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from waterfurnace.waterfurnace import WFCredentialError, WFEnergyData
|
||||
|
||||
from homeassistant.components.recorder import Recorder
|
||||
from homeassistant.components.recorder.statistics import (
|
||||
StatisticsRow,
|
||||
statistics_during_period,
|
||||
)
|
||||
from homeassistant.components.waterfurnace.const import DOMAIN, ENERGY_UPDATE_INTERVAL
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
from tests.components.recorder.common import async_wait_recording_done
|
||||
|
||||
STATISTIC_ID = f"{DOMAIN}:test_gwid_12345_energy"
|
||||
|
||||
# All time-sensitive tests are pinned to this instant.
|
||||
NOW = "2025-01-15 12:00:00+00:00"
|
||||
_NOW_DT = dt_util.as_utc(dt_util.parse_datetime(NOW))
|
||||
|
||||
|
||||
def _make_energy_data(readings: list[tuple[datetime, float]]) -> WFEnergyData:
|
||||
"""Build a WFEnergyData from (timestamp, total_power_kwh) pairs."""
|
||||
columns = ["total_power"]
|
||||
index = [int(ts.timestamp() * 1000) for ts, _ in readings]
|
||||
data = [[kwh] for _, kwh in readings]
|
||||
return WFEnergyData({"columns": columns, "index": index, "data": data})
|
||||
|
||||
|
||||
async def _get_stats(
|
||||
hass: HomeAssistant,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
) -> list[StatisticsRow]:
|
||||
"""Get statistics for the test statistic_id."""
|
||||
stats = await hass.async_add_executor_job(
|
||||
statistics_during_period,
|
||||
hass,
|
||||
start,
|
||||
end,
|
||||
{STATISTIC_ID},
|
||||
"hour",
|
||||
None,
|
||||
{"state", "sum"},
|
||||
)
|
||||
return stats.get(STATISTIC_ID, [])
|
||||
|
||||
|
||||
async def _trigger_energy_poll(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory
|
||||
) -> None:
|
||||
"""Advance time to trigger an energy poll."""
|
||||
freezer.tick(ENERGY_UPDATE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
# The coordinator reads from the recorder on an executor thread, then
|
||||
# calls async_add_external_statistics which queues a write back to the
|
||||
# recorder thread. A single flush isn't enough because the write is
|
||||
# queued after the event loop task completes. Flush twice to ensure the
|
||||
# full event-loop → recorder → event-loop → recorder chain settles.
|
||||
await async_wait_recording_done(hass)
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
|
||||
@pytest.mark.freeze_time(NOW)
|
||||
async def test_poll_inserts_statistics(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_waterfurnace_client: Mock,
|
||||
) -> None:
|
||||
"""Test that energy data is fetched and inserted as statistics."""
|
||||
t1 = _NOW_DT - timedelta(hours=2)
|
||||
t2 = _NOW_DT - timedelta(hours=1)
|
||||
|
||||
mock_waterfurnace_client.get_energy_data.side_effect = None
|
||||
mock_waterfurnace_client.get_energy_data.return_value = _make_energy_data(
|
||||
[(t1, 2.0), (t2, 3.0)]
|
||||
)
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
entries = await _get_stats(hass, t1, t2 + timedelta(seconds=1))
|
||||
assert len(entries) == 2
|
||||
assert entries[0]["state"] == pytest.approx(2.0)
|
||||
assert entries[0]["sum"] == pytest.approx(2.0)
|
||||
assert entries[1]["state"] == pytest.approx(3.0)
|
||||
assert entries[1]["sum"] == pytest.approx(5.0)
|
||||
|
||||
|
||||
@pytest.mark.freeze_time(NOW)
|
||||
async def test_poll_skips_current_hour(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_waterfurnace_client: Mock,
|
||||
) -> None:
|
||||
"""Test that readings from the current incomplete hour are skipped."""
|
||||
t_completed = _NOW_DT - timedelta(hours=1)
|
||||
t_current = _NOW_DT
|
||||
|
||||
mock_waterfurnace_client.get_energy_data.side_effect = None
|
||||
mock_waterfurnace_client.get_energy_data.return_value = _make_energy_data(
|
||||
[(t_completed, 2.0), (t_current, 5.0)]
|
||||
)
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
entries = await _get_stats(hass, t_completed, t_current + timedelta(seconds=1))
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["sum"] == pytest.approx(2.0)
|
||||
|
||||
|
||||
async def test_poll_empty_response(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_waterfurnace_client: Mock,
|
||||
) -> None:
|
||||
"""Test that empty energy data response is handled gracefully."""
|
||||
mock_waterfurnace_client.get_energy_data.side_effect = None
|
||||
mock_waterfurnace_client.get_energy_data.return_value = _make_energy_data([])
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.freeze_time(NOW)
|
||||
async def test_subsequent_poll_resumes_sum(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_waterfurnace_client: Mock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that subsequent polls correctly resume from the last recorded sum."""
|
||||
t1 = _NOW_DT - timedelta(hours=1)
|
||||
|
||||
mock_waterfurnace_client.get_energy_data.side_effect = None
|
||||
mock_waterfurnace_client.get_energy_data.return_value = _make_energy_data(
|
||||
[(t1, 4.0)]
|
||||
)
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
# Advance time so t2 becomes a completed hour, then poll again
|
||||
t2 = _NOW_DT
|
||||
mock_waterfurnace_client.get_energy_data.return_value = _make_energy_data(
|
||||
[(t2, 6.0)]
|
||||
)
|
||||
await _trigger_energy_poll(hass, freezer)
|
||||
|
||||
entries = await _get_stats(hass, t2, t2 + timedelta(seconds=1))
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["state"] == pytest.approx(6.0)
|
||||
assert entries[0]["sum"] == pytest.approx(10.0)
|
||||
|
||||
|
||||
async def test_no_data_error_handled_gracefully(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_waterfurnace_client: Mock,
|
||||
) -> None:
|
||||
"""Test that WFNoDataError does not prevent integration setup."""
|
||||
# Default conftest sets get_energy_data.side_effect = WFNoDataError
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.freeze_time(NOW)
|
||||
async def test_login_credential_error_raises_update_failed(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_waterfurnace_client: Mock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that WFCredentialError during energy login raises UpdateFailed."""
|
||||
# First setup succeeds with no data
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
# On next poll, login fails with credential error
|
||||
mock_waterfurnace_client.login.side_effect = WFCredentialError("bad creds")
|
||||
mock_waterfurnace_client.get_energy_data.side_effect = None
|
||||
|
||||
await _trigger_energy_poll(hass, freezer)
|
||||
|
||||
device_data = mock_config_entry.runtime_data["TEST_GWID_12345"]
|
||||
assert device_data.energy.last_update_success is False
|
||||
|
||||
|
||||
@pytest.mark.freeze_time(NOW)
|
||||
async def test_timezone_conversion(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_waterfurnace_client: Mock,
|
||||
) -> None:
|
||||
"""Test that energy data is correctly handled across time zones."""
|
||||
await hass.config.async_set_time_zone("America/New_York")
|
||||
|
||||
t1 = _NOW_DT - timedelta(hours=2)
|
||||
t2 = _NOW_DT - timedelta(hours=1)
|
||||
|
||||
mock_waterfurnace_client.get_energy_data.side_effect = None
|
||||
mock_waterfurnace_client.get_energy_data.return_value = _make_energy_data(
|
||||
[(t1, 1.5), (t2, 2.5)]
|
||||
)
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
entries = await _get_stats(hass, t1, t2 + timedelta(seconds=1))
|
||||
assert len(entries) == 2
|
||||
assert entries[0]["sum"] == pytest.approx(1.5)
|
||||
assert entries[1]["sum"] == pytest.approx(4.0)
|
||||
|
||||
# Verify the API was called with dates in the configured timezone
|
||||
call_args = mock_waterfurnace_client.get_energy_data.call_args
|
||||
assert call_args.kwargs.get("timezone_str") == "America/New_York"
|
||||
Reference in New Issue
Block a user