diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 9449841d7245..45dd671a3566 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -112,6 +112,7 @@ STREAM_TOPICS: Final = ( SseTopic.LIVE_STATUS, SseTopic.SITE_INFO, SseTopic.TARIFF_CONTENT_V2, + SseTopic.ENERGY_TOTALS, ) @@ -703,10 +704,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - for vehicle in vehicles if vehicle.poll ), - *( - energysite.info_coordinator.async_config_entry_first_refresh() - for energysite in energysites - ), + *(_async_refresh_energy_site(energysite) for energysite in energysites), ) # Setup energy devices with models, versions, and listeners @@ -817,6 +815,8 @@ def create_handle_energy_stream_connection( if energysite.live_coordinator is not None: energysite.live_coordinator.async_set_update_error(error) energysite.info_coordinator.async_set_update_error(error) + if energysite.history_coordinator is not None: + energysite.history_coordinator.async_set_update_error(error) return handle_connection @@ -890,14 +890,27 @@ async def _async_setup_energy_site( ) history_coordinator = ( - TeslemetryEnergyHistoryCoordinator(hass, entry, energy_site) - if powerwall - else None + TeslemetryEnergyHistoryCoordinator(hass, entry, site_id) if powerwall else None ) + if history_coordinator is not None: + entry.async_on_unload( + stream_energysite.listen_EnergyTotals( + history_coordinator.handle_stream_update + ) + ) return live_coordinator, info_coordinator, history_coordinator +async def _async_refresh_energy_site(energysite: TeslemetryEnergyData) -> None: + """Cold read the site info, then resolve the site timezone it carries.""" + await energysite.info_coordinator.async_config_entry_first_refresh() + if energysite.history_coordinator is not None: + await energysite.history_coordinator.async_set_time_zone( + energysite.info_coordinator.data.get("installation_time_zone") + ) + + async def async_unload_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool: """Unload Teslemetry Config.""" unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 38af78da7aad..f861cf277992 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -1,9 +1,10 @@ """Teslemetry Data Coordinator.""" -from datetime import timedelta +from dataclasses import asdict +from datetime import date, datetime, time, timedelta, tzinfo from typing import TYPE_CHECKING, Any, override -from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint +from tesla_fleet_api.const import VehicleDataEndpoint from tesla_fleet_api.exceptions import ( GatewayTimeout, InsufficientCredits, @@ -16,15 +17,17 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, ) from tesla_fleet_api.teslemetry import EnergySite, Teslemetry, Vehicle +from teslemetry_stream.const import EnergyTotalsEvent from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util if TYPE_CHECKING: from . import TeslemetryConfigEntry -from .const import DOMAIN, ENERGY_HISTORY_FIELDS, LOGGER +from .const import DOMAIN, LOGGER from .helpers import async_update_device_sw_version, flatten RETRY_EXCEPTIONS = ( @@ -45,9 +48,12 @@ def _get_retry_after(e: TeslaFleetError) -> float: VEHICLE_INTERVAL = timedelta(seconds=60) VEHICLE_WAIT = timedelta(minutes=15) -ENERGY_HISTORY_INTERVAL = timedelta(seconds=60) METADATA_INTERVAL = timedelta(hours=1) +# Start of the day the energy history totals cover. Kept out of +# ENERGY_HISTORY_FIELDS, which is the list of keys that become sensors. +PERIOD_START = "_period_start" + # Keys within tariff_content_v2 kept as nested dicts rather than flattened, # since entities and calendars read them as whole structures. TARIFF_SKIP_KEYS = ["daily_charges", "demand_charges", "energy_charges", "seasons"] @@ -330,7 +336,11 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """Class to manage fetching energy site info from the Teslemetry API.""" + """Class to manage energy site history totals from the Teslemetry stream. + + The server sums each day's periods itself and publishes cumulative + ``energy_totals``; there is no REST poll and no local accumulation. + """ config_entry: TeslemetryConfigEntry @@ -338,54 +348,49 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, config_entry: TeslemetryConfigEntry, - api: EnergySite, + site_id: int, ) -> None: - """Initialize Teslemetry Energy Info coordinator.""" + """Initialize Teslemetry Energy History coordinator.""" super().__init__( hass, LOGGER, config_entry=config_entry, - name=f"Teslemetry Energy History {api.energy_site_id}", - update_interval=ENERGY_HISTORY_INTERVAL, + name=f"Teslemetry Energy History {site_id}", ) - self.api = api + self.site_id = site_id + self.time_zone: tzinfo | None = None self.data = {} + async def async_set_time_zone(self, name: str | None) -> None: + """Resolve the site's installation timezone.""" + if not name: + return + if (zone := await dt_util.async_get_time_zone(name)) is None: + LOGGER.warning( + "Unknown timezone %s for energy site %s, falling back to the Home Assistant timezone", + name, + self.site_id, + ) + return + self.time_zone = zone + @override async def _async_update_data(self) -> dict[str, Any]: - """Update energy site data using Teslemetry API.""" - try: - data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"] - except (InvalidToken, SubscriptionRequired, LoginRequired) as e: - raise ConfigEntryAuthFailed from e - except RETRY_EXCEPTIONS as e: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed", - translation_placeholders={"message": e.message}, - retry_after=_get_retry_after(e), - ) from e - except TeslaFleetError as e: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed", - translation_placeholders={"message": e.message}, - ) from e + """Return the current totals; there is nothing to fetch. - if not data or not isinstance(data.get("time_series"), list): - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed_invalid_data", - ) + Only reached through the generic entity update service, which must not + fail on a coordinator the stream alone feeds. + """ + return self.data - # Add all time periods together - output = dict.fromkeys(ENERGY_HISTORY_FIELDS, None) - for period in data.get("time_series", []): - for key in ENERGY_HISTORY_FIELDS: - if key in period: - if output[key] is None: - output[key] = period[key] - else: - output[key] += period[key] - - return output + def handle_stream_update(self, event: EnergyTotalsEvent) -> None: + """Handle an energy_totals document from the stream.""" + data: dict[str, Any] = asdict(event.totals) + # The server finalises a day after the site's local midnight, so a late + # event must stay on the day it reports rather than the current one. + data[PERIOD_START] = datetime.combine( + date.fromisoformat(event.date), + time(), + tzinfo=self.time_zone or dt_util.get_default_time_zone(), + ) + self.async_set_updated_data(data) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 1085020024ee..066389fb5b19 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -40,6 +40,7 @@ from homeassistant.util.variance import ignore_variance from . import TeslemetryConfigEntry from .const import ENERGY_HISTORY_FIELDS +from .coordinator import PERIOD_START from .entity import ( TeslemetryEnergyHistoryEntity, TeslemetryEnergyInfoEntity, @@ -1653,7 +1654,7 @@ ENERGY_HISTORY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = tuple( native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, entity_registry_enabled_default=( key.startswith("total") or key == "grid_energy_imported" ), @@ -1966,6 +1967,7 @@ class TeslemetryEnergyHistorySensorEntity(TeslemetryEnergyHistoryEntity, SensorE def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" self._attr_native_value = self._value + self._attr_last_reset = self.coordinator.data.get(PERIOD_START) class TeslemetryCreditBalanceSensor(RestoreSensor): diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 09e5e0a0544b..a8eb1d7cc6ad 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1289,9 +1289,6 @@ "update_failed_insufficient_credits": { "message": "Teslemetry account has insufficient command credits, pausing updates until credits are added" }, - "update_failed_invalid_data": { - "message": "Received invalid data from API" - }, "wake_up_failed": { "message": "Failed to wake up vehicle: {message}" }, diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index 681b800b59cc..5078e6edb70d 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -6,13 +6,15 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest +from teslemetry_stream.const import EnergyTotalsEvent from teslemetry_stream.stream import recursive_match from homeassistant.components.teslemetry.const import TOKEN_URL from .const import ( COMMAND_OK, - ENERGY_HISTORY, + ENERGY_TOTALS, + ENERGY_TOTALS_DATE, LIVE_STATUS, METADATA, METADATA_ENERGY, @@ -142,16 +144,6 @@ def mock_site_info(): yield mock_live_status -@pytest.fixture(autouse=True) -def mock_energy_history(): - """Mock Teslemetry Energy Specific site_info method.""" - with patch( - "tesla_fleet_api.tesla.energysite.EnergySite.energy_history", - return_value=ENERGY_HISTORY, - ) as mock_live_status: - yield mock_live_status - - @pytest.fixture(autouse=True) def mock_stream_listen(): """Mock Teslemetry Stream listen method.""" @@ -252,6 +244,43 @@ def mock_energy_info_stream() -> Generator[MagicMock]: yield mock_listen +@pytest.fixture +def mock_energy_totals_stream() -> Generator[MagicMock]: + """Capture the callback the integration registers for energy_totals events.""" + with patch( + "teslemetry_stream.TeslemetryStreamEnergySite.listen_EnergyTotals", + ) as mock_listen: + callbacks: list = [] + + def side_effect(callback): + callbacks.append(callback) + return MagicMock() + + def send( + totals: dict[str, float | None] | None = None, + *, + date: str = ENERGY_TOTALS_DATE, + created_at: str = "2024-09-18T08:50:00.000Z", + is_cache: bool = False, + ) -> None: + """Deliver a wire event through the library's own parsing.""" + event = EnergyTotalsEvent.from_dict( + { + "id": 123456, + "date": date, + "createdAt": created_at, + "isCache": is_cache, + "totals": ENERGY_TOTALS if totals is None else totals, + } + ) + for callback in callbacks: + callback(event) + + mock_listen.side_effect = side_effect + mock_listen.send = send + yield mock_listen + + @pytest.fixture def mock_energy_tariff_stream() -> Generator[MagicMock]: """Capture the callback the integration registers for tariff events.""" diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index ae574bc9470f..e219acffc8d5 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -29,8 +29,39 @@ SITE_INFO_WEEK_CROSSING = load_json_object_fixture( "site_info_week_crossing.json", DOMAIN ) SITE_INFO_MULTI_SEASON = load_json_object_fixture("site_info_multi_season.json", DOMAIN) -ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN) -ENERGY_HISTORY_EMPTY = load_json_object_fixture("energy_history_empty.json", DOMAIN) + +# The site-local day the streamed energy_totals fixtures below belong to. The +# site timezone site_info.json declares is deliberately not the test machine's. +ENERGY_TOTALS_DATE = "2024-09-18" + +# A valid, non-empty day: the server sums every period itself and sends 0 for a +# field that never appeared. +ENERGY_TOTALS = { + "solar_energy_exported": 724, + "generator_energy_exported": 0, + "grid_energy_imported": 0, + "grid_services_energy_imported": 0, + "grid_services_energy_exported": 0, + "grid_energy_exported_from_solar": 2, + "grid_energy_exported_from_generator": 0, + "grid_energy_exported_from_battery": 0, + "battery_energy_exported": 36, + "battery_energy_imported_from_grid": 0, + "battery_energy_imported_from_solar": 684, + "battery_energy_imported_from_generator": 0, + "consumer_energy_imported_from_grid": 0, + "consumer_energy_imported_from_solar": 38, + "consumer_energy_imported_from_battery": 36, + "consumer_energy_imported_from_generator": 0, + "total_home_usage": 74, + "total_battery_charge": 684, + "total_battery_discharge": 36, + "total_solar_generation": 724, + "total_grid_energy_exported": 2, +} + +# An empty or malformed day: every field comes back null. +ENERGY_TOTALS_NULL = dict.fromkeys(ENERGY_TOTALS) COMMAND_OK = {"response": {"result": True, "reason": ""}} COMMAND_REASON = {"response": {"result": False, "reason": "already closed"}} diff --git a/tests/components/teslemetry/fixtures/energy_history.json b/tests/components/teslemetry/fixtures/energy_history.json deleted file mode 100644 index 2b787beafacd..000000000000 --- a/tests/components/teslemetry/fixtures/energy_history.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "response": { - "serial_number": "xxxxxx", - "period": "day", - "installation_time_zone": "Australia/Brisbane", - "time_series": [ - { - "timestamp": "2024-09-18T00:00:00+10:00", - "solar_energy_exported": 0, - "generator_energy_exported": 0, - "grid_energy_imported": 0, - "grid_services_energy_imported": 0, - "grid_services_energy_exported": 0, - "grid_energy_exported_from_solar": 0, - "grid_energy_exported_from_generator": 0, - "grid_energy_exported_from_battery": 0, - "battery_energy_exported": 36, - "battery_energy_imported_from_grid": 0, - "battery_energy_imported_from_solar": 0, - "battery_energy_imported_from_generator": 0, - "consumer_energy_imported_from_grid": 0, - "consumer_energy_imported_from_solar": 0, - "consumer_energy_imported_from_battery": 36, - "consumer_energy_imported_from_generator": 0, - "raw_timestamp": "2024-09-18T00:00:00+10:00", - "total_home_usage": 36, - "total_battery_discharge": 36 - }, - { - "timestamp": "2024-09-18T08:45:00+10:00", - "solar_energy_exported": 724, - "generator_energy_exported": 0, - "grid_energy_imported": 0, - "grid_services_energy_imported": 0, - "grid_services_energy_exported": 0, - "grid_energy_exported_from_solar": 2, - "grid_energy_exported_from_generator": 0, - "grid_energy_exported_from_battery": 0, - "battery_energy_exported": 0, - "battery_energy_imported_from_grid": 0, - "battery_energy_imported_from_solar": 684, - "battery_energy_imported_from_generator": 0, - "consumer_energy_imported_from_grid": 0, - "consumer_energy_imported_from_solar": 38, - "consumer_energy_imported_from_battery": 0, - "consumer_energy_imported_from_generator": 0, - "raw_timestamp": "2024-09-18T08:45:00+10:00", - "total_home_usage": 38, - "total_solar_generation": 724, - "total_battery_charge": 684, - "total_grid_energy_exported": 2 - } - ] - } -} diff --git a/tests/components/teslemetry/fixtures/energy_history_empty.json b/tests/components/teslemetry/fixtures/energy_history_empty.json deleted file mode 100644 index cc54000115a6..000000000000 --- a/tests/components/teslemetry/fixtures/energy_history_empty.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "response": { - "serial_number": "xxxxxx", - "period": "day", - "installation_time_zone": "Australia/Brisbane", - "time_series": null - } -} diff --git a/tests/components/teslemetry/fixtures/site_info.json b/tests/components/teslemetry/fixtures/site_info.json index 43bc7a7bc353..bdbd0ad58e3e 100644 --- a/tests/components/teslemetry/fixtures/site_info.json +++ b/tests/components/teslemetry/fixtures/site_info.json @@ -119,7 +119,7 @@ }, "nameplate_power": 15000, "nameplate_energy": 40500, - "installation_time_zone": "", + "installation_time_zone": "Australia/Brisbane", "max_site_meter_power_ac": 1000000000, "min_site_meter_power_ac": -1000000000, "tariff_content_v2": { diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index ed042ae2c20f..8c238975c72d 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -4,6 +4,7 @@ 'energysites': list([ dict({ 'history': dict({ + '_period_start': '2024-09-18T00:00:00+10:00', 'battery_energy_exported': 36, 'battery_energy_imported_from_generator': 0, 'battery_energy_imported_from_grid': 0, @@ -114,7 +115,7 @@ 'default_real_mode': 'self_consumption', 'id': '1233-abcd', 'installation_date': '**REDACTED**', - 'installation_time_zone': '', + 'installation_time_zone': 'Australia/Brisbane', 'max_site_meter_power_ac': 1000000000, 'min_site_meter_power_ac': -1000000000, 'nameplate_energy': 40500, diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index 185aa943867c..179762defef3 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -6,7 +6,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -49,7 +49,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Battery charged', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -57,7 +58,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.684', }) # --- # name: test_sensors[sensor.energy_site_battery_charged-statealt] @@ -65,7 +66,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Battery charged', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -83,7 +85,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -126,7 +128,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Battery discharged', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -134,7 +137,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.036', }) # --- # name: test_sensors[sensor.energy_site_battery_discharged-statealt] @@ -142,7 +145,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Battery discharged', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -160,7 +164,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -203,7 +207,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Battery exported', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -211,7 +216,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.036', }) # --- # name: test_sensors[sensor.energy_site_battery_exported-statealt] @@ -219,7 +224,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Battery exported', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -237,7 +243,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -280,7 +286,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Battery imported from generator', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -288,7 +295,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.0', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_generator-statealt] @@ -296,7 +303,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Battery imported from generator', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -314,7 +322,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -357,7 +365,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Battery imported from grid', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -365,7 +374,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.0', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_grid-statealt] @@ -373,7 +382,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Battery imported from grid', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -391,7 +401,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -434,7 +444,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Battery imported from solar', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -442,7 +453,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.684', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_solar-statealt] @@ -450,7 +461,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Battery imported from solar', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -545,7 +557,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -588,7 +600,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Consumer imported from battery', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -596,7 +609,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.036', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_battery-statealt] @@ -604,7 +617,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Consumer imported from battery', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -622,7 +636,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -665,7 +679,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Consumer imported from generator', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -673,7 +688,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.0', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_generator-statealt] @@ -681,7 +696,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Consumer imported from generator', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -699,7 +715,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -742,7 +758,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Consumer imported from grid', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -750,7 +767,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.0', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_grid-statealt] @@ -758,7 +775,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Consumer imported from grid', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -776,7 +794,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -819,7 +837,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Consumer imported from solar', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -827,7 +846,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.038', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_solar-statealt] @@ -835,7 +854,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Consumer imported from solar', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -930,7 +950,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -973,7 +993,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Generator exported', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -981,7 +1002,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.0', }) # --- # name: test_sensors[sensor.energy_site_generator_exported-statealt] @@ -989,7 +1010,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Generator exported', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -1084,7 +1106,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -1127,7 +1149,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Grid exported', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -1135,7 +1158,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.002', }) # --- # name: test_sensors[sensor.energy_site_grid_exported-statealt] @@ -1143,7 +1166,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Grid exported', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -1161,7 +1185,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -1204,7 +1228,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Grid exported from battery', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -1212,7 +1237,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.0', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_battery-statealt] @@ -1220,7 +1245,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Grid exported from battery', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -1238,7 +1264,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -1281,7 +1307,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Grid exported from generator', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -1289,7 +1316,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.0', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_generator-statealt] @@ -1297,7 +1324,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Grid exported from generator', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -1315,7 +1343,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -1358,7 +1386,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Grid exported from solar', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -1366,7 +1395,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.002', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_solar-statealt] @@ -1374,7 +1403,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Grid exported from solar', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -1392,7 +1422,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -1435,7 +1465,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Grid imported', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -1443,7 +1474,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.0', }) # --- # name: test_sensors[sensor.energy_site_grid_imported-statealt] @@ -1451,7 +1482,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Grid imported', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -1546,7 +1578,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -1589,7 +1621,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Grid services exported', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -1597,7 +1630,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.0', }) # --- # name: test_sensors[sensor.energy_site_grid_services_exported-statealt] @@ -1605,7 +1638,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Grid services exported', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -1623,7 +1657,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -1666,7 +1700,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Grid services imported', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -1674,7 +1709,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.0', }) # --- # name: test_sensors[sensor.energy_site_grid_services_imported-statealt] @@ -1682,7 +1717,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Grid services imported', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -1777,7 +1813,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -1820,7 +1856,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Home usage', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -1828,7 +1865,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.074', }) # --- # name: test_sensors[sensor.energy_site_home_usage-statealt] @@ -1836,7 +1873,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Home usage', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -2092,7 +2130,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -2135,7 +2173,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Solar exported', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -2143,7 +2182,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.724', }) # --- # name: test_sensors[sensor.energy_site_solar_exported-statealt] @@ -2151,7 +2190,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Solar exported', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -2169,7 +2209,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -2212,7 +2252,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Solar generated', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , @@ -2220,7 +2261,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.724', }) # --- # name: test_sensors[sensor.energy_site_solar_generated-statealt] @@ -2228,7 +2269,8 @@ 'attributes': ReadOnlyDict({ : 'energy', : 'Energy Site Solar generated', - : , + : '2024-09-18T00:00:00+10:00', + : , : , }), 'context': , diff --git a/tests/components/teslemetry/test_diagnostics.py b/tests/components/teslemetry/test_diagnostics.py index 5737a5ebe2c7..56aa1509c54f 100644 --- a/tests/components/teslemetry/test_diagnostics.py +++ b/tests/components/teslemetry/test_diagnostics.py @@ -1,6 +1,6 @@ """Test the Telemetry Diagnostics.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion @@ -21,6 +21,7 @@ async def test_diagnostics( snapshot: SnapshotAssertion, freezer: FrozenDateTimeFactory, mock_legacy: AsyncMock, + mock_energy_totals_stream: MagicMock, ) -> None: """Test diagnostics.""" @@ -31,5 +32,8 @@ async def test_diagnostics( async_fire_time_changed(hass) await hass.async_block_till_done() + mock_energy_totals_stream.send() + await hass.async_block_till_done() + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert diag == snapshot diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index d0a6ebd242ab..8ce8968b2f7f 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -52,7 +52,6 @@ from homeassistant.components.teslemetry.const import ( # Coordinator constants from homeassistant.components.teslemetry.coordinator import ( - ENERGY_HISTORY_INTERVAL, INSUFFICIENT_CREDITS_RETRY_AFTER, METADATA_INTERVAL, VEHICLE_INTERVAL, @@ -89,7 +88,6 @@ from homeassistant.helpers.update_coordinator import UpdateFailed from . import mock_config_entry, setup_platform from .const import ( CONFIG_V1, - ENERGY_HISTORY, LIVE_STATUS, METADATA, METADATA_NOSCOPE, @@ -652,54 +650,6 @@ async def test_live_status_coordinator_retry_exceptions( assert entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize(("exception", "expected_retry_after"), RETRY_EXCEPTIONS) -async def test_energy_history_coordinator_retry_exceptions( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_energy_history: AsyncMock, - exception: TeslaFleetError, - expected_retry_after: float, -) -> None: - """Test energy history coordinator raises UpdateFailed with retry_after.""" - call_count = 0 - - def energy_history_side_effect(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - raise exception # First call raises exception - return ENERGY_HISTORY # Subsequent calls succeed - - mock_energy_history.side_effect = energy_history_side_effect - - entry = await setup_platform(hass) - assert entry.state is ConfigEntryState.LOADED - # Energy history doesn't have first_refresh during setup - assert call_count == 0 - - # Trigger first coordinator refresh - this will raise the exception - freezer.tick(ENERGY_HISTORY_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - # API was called exactly once (no manual retry loop) - assert call_count == 1 - # Entry stays loaded - UpdateFailed with retry_after doesn't break the entry - assert entry.state is ConfigEntryState.LOADED - - # The coordinator staggers its scheduling deliberately, so these ticks - # bracket retry_after with a margin either side rather than sitting on it. - freezer.tick(timedelta(seconds=expected_retry_after - 2)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert call_count == 1 - - freezer.tick(timedelta(seconds=3)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert call_count == 2 - - async def test_live_status_auth_error( hass: HomeAssistant, ) -> None: @@ -1093,33 +1043,6 @@ async def test_live_status_coordinator_refresh_error( assert entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize( - "side_effect", - [ - [InvalidToken], - [TeslaFleetError], - [ENERGY_HISTORY, {"response": {}}], - ], -) -async def test_energy_history_coordinator_refresh_errors( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_energy_history: AsyncMock, - side_effect: list, -) -> None: - """Test energy history coordinator handles errors during refresh.""" - mock_energy_history.side_effect = side_effect - - entry = await setup_platform(hass) - assert entry.state is ConfigEntryState.LOADED - - freezer.tick(ENERGY_HISTORY_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.LOADED - - async def test_dynamic_device_discovery_triggers_reload( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -1826,6 +1749,7 @@ def test_stream_topic_allowlist() -> None: "live_status", "site_info", "tariff_content_v2", + "energy_totals", ] @@ -1835,18 +1759,22 @@ async def test_energy_stream_no_recurring_rest_polling( mock_live_status: AsyncMock, mock_site_info: AsyncMock, ) -> None: - """The live/info REST cold reads happen once and do not recur.""" - await setup_platform(hass, [Platform.SENSOR]) - assert mock_live_status.call_count == 1 - assert mock_site_info.call_count == 1 + """The live/info REST cold reads happen once, and history never reads at all.""" + with patch( + "tesla_fleet_api.tesla.energysite.EnergySite.energy_history" + ) as mock_energy_history: + await setup_platform(hass, [Platform.SENSOR]) + assert mock_live_status.call_count == 1 + assert mock_site_info.call_count == 1 - # Advancing well past the old 30-second poll intervals triggers no REST reads. - freezer.tick(ENERGY_HISTORY_INTERVAL * 2) - async_fire_time_changed(hass) - await hass.async_block_till_done() + # Advancing well past the old poll intervals triggers no REST reads. + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() assert mock_live_status.call_count == 1 assert mock_site_info.call_count == 1 + mock_energy_history.assert_not_called() async def test_energy_stream_unload_unsubscribes_and_closes_stream( @@ -1856,6 +1784,7 @@ async def test_energy_stream_unload_unsubscribes_and_closes_stream( live_unsub = MagicMock() info_unsub = MagicMock() tariff_unsub = MagicMock() + totals_unsub = MagicMock() with ( patch( @@ -1870,6 +1799,10 @@ async def test_energy_stream_unload_unsubscribes_and_closes_stream( "teslemetry_stream.TeslemetryStreamEnergySite.listen_TariffContentV2", return_value=tariff_unsub, ), + patch( + "teslemetry_stream.TeslemetryStreamEnergySite.listen_EnergyTotals", + return_value=totals_unsub, + ), patch("teslemetry_stream.TeslemetryStream.close") as mock_close, ): entry = await setup_platform(hass, [Platform.SENSOR]) @@ -1881,6 +1814,7 @@ async def test_energy_stream_unload_unsubscribes_and_closes_stream( live_unsub.assert_called_once() info_unsub.assert_called_once() tariff_unsub.assert_called_once() + totals_unsub.assert_called_once() mock_close.assert_called_once() diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index 3d5ffeda3628..5978095e908b 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -1,17 +1,24 @@ """Test the Teslemetry sensor platform.""" from copy import deepcopy +from datetime import timedelta from unittest.mock import AsyncMock, MagicMock from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from teslemetry_stream import Signal +from teslemetry_stream.const import EnergyHistoryTotals -from homeassistant.components.teslemetry.const import DOMAIN +from homeassistant.components.homeassistant import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.components.teslemetry.const import DOMAIN, ENERGY_HISTORY_FIELDS from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( + ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, EntityCategory, @@ -19,13 +26,16 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component from . import assert_entities, assert_entities_alt, setup_platform from .const import ( - ENERGY_HISTORY_EMPTY, + ENERGY_TOTALS, + ENERGY_TOTALS_NULL, LIVE_STATUS, METADATA, PRODUCTS, + SITE_INFO, VEHICLE_DATA_ALT, ) @@ -34,6 +44,12 @@ from tests.common import async_fire_time_changed # VIN used across the Teslemetry test fixtures. VEHICLE_VIN = "LRW3F7EK4NC700000" +ENERGY_HISTORY_ENTITY = "sensor.energy_site_battery_discharged" +# Midnight in Australia/Brisbane, the timezone site_info.json declares. Home +# Assistant runs on US/Pacific in tests, so a last_reset derived from its clock +# or from the event's created_at cannot produce this. +SITE_MIDNIGHT = "2024-09-18T00:00:00+10:00" + def _products_with_driver_assist(driver_assist: str) -> dict: """Return a products response with the vehicle's driver-assist capability set.""" @@ -103,6 +119,7 @@ async def test_sensors( freezer: FrozenDateTimeFactory, mock_vehicle_data: AsyncMock, mock_legacy: AsyncMock, + mock_energy_totals_stream: MagicMock, ) -> None: """Tests that the sensor entities with the legacy polling are correct.""" @@ -112,6 +129,9 @@ async def test_sensors( entry = await setup_platform(hass, [Platform.SENSOR]) + mock_energy_totals_stream.send() + await hass.async_block_till_done() + assert_entities(hass, entry.entry_id, entity_registry, snapshot) # Coordinator refresh @@ -491,26 +511,169 @@ async def test_streaming_enum_none_clears_state( assert hass.states.get(entity_id).state == STATE_UNKNOWN -async def test_energy_history_no_time_series( +def test_energy_history_fields_match_stream_totals() -> None: + """The sensor key list mirrors the totals the stream event carries.""" + assert list(EnergyHistoryTotals.__dataclass_fields__) == ENERGY_HISTORY_FIELDS + + +async def test_energy_history_stream_updates( + hass: HomeAssistant, + mock_energy_totals_stream: MagicMock, +) -> None: + """A streamed energy_totals event drives the history sensors and their last_reset.""" + await setup_platform(hass, [Platform.SENSOR]) + + # Nothing has streamed yet, so the sensors are available but hold no value. + assert (state := hass.states.get(ENERGY_HISTORY_ENTITY)) + assert state.state == STATE_UNKNOWN + assert "last_reset" not in state.attributes + + mock_energy_totals_stream.send() + await hass.async_block_till_done() + + assert (state := hass.states.get(ENERGY_HISTORY_ENTITY)) + assert state.state == "0.036" + assert state.attributes["state_class"] == "total" + assert state.attributes["last_reset"] == SITE_MIDNIGHT + assert hass.states.get("sensor.energy_site_solar_generated").state == "0.724" + + +async def test_energy_history_late_event_keeps_previous_day( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - mock_energy_history: AsyncMock, + mock_energy_totals_stream: MagicMock, ) -> None: - """Test energy history coordinator when time_series is not a list.""" - # Mock energy history to return data without time_series as a list + """A late event finalising the previous day keeps that day's last_reset. - entry = await setup_platform(hass, [Platform.SENSOR]) - assert entry.state is ConfigEntryState.LOADED + The server finalises a day after the site's local midnight. Deriving + last_reset from the current day instead would start a second cycle and bank + the whole of the previous day again. + """ + # 00:05 on the 19th in Brisbane, so "today" at the site is no longer the 18th. + freezer.move_to("2024-09-18T14:05:00+00:00") + await setup_platform(hass, [Platform.SENSOR]) - entity_id = "sensor.energy_site_battery_discharged" - state = hass.states.get(entity_id) - assert state.state == STATE_UNKNOWN + mock_energy_totals_stream.send(date="2024-09-18") + await hass.async_block_till_done() - mock_energy_history.return_value = ENERGY_HISTORY_EMPTY + assert (state := hass.states.get(ENERGY_HISTORY_ENTITY)) + assert state.attributes["last_reset"] == SITE_MIDNIGHT - freezer.tick(VEHICLE_INTERVAL) + +async def test_energy_history_cache_snapshot_populates_sensors( + hass: HomeAssistant, + mock_energy_totals_stream: MagicMock, +) -> None: + """The connect-time snapshot is applied exactly like a live update.""" + await setup_platform(hass, [Platform.SENSOR]) + + mock_energy_totals_stream.send(is_cache=True) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENERGY_HISTORY_ENTITY)) + assert state.state == "0.036" + assert state.attributes["last_reset"] == SITE_MIDNIGHT + + +@pytest.mark.parametrize( + ("totals", "expected"), + [ + pytest.param(ENERGY_TOTALS, "0.0", id="absent_field_sent_as_zero"), + pytest.param(ENERGY_TOTALS_NULL, STATE_UNKNOWN, id="empty_day_sent_as_null"), + ], +) +async def test_energy_history_zero_and_null_totals( + hass: HomeAssistant, + mock_energy_totals_stream: MagicMock, + totals: dict[str, float | None], + expected: str, +) -> None: + """A zero total reads as zero and a null total reads as unknown, never unavailable.""" + await setup_platform(hass, [Platform.SENSOR]) + + mock_energy_totals_stream.send(totals) + await hass.async_block_till_done() + + assert (state := hass.states.get("sensor.energy_site_grid_imported")) + assert state.state == expected + assert state.attributes["last_reset"] == SITE_MIDNIGHT + + +@pytest.mark.parametrize( + "installation_time_zone", + [pytest.param("", id="not_provided"), pytest.param("Mars/Olympus", id="unknown")], +) +async def test_energy_history_time_zone_fallback( + hass: HomeAssistant, + mock_site_info: AsyncMock, + mock_energy_totals_stream: MagicMock, + installation_time_zone: str, +) -> None: + """Without a usable site timezone, last_reset falls back to Home Assistant's.""" + site_info = deepcopy(SITE_INFO) + site_info["response"]["installation_time_zone"] = installation_time_zone + mock_site_info.side_effect = lambda: deepcopy(site_info) + + await setup_platform(hass, [Platform.SENSOR]) + + mock_energy_totals_stream.send() + await hass.async_block_till_done() + + assert (state := hass.states.get(ENERGY_HISTORY_ENTITY)) + # Home Assistant runs on US/Pacific in tests, which was in PDT on this date. + assert state.attributes["last_reset"] == "2024-09-18T00:00:00-07:00" + + +async def test_energy_history_update_entity_service_is_a_noop( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + mock_energy_totals_stream: MagicMock, +) -> None: + """The generic update service keeps the streamed totals instead of failing. + + The coordinator has nothing to fetch, so the service must not leave the + sensors unavailable on a stream that is perfectly healthy. + """ + await setup_platform(hass, [Platform.SENSOR]) + await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) + + mock_energy_totals_stream.send() + await hass.async_block_till_done() + + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENERGY_HISTORY_ENTITY}, + blocking=True, + ) + # The coordinator debounces refresh requests, so let the deferred one land. + freezer.tick(timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state.state == STATE_UNAVAILABLE + assert "NotImplementedError" not in caplog.text + assert (state := hass.states.get(ENERGY_HISTORY_ENTITY)) + assert state.state == "0.036" + assert state.attributes["last_reset"] == SITE_MIDNIGHT + + +async def test_energy_history_unavailable_while_stream_disconnected( + hass: HomeAssistant, + mock_add_connection_listener: MagicMock, + mock_energy_totals_stream: MagicMock, +) -> None: + """A dropped stream makes the history sensors unavailable until a snapshot returns.""" + await setup_platform(hass, [Platform.SENSOR]) + + mock_energy_totals_stream.send() + await hass.async_block_till_done() + assert hass.states.get(ENERGY_HISTORY_ENTITY).state == "0.036" + + mock_add_connection_listener.send(False) + await hass.async_block_till_done() + assert hass.states.get(ENERGY_HISTORY_ENTITY).state == STATE_UNAVAILABLE + + mock_energy_totals_stream.send(is_cache=True) + await hass.async_block_till_done() + assert hass.states.get(ENERGY_HISTORY_ENTITY).state == "0.036"