Add Sofar service actions for the paired-register controls (#180892)

This commit is contained in:
darkrain-nl
2026-09-03 19:52:12 +02:00
committed by GitHub
parent 3c46bcb85a
commit 06c07ad41b
7 changed files with 727 additions and 9 deletions
@@ -16,14 +16,17 @@ from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
restore_state,
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_UNIT_ID, DOMAIN, SCAN_INTERVAL, SETTINGS_SCAN_INTERVAL
from .coordinator import SofarConfigEntry, SofarDataUpdateCoordinator, SofarRuntimeData
from .sensor import SENSOR_DESCRIPTIONS
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
@@ -37,6 +40,8 @@ PLATFORMS: list[Platform] = [
_IDENTITY_ATTEMPTS = 3
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
def _async_remove_stale_waiting_time(hass: HomeAssistant, serial: str) -> None:
"""Drop the removed waiting-time entity so it doesn't linger unavailable."""
@@ -84,6 +89,12 @@ def _async_seed_high_water_marks(
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Sofar integration."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: SofarConfigEntry) -> bool:
"""Set up Sofar Inverter Modbus from a config entry."""
serial = entry.unique_id
+14
View File
@@ -31,5 +31,19 @@
"default": "mdi:battery-heart"
}
}
},
"services": {
"set_active_power_limit": {
"service": "mdi:speedometer-slow"
},
"set_feed_in_limit": {
"service": "mdi:transmission-tower-export"
},
"set_passive_mode_power": {
"service": "mdi:home-battery"
},
"set_passive_mode_timeout": {
"service": "mdi:timer-cog-outline"
}
}
}
@@ -1,17 +1,13 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not register any service actions.
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not register any service actions.
docs-actions: done
docs-conditions:
status: exempt
comment: This integration does not provide any conditions.
@@ -32,9 +28,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not register any service actions.
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
+191
View File
@@ -0,0 +1,191 @@
"""Services for the Sofar integration."""
from collections.abc import Awaitable
from modbus_connection import ModbusError
from sofar_modbus.modern.enums import FeedinLimitationMode, PassiveModeTimeoutAction
import voluptuous as vol
from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_MODE
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import (
async_get_config_entry,
async_register_admin_service,
)
from .const import DOMAIN
from .coordinator import SofarConfigEntry
SERVICE_SET_ACTIVE_POWER_LIMIT = "set_active_power_limit"
SERVICE_SET_FEED_IN_LIMIT = "set_feed_in_limit"
SERVICE_SET_PASSIVE_MODE_POWER = "set_passive_mode_power"
SERVICE_SET_PASSIVE_MODE_TIMEOUT = "set_passive_mode_timeout"
ATTR_ACTION = "action"
ATTR_BATTERY_POWER_MAX = "battery_power_max"
ATTR_BATTERY_POWER_MIN = "battery_power_min"
ATTR_ENABLED = "enabled"
ATTR_GRID_POWER = "grid_power"
ATTR_LIMIT = "limit"
ATTR_MAX_POWER = "max_power"
ATTR_TIMEOUT = "timeout"
_ENTRY_SCHEMA = vol.Schema({vol.Required(ATTR_CONFIG_ENTRY_ID): str})
# The selectors in services.yaml only bound the UI, not a scripted call.
_POWER_RANGE = vol.All(int, vol.Range(min=-100000, max=100000))
SET_FEED_IN_LIMIT_SCHEMA = _ENTRY_SCHEMA.extend(
{
vol.Required(ATTR_MODE): vol.In(
[mode.name.lower() for mode in FeedinLimitationMode]
),
# Kept fractional so the multiple-of-100 check below sees the real
# value; cv.positive_int would truncate 3000.9 into a valid 3000.
vol.Required(ATTR_MAX_POWER): vol.All(
vol.Coerce(float), vol.Range(min=0, max=100000)
),
}
)
SET_ACTIVE_POWER_LIMIT_SCHEMA = _ENTRY_SCHEMA.extend(
{
vol.Required(ATTR_ENABLED): cv.boolean,
vol.Required(ATTR_LIMIT): vol.All(vol.Coerce(float), vol.Range(min=0, max=100)),
}
)
SET_PASSIVE_MODE_TIMEOUT_SCHEMA = _ENTRY_SCHEMA.extend(
{
vol.Required(ATTR_TIMEOUT): vol.All(cv.positive_int, vol.Range(max=65535)),
vol.Required(ATTR_ACTION): vol.In(
[action.name.lower() for action in PassiveModeTimeoutAction]
),
}
)
SET_PASSIVE_MODE_POWER_SCHEMA = _ENTRY_SCHEMA.extend(
{
vol.Required(ATTR_GRID_POWER): _POWER_RANGE,
vol.Required(ATTR_BATTERY_POWER_MIN): _POWER_RANGE,
vol.Required(ATTR_BATTERY_POWER_MAX): _POWER_RANGE,
}
)
def _get_entry(
hass: HomeAssistant, call: ServiceCall, component: str
) -> SofarConfigEntry:
"""Return a loaded entry whose inverter serves the needed registers."""
entry: SofarConfigEntry = async_get_config_entry(
hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
if component not in entry.runtime_data.served_components:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unsupported_action",
translation_placeholders={"title": entry.title},
)
return entry
async def _write(entry: SofarConfigEntry, write: Awaitable[None]) -> None:
"""Translate a failed write, then let the settings sensors catch up."""
try:
await write
except ValueError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_action_value",
) from err
except ModbusError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="write_failed",
) from err
await entry.runtime_data.settings.async_request_refresh()
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register the Sofar services."""
async def _handle_set_feed_in_limit(call: ServiceCall) -> None:
entry = _get_entry(hass, call, "feed_in")
device = entry.runtime_data.readings.device
max_power = call.data[ATTR_MAX_POWER]
if max_power % 100:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="max_power_not_a_multiple_of_100",
)
await _write(
entry,
device.feed_in.async_write_limit(
FeedinLimitationMode[call.data[ATTR_MODE].upper()], int(max_power)
),
)
async def _handle_set_active_power_limit(call: ServiceCall) -> None:
entry = _get_entry(hass, call, "active_power_control")
device = entry.runtime_data.readings.device
await _write(
entry,
device.active_power_control.async_write_active_power_limit(
call.data[ATTR_ENABLED], call.data[ATTR_LIMIT]
),
)
async def _handle_set_passive_mode_timeout(call: ServiceCall) -> None:
entry = _get_entry(hass, call, "passive")
device = entry.runtime_data.readings.device
await _write(
entry,
device.passive.async_write_timeout(
call.data[ATTR_TIMEOUT],
PassiveModeTimeoutAction[call.data[ATTR_ACTION].upper()],
),
)
async def _handle_set_passive_mode_power(call: ServiceCall) -> None:
entry = _get_entry(hass, call, "passive")
device = entry.runtime_data.readings.device
await _write(
entry,
device.passive.async_write_power(
call.data[ATTR_GRID_POWER],
call.data[ATTR_BATTERY_POWER_MIN],
call.data[ATTR_BATTERY_POWER_MAX],
),
)
async_register_admin_service(
hass,
DOMAIN,
SERVICE_SET_FEED_IN_LIMIT,
_handle_set_feed_in_limit,
schema=SET_FEED_IN_LIMIT_SCHEMA,
)
async_register_admin_service(
hass,
DOMAIN,
SERVICE_SET_ACTIVE_POWER_LIMIT,
_handle_set_active_power_limit,
schema=SET_ACTIVE_POWER_LIMIT_SCHEMA,
)
async_register_admin_service(
hass,
DOMAIN,
SERVICE_SET_PASSIVE_MODE_TIMEOUT,
_handle_set_passive_mode_timeout,
schema=SET_PASSIVE_MODE_TIMEOUT_SCHEMA,
)
async_register_admin_service(
hass,
DOMAIN,
SERVICE_SET_PASSIVE_MODE_POWER,
_handle_set_passive_mode_power,
schema=SET_PASSIVE_MODE_POWER_SCHEMA,
)
@@ -0,0 +1,102 @@
set_feed_in_limit:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: sofar
mode:
required: true
selector:
select:
translation_key: feedin_limitation_mode
options:
- disabled
- enabled_feed_in_limitation
- enabled_3_phase_limit
max_power:
required: true
selector:
number:
min: 0
max: 100000
step: 100
unit_of_measurement: W
mode: box
set_active_power_limit:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: sofar
enabled:
required: true
selector:
boolean:
limit:
required: true
selector:
number:
min: 0
max: 100
step: 0.1
unit_of_measurement: "%"
mode: box
set_passive_mode_timeout:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: sofar
timeout:
required: true
selector:
number:
min: 0
max: 65535
unit_of_measurement: s
mode: box
action:
required: true
selector:
select:
translation_key: passive_mode_timeout_action
options:
- force_standby
- return_to_previous_mode
set_passive_mode_power:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: sofar
grid_power:
required: true
selector:
number:
min: -100000
max: 100000
unit_of_measurement: W
mode: box
battery_power_min:
required: true
selector:
number:
min: -100000
max: 100000
unit_of_measurement: W
mode: box
battery_power_max:
required: true
selector:
number:
min: -100000
max: 100000
unit_of_measurement: W
mode: box
+105
View File
@@ -569,6 +569,12 @@
}
},
"exceptions": {
"invalid_action_value": {
"message": "The provided value is not valid for this action."
},
"max_power_not_a_multiple_of_100": {
"message": "Maximum power must be a multiple of 100 W."
},
"modbus_error": {
"message": "{error}"
},
@@ -577,6 +583,105 @@
},
"unrecognized_inverter_model": {
"message": "Unrecognized Sofar inverter model for {title}."
},
"unsupported_action": {
"message": "{title} does not support this action."
},
"write_failed": {
"message": "Failed to write to the inverter."
}
},
"selector": {
"feedin_limitation_mode": {
"options": {
"disabled": "Disabled",
"enabled_3_phase_limit": "Enabled, three-phase limit",
"enabled_feed_in_limitation": "Enabled"
}
},
"passive_mode_timeout_action": {
"options": {
"force_standby": "Force standby",
"return_to_previous_mode": "Return to previous mode"
}
}
},
"services": {
"set_active_power_limit": {
"description": "Caps the inverter's own output as a percentage of its rated power, which is the power rating on the inverter's nameplate, usually also in its model name.",
"fields": {
"config_entry_id": {
"description": "The Sofar inverter to send this to.",
"name": "Inverter"
},
"enabled": {
"description": "Whether the inverter applies the limit. Limit is required either way: disabling still writes it, but the device ignores it until re-enabled.",
"name": "Enabled"
},
"limit": {
"description": "The output ceiling, as a percentage of rated power. On a 4.4 kW inverter, 50% caps it at 2.2 kW.",
"name": "Limit"
}
},
"name": "Set active power limit"
},
"set_feed_in_limit": {
"description": "Limits how much power the inverter exports to the grid.",
"fields": {
"config_entry_id": {
"description": "The Sofar inverter to send this to.",
"name": "Inverter"
},
"max_power": {
"description": "The export ceiling in watts. The inverter only accepts multiples of 100 W.",
"name": "Maximum power"
},
"mode": {
"description": "Whether to limit the total exported power, or per phase.",
"name": "Mode"
}
},
"name": "Set feed-in limit"
},
"set_passive_mode_power": {
"description": "Commands the passive-mode setpoints. Only for inverters with battery storage.",
"fields": {
"battery_power_max": {
"description": "The upper end of the battery power window.",
"name": "Maximum battery power"
},
"battery_power_min": {
"description": "The lower end of the battery power window.",
"name": "Minimum battery power"
},
"config_entry_id": {
"description": "The Sofar inverter to send this to.",
"name": "Inverter"
},
"grid_power": {
"description": "The power to draw from the grid, or to export when negative.",
"name": "Grid power"
}
},
"name": "Set passive mode power"
},
"set_passive_mode_timeout": {
"description": "Sets how long a passive-mode command holds, and what the inverter does when it expires. Only for inverters with battery storage.",
"fields": {
"action": {
"description": "What the inverter does once the timeout expires.",
"name": "Timeout action"
},
"config_entry_id": {
"description": "The Sofar inverter to send this to.",
"name": "Inverter"
},
"timeout": {
"description": "How long a passive-mode command holds, in seconds.",
"name": "Timeout"
}
},
"name": "Set passive mode timeout"
}
}
}
+301
View File
@@ -0,0 +1,301 @@
"""Test the Sofar Inverter Modbus services."""
from unittest.mock import patch
from modbus_connection import ModbusError
from modbus_connection.mock import MockModbusConnection
import pytest
import voluptuous as vol
from homeassistant.components.sofar.const import DOMAIN
from homeassistant.components.sofar.services import (
ATTR_ACTION,
ATTR_BATTERY_POWER_MAX,
ATTR_BATTERY_POWER_MIN,
ATTR_ENABLED,
ATTR_GRID_POWER,
ATTR_LIMIT,
ATTR_MAX_POWER,
ATTR_TIMEOUT,
SERVICE_SET_ACTIVE_POWER_LIMIT,
SERVICE_SET_FEED_IN_LIMIT,
SERVICE_SET_PASSIVE_MODE_POWER,
SERVICE_SET_PASSIVE_MODE_TIMEOUT,
)
from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_MODE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from . import (
MOCK_HYBRID_MODEL,
MOCK_HYBRID_SERIAL,
MOCK_USER_INPUT,
seed_hybrid_inverter,
)
from tests.common import MockConfigEntry
FEED_IN_MODE_REGISTER = 0x1023
FEED_IN_POWER_REGISTER = 0x1024
POWER_CONTROL_REGISTER = 0x1105
ACTIVE_POWER_LIMIT_REGISTER = 0x1106
PASSIVE_TIMEOUT_REGISTER = 0x1184
PASSIVE_TIMEOUT_ACTION_REGISTER = 0x1185
PASSIVE_GRID_POWER_REGISTER = 0x1187
PASSIVE_BATTERY_POWER_MIN_REGISTER = 0x1189
PASSIVE_BATTERY_POWER_MAX_REGISTER = 0x118B
async def _setup_hybrid(
hass: HomeAssistant,
) -> tuple[MockConfigEntry, MockModbusConnection]:
"""Set up a hybrid inverter, which serves the passive-mode registers."""
connection = MockModbusConnection()
seed_hybrid_inverter(connection.for_unit(1))
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_HYBRID_SERIAL,
data=MOCK_USER_INPUT,
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),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
return entry, connection
async def test_set_feed_in_limit(
hass: HomeAssistant,
mock_connection: MockModbusConnection,
init_integration: MockConfigEntry,
) -> None:
"""Test the feed-in limit reaches both registers as one write."""
await hass.services.async_call(
DOMAIN,
SERVICE_SET_FEED_IN_LIMIT,
{
ATTR_CONFIG_ENTRY_ID: init_integration.entry_id,
ATTR_MODE: "enabled_feed_in_limitation",
ATTR_MAX_POWER: 3000,
},
blocking=True,
)
holding = mock_connection.for_unit(1).holding
assert holding[FEED_IN_MODE_REGISTER] == 1
# The register counts in 100 W steps.
assert holding[FEED_IN_POWER_REGISTER] == 30
async def test_set_active_power_limit(
hass: HomeAssistant,
mock_connection: MockModbusConnection,
init_integration: MockConfigEntry,
) -> None:
"""Test the active power limit arms its flag and writes the percentage."""
await hass.services.async_call(
DOMAIN,
SERVICE_SET_ACTIVE_POWER_LIMIT,
{
ATTR_CONFIG_ENTRY_ID: init_integration.entry_id,
ATTR_ENABLED: True,
ATTR_LIMIT: 42.5,
},
blocking=True,
)
holding = mock_connection.for_unit(1).holding
assert holding[POWER_CONTROL_REGISTER] == 1
# The register counts in 0.1% steps.
assert holding[ACTIVE_POWER_LIMIT_REGISTER] == 425
async def test_set_passive_mode_timeout(hass: HomeAssistant) -> None:
"""Test the passive-mode timeout and its action go out together."""
entry, connection = await _setup_hybrid(hass)
await hass.services.async_call(
DOMAIN,
SERVICE_SET_PASSIVE_MODE_TIMEOUT,
{
ATTR_CONFIG_ENTRY_ID: entry.entry_id,
ATTR_TIMEOUT: 300,
ATTR_ACTION: "return_to_previous_mode",
},
blocking=True,
)
holding = connection.for_unit(1).holding
assert holding[PASSIVE_TIMEOUT_REGISTER] == 300
assert holding[PASSIVE_TIMEOUT_ACTION_REGISTER] == 1
async def test_set_passive_mode_power(hass: HomeAssistant) -> None:
"""Test the three passive-mode setpoints go out as one block."""
entry, connection = await _setup_hybrid(hass)
await hass.services.async_call(
DOMAIN,
SERVICE_SET_PASSIVE_MODE_POWER,
{
ATTR_CONFIG_ENTRY_ID: entry.entry_id,
ATTR_GRID_POWER: 1000,
ATTR_BATTERY_POWER_MIN: -2000,
ATTR_BATTERY_POWER_MAX: 2000,
},
blocking=True,
)
holding = connection.for_unit(1).holding
# Each setpoint is a signed 32-bit value over two registers.
assert holding[PASSIVE_GRID_POWER_REGISTER] == 0
assert holding[PASSIVE_GRID_POWER_REGISTER + 1] == 1000
assert holding[PASSIVE_BATTERY_POWER_MIN_REGISTER] == 0xFFFF
assert holding[PASSIVE_BATTERY_POWER_MIN_REGISTER + 1] == 63536
assert holding[PASSIVE_BATTERY_POWER_MAX_REGISTER] == 0
assert holding[PASSIVE_BATTERY_POWER_MAX_REGISTER + 1] == 2000
@pytest.mark.parametrize(
("service", "data"),
[
(
SERVICE_SET_PASSIVE_MODE_TIMEOUT,
{ATTR_TIMEOUT: 60, ATTR_ACTION: "force_standby"},
),
(
SERVICE_SET_PASSIVE_MODE_POWER,
{
ATTR_GRID_POWER: 0,
ATTR_BATTERY_POWER_MIN: 0,
ATTR_BATTERY_POWER_MAX: 0,
},
),
],
)
async def test_action_rejected_when_unsupported(
hass: HomeAssistant,
init_integration: MockConfigEntry,
service: str,
data: dict[str, int | str],
) -> None:
"""Test a passive-mode action is refused by a PV-only inverter."""
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
DOMAIN,
service,
{ATTR_CONFIG_ENTRY_ID: init_integration.entry_id, **data},
blocking=True,
)
@pytest.mark.parametrize(
"max_power",
[pytest.param(3050, id="int"), pytest.param(3000.9, id="fractional")],
)
async def test_max_power_not_a_multiple_of_100_is_a_service_error(
hass: HomeAssistant, init_integration: MockConfigEntry, max_power: float
) -> None:
"""Test a non-multiple-of-100 max_power is rejected before writing."""
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
DOMAIN,
SERVICE_SET_FEED_IN_LIMIT,
{
ATTR_CONFIG_ENTRY_ID: init_integration.entry_id,
ATTR_MODE: "disabled",
ATTR_MAX_POWER: max_power,
},
blocking=True,
)
assert exc_info.value.translation_key == "max_power_not_a_multiple_of_100"
async def test_unexpected_value_error_is_a_service_error(
hass: HomeAssistant, init_integration: MockConfigEntry
) -> None:
"""Test a library ValueError the handlers don't preempt still translates."""
device = init_integration.runtime_data.readings.device
with (
patch.object(
device.feed_in, "async_write_limit", side_effect=ValueError("boom")
),
pytest.raises(ServiceValidationError) as exc_info,
):
await hass.services.async_call(
DOMAIN,
SERVICE_SET_FEED_IN_LIMIT,
{
ATTR_CONFIG_ENTRY_ID: init_integration.entry_id,
ATTR_MODE: "disabled",
ATTR_MAX_POWER: 3000,
},
blocking=True,
)
assert exc_info.value.translation_key == "invalid_action_value"
async def test_write_failure_is_a_home_assistant_error(
hass: HomeAssistant,
mock_connection: MockModbusConnection,
init_integration: MockConfigEntry,
) -> None:
"""Test a ModbusError surfaces as a HomeAssistantError."""
mock_connection.for_unit(1).fail_write(FEED_IN_MODE_REGISTER, ModbusError("busy"))
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
DOMAIN,
SERVICE_SET_FEED_IN_LIMIT,
{
ATTR_CONFIG_ENTRY_ID: init_integration.entry_id,
ATTR_MODE: "disabled",
ATTR_MAX_POWER: 0,
},
blocking=True,
)
@pytest.mark.parametrize(
("service", "data"),
[
pytest.param(
SERVICE_SET_FEED_IN_LIMIT,
{ATTR_MODE: "disabled", ATTR_MAX_POWER: 10_000_000},
id="feed_in_max_power",
),
pytest.param(
SERVICE_SET_PASSIVE_MODE_TIMEOUT,
{ATTR_TIMEOUT: 70000, ATTR_ACTION: "force_standby"},
id="passive_timeout",
),
pytest.param(
SERVICE_SET_PASSIVE_MODE_POWER,
{
ATTR_GRID_POWER: 200_000,
ATTR_BATTERY_POWER_MIN: 0,
ATTR_BATTERY_POWER_MAX: 0,
},
id="passive_grid_power",
),
],
)
async def test_value_past_the_selector_bounds_is_refused(
hass: HomeAssistant,
service: str,
data: dict[str, int | str],
) -> None:
"""Test the schema bounds a scripted call, which skips the selectors."""
entry, _ = await _setup_hybrid(hass)
with pytest.raises(vol.Invalid):
await hass.services.async_call(
DOMAIN,
service,
{ATTR_CONFIG_ENTRY_ID: entry.entry_id, **data},
blocking=True,
)