Add RTC sync and IV curve scan buttons for Sofar inverters (#180392)

This commit is contained in:
darkrain-nl
2026-08-27 14:32:02 +02:00
committed by GitHub
parent e78ab47c77
commit 0e1b29b04f
7 changed files with 420 additions and 6 deletions
+1 -1
View File
@@ -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
+76
View File
@@ -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()
@@ -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"
@@ -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"
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.hydxxktl_3p_iv_curve_scan',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'HYDxxKTL-3P IV curve scan',
}),
'context': <ANY>,
'entity_id': 'button.hydxxktl_3p_iv_curve_scan',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_hybrid_entities[button.hydxxktl_3p_rtc_sync-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.hydxxktl_3p_rtc_sync',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'HYDxxKTL-3P RTC sync',
}),
'context': <ANY>,
'entity_id': 'button.hydxxktl_3p_rtc_sync',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.4_4_ktlx_g3_rtc_sync',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: '4.4 KTLX-G3 RTC sync',
}),
'context': <ANY>,
'entity_id': 'button.4_4_ktlx_g3_rtc_sync',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
+162
View File
@@ -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,
)
+14 -5
View File
@@ -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