1
0
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:
Andres Ruiz
2026-04-01 14:03:15 -04:00
committed by GitHub
parent 7daaf3de6a
commit 0fc62c3150
8 changed files with 541 additions and 14 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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