Add a switch entity for the Sofar remote on/off control (#180383)

This commit is contained in:
darkrain-nl
2026-08-30 20:26:52 +02:00
committed by GitHub
parent d147e41013
commit d3fc4e6552
4 changed files with 254 additions and 1 deletions
+6 -1
View File
@@ -27,7 +27,12 @@ from .sensor import SENSOR_DESCRIPTIONS
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SELECT, Platform.SENSOR]
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
_IDENTITY_ATTEMPTS = 3
+81
View File
@@ -0,0 +1,81 @@
"""Support for Sofar switches."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, override
from sofar_modbus.modern.device import SofarInverter
from sofar_modbus.modern.enums import RemoteSwitchOnOff
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
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 SofarSwitchEntityDescription(SwitchEntityDescription, SofarEntityDescription):
"""Describe a Sofar switch entity."""
write_fn: Callable[[SofarInverter, bool], Awaitable[None]]
SWITCH_DESCRIPTIONS: tuple[SofarSwitchEntityDescription, ...] = (
SofarSwitchEntityDescription(
key="remote_switch_on_off",
component="remote",
name=None,
write_fn=lambda device, value: device.remote.write(
"remote_switch_on_off",
RemoteSwitchOnOff.ON if value else RemoteSwitchOnOff.OFF,
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: SofarConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Sofar Inverter Modbus switch platform."""
runtime_data = entry.runtime_data
served = runtime_data.served_components
async_add_entities(
SofarSwitch(runtime_data, description)
for description in SWITCH_DESCRIPTIONS
if description.component in served
)
class SofarSwitch(SofarEntity, SwitchEntity):
"""Defines a Sofar switch entity."""
entity_description: SofarSwitchEntityDescription
@property
@override
def is_on(self) -> bool | None:
"""Return whether the remote switch is on."""
component = getattr(self.coordinator.device, self.entity_description.component)
value = getattr(component, self.entity_description.key)
return None if value is None else bool(value)
async def _async_write(self, value: bool) -> None:
"""Write the switch state to the device."""
await self.entity_description.write_fn(self.coordinator.device, value)
await self.coordinator.async_request_refresh()
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the remote switch on."""
await self._async_write(True)
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the remote switch off."""
await self._async_write(False)
@@ -0,0 +1,51 @@
# serializer version: 1
# name: test_pv_entities[switch.4_4_ktlx_g3-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': 'switch',
'entity_category': None,
'entity_id': 'switch.4_4_ktlx_g3',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'sofar',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'SS2ES104N5S445_remote_switch_on_off',
'unit_of_measurement': None,
})
# ---
# name: test_pv_entities[switch.4_4_ktlx_g3-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: '4.4 KTLX-G3',
}),
'context': <ANY>,
'entity_id': 'switch.4_4_ktlx_g3',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
+116
View File
@@ -0,0 +1,116 @@
"""Test the Sofar Inverter Modbus switch platform."""
from unittest.mock import patch
from modbus_connection import ModbusError
from modbus_connection.mock import MockModbusConnection
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.sofar.const import DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
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
async def _setup_pv(
hass: HomeAssistant, *, remote_on: bool = False
) -> tuple[MockConfigEntry, MockModbusConnection]:
"""Set up a PV-only inverter with only the switch platform loaded."""
connection = MockModbusConnection()
seed_pv_inverter(connection.for_unit(1))
if remote_on:
connection.for_unit(1).holding[0x1104] = 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.SWITCH]),
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 the switch entities a PV-only inverter serves."""
entry, _ = await _setup_pv(hass)
await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
@pytest.mark.parametrize(
("remote_on", "service", "initial", "final"),
[
pytest.param(False, SERVICE_TURN_ON, STATE_OFF, STATE_ON, id="turn_on"),
pytest.param(True, SERVICE_TURN_OFF, STATE_ON, STATE_OFF, id="turn_off"),
],
)
async def test_remote_switch_toggle(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
remote_on: bool,
service: str,
initial: str,
final: str,
) -> None:
"""Test toggling the remote switch writes the register."""
await _setup_pv(hass, remote_on=remote_on)
entity_id = entity_registry.async_get_entity_id(
SWITCH_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_remote_switch_on_off"
)
assert entity_id is not None
assert (state := hass.states.get(entity_id)) is not None
assert state.state == initial
await hass.services.async_call(
SWITCH_DOMAIN,
service,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert (state := hass.states.get(entity_id)) is not None
assert state.state == final
async def test_turn_on_modbus_error(
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
"""Test a write failure propagates as-is."""
_, connection = await _setup_pv(hass)
entity_id = entity_registry.async_get_entity_id(
SWITCH_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_remote_switch_on_off"
)
assert entity_id is not None
connection.for_unit(1).fail_write(0x1104, ModbusError("busy"))
with pytest.raises(ModbusError, match="busy"):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)