diff --git a/homeassistant/components/sofar/__init__.py b/homeassistant/components/sofar/__init__.py index eb4d03ef439b..0a6fd89442fd 100644 --- a/homeassistant/components/sofar/__init__.py +++ b/homeassistant/components/sofar/__init__.py @@ -17,7 +17,7 @@ from .coordinator import SofarConfigEntry, SofarDataUpdateCoordinator, SofarRunt _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] _IDENTITY_ATTEMPTS = 3 diff --git a/homeassistant/components/sofar/button.py b/homeassistant/components/sofar/button.py new file mode 100644 index 000000000000..29bbe91a4bde --- /dev/null +++ b/homeassistant/components/sofar/button.py @@ -0,0 +1,76 @@ +"""Support for Sofar buttons.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import override + +from sofar_modbus.modern.device import SofarInverter +from sofar_modbus.variants import HYBRID, PV, InverterType, matches + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +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 = 1 + + +@dataclass(frozen=True, kw_only=True) +class SofarButtonEntityDescription(ButtonEntityDescription, SofarEntityDescription): + """Describe a Sofar button entity.""" + + applies_to: InverterType + press_fn: Callable[[SofarInverter], Awaitable[None]] + refresh_after: bool = True + + +BUTTON_DESCRIPTIONS: tuple[SofarButtonEntityDescription, ...] = ( + SofarButtonEntityDescription( + key="rtc_sync", + component="rtc_sync", + translation_key="rtc_sync", + entity_category=EntityCategory.CONFIG, + applies_to=PV | HYBRID, + press_fn=lambda device: device.async_set_time(), + ), + SofarButtonEntityDescription( + key="iv_curve_scan", + component="state", + translation_key="iv_curve_scan", + entity_category=EntityCategory.CONFIG, + applies_to=HYBRID, + press_fn=lambda device: device.async_start_iv_curve_scan(), + refresh_after=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SofarConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Sofar Inverter Modbus button platform.""" + runtime_data = entry.runtime_data + inverter_type = runtime_data.readings.device.inverter_type + async_add_entities( + SofarButton(runtime_data, description) + for description in BUTTON_DESCRIPTIONS + if inverter_type is not None and matches(inverter_type, description.applies_to) + ) + + +class SofarButton(SofarEntity, ButtonEntity): + """Defines a Sofar button entity.""" + + entity_description: SofarButtonEntityDescription + + @override + async def async_press(self) -> None: + """Press the button.""" + await self.entity_description.press_fn(self.coordinator.device) + if self.entity_description.refresh_after: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/sofar/icons.json b/homeassistant/components/sofar/icons.json index 98b8d9673e3c..65fbca1c7fea 100644 --- a/homeassistant/components/sofar/icons.json +++ b/homeassistant/components/sofar/icons.json @@ -1,5 +1,13 @@ { "entity": { + "button": { + "iv_curve_scan": { + "default": "mdi:chart-bell-curve" + }, + "rtc_sync": { + "default": "mdi:home-clock" + } + }, "sensor": { "bat_config_capacity": { "default": "mdi:battery-check-outline" diff --git a/homeassistant/components/sofar/strings.json b/homeassistant/components/sofar/strings.json index 9d052060427b..55c3e68343bf 100644 --- a/homeassistant/components/sofar/strings.json +++ b/homeassistant/components/sofar/strings.json @@ -32,6 +32,14 @@ } }, "entity": { + "button": { + "iv_curve_scan": { + "name": "IV curve scan" + }, + "rtc_sync": { + "name": "RTC sync" + } + }, "sensor": { "active_power_load_sys": { "name": "Active power load system" diff --git a/tests/components/sofar/snapshots/test_button.ambr b/tests/components/sofar/snapshots/test_button.ambr new file mode 100644 index 000000000000..aaf1fccdcb78 --- /dev/null +++ b/tests/components/sofar/snapshots/test_button.ambr @@ -0,0 +1,151 @@ +# serializer version: 1 +# name: test_hybrid_entities[button.hydxxktl_3p_iv_curve_scan-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': 'button', + 'entity_category': , + 'entity_id': 'button.hydxxktl_3p_iv_curve_scan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'IV curve scan', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'IV curve scan', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'iv_curve_scan', + 'unique_id': 'SP1XXES100XX_iv_curve_scan', + 'unit_of_measurement': None, + }) +# --- +# name: test_hybrid_entities[button.hydxxktl_3p_iv_curve_scan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'HYDxxKTL-3P IV curve scan', + }), + 'context': , + 'entity_id': 'button.hydxxktl_3p_iv_curve_scan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_hybrid_entities[button.hydxxktl_3p_rtc_sync-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': 'button', + 'entity_category': , + 'entity_id': 'button.hydxxktl_3p_rtc_sync', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'RTC sync', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RTC sync', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rtc_sync', + 'unique_id': 'SP1XXES100XX_rtc_sync', + 'unit_of_measurement': None, + }) +# --- +# name: test_hybrid_entities[button.hydxxktl_3p_rtc_sync-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'HYDxxKTL-3P RTC sync', + }), + 'context': , + 'entity_id': 'button.hydxxktl_3p_rtc_sync', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_pv_entities[button.4_4_ktlx_g3_rtc_sync-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': 'button', + 'entity_category': , + 'entity_id': 'button.4_4_ktlx_g3_rtc_sync', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'RTC sync', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RTC sync', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rtc_sync', + 'unique_id': 'SS2ES104N5S445_rtc_sync', + 'unit_of_measurement': None, + }) +# --- +# name: test_pv_entities[button.4_4_ktlx_g3_rtc_sync-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '4.4 KTLX-G3 RTC sync', + }), + 'context': , + 'entity_id': 'button.4_4_ktlx_g3_rtc_sync', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/sofar/test_button.py b/tests/components/sofar/test_button.py new file mode 100644 index 000000000000..71f4f1b739af --- /dev/null +++ b/tests/components/sofar/test_button.py @@ -0,0 +1,162 @@ +"""Test the Sofar Inverter Modbus button platform.""" + +from collections.abc import Callable, Sequence +from unittest.mock import patch + +from modbus_connection import ModbusError +from modbus_connection.mock import MockModbusConnection, MockModbusUnit +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sofar.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import ( + MOCK_HYBRID_MODEL, + MOCK_HYBRID_SERIAL, + MOCK_MODEL, + MOCK_SERIAL, + MOCK_USER_INPUT, + seed_hybrid_inverter, + seed_pv_inverter, +) + +from tests.common import MockConfigEntry, snapshot_platform + + +async def _setup( + hass: HomeAssistant, + serial: str, + model: str, + seed: Callable[[MockModbusUnit], None], + platforms: Sequence[Platform] = (Platform.BUTTON,), +) -> tuple[MockConfigEntry, MockModbusConnection]: + """Set up an inverter with the given platforms loaded.""" + connection = MockModbusConnection() + seed(connection.for_unit(1)) + entry = MockConfigEntry( + domain=DOMAIN, unique_id=serial, data=MOCK_USER_INPUT, title=model + ) + entry.add_to_hass(hass) + with ( + patch("homeassistant.components.sofar.PLATFORMS", list(platforms)), + 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) + return entry, connection + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_pv_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test a PV-only inverter only gets the RTC sync button.""" + entry, _ = await _setup(hass, MOCK_SERIAL, MOCK_MODEL, seed_pv_inverter) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_hybrid_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test a hybrid inverter gets both buttons.""" + entry, _ = await _setup( + hass, MOCK_HYBRID_SERIAL, MOCK_HYBRID_MODEL, seed_hybrid_inverter + ) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + ("key", "register"), + [ + pytest.param("rtc_sync", 0x100A, id="rtc_sync"), + pytest.param("iv_curve_scan", 0x1027, id="iv_curve_scan"), + ], +) +async def test_button_press( + hass: HomeAssistant, entity_registry: er.EntityRegistry, key: str, register: int +) -> None: + """Test pressing a button writes its trigger register.""" + _, connection = await _setup( + hass, MOCK_HYBRID_SERIAL, MOCK_HYBRID_MODEL, seed_hybrid_inverter + ) + entity_id = entity_registry.async_get_entity_id( + BUTTON_DOMAIN, DOMAIN, f"{MOCK_HYBRID_SERIAL}_{key}" + ) + assert entity_id is not None + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert connection.for_unit(1).holding[register] == 1 + + +async def test_rtc_sync_reported_to_hass( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test the RTC sync result reaches its sensor after the refresh.""" + await _setup( + hass, + MOCK_HYBRID_SERIAL, + MOCK_HYBRID_MODEL, + seed_hybrid_inverter, + platforms=(Platform.BUTTON, Platform.SENSOR), + ) + button_id = entity_registry.async_get_entity_id( + BUTTON_DOMAIN, DOMAIN, f"{MOCK_HYBRID_SERIAL}_rtc_sync" + ) + result_id = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_HYBRID_SERIAL}_sync_rtc_result" + ) + assert button_id is not None + assert result_id is not None + assert (state := hass.states.get(result_id)) is not None + assert state.state == "successful" + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: button_id}, + blocking=True, + ) + + assert (state := hass.states.get(result_id)) is not None + assert state.state == "operation_in_progress" + + +async def test_press_modbus_error( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a write failure propagates as-is.""" + _, connection = await _setup(hass, MOCK_SERIAL, MOCK_MODEL, seed_pv_inverter) + entity_id = entity_registry.async_get_entity_id( + BUTTON_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_rtc_sync" + ) + assert entity_id is not None + connection.for_unit(1).fail_write(0x1004, ModbusError("busy")) + + with pytest.raises(ModbusError, match="busy"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/sofar/test_sensor.py b/tests/components/sofar/test_sensor.py index 805ec980736f..86998a9b50ac 100644 --- a/tests/components/sofar/test_sensor.py +++ b/tests/components/sofar/test_sensor.py @@ -20,7 +20,7 @@ from homeassistant.components.sofar.sensor import ( SofarSensorDescription, SofarTotalSensor, ) -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -54,9 +54,14 @@ async def test_all_entities( title=MOCK_HYBRID_MODEL, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.sofar.async_get_unit", - side_effect=lambda hass, entry, params, unit_id: connection.for_unit(unit_id), + with ( + patch("homeassistant.components.sofar.PLATFORMS", [Platform.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) @@ -132,7 +137,11 @@ async def test_enabled_by_default_partition( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) - entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + entries = [ + e + for e in er.async_entries_for_config_entry(entity_registry, entry.entry_id) + if e.domain == SENSOR_DOMAIN + ] # Literal counts: an accidental flip has to be acknowledged here. assert len(entries) == created assert len([e for e in entries if e.disabled_by is None]) == enabled