Set Fronius entities to "unknown" when receiving invalid zero value (#102270)

pull/102646/head
Matthias Alphart 2023-10-25 06:27:46 +02:00 committed by GitHub
parent 704881743b
commit fb13d9ce7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 70 additions and 11 deletions

View File

@ -99,6 +99,9 @@ class FroniusSensorEntityDescription(SensorEntityDescription):
"""Describes Fronius sensor entity.""" """Describes Fronius sensor entity."""
default_value: StateType | None = None 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] = [ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
@ -119,6 +122,7 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
invalid_when_falsy=True,
), ),
FroniusSensorEntityDescription( FroniusSensorEntityDescription(
key="frequency_ac", key="frequency_ac",
@ -253,6 +257,7 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
icon="mdi:lightning-bolt-outline", icon="mdi:lightning-bolt-outline",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
invalid_when_falsy=True,
), ),
FroniusSensorEntityDescription( FroniusSensorEntityDescription(
key="energy_reactive_ac_produced", key="energy_reactive_ac_produced",
@ -260,6 +265,7 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
icon="mdi:lightning-bolt-outline", icon="mdi:lightning-bolt-outline",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
invalid_when_falsy=True,
), ),
FroniusSensorEntityDescription( FroniusSensorEntityDescription(
key="energy_real_ac_minus", key="energy_real_ac_minus",
@ -267,6 +273,7 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
invalid_when_falsy=True,
), ),
FroniusSensorEntityDescription( FroniusSensorEntityDescription(
key="energy_real_ac_plus", key="energy_real_ac_plus",
@ -274,18 +281,21 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
invalid_when_falsy=True,
), ),
FroniusSensorEntityDescription( FroniusSensorEntityDescription(
key="energy_real_consumed", key="energy_real_consumed",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
invalid_when_falsy=True,
), ),
FroniusSensorEntityDescription( FroniusSensorEntityDescription(
key="energy_real_produced", key="energy_real_produced",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
invalid_when_falsy=True,
), ),
FroniusSensorEntityDescription( FroniusSensorEntityDescription(
key="frequency_phase_average", key="frequency_phase_average",
@ -461,6 +471,7 @@ OHMPILOT_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
invalid_when_falsy=True,
), ),
FroniusSensorEntityDescription( FroniusSensorEntityDescription(
key="power_real_ac", key="power_real_ac",
@ -508,6 +519,7 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
invalid_when_falsy=True,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
FroniusSensorEntityDescription( FroniusSensorEntityDescription(
@ -648,6 +660,8 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn
]["value"] ]["value"]
if new_value is None: if new_value is None:
return self.entity_description.default_value 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): if isinstance(new_value, float):
return round(new_value, 4) return round(new_value, 4)
return new_value return new_value
@ -657,8 +671,10 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
try: try:
self._attr_native_value = self._get_entity_value() 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 # 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._attr_native_value = self.entity_description.default_value
self.async_write_ha_state() self.async_write_ha_state()

View File

@ -1,6 +1,10 @@
"""Tests for the Fronius integration.""" """Tests for the Fronius integration."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
import json
from typing import Any
from homeassistant.components.fronius.const import DOMAIN from homeassistant.components.fronius.const import DOMAIN
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST from homeassistant.const import CONF_HOST
@ -32,55 +36,78 @@ async def setup_fronius_integration(
return entry 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( def mock_responses(
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
host: str = MOCK_HOST, host: str = MOCK_HOST,
fixture_set: str = "symo", fixture_set: str = "symo",
inverter_ids: list[str | int] = [1], inverter_ids: list[str | int] = [1],
night: bool = False, night: bool = False,
override_data: dict[str, list[tuple[list[str], Any]]]
| None = None, # {filename: [([list of nested keys], patch_value)]}
) -> None: ) -> None:
"""Mock responses for Fronius devices.""" """Mock responses for Fronius devices."""
aioclient_mock.clear_requests() aioclient_mock.clear_requests()
_night = "_night" if night else "" _night = "_night" if night else ""
_load = _load_and_patch_fixture(override_data) if override_data else load_fixture
aioclient_mock.get( aioclient_mock.get(
f"{host}/solar_api/GetAPIVersion.cgi", 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: for inverter_id in inverter_ids:
aioclient_mock.get( aioclient_mock.get(
f"{host}/solar_api/v1/GetInverterRealtimeData.cgi?Scope=Device&" f"{host}/solar_api/v1/GetInverterRealtimeData.cgi?Scope=Device&"
f"DeviceId={inverter_id}&DataCollection=CommonInverterData", f"DeviceId={inverter_id}&DataCollection=CommonInverterData",
text=load_fixture( text=_load(
f"{fixture_set}/GetInverterRealtimeData_Device_{inverter_id}{_night}.json", f"{fixture_set}/GetInverterRealtimeData_Device_{inverter_id}{_night}.json",
"fronius", "fronius",
), ),
) )
aioclient_mock.get( aioclient_mock.get(
f"{host}/solar_api/v1/GetInverterInfo.cgi", 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( aioclient_mock.get(
f"{host}/solar_api/v1/GetLoggerInfo.cgi", 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( aioclient_mock.get(
f"{host}/solar_api/v1/GetMeterRealtimeData.cgi?Scope=System", 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( aioclient_mock.get(
f"{host}/solar_api/v1/GetPowerFlowRealtimeData.fcgi", f"{host}/solar_api/v1/GetPowerFlowRealtimeData.fcgi",
text=load_fixture( text=_load(f"{fixture_set}/GetPowerFlowRealtimeData{_night}.json", "fronius"),
f"{fixture_set}/GetPowerFlowRealtimeData{_night}.json", "fronius"
),
) )
aioclient_mock.get( aioclient_mock.get(
f"{host}/solar_api/v1/GetStorageRealtimeData.cgi?Scope=System", 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( aioclient_mock.get(
f"{host}/solar_api/v1/GetOhmPilotRealtimeData.cgi?Scope=System", 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"),
) )

View File

@ -302,6 +302,22 @@ async def test_gen24(
assert_state("sensor.solarnet_relative_autonomy", 5.3592) assert_state("sensor.solarnet_relative_autonomy", 5.3592)
assert_state("sensor.solarnet_total_energy", 1530193.42) 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( async def test_gen24_storage(
hass: HomeAssistant, hass: HomeAssistant,