mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Add RTC sync and IV curve scan buttons for Sofar inverters (#180392)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user