Add textual representation entities for Fronius status codes (#94155)

* optionally decouple `EntityDescription.key` from API response key

this makes it possible to have multiple entities for a single API response field

* Add optional `value_fn` to EntityDescriptions

eg. to be able to map a API response value to a different value (status_code -> message)

* Add inverter `status_message` entity

* Add meter `meter_location_description` entity

* add external battery state

* Make Ohmpilot entity state translateable

* use built-in StrEnum

* test coverage

* remove unnecessary checks

None is handled before
This commit is contained in:
Matthias Alphart 2023-11-27 13:59:25 +01:00 committed by GitHub
parent ba8e2ed7d6
commit 5550dcbec8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 303 additions and 79 deletions

View File

@ -1,7 +1,9 @@
"""Constants for the Fronius integration.""" """Constants for the Fronius integration."""
from enum import StrEnum
from typing import Final, NamedTuple, TypedDict from typing import Final, NamedTuple, TypedDict
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.typing import StateType
DOMAIN: Final = "fronius" DOMAIN: Final = "fronius"
@ -25,3 +27,97 @@ class FroniusDeviceInfo(NamedTuple):
device_info: DeviceInfo device_info: DeviceInfo
solar_net_id: SolarNetId solar_net_id: SolarNetId
unique_id: str unique_id: str
class InverterStatusCodeOption(StrEnum):
"""Status codes for Fronius inverters."""
# these are keys for state translations - so snake_case is used
STARTUP = "startup"
RUNNING = "running"
STANDBY = "standby"
BOOTLOADING = "bootloading"
ERROR = "error"
IDLE = "idle"
READY = "ready"
SLEEPING = "sleeping"
UNKNOWN = "unknown"
INVALID = "invalid"
_INVERTER_STATUS_CODES: Final[dict[int, InverterStatusCodeOption]] = {
0: InverterStatusCodeOption.STARTUP,
1: InverterStatusCodeOption.STARTUP,
2: InverterStatusCodeOption.STARTUP,
3: InverterStatusCodeOption.STARTUP,
4: InverterStatusCodeOption.STARTUP,
5: InverterStatusCodeOption.STARTUP,
6: InverterStatusCodeOption.STARTUP,
7: InverterStatusCodeOption.RUNNING,
8: InverterStatusCodeOption.STANDBY,
9: InverterStatusCodeOption.BOOTLOADING,
10: InverterStatusCodeOption.ERROR,
11: InverterStatusCodeOption.IDLE,
12: InverterStatusCodeOption.READY,
13: InverterStatusCodeOption.SLEEPING,
255: InverterStatusCodeOption.UNKNOWN,
}
def get_inverter_status_message(code: StateType) -> InverterStatusCodeOption:
"""Return a status message for a given status code."""
return _INVERTER_STATUS_CODES.get(code, InverterStatusCodeOption.INVALID) # type: ignore[arg-type]
class MeterLocationCodeOption(StrEnum):
"""Meter location codes for Fronius meters."""
# these are keys for state translations - so snake_case is used
FEED_IN = "feed_in"
CONSUMPTION_PATH = "consumption_path"
GENERATOR = "external_generator"
EXT_BATTERY = "external_battery"
SUBLOAD = "subload"
def get_meter_location_description(code: StateType) -> MeterLocationCodeOption | None:
"""Return a location_description for a given location code."""
match int(code): # type: ignore[arg-type]
case 0:
return MeterLocationCodeOption.FEED_IN
case 1:
return MeterLocationCodeOption.CONSUMPTION_PATH
case 3:
return MeterLocationCodeOption.GENERATOR
case 4:
return MeterLocationCodeOption.EXT_BATTERY
case _ as _code if 256 <= _code <= 511:
return MeterLocationCodeOption.SUBLOAD
return None
class OhmPilotStateCodeOption(StrEnum):
"""OhmPilot state codes for Fronius inverters."""
# these are keys for state translations - so snake_case is used
UP_AND_RUNNING = "up_and_running"
KEEP_MINIMUM_TEMPERATURE = "keep_minimum_temperature"
LEGIONELLA_PROTECTION = "legionella_protection"
CRITICAL_FAULT = "critical_fault"
FAULT = "fault"
BOOST_MODE = "boost_mode"
_OHMPILOT_STATE_CODES: Final[dict[int, OhmPilotStateCodeOption]] = {
0: OhmPilotStateCodeOption.UP_AND_RUNNING,
1: OhmPilotStateCodeOption.KEEP_MINIMUM_TEMPERATURE,
2: OhmPilotStateCodeOption.LEGIONELLA_PROTECTION,
3: OhmPilotStateCodeOption.CRITICAL_FAULT,
4: OhmPilotStateCodeOption.FAULT,
5: OhmPilotStateCodeOption.BOOST_MODE,
}
def get_ohmpilot_state_message(code: StateType) -> OhmPilotStateCodeOption | None:
"""Return a status message for a given status code."""
return _OHMPILOT_STATE_CODES.get(code) # type: ignore[arg-type]

View File

@ -49,8 +49,10 @@ class FroniusCoordinatorBase(
"""Set up the FroniusCoordinatorBase class.""" """Set up the FroniusCoordinatorBase class."""
self._failed_update_count = 0 self._failed_update_count = 0
self.solar_net = solar_net self.solar_net = solar_net
# unregistered_keys are used to create entities in platform module # unregistered_descriptors are used to create entities in platform module
self.unregistered_keys: dict[SolarNetId, set[str]] = {} self.unregistered_descriptors: dict[
SolarNetId, list[FroniusSensorEntityDescription]
] = {}
super().__init__(*args, update_interval=self.default_interval, **kwargs) super().__init__(*args, update_interval=self.default_interval, **kwargs)
@abstractmethod @abstractmethod
@ -73,11 +75,11 @@ class FroniusCoordinatorBase(
self.update_interval = self.default_interval self.update_interval = self.default_interval
for solar_net_id in data: for solar_net_id in data:
if solar_net_id not in self.unregistered_keys: if solar_net_id not in self.unregistered_descriptors:
# id seen for the first time # id seen for the first time
self.unregistered_keys[solar_net_id] = { self.unregistered_descriptors[
desc.key for desc in self.valid_descriptions solar_net_id
} ] = self.valid_descriptions.copy()
return data return data
@callback @callback
@ -92,22 +94,34 @@ class FroniusCoordinatorBase(
""" """
@callback @callback
def _add_entities_for_unregistered_keys() -> None: def _add_entities_for_unregistered_descriptors() -> None:
"""Add entities for keys seen for the first time.""" """Add entities for keys seen for the first time."""
new_entities: list = [] new_entities: list[_FroniusEntityT] = []
for solar_net_id, device_data in self.data.items(): for solar_net_id, device_data in self.data.items():
for key in self.unregistered_keys[solar_net_id].intersection( remaining_unregistered_descriptors = []
device_data for description in self.unregistered_descriptors[solar_net_id]:
): key = description.response_key or description.key
if device_data[key]["value"] is None: if key not in device_data:
remaining_unregistered_descriptors.append(description)
continue continue
new_entities.append(entity_constructor(self, key, solar_net_id)) if device_data[key]["value"] is None:
self.unregistered_keys[solar_net_id].remove(key) remaining_unregistered_descriptors.append(description)
continue
new_entities.append(
entity_constructor(
coordinator=self,
description=description,
solar_net_id=solar_net_id,
)
)
self.unregistered_descriptors[
solar_net_id
] = remaining_unregistered_descriptors
async_add_entities(new_entities) async_add_entities(new_entities)
_add_entities_for_unregistered_keys() _add_entities_for_unregistered_descriptors()
self.solar_net.cleanup_callbacks.append( self.solar_net.cleanup_callbacks.append(
self.async_add_listener(_add_entities_for_unregistered_keys) self.async_add_listener(_add_entities_for_unregistered_descriptors)
) )

View File

@ -1,6 +1,7 @@
"""Support for Fronius devices.""" """Support for Fronius devices."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final from typing import TYPE_CHECKING, Any, Final
@ -30,7 +31,16 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, SOLAR_NET_DISCOVERY_NEW from .const import (
DOMAIN,
SOLAR_NET_DISCOVERY_NEW,
InverterStatusCodeOption,
MeterLocationCodeOption,
OhmPilotStateCodeOption,
get_inverter_status_message,
get_meter_location_description,
get_ohmpilot_state_message,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from . import FroniusSolarNet from . import FroniusSolarNet
@ -102,6 +112,8 @@ class FroniusSensorEntityDescription(SensorEntityDescription):
# Gen24 devices may report 0 for total energy while doing firmware updates. # Gen24 devices may report 0 for total energy while doing firmware updates.
# Handling such values shall mitigate spikes in delta calculations. # Handling such values shall mitigate spikes in delta calculations.
invalid_when_falsy: bool = False invalid_when_falsy: bool = False
response_key: str | None = None
value_fn: Callable[[StateType], StateType] | None = None
INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
@ -198,6 +210,15 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
FroniusSensorEntityDescription( FroniusSensorEntityDescription(
key="status_code", key="status_code",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
FroniusSensorEntityDescription(
key="status_message",
response_key="status_code",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=[opt.value for opt in InverterStatusCodeOption],
value_fn=get_inverter_status_message,
), ),
FroniusSensorEntityDescription( FroniusSensorEntityDescription(
key="led_state", key="led_state",
@ -306,6 +327,15 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
FroniusSensorEntityDescription( FroniusSensorEntityDescription(
key="meter_location", key="meter_location",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
value_fn=int, # type: ignore[arg-type]
),
FroniusSensorEntityDescription(
key="meter_location_description",
response_key="meter_location",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=[opt.value for opt in MeterLocationCodeOption],
value_fn=get_meter_location_description,
), ),
FroniusSensorEntityDescription( FroniusSensorEntityDescription(
key="power_apparent_phase_1", key="power_apparent_phase_1",
@ -495,7 +525,11 @@ OHMPILOT_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
), ),
FroniusSensorEntityDescription( FroniusSensorEntityDescription(
key="state_message", key="state_message",
response_key="state_code",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=[opt.value for opt in OhmPilotStateCodeOption],
value_fn=get_ohmpilot_state_message,
), ),
] ]
@ -630,24 +664,22 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn
"""Defines a Fronius coordinator entity.""" """Defines a Fronius coordinator entity."""
entity_description: FroniusSensorEntityDescription entity_description: FroniusSensorEntityDescription
entity_descriptions: list[FroniusSensorEntityDescription]
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__( def __init__(
self, self,
coordinator: FroniusCoordinatorBase, coordinator: FroniusCoordinatorBase,
key: str, description: FroniusSensorEntityDescription,
solar_net_id: str, solar_net_id: str,
) -> None: ) -> None:
"""Set up an individual Fronius meter sensor.""" """Set up an individual Fronius meter sensor."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = next( self.entity_description = description
desc for desc in self.entity_descriptions if desc.key == key self.response_key = description.response_key or description.key
)
self.solar_net_id = solar_net_id self.solar_net_id = solar_net_id
self._attr_native_value = self._get_entity_value() self._attr_native_value = self._get_entity_value()
self._attr_translation_key = self.entity_description.key self._attr_translation_key = description.key
def _device_data(self) -> dict[str, Any]: def _device_data(self) -> dict[str, Any]:
"""Extract information for SolarNet device from coordinator data.""" """Extract information for SolarNet device from coordinator data."""
@ -655,13 +687,13 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn
def _get_entity_value(self) -> Any: def _get_entity_value(self) -> Any:
"""Extract entity value from coordinator. Raises KeyError if not included in latest update.""" """Extract entity value from coordinator. Raises KeyError if not included in latest update."""
new_value = self.coordinator.data[self.solar_net_id][ new_value = self.coordinator.data[self.solar_net_id][self.response_key]["value"]
self.entity_description.key
]["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: if self.entity_description.invalid_when_falsy and not new_value:
return None return None
if self.entity_description.value_fn is not None:
return self.entity_description.value_fn(new_value)
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
@ -681,54 +713,54 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn
class InverterSensor(_FroniusSensorEntity): class InverterSensor(_FroniusSensorEntity):
"""Defines a Fronius inverter device sensor entity.""" """Defines a Fronius inverter device sensor entity."""
entity_descriptions = INVERTER_ENTITY_DESCRIPTIONS
def __init__( def __init__(
self, self,
coordinator: FroniusInverterUpdateCoordinator, coordinator: FroniusInverterUpdateCoordinator,
key: str, description: FroniusSensorEntityDescription,
solar_net_id: str, solar_net_id: str,
) -> None: ) -> None:
"""Set up an individual Fronius inverter sensor.""" """Set up an individual Fronius inverter sensor."""
super().__init__(coordinator, key, solar_net_id) super().__init__(coordinator, description, solar_net_id)
# device_info created in __init__ from a `GetInverterInfo` request # device_info created in __init__ from a `GetInverterInfo` request
self._attr_device_info = coordinator.inverter_info.device_info self._attr_device_info = coordinator.inverter_info.device_info
self._attr_unique_id = f"{coordinator.inverter_info.unique_id}-{key}" self._attr_unique_id = (
f"{coordinator.inverter_info.unique_id}-{description.key}"
)
class LoggerSensor(_FroniusSensorEntity): class LoggerSensor(_FroniusSensorEntity):
"""Defines a Fronius logger device sensor entity.""" """Defines a Fronius logger device sensor entity."""
entity_descriptions = LOGGER_ENTITY_DESCRIPTIONS
def __init__( def __init__(
self, self,
coordinator: FroniusLoggerUpdateCoordinator, coordinator: FroniusLoggerUpdateCoordinator,
key: str, description: FroniusSensorEntityDescription,
solar_net_id: str, solar_net_id: str,
) -> None: ) -> None:
"""Set up an individual Fronius meter sensor.""" """Set up an individual Fronius meter sensor."""
super().__init__(coordinator, key, solar_net_id) super().__init__(coordinator, description, solar_net_id)
logger_data = self._device_data() logger_data = self._device_data()
# Logger device is already created in FroniusSolarNet._create_solar_net_device # Logger device is already created in FroniusSolarNet._create_solar_net_device
self._attr_device_info = coordinator.solar_net.system_device_info self._attr_device_info = coordinator.solar_net.system_device_info
self._attr_native_unit_of_measurement = logger_data[key].get("unit") self._attr_native_unit_of_measurement = logger_data[self.response_key].get(
self._attr_unique_id = f'{logger_data["unique_identifier"]["value"]}-{key}' "unit"
)
self._attr_unique_id = (
f'{logger_data["unique_identifier"]["value"]}-{description.key}'
)
class MeterSensor(_FroniusSensorEntity): class MeterSensor(_FroniusSensorEntity):
"""Defines a Fronius meter device sensor entity.""" """Defines a Fronius meter device sensor entity."""
entity_descriptions = METER_ENTITY_DESCRIPTIONS
def __init__( def __init__(
self, self,
coordinator: FroniusMeterUpdateCoordinator, coordinator: FroniusMeterUpdateCoordinator,
key: str, description: FroniusSensorEntityDescription,
solar_net_id: str, solar_net_id: str,
) -> None: ) -> None:
"""Set up an individual Fronius meter sensor.""" """Set up an individual Fronius meter sensor."""
super().__init__(coordinator, key, solar_net_id) super().__init__(coordinator, description, solar_net_id)
meter_data = self._device_data() meter_data = self._device_data()
# S0 meters connected directly to inverters respond "n.a." as serial number # S0 meters connected directly to inverters respond "n.a." as serial number
# `model` contains the inverter id: "S0 Meter at inverter 1" # `model` contains the inverter id: "S0 Meter at inverter 1"
@ -745,22 +777,20 @@ class MeterSensor(_FroniusSensorEntity):
name=meter_data["model"]["value"], name=meter_data["model"]["value"],
via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id),
) )
self._attr_unique_id = f"{meter_uid}-{key}" self._attr_unique_id = f"{meter_uid}-{description.key}"
class OhmpilotSensor(_FroniusSensorEntity): class OhmpilotSensor(_FroniusSensorEntity):
"""Defines a Fronius Ohmpilot sensor entity.""" """Defines a Fronius Ohmpilot sensor entity."""
entity_descriptions = OHMPILOT_ENTITY_DESCRIPTIONS
def __init__( def __init__(
self, self,
coordinator: FroniusOhmpilotUpdateCoordinator, coordinator: FroniusOhmpilotUpdateCoordinator,
key: str, description: FroniusSensorEntityDescription,
solar_net_id: str, solar_net_id: str,
) -> None: ) -> None:
"""Set up an individual Fronius meter sensor.""" """Set up an individual Fronius meter sensor."""
super().__init__(coordinator, key, solar_net_id) super().__init__(coordinator, description, solar_net_id)
device_data = self._device_data() device_data = self._device_data()
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
@ -771,45 +801,41 @@ class OhmpilotSensor(_FroniusSensorEntity):
sw_version=device_data["software"]["value"], sw_version=device_data["software"]["value"],
via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id),
) )
self._attr_unique_id = f'{device_data["serial"]["value"]}-{key}' self._attr_unique_id = f'{device_data["serial"]["value"]}-{description.key}'
class PowerFlowSensor(_FroniusSensorEntity): class PowerFlowSensor(_FroniusSensorEntity):
"""Defines a Fronius power flow sensor entity.""" """Defines a Fronius power flow sensor entity."""
entity_descriptions = POWER_FLOW_ENTITY_DESCRIPTIONS
def __init__( def __init__(
self, self,
coordinator: FroniusPowerFlowUpdateCoordinator, coordinator: FroniusPowerFlowUpdateCoordinator,
key: str, description: FroniusSensorEntityDescription,
solar_net_id: str, solar_net_id: str,
) -> None: ) -> None:
"""Set up an individual Fronius power flow sensor.""" """Set up an individual Fronius power flow sensor."""
super().__init__(coordinator, key, solar_net_id) super().__init__(coordinator, description, solar_net_id)
# SolarNet device is already created in FroniusSolarNet._create_solar_net_device # SolarNet device is already created in FroniusSolarNet._create_solar_net_device
self._attr_device_info = coordinator.solar_net.system_device_info self._attr_device_info = coordinator.solar_net.system_device_info
self._attr_unique_id = ( self._attr_unique_id = (
f"{coordinator.solar_net.solar_net_device_id}-power_flow-{key}" f"{coordinator.solar_net.solar_net_device_id}-power_flow-{description.key}"
) )
class StorageSensor(_FroniusSensorEntity): class StorageSensor(_FroniusSensorEntity):
"""Defines a Fronius storage device sensor entity.""" """Defines a Fronius storage device sensor entity."""
entity_descriptions = STORAGE_ENTITY_DESCRIPTIONS
def __init__( def __init__(
self, self,
coordinator: FroniusStorageUpdateCoordinator, coordinator: FroniusStorageUpdateCoordinator,
key: str, description: FroniusSensorEntityDescription,
solar_net_id: str, solar_net_id: str,
) -> None: ) -> None:
"""Set up an individual Fronius storage sensor.""" """Set up an individual Fronius storage sensor."""
super().__init__(coordinator, key, solar_net_id) super().__init__(coordinator, description, solar_net_id)
storage_data = self._device_data() storage_data = self._device_data()
self._attr_unique_id = f'{storage_data["serial"]["value"]}-{key}' self._attr_unique_id = f'{storage_data["serial"]["value"]}-{description.key}'
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, storage_data["serial"]["value"])}, identifiers={(DOMAIN, storage_data["serial"]["value"])},
manufacturer=storage_data["manufacturer"]["value"], manufacturer=storage_data["manufacturer"]["value"],

View File

@ -66,6 +66,21 @@
"status_code": { "status_code": {
"name": "Status code" "name": "Status code"
}, },
"status_message": {
"name": "Status message",
"state": {
"startup": "Startup",
"running": "Running",
"standby": "Standby",
"bootloading": "Bootloading",
"error": "Error",
"idle": "Idle",
"ready": "Ready",
"sleeping": "Sleeping",
"unknown": "Unknown",
"invalid": "Invalid"
}
},
"led_state": { "led_state": {
"name": "LED state" "name": "LED state"
}, },
@ -114,6 +129,16 @@
"meter_location": { "meter_location": {
"name": "Meter location" "name": "Meter location"
}, },
"meter_location_description": {
"name": "Meter location description",
"state": {
"feed_in": "Grid interconnection point",
"consumption_path": "Consumption path",
"external_generator": "External generator",
"external_battery": "External battery",
"subload": "Subload"
}
},
"power_apparent_phase_1": { "power_apparent_phase_1": {
"name": "Apparent power phase 1" "name": "Apparent power phase 1"
}, },
@ -193,7 +218,15 @@
"name": "State code" "name": "State code"
}, },
"state_message": { "state_message": {
"name": "State message" "name": "State message",
"state": {
"up_and_running": "Up and running",
"keep_minimum_temperature": "Keep minimum temperature",
"legionella_protection": "Legionella protection",
"critical_fault": "Critical fault",
"fault": "Fault",
"boost_mode": "Boost mode"
}
}, },
"meter_mode": { "meter_mode": {
"name": "Meter mode" "name": "Meter mode"

View File

@ -1,6 +1,6 @@
"""Tests for the Fronius sensor platform.""" """Tests for the Fronius sensor platform."""
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.fronius.const import DOMAIN from homeassistant.components.fronius.const import DOMAIN
from homeassistant.components.fronius.coordinator import ( from homeassistant.components.fronius.coordinator import (
@ -33,33 +33,34 @@ async def test_symo_inverter(
mock_responses(aioclient_mock, night=True) mock_responses(aioclient_mock, night=True)
config_entry = await setup_fronius_integration(hass) config_entry = await setup_fronius_integration(hass)
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 20 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 21
await enable_all_entities( await enable_all_entities(
hass, hass,
freezer, freezer,
config_entry.entry_id, config_entry.entry_id,
FroniusInverterUpdateCoordinator.default_interval, FroniusInverterUpdateCoordinator.default_interval,
) )
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54
assert_state("sensor.symo_20_dc_current", 0) assert_state("sensor.symo_20_dc_current", 0)
assert_state("sensor.symo_20_energy_day", 10828) assert_state("sensor.symo_20_energy_day", 10828)
assert_state("sensor.symo_20_total_energy", 44186900) assert_state("sensor.symo_20_total_energy", 44186900)
assert_state("sensor.symo_20_energy_year", 25507686) assert_state("sensor.symo_20_energy_year", 25507686)
assert_state("sensor.symo_20_dc_voltage", 16) assert_state("sensor.symo_20_dc_voltage", 16)
assert_state("sensor.symo_20_status_message", "startup")
# Second test at daytime when inverter is producing # Second test at daytime when inverter is producing
mock_responses(aioclient_mock, night=False) mock_responses(aioclient_mock, night=False)
freezer.tick(FroniusInverterUpdateCoordinator.default_interval) freezer.tick(FroniusInverterUpdateCoordinator.default_interval)
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 56 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58
await enable_all_entities( await enable_all_entities(
hass, hass,
freezer, freezer,
config_entry.entry_id, config_entry.entry_id,
FroniusInverterUpdateCoordinator.default_interval, FroniusInverterUpdateCoordinator.default_interval,
) )
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 60
# 4 additional AC entities # 4 additional AC entities
assert_state("sensor.symo_20_dc_current", 2.19) assert_state("sensor.symo_20_dc_current", 2.19)
assert_state("sensor.symo_20_energy_day", 1113) assert_state("sensor.symo_20_energy_day", 1113)
@ -70,6 +71,7 @@ async def test_symo_inverter(
assert_state("sensor.symo_20_frequency", 49.94) assert_state("sensor.symo_20_frequency", 49.94)
assert_state("sensor.symo_20_ac_power", 1190) assert_state("sensor.symo_20_ac_power", 1190)
assert_state("sensor.symo_20_ac_voltage", 227.90) assert_state("sensor.symo_20_ac_voltage", 227.90)
assert_state("sensor.symo_20_status_message", "running")
# Third test at nighttime - additional AC entities default to 0 # Third test at nighttime - additional AC entities default to 0
mock_responses(aioclient_mock, night=True) mock_responses(aioclient_mock, night=True)
@ -94,7 +96,7 @@ async def test_symo_logger(
mock_responses(aioclient_mock) mock_responses(aioclient_mock)
await setup_fronius_integration(hass) await setup_fronius_integration(hass)
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 24 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 25
# states are rounded to 4 decimals # states are rounded to 4 decimals
assert_state("sensor.solarnet_grid_export_tariff", 0.078) assert_state("sensor.solarnet_grid_export_tariff", 0.078)
assert_state("sensor.solarnet_co2_factor", 0.53) assert_state("sensor.solarnet_co2_factor", 0.53)
@ -116,14 +118,14 @@ async def test_symo_meter(
mock_responses(aioclient_mock) mock_responses(aioclient_mock)
config_entry = await setup_fronius_integration(hass) config_entry = await setup_fronius_integration(hass)
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 24 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 25
await enable_all_entities( await enable_all_entities(
hass, hass,
freezer, freezer,
config_entry.entry_id, config_entry.entry_id,
FroniusMeterUpdateCoordinator.default_interval, FroniusMeterUpdateCoordinator.default_interval,
) )
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 60
# states are rounded to 4 decimals # states are rounded to 4 decimals
assert_state("sensor.smart_meter_63a_current_phase_1", 7.755) assert_state("sensor.smart_meter_63a_current_phase_1", 7.755)
assert_state("sensor.smart_meter_63a_current_phase_2", 6.68) assert_state("sensor.smart_meter_63a_current_phase_2", 6.68)
@ -157,6 +159,50 @@ async def test_symo_meter(
assert_state("sensor.smart_meter_63a_voltage_phase_1_2", 395.9) assert_state("sensor.smart_meter_63a_voltage_phase_1_2", 395.9)
assert_state("sensor.smart_meter_63a_voltage_phase_2_3", 398) assert_state("sensor.smart_meter_63a_voltage_phase_2_3", 398)
assert_state("sensor.smart_meter_63a_voltage_phase_3_1", 398) assert_state("sensor.smart_meter_63a_voltage_phase_3_1", 398)
assert_state("sensor.smart_meter_63a_meter_location", 0)
assert_state("sensor.smart_meter_63a_meter_location_description", "feed_in")
@pytest.mark.parametrize(
("location_code", "expected_code", "expected_description"),
[
(-1, -1, "unknown"),
(3, 3, "external_generator"),
(4, 4, "external_battery"),
(7, 7, "unknown"),
(256, 256, "subload"),
(511, 511, "subload"),
(512, 512, "unknown"),
],
)
async def test_symo_meter_forged(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
location_code: int | None,
expected_code: int | str,
expected_description: str,
) -> None:
"""Tests for meter location codes we have no fixture for."""
def assert_state(entity_id, expected_state):
state = hass.states.get(entity_id)
assert state
assert state.state == str(expected_state)
mock_responses(
aioclient_mock,
fixture_set="symo",
override_data={
"symo/GetMeterRealtimeData.json": [
(["Body", "Data", "0", "Meter_Location_Current"], location_code),
],
},
)
await setup_fronius_integration(hass)
assert_state("sensor.smart_meter_63a_meter_location", expected_code)
assert_state(
"sensor.smart_meter_63a_meter_location_description", expected_description
)
async def test_symo_power_flow( async def test_symo_power_flow(
@ -175,14 +221,14 @@ async def test_symo_power_flow(
mock_responses(aioclient_mock, night=True) mock_responses(aioclient_mock, night=True)
config_entry = await setup_fronius_integration(hass) config_entry = await setup_fronius_integration(hass)
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 20 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 21
await enable_all_entities( await enable_all_entities(
hass, hass,
freezer, freezer,
config_entry.entry_id, config_entry.entry_id,
FroniusInverterUpdateCoordinator.default_interval, FroniusInverterUpdateCoordinator.default_interval,
) )
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54
# states are rounded to 4 decimals # states are rounded to 4 decimals
assert_state("sensor.solarnet_energy_day", 10828) assert_state("sensor.solarnet_energy_day", 10828)
assert_state("sensor.solarnet_total_energy", 44186900) assert_state("sensor.solarnet_total_energy", 44186900)
@ -197,7 +243,7 @@ async def test_symo_power_flow(
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
# 54 because power_flow `rel_SelfConsumption` and `P_PV` is not `null` anymore # 54 because power_flow `rel_SelfConsumption` and `P_PV` is not `null` anymore
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 56
assert_state("sensor.solarnet_energy_day", 1101.7001) assert_state("sensor.solarnet_energy_day", 1101.7001)
assert_state("sensor.solarnet_total_energy", 44188000) assert_state("sensor.solarnet_total_energy", 44188000)
assert_state("sensor.solarnet_energy_year", 25508788) assert_state("sensor.solarnet_energy_year", 25508788)
@ -212,7 +258,7 @@ async def test_symo_power_flow(
freezer.tick(FroniusPowerFlowUpdateCoordinator.default_interval) freezer.tick(FroniusPowerFlowUpdateCoordinator.default_interval)
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 56
assert_state("sensor.solarnet_energy_day", 10828) assert_state("sensor.solarnet_energy_day", 10828)
assert_state("sensor.solarnet_total_energy", 44186900) assert_state("sensor.solarnet_total_energy", 44186900)
assert_state("sensor.solarnet_energy_year", 25507686) assert_state("sensor.solarnet_energy_year", 25507686)
@ -238,18 +284,19 @@ async def test_gen24(
mock_responses(aioclient_mock, fixture_set="gen24") mock_responses(aioclient_mock, fixture_set="gen24")
config_entry = await setup_fronius_integration(hass, is_logger=False) config_entry = await setup_fronius_integration(hass, is_logger=False)
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 22 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 23
await enable_all_entities( await enable_all_entities(
hass, hass,
freezer, freezer,
config_entry.entry_id, config_entry.entry_id,
FroniusMeterUpdateCoordinator.default_interval, FroniusMeterUpdateCoordinator.default_interval,
) )
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54
# inverter 1 # inverter 1
assert_state("sensor.inverter_name_ac_current", 0.1589) assert_state("sensor.inverter_name_ac_current", 0.1589)
assert_state("sensor.inverter_name_dc_current_2", 0.0754) assert_state("sensor.inverter_name_dc_current_2", 0.0754)
assert_state("sensor.inverter_name_status_code", 7) assert_state("sensor.inverter_name_status_code", 7)
assert_state("sensor.inverter_name_status_message", "running")
assert_state("sensor.inverter_name_dc_current", 0.0783) assert_state("sensor.inverter_name_dc_current", 0.0783)
assert_state("sensor.inverter_name_dc_voltage_2", 403.4312) assert_state("sensor.inverter_name_dc_voltage_2", 403.4312)
assert_state("sensor.inverter_name_ac_power", 37.3204) assert_state("sensor.inverter_name_ac_power", 37.3204)
@ -264,7 +311,8 @@ async def test_gen24(
assert_state("sensor.smart_meter_ts_65a_3_real_energy_consumed", 2013105.0) assert_state("sensor.smart_meter_ts_65a_3_real_energy_consumed", 2013105.0)
assert_state("sensor.smart_meter_ts_65a_3_real_power", 653.1) assert_state("sensor.smart_meter_ts_65a_3_real_power", 653.1)
assert_state("sensor.smart_meter_ts_65a_3_frequency_phase_average", 49.9) assert_state("sensor.smart_meter_ts_65a_3_frequency_phase_average", 49.9)
assert_state("sensor.smart_meter_ts_65a_3_meter_location", 0.0) assert_state("sensor.smart_meter_ts_65a_3_meter_location", 0)
assert_state("sensor.smart_meter_ts_65a_3_meter_location_description", "feed_in")
assert_state("sensor.smart_meter_ts_65a_3_power_factor", 0.828) assert_state("sensor.smart_meter_ts_65a_3_power_factor", 0.828)
assert_state("sensor.smart_meter_ts_65a_3_reactive_energy_consumed", 88221.0) assert_state("sensor.smart_meter_ts_65a_3_reactive_energy_consumed", 88221.0)
assert_state("sensor.smart_meter_ts_65a_3_real_energy_minus", 3863340.0) assert_state("sensor.smart_meter_ts_65a_3_real_energy_minus", 3863340.0)
@ -336,14 +384,14 @@ async def test_gen24_storage(
hass, is_logger=False, unique_id="12345678" hass, is_logger=False, unique_id="12345678"
) )
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 34 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 35
await enable_all_entities( await enable_all_entities(
hass, hass,
freezer, freezer,
config_entry.entry_id, config_entry.entry_id,
FroniusMeterUpdateCoordinator.default_interval, FroniusMeterUpdateCoordinator.default_interval,
) )
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 64 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 66
# inverter 1 # inverter 1
assert_state("sensor.gen24_storage_dc_current", 0.3952) assert_state("sensor.gen24_storage_dc_current", 0.3952)
assert_state("sensor.gen24_storage_dc_voltage_2", 318.8103) assert_state("sensor.gen24_storage_dc_voltage_2", 318.8103)
@ -352,6 +400,7 @@ async def test_gen24_storage(
assert_state("sensor.gen24_storage_ac_power", 250.9093) assert_state("sensor.gen24_storage_ac_power", 250.9093)
assert_state("sensor.gen24_storage_error_code", 0) assert_state("sensor.gen24_storage_error_code", 0)
assert_state("sensor.gen24_storage_status_code", 7) assert_state("sensor.gen24_storage_status_code", 7)
assert_state("sensor.gen24_storage_status_message", "running")
assert_state("sensor.gen24_storage_total_energy", 7512794.0117) assert_state("sensor.gen24_storage_total_energy", 7512794.0117)
assert_state("sensor.gen24_storage_inverter_state", "Running") assert_state("sensor.gen24_storage_inverter_state", "Running")
assert_state("sensor.gen24_storage_dc_voltage", 419.1009) assert_state("sensor.gen24_storage_dc_voltage", 419.1009)
@ -363,7 +412,8 @@ async def test_gen24_storage(
assert_state("sensor.smart_meter_ts_65a_3_power_factor", 0.698) assert_state("sensor.smart_meter_ts_65a_3_power_factor", 0.698)
assert_state("sensor.smart_meter_ts_65a_3_real_energy_consumed", 1247204.0) assert_state("sensor.smart_meter_ts_65a_3_real_energy_consumed", 1247204.0)
assert_state("sensor.smart_meter_ts_65a_3_frequency_phase_average", 49.9) assert_state("sensor.smart_meter_ts_65a_3_frequency_phase_average", 49.9)
assert_state("sensor.smart_meter_ts_65a_3_meter_location", 0.0) assert_state("sensor.smart_meter_ts_65a_3_meter_location", 0)
assert_state("sensor.smart_meter_ts_65a_3_meter_location_description", "feed_in")
assert_state("sensor.smart_meter_ts_65a_3_reactive_power", -501.5) assert_state("sensor.smart_meter_ts_65a_3_reactive_power", -501.5)
assert_state("sensor.smart_meter_ts_65a_3_reactive_energy_produced", 3266105.0) assert_state("sensor.smart_meter_ts_65a_3_reactive_energy_produced", 3266105.0)
assert_state("sensor.smart_meter_ts_65a_3_real_power_phase_3", 19.6) assert_state("sensor.smart_meter_ts_65a_3_real_power_phase_3", 19.6)
@ -396,7 +446,7 @@ async def test_gen24_storage(
assert_state("sensor.ohmpilot_power", 0.0) assert_state("sensor.ohmpilot_power", 0.0)
assert_state("sensor.ohmpilot_temperature", 38.9) assert_state("sensor.ohmpilot_temperature", 38.9)
assert_state("sensor.ohmpilot_state_code", 0.0) assert_state("sensor.ohmpilot_state_code", 0.0)
assert_state("sensor.ohmpilot_state_message", "Up and running") assert_state("sensor.ohmpilot_state_message", "up_and_running")
# power_flow # power_flow
assert_state("sensor.solarnet_power_grid", 2274.9) assert_state("sensor.solarnet_power_grid", 2274.9)
assert_state("sensor.solarnet_power_battery", 0.1591) assert_state("sensor.solarnet_power_battery", 0.1591)
@ -463,14 +513,14 @@ async def test_primo_s0(
mock_responses(aioclient_mock, fixture_set="primo_s0", inverter_ids=[1, 2]) mock_responses(aioclient_mock, fixture_set="primo_s0", inverter_ids=[1, 2])
config_entry = await setup_fronius_integration(hass, is_logger=True) config_entry = await setup_fronius_integration(hass, is_logger=True)
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 29 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 30
await enable_all_entities( await enable_all_entities(
hass, hass,
freezer, freezer,
config_entry.entry_id, config_entry.entry_id,
FroniusMeterUpdateCoordinator.default_interval, FroniusMeterUpdateCoordinator.default_interval,
) )
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 40 assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 43
# logger # logger
assert_state("sensor.solarnet_grid_export_tariff", 1) assert_state("sensor.solarnet_grid_export_tariff", 1)
assert_state("sensor.solarnet_co2_factor", 0.53) assert_state("sensor.solarnet_co2_factor", 0.53)
@ -483,6 +533,7 @@ async def test_primo_s0(
assert_state("sensor.primo_5_0_1_error_code", 0) assert_state("sensor.primo_5_0_1_error_code", 0)
assert_state("sensor.primo_5_0_1_dc_current", 4.23) assert_state("sensor.primo_5_0_1_dc_current", 4.23)
assert_state("sensor.primo_5_0_1_status_code", 7) assert_state("sensor.primo_5_0_1_status_code", 7)
assert_state("sensor.primo_5_0_1_status_message", "running")
assert_state("sensor.primo_5_0_1_energy_year", 7532755.5) assert_state("sensor.primo_5_0_1_energy_year", 7532755.5)
assert_state("sensor.primo_5_0_1_ac_current", 3.85) assert_state("sensor.primo_5_0_1_ac_current", 3.85)
assert_state("sensor.primo_5_0_1_ac_voltage", 223.9) assert_state("sensor.primo_5_0_1_ac_voltage", 223.9)
@ -497,6 +548,7 @@ async def test_primo_s0(
assert_state("sensor.primo_3_0_1_error_code", 0) assert_state("sensor.primo_3_0_1_error_code", 0)
assert_state("sensor.primo_3_0_1_dc_current", 0.97) assert_state("sensor.primo_3_0_1_dc_current", 0.97)
assert_state("sensor.primo_3_0_1_status_code", 7) assert_state("sensor.primo_3_0_1_status_code", 7)
assert_state("sensor.primo_3_0_1_status_message", "running")
assert_state("sensor.primo_3_0_1_energy_year", 3596193.25) assert_state("sensor.primo_3_0_1_energy_year", 3596193.25)
assert_state("sensor.primo_3_0_1_ac_current", 1.32) assert_state("sensor.primo_3_0_1_ac_current", 1.32)
assert_state("sensor.primo_3_0_1_ac_voltage", 223.6) assert_state("sensor.primo_3_0_1_ac_voltage", 223.6)
@ -505,6 +557,9 @@ async def test_primo_s0(
assert_state("sensor.primo_3_0_1_led_state", 0) assert_state("sensor.primo_3_0_1_led_state", 0)
# meter # meter
assert_state("sensor.s0_meter_at_inverter_1_meter_location", 1) assert_state("sensor.s0_meter_at_inverter_1_meter_location", 1)
assert_state(
"sensor.s0_meter_at_inverter_1_meter_location_description", "consumption_path"
)
assert_state("sensor.s0_meter_at_inverter_1_real_power", -2216.7487) assert_state("sensor.s0_meter_at_inverter_1_real_power", -2216.7487)
# power_flow # power_flow
assert_state("sensor.solarnet_power_load", -2218.9349) assert_state("sensor.solarnet_power_load", -2218.9349)