Set Fronius entities to "unknown" when receiving invalid zero value (#102270)
parent
704881743b
commit
fb13d9ce7c
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue