diff --git a/homeassistant/components/waterfurnace/__init__.py b/homeassistant/components/waterfurnace/__init__.py index 066fbc530af..bdb370084b4 100644 --- a/homeassistant/components/waterfurnace/__init__.py +++ b/homeassistant/components/waterfurnace/__init__.py @@ -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) diff --git a/homeassistant/components/waterfurnace/const.py b/homeassistant/components/waterfurnace/const.py index 5f12739eb05..f2382045df8 100644 --- a/homeassistant/components/waterfurnace/const.py +++ b/homeassistant/components/waterfurnace/const.py @@ -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) diff --git a/homeassistant/components/waterfurnace/coordinator.py b/homeassistant/components/waterfurnace/coordinator.py index 66816763232..483bc9e54c8 100644 --- a/homeassistant/components/waterfurnace/coordinator.py +++ b/homeassistant/components/waterfurnace/coordinator.py @@ -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 + ) diff --git a/homeassistant/components/waterfurnace/manifest.json b/homeassistant/components/waterfurnace/manifest.json index bcdfff1ca99..31934f71ae5 100644 --- a/homeassistant/components/waterfurnace/manifest.json +++ b/homeassistant/components/waterfurnace/manifest.json @@ -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", diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py index 519ea0acea1..9634baabb51 100644 --- a/homeassistant/components/waterfurnace/sensor.py +++ b/homeassistant/components/waterfurnace/sensor.py @@ -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 ) diff --git a/tests/components/waterfurnace/conftest.py b/tests/components/waterfurnace/conftest.py index f1472946e73..0abc5d3c20b 100644 --- a/tests/components/waterfurnace/conftest.py +++ b/tests/components/waterfurnace/conftest.py @@ -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, diff --git a/tests/components/waterfurnace/test_init.py b/tests/components/waterfurnace/test_init.py index ba686caf16e..9679c4f980e 100644 --- a/tests/components/waterfurnace/test_init.py +++ b/tests/components/waterfurnace/test_init.py @@ -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", + } diff --git a/tests/components/waterfurnace/test_statistics.py b/tests/components/waterfurnace/test_statistics.py new file mode 100644 index 00000000000..c4c593cb235 --- /dev/null +++ b/tests/components/waterfurnace/test_statistics.py @@ -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"