diff --git a/homeassistant/components/sofar/__init__.py b/homeassistant/components/sofar/__init__.py index c232ba686ae4..dd455c2a3456 100644 --- a/homeassistant/components/sofar/__init__.py +++ b/homeassistant/components/sofar/__init__.py @@ -28,6 +28,7 @@ from .sensor import SENSOR_DESCRIPTIONS _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/sofar/binary_sensor.py b/homeassistant/components/sofar/binary_sensor.py new file mode 100644 index 000000000000..fc1e215ceff4 --- /dev/null +++ b/homeassistant/components/sofar/binary_sensor.py @@ -0,0 +1,82 @@ +"""Support for Sofar binary sensors.""" + +from dataclasses import dataclass +from typing import override + +from sofar_modbus.modern.faults import FaultCategory + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SofarConfigEntry +from .entity import SofarEntity, SofarEntityDescription + +PARALLEL_UPDATES = 0 + +_DISABLED_BY_DEFAULT = frozenset( + { + FaultCategory.ARC_FAULT, + FaultCategory.COMBINER_BOX, + FaultCategory.INPUT_FUSE, + FaultCategory.STRING_FUSE, + } +) + + +@dataclass(frozen=True, kw_only=True) +class SofarFaultBinarySensorDescription( + SofarEntityDescription, BinarySensorEntityDescription +): + """Describe a Sofar fault-category binary sensor.""" + + category: FaultCategory + + +FAULT_SENSOR_DESCRIPTIONS: tuple[SofarFaultBinarySensorDescription, ...] = tuple( + SofarFaultBinarySensorDescription( + key=f"fault_{category.value}", + component="state", + translation_key=f"fault_{category.value}", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=category not in _DISABLED_BY_DEFAULT, + category=category, + ) + for category in FaultCategory +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SofarConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Sofar Inverter Modbus binary sensor platform.""" + runtime_data = entry.runtime_data + served = runtime_data.served_components + async_add_entities( + SofarFaultBinarySensor(runtime_data, description) + for description in FAULT_SENSOR_DESCRIPTIONS + if description.component in served + ) + + +class SofarFaultBinarySensor(SofarEntity, BinarySensorEntity): + """Reports whether any fault in one subsystem is currently active.""" + + entity_description: SofarFaultBinarySensorDescription + + @property + @override + def is_on(self) -> bool: + component = getattr(self.coordinator.device, self.entity_description.component) + return any( + fault.category is self.entity_description.category + for fault in component.active_faults + ) diff --git a/homeassistant/components/sofar/diagnostics.py b/homeassistant/components/sofar/diagnostics.py index 8a4b2044aacf..3ecea362805e 100644 --- a/homeassistant/components/sofar/diagnostics.py +++ b/homeassistant/components/sofar/diagnostics.py @@ -29,6 +29,7 @@ async def async_get_config_entry_diagnostics( "serial_number": device.serial_number, "readings_components": device.readings_components, "settings_components": device.settings_components, + "active_faults": sorted(fault.key for fault in device.state.active_faults), "raw": raw, }, TO_REDACT, diff --git a/homeassistant/components/sofar/strings.json b/homeassistant/components/sofar/strings.json index c9be7334977b..5e9c01bda521 100644 --- a/homeassistant/components/sofar/strings.json +++ b/homeassistant/components/sofar/strings.json @@ -46,6 +46,59 @@ } }, "entity": { + "binary_sensor": { + "fault_ac_output": { + "name": "AC output fault" + }, + "fault_arc_fault": { + "name": "Arc fault (AFCI)" + }, + "fault_battery": { + "name": "Battery fault" + }, + "fault_battery_pack": { + "name": "Battery pack communication fault" + }, + "fault_combiner_box": { + "name": "Combiner box fault" + }, + "fault_communication": { + "name": "Communication fault" + }, + "fault_dc_bus": { + "name": "DC bus fault" + }, + "fault_derating": { + "name": "Derating" + }, + "fault_fan": { + "name": "Fan fault" + }, + "fault_grid": { + "name": "Grid fault" + }, + "fault_input_fuse": { + "name": "Input fuse open" + }, + "fault_insulation": { + "name": "Insulation fault" + }, + "fault_internal": { + "name": "Internal fault" + }, + "fault_pv": { + "name": "PV fault" + }, + "fault_shutdown": { + "name": "Shutdown" + }, + "fault_string_fuse": { + "name": "String fuse open" + }, + "fault_thermal": { + "name": "Thermal fault" + } + }, "button": { "iv_curve_scan": { "name": "IV curve scan" diff --git a/tests/components/sofar/snapshots/test_binary_sensor.ambr b/tests/components/sofar/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..2d17ac76681e --- /dev/null +++ b/tests/components/sofar/snapshots/test_binary_sensor.ambr @@ -0,0 +1,868 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_ac_output_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_ac_output_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AC output fault', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC output fault', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_ac_output', + 'unique_id': 'SS2ES104N5S445_fault_ac_output', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_ac_output_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 AC output fault', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_ac_output_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_arc_fault_afci-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_arc_fault_afci', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Arc fault (AFCI)', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Arc fault (AFCI)', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_arc_fault', + 'unique_id': 'SS2ES104N5S445_fault_arc_fault', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_arc_fault_afci-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 Arc fault (AFCI)', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_arc_fault_afci', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_battery_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_battery_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery fault', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery fault', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_battery', + 'unique_id': 'SS2ES104N5S445_fault_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_battery_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 Battery fault', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_battery_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_battery_pack_communication_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_battery_pack_communication_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery pack communication fault', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery pack communication fault', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_battery_pack', + 'unique_id': 'SS2ES104N5S445_fault_battery_pack', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_battery_pack_communication_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 Battery pack communication fault', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_battery_pack_communication_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_combiner_box_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_combiner_box_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Combiner box fault', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Combiner box fault', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_combiner_box', + 'unique_id': 'SS2ES104N5S445_fault_combiner_box', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_combiner_box_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 Combiner box fault', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_combiner_box_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_communication_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_communication_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Communication fault', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Communication fault', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_communication', + 'unique_id': 'SS2ES104N5S445_fault_communication', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_communication_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 Communication fault', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_communication_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_dc_bus_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_dc_bus_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DC bus fault', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC bus fault', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_dc_bus', + 'unique_id': 'SS2ES104N5S445_fault_dc_bus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_dc_bus_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 DC bus fault', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_dc_bus_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_derating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_derating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Derating', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Derating', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_derating', + 'unique_id': 'SS2ES104N5S445_fault_derating', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_derating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 Derating', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_derating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_fan_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_fan_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fan fault', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fan fault', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_fan', + 'unique_id': 'SS2ES104N5S445_fault_fan', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_fan_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 Fan fault', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_fan_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_grid_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_grid_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid fault', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid fault', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_grid', + 'unique_id': 'SS2ES104N5S445_fault_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_grid_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 Grid fault', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_grid_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_input_fuse_open-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_input_fuse_open', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Input fuse open', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input fuse open', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_input_fuse', + 'unique_id': 'SS2ES104N5S445_fault_input_fuse', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_input_fuse_open-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 Input fuse open', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_input_fuse_open', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_insulation_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_insulation_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Insulation fault', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Insulation fault', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_insulation', + 'unique_id': 'SS2ES104N5S445_fault_insulation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_insulation_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 Insulation fault', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_insulation_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_internal_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_internal_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Internal fault', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Internal fault', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_internal', + 'unique_id': 'SS2ES104N5S445_fault_internal', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_internal_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 Internal fault', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_internal_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_pv_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_pv_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PV fault', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PV fault', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_pv', + 'unique_id': 'SS2ES104N5S445_fault_pv', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_pv_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 PV fault', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_pv_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_shutdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_shutdown', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Shutdown', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Shutdown', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_shutdown', + 'unique_id': 'SS2ES104N5S445_fault_shutdown', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_shutdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 Shutdown', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_shutdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_string_fuse_open-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_string_fuse_open', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'String fuse open', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'String fuse open', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_string_fuse', + 'unique_id': 'SS2ES104N5S445_fault_string_fuse', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_string_fuse_open-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 String fuse open', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_string_fuse_open', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_thermal_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_thermal_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Thermal fault', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermal fault', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fault_thermal', + 'unique_id': 'SS2ES104N5S445_fault_thermal', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_thermal_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : '4.4 KTLX-G3 Thermal fault', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_thermal_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/sofar/snapshots/test_diagnostics.ambr b/tests/components/sofar/snapshots/test_diagnostics.ambr index 4770d42925d2..a8e8adc7909e 100644 --- a/tests/components/sofar/snapshots/test_diagnostics.ambr +++ b/tests/components/sofar/snapshots/test_diagnostics.ambr @@ -1,6 +1,8 @@ # serializer version: 1 # name: test_diagnostics dict({ + 'active_faults': list([ + ]), 'inverter_type': 1537, 'model': '4.4 KTLX-G3', 'raw': dict({ diff --git a/tests/components/sofar/test_binary_sensor.py b/tests/components/sofar/test_binary_sensor.py new file mode 100644 index 000000000000..60ae99abae99 --- /dev/null +++ b/tests/components/sofar/test_binary_sensor.py @@ -0,0 +1,124 @@ +"""Test the Sofar Inverter Modbus binary sensor platform.""" + +from unittest.mock import patch + +from modbus_connection.mock import MockModbusConnection +import pytest +from sofar_modbus.modern.faults import FaultCategory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sofar.binary_sensor import FAULT_SENSOR_DESCRIPTIONS +from homeassistant.components.sofar.const import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MOCK_MODEL, MOCK_SERIAL, MOCK_USER_INPUT, seed_pv_inverter + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities match their snapshot on a PV device.""" + connection = MockModbusConnection() + seed_pv_inverter(connection.for_unit(1)) + entry = MockConfigEntry( + domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT, title=MOCK_MODEL + ) + entry.add_to_hass(hass) + with ( + patch("homeassistant.components.sofar.PLATFORMS", [Platform.BINARY_SENSOR]), + patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: connection.for_unit( + unit_id + ), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_no_faults_reports_off( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test every category is off when nothing is seeded.""" + entity_id = entity_registry.async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_fault_grid" + ) + assert entity_id is not None + assert (state := hass.states.get(entity_id)) is not None + assert state.state == STATE_OFF + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_one_bit_only_lights_its_own_category( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_connection: MockModbusConnection, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a fan failure isn't masked by a shutdown bit in the same register.""" + unit = mock_connection.for_unit(1) + unit.holding[0x0405] = 0b1 # ID001_GRID_OVER_VOLTAGE + unit.holding[0x040F] = 0b100000001 # ID161_FORCED_SHUTDOWN | ID169_FAN_1_FAILURE + unit.holding[0x0410] = 0b100 # ID179_BMS_HIGH_TEMPERATURE_PROTECTION + + with patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + on_categories = {"grid", "shutdown", "fan", "battery"} + for description in FAULT_SENSOR_DESCRIPTIONS: + entity_id = entity_registry.async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_{description.key}" + ) + assert entity_id is not None + assert (state := hass.states.get(entity_id)) is not None + expected = ( + STATE_ON if description.category.value in on_categories else STATE_OFF + ) + assert state.state == expected, description.category + + +async def test_enabled_by_default_excludes_commercial_hardware( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test combiner box, string fuse, input fuse and AFCI start disabled.""" + entries = [ + e + for e in er.async_entries_for_config_entry( + entity_registry, init_integration.entry_id + ) + if e.domain == BINARY_SENSOR_DOMAIN + ] + assert len(entries) == len(FAULT_SENSOR_DESCRIPTIONS) + + disabled_categories = { + FaultCategory(e.unique_id.removeprefix(f"{MOCK_SERIAL}_fault_")) + for e in entries + if e.disabled_by is er.RegistryEntryDisabler.INTEGRATION + } + assert disabled_categories == { + FaultCategory.ARC_FAULT, + FaultCategory.COMBINER_BOX, + FaultCategory.INPUT_FUSE, + FaultCategory.STRING_FUSE, + } diff --git a/tests/components/sofar/test_diagnostics.py b/tests/components/sofar/test_diagnostics.py index cd2a4f2e71ea..d7aff386402c 100644 --- a/tests/components/sofar/test_diagnostics.py +++ b/tests/components/sofar/test_diagnostics.py @@ -1,5 +1,8 @@ """Test the Sofar Inverter Modbus diagnostics.""" +from unittest.mock import patch + +from modbus_connection.mock import MockModbusConnection from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -21,6 +24,30 @@ async def test_diagnostics( assert diag == snapshot +async def test_diagnostics_includes_active_faults( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_connection: MockModbusConnection, + mock_config_entry: MockConfigEntry, +) -> None: + """Test active faults are decoded by name in the diagnostics dump.""" + mock_connection.for_unit(1).holding[0x0405] = 0b1 # ID001_GRID_OVER_VOLTAGE + + with patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + diag = await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + + assert diag["active_faults"] == ["grid_over_voltage"] + + async def test_diagnostics_redacts_serial_number( hass: HomeAssistant, hass_client: ClientSessionGenerator,