diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 6d5e43a94ee..dfc76ae1415 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -99,6 +99,9 @@ class FroniusSensorEntityDescription(SensorEntityDescription): """Describes Fronius sensor entity.""" default_value: StateType | None = None + # Gen24 devices may report 0 for total energy while doing firmware updates. + # Handling such values shall mitigate spikes in delta calculations. + invalid_when_falsy: bool = False INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ @@ -119,6 +122,7 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="frequency_ac", @@ -253,6 +257,7 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:lightning-bolt-outline", entity_registry_enabled_default=False, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_reactive_ac_produced", @@ -260,6 +265,7 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:lightning-bolt-outline", entity_registry_enabled_default=False, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_real_ac_minus", @@ -267,6 +273,7 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_real_ac_plus", @@ -274,18 +281,21 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_real_consumed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_real_produced", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="frequency_phase_average", @@ -461,6 +471,7 @@ OHMPILOT_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="power_real_ac", @@ -508,6 +519,7 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, entity_registry_enabled_default=False, ), FroniusSensorEntityDescription( @@ -648,6 +660,8 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn ]["value"] if new_value is None: return self.entity_description.default_value + if self.entity_description.invalid_when_falsy and not new_value: + raise ValueError(f"Ignoring zero value for {self.entity_id}.") if isinstance(new_value, float): return round(new_value, 4) return new_value @@ -657,8 +671,10 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn """Handle updated data from the coordinator.""" try: self._attr_native_value = self._get_entity_value() - except KeyError: + except (KeyError, ValueError): # sets state to `None` if no default_value is defined in entity description + # KeyError: raised when omitted in response - eg. at night when no production + # ValueError: raised when invalid zero value received self._attr_native_value = self.entity_description.default_value self.async_write_ha_state() diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index 5a757da1e9c..c64972b7904 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -1,6 +1,10 @@ """Tests for the Fronius integration.""" from __future__ import annotations +from collections.abc import Callable +import json +from typing import Any + from homeassistant.components.fronius.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST @@ -32,55 +36,78 @@ async def setup_fronius_integration( return entry +def _load_and_patch_fixture( + override_data: dict[str, list[tuple[list[str], Any]]] +) -> Callable[[str, str | None], str]: + """Return a fixture loader that patches values at nested keys for a given filename.""" + + def load_and_patch(filename: str, integration: str): + """Load a fixture and patch given values.""" + text = load_fixture(filename, integration) + if filename not in override_data: + return text + + _loaded = json.loads(text) + for keys, value in override_data[filename]: + _dic = _loaded + for key in keys[:-1]: + _dic = _dic[key] + _dic[keys[-1]] = value + return json.dumps(_loaded) + + return load_and_patch + + def mock_responses( aioclient_mock: AiohttpClientMocker, host: str = MOCK_HOST, fixture_set: str = "symo", inverter_ids: list[str | int] = [1], night: bool = False, + override_data: dict[str, list[tuple[list[str], Any]]] + | None = None, # {filename: [([list of nested keys], patch_value)]} ) -> None: """Mock responses for Fronius devices.""" aioclient_mock.clear_requests() _night = "_night" if night else "" + _load = _load_and_patch_fixture(override_data) if override_data else load_fixture aioclient_mock.get( f"{host}/solar_api/GetAPIVersion.cgi", - text=load_fixture(f"{fixture_set}/GetAPIVersion.json", "fronius"), + text=_load(f"{fixture_set}/GetAPIVersion.json", "fronius"), ) for inverter_id in inverter_ids: aioclient_mock.get( f"{host}/solar_api/v1/GetInverterRealtimeData.cgi?Scope=Device&" f"DeviceId={inverter_id}&DataCollection=CommonInverterData", - text=load_fixture( + text=_load( f"{fixture_set}/GetInverterRealtimeData_Device_{inverter_id}{_night}.json", "fronius", ), ) aioclient_mock.get( f"{host}/solar_api/v1/GetInverterInfo.cgi", - text=load_fixture(f"{fixture_set}/GetInverterInfo{_night}.json", "fronius"), + text=_load(f"{fixture_set}/GetInverterInfo{_night}.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetLoggerInfo.cgi", - text=load_fixture(f"{fixture_set}/GetLoggerInfo.json", "fronius"), + text=_load(f"{fixture_set}/GetLoggerInfo.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetMeterRealtimeData.cgi?Scope=System", - text=load_fixture(f"{fixture_set}/GetMeterRealtimeData.json", "fronius"), + text=_load(f"{fixture_set}/GetMeterRealtimeData.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetPowerFlowRealtimeData.fcgi", - text=load_fixture( - f"{fixture_set}/GetPowerFlowRealtimeData{_night}.json", "fronius" - ), + text=_load(f"{fixture_set}/GetPowerFlowRealtimeData{_night}.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetStorageRealtimeData.cgi?Scope=System", - text=load_fixture(f"{fixture_set}/GetStorageRealtimeData.json", "fronius"), + text=_load(f"{fixture_set}/GetStorageRealtimeData.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetOhmPilotRealtimeData.cgi?Scope=System", - text=load_fixture(f"{fixture_set}/GetOhmPilotRealtimeData.json", "fronius"), + text=_load(f"{fixture_set}/GetOhmPilotRealtimeData.json", "fronius"), ) diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index c2e0c4ad969..f94b0f3a55c 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -302,6 +302,22 @@ async def test_gen24( assert_state("sensor.solarnet_relative_autonomy", 5.3592) assert_state("sensor.solarnet_total_energy", 1530193.42) + # Gen24 devices may report 0 for total energy while doing firmware updates. + # This should yield "unknown" state instead of 0. + mock_responses( + aioclient_mock, + fixture_set="gen24", + override_data={ + "gen24/GetInverterRealtimeData_Device_1.json": [ + (["Body", "Data", "TOTAL_ENERGY", "Value"], 0), + ], + }, + ) + freezer.tick(FroniusInverterUpdateCoordinator.default_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert_state("sensor.inverter_name_total_energy", "unknown") + async def test_gen24_storage( hass: HomeAssistant,