From 3052056820b9b79c69f1a2740835f35307196e3d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Aug 2026 21:44:58 +0200 Subject: [PATCH] Add binary sensors to SolarEdge Modbus (#180687) --- .../components/solaredge_modbus/__init__.py | 2 +- .../solaredge_modbus/binary_sensor.py | 136 ++++++++++++ .../components/solaredge_modbus/icons.json | 8 + .../components/solaredge_modbus/strings.json | 9 + .../snapshots/test_binary_sensor.ambr | 204 ++++++++++++++++++ .../solaredge_modbus/test_binary_sensor.py | 118 ++++++++++ 6 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/solaredge_modbus/binary_sensor.py create mode 100644 tests/components/solaredge_modbus/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/solaredge_modbus/test_binary_sensor.py diff --git a/homeassistant/components/solaredge_modbus/__init__.py b/homeassistant/components/solaredge_modbus/__init__.py index d26ae2bf0ea5..e85477642f65 100644 --- a/homeassistant/components/solaredge_modbus/__init__.py +++ b/homeassistant/components/solaredge_modbus/__init__.py @@ -38,7 +38,7 @@ from .coordinator import ( from .entity import attachment_identity, inverter_device_info from .helpers import create_modbus_params -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry( diff --git a/homeassistant/components/solaredge_modbus/binary_sensor.py b/homeassistant/components/solaredge_modbus/binary_sensor.py new file mode 100644 index 000000000000..29de70450882 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/binary_sensor.py @@ -0,0 +1,136 @@ +"""Support for SolarEdge Modbus binary sensor entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from solaredged import ( + Battery, + BatteryStatus, + Inverter, + InverterExtended, + InverterStatus, +) + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SolarEdgeModbusConfigEntry +from .entity import SolarEdgeModbusBatteryEntity, SolarEdgeModbusInverterEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class SolarEdgeModbusBinarySensorEntityDescription[ComponentT]( + BinarySensorEntityDescription +): + """Describes a SolarEdge Modbus binary sensor entity.""" + + exists_fn: Callable[[ComponentT], bool] = lambda _: True + is_on_fn: Callable[[ComponentT], bool | None] + + +def _faulted(inverter: Inverter) -> bool | None: + """Whether the inverter reports a fault, unknown while its status is.""" + if inverter.status is None: + return None + return inverter.status is InverterStatus.FAULT + + +def _charging(battery: Battery) -> bool | None: + """Whether the battery is taking charge, unknown while its status is.""" + if battery.status is None: + return None + return battery.status is BatteryStatus.CHARGE + + +INVERTER_BINARY_SENSORS: tuple[ + SolarEdgeModbusBinarySensorEntityDescription[Inverter], ... +] = ( + SolarEdgeModbusBinarySensorEntityDescription( + key="problem", + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_fn=_faulted, + ), + SolarEdgeModbusBinarySensorEntityDescription( + key="on_grid", + translation_key="on_grid", + # Grid status is a firmware extension; without it there is nothing to + # show, and the library only carries the field where it answered. + exists_fn=lambda inverter: isinstance(inverter, InverterExtended), + is_on_fn=lambda inverter: inverter.on_grid, + ), +) + +BATTERY_BINARY_SENSORS: tuple[ + SolarEdgeModbusBinarySensorEntityDescription[Battery], ... +] = ( + # The status sensor carries the whole story, but Home Assistant's + # battery-charging triggers and conditions only look at this device class. + SolarEdgeModbusBinarySensorEntityDescription( + key="charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + is_on_fn=_charging, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SolarEdgeModbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SolarEdge Modbus binary sensor entities based on a config entry.""" + solaredge = entry.runtime_data.solaredge + + entities: list[BinarySensorEntity] = [ + SolarEdgeModbusInverterBinarySensorEntity(entry=entry, description=description) + for description in INVERTER_BINARY_SENSORS + if description.exists_fn(solaredge.inverter) + ] + entities.extend( + SolarEdgeModbusBatteryBinarySensorEntity( + entry=entry, description=description, index=index + ) + for index in range(1, len(solaredge.batteries) + 1) + for description in BATTERY_BINARY_SENSORS + if description.exists_fn(solaredge.batteries[index - 1]) + ) + + async_add_entities(entities) + + +class SolarEdgeModbusInverterBinarySensorEntity( + SolarEdgeModbusInverterEntity, BinarySensorEntity +): + """Defines a SolarEdge Modbus inverter binary sensor entity.""" + + entity_description: SolarEdgeModbusBinarySensorEntityDescription[Inverter] + + @property + @override + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return self.entity_description.is_on_fn(self.coordinator.solaredge.inverter) + + +class SolarEdgeModbusBatteryBinarySensorEntity( + SolarEdgeModbusBatteryEntity, BinarySensorEntity +): + """Defines a SolarEdge Modbus battery binary sensor entity.""" + + entity_description: SolarEdgeModbusBinarySensorEntityDescription[Battery] + + @property + @override + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return self.entity_description.is_on_fn( + self.coordinator.solaredge.batteries[self._index - 1] + ) diff --git a/homeassistant/components/solaredge_modbus/icons.json b/homeassistant/components/solaredge_modbus/icons.json index f5c90440354c..c7e0550aac4a 100644 --- a/homeassistant/components/solaredge_modbus/icons.json +++ b/homeassistant/components/solaredge_modbus/icons.json @@ -1,5 +1,13 @@ { "entity": { + "binary_sensor": { + "on_grid": { + "default": "mdi:transmission-tower", + "state": { + "off": "mdi:transmission-tower-off" + } + } + }, "sensor": { "battery_status": { "default": "mdi:home-battery" diff --git a/homeassistant/components/solaredge_modbus/strings.json b/homeassistant/components/solaredge_modbus/strings.json index dcb652f9cf3e..659a7c6caae2 100644 --- a/homeassistant/components/solaredge_modbus/strings.json +++ b/homeassistant/components/solaredge_modbus/strings.json @@ -100,6 +100,15 @@ } }, "entity": { + "binary_sensor": { + "on_grid": { + "name": "On grid", + "state": { + "off": "[%key:common::state::no%]", + "on": "[%key:common::state::yes%]" + } + } + }, "sensor": { "battery_status": { "name": "Status", diff --git a/tests/components/solaredge_modbus/snapshots/test_binary_sensor.ambr b/tests/components/solaredge_modbus/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..b7428ba43a92 --- /dev/null +++ b/tests/components/solaredge_modbus/snapshots/test_binary_sensor.ambr @@ -0,0 +1,204 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.battery_1_charging-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': None, + 'entity_id': 'binary_sensor.battery_1_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_battery_7E7C33E4_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.battery_1_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery_charging', + : 'Battery 1 Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.battery_1_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.battery_2_charging-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': None, + 'entity_id': 'binary_sensor.battery_2_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_battery_7E8D44F5_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.battery_2_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery_charging', + : 'Battery 2 Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.battery_2_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.solaredge_se10000h_on_grid-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': None, + 'entity_id': 'binary_sensor.solaredge_se10000h_on_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'On grid', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'On grid', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'on_grid', + 'unique_id': '7E123ABC_on_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.solaredge_se10000h_on_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H On grid', + }), + 'context': , + 'entity_id': 'binary_sensor.solaredge_se10000h_on_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.solaredge_se10000h_problem-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': None, + 'entity_id': 'binary_sensor.solaredge_se10000h_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Problem', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_problem', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.solaredge_se10000h_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : 'SolarEdge SE10000H Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.solaredge_se10000h_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/solaredge_modbus/test_binary_sensor.py b/tests/components/solaredge_modbus/test_binary_sensor.py new file mode 100644 index 000000000000..0d82a3324b46 --- /dev/null +++ b/tests/components/solaredge_modbus/test_binary_sensor.py @@ -0,0 +1,118 @@ +"""Tests for the SolarEdge Modbus binary sensor entities.""" + +from unittest.mock import patch + +from modbus_connection import IllegalDataAddressError +from modbus_connection.encode import encode_int +from modbus_connection.mock import MockModbusUnit +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +CHARGING_ENTITY = "binary_sensor.battery_1_charging" +PROBLEM_ENTITY = "binary_sensor.solaredge_se10000h_problem" +ON_GRID_ENTITY = "binary_sensor.solaredge_se10000h_on_grid" +BATTERY_STATUS_REGISTER = 57734 + + +async def _setup_binary_sensor_platform( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + with patch( + "homeassistant.components.solaredge_modbus.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +async def test_binary_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """All binary sensor entities and their states match the snapshot.""" + await _setup_binary_sensor_platform(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_no_on_grid_sensor_without_grid_status_extension( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Firmware without the grid status extension gets no on-grid sensor.""" + # A real device answers reads of the absent extension with a Modbus + # exception (illegal data address). + mock_modbus_unit.fail_read(40113, IllegalDataAddressError()) + + await _setup_binary_sensor_platform(hass, mock_config_entry) + + assert hass.states.get(ON_GRID_ENTITY) is None + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + pytest.param(3, STATE_ON, id="charging"), + pytest.param(4, STATE_OFF, id="discharging"), + pytest.param(6, STATE_OFF, id="preserving charge"), + pytest.param(0xFFFFFFFF, STATE_UNKNOWN, id="not implemented"), + ], +) +async def test_battery_charging( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, + status: int, + expected: str, +) -> None: + """The charging sensor follows the battery status, and admits ignorance. + + Home Assistant's battery-charging triggers and conditions key on this + device class, so the status enum alone would leave them out of reach. + """ + # The battery block is word-swapped, which the encoder can do itself. + words = encode_int(status, count=2, word_order="little") + for offset, word in enumerate(words): + mock_modbus_unit.holding[BATTERY_STATUS_REGISTER + offset] = word + + await _setup_binary_sensor_platform(hass, mock_config_entry) + + state = hass.states.get(CHARGING_ENTITY) + assert state is not None + assert state.state == expected + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + pytest.param(7, STATE_ON, id="fault"), + pytest.param(4, STATE_OFF, id="producing"), + pytest.param(2, STATE_OFF, id="sleeping"), + pytest.param(99, STATE_UNKNOWN, id="not a known status"), + ], +) +async def test_inverter_problem( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, + status: int, + expected: str, +) -> None: + """A faulted inverter is a problem, and an unreadable status is unknown.""" + mock_modbus_unit.holding[40107] = status + + await _setup_binary_sensor_platform(hass, mock_config_entry) + + state = hass.states.get(PROBLEM_ENTITY) + assert state is not None + assert state.state == expected