From 06c07ad41b73881688104062a94f8b885c7c822d Mon Sep 17 00:00:00 2001 From: darkrain-nl <24763370+darkrain-nl@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:52:12 +0200 Subject: [PATCH] Add Sofar service actions for the paired-register controls (#180892) --- homeassistant/components/sofar/__init__.py | 11 + homeassistant/components/sofar/icons.json | 14 + .../components/sofar/quality_scale.yaml | 12 +- homeassistant/components/sofar/services.py | 191 +++++++++++ homeassistant/components/sofar/services.yaml | 102 ++++++ homeassistant/components/sofar/strings.json | 105 ++++++ tests/components/sofar/test_services.py | 301 ++++++++++++++++++ 7 files changed, 727 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/sofar/services.py create mode 100644 homeassistant/components/sofar/services.yaml create mode 100644 tests/components/sofar/test_services.py diff --git a/homeassistant/components/sofar/__init__.py b/homeassistant/components/sofar/__init__.py index dd455c2a3456..1af5d8b5ffca 100644 --- a/homeassistant/components/sofar/__init__.py +++ b/homeassistant/components/sofar/__init__.py @@ -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 diff --git a/homeassistant/components/sofar/icons.json b/homeassistant/components/sofar/icons.json index 65fbca1c7fea..34798ee1ed38 100644 --- a/homeassistant/components/sofar/icons.json +++ b/homeassistant/components/sofar/icons.json @@ -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" + } } } diff --git a/homeassistant/components/sofar/quality_scale.yaml b/homeassistant/components/sofar/quality_scale.yaml index 12e6a96b089c..43cf2d912eaf 100644 --- a/homeassistant/components/sofar/quality_scale.yaml +++ b/homeassistant/components/sofar/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/sofar/services.py b/homeassistant/components/sofar/services.py new file mode 100644 index 000000000000..06282eac6834 --- /dev/null +++ b/homeassistant/components/sofar/services.py @@ -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, + ) diff --git a/homeassistant/components/sofar/services.yaml b/homeassistant/components/sofar/services.yaml new file mode 100644 index 000000000000..9c292dc685be --- /dev/null +++ b/homeassistant/components/sofar/services.yaml @@ -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 diff --git a/homeassistant/components/sofar/strings.json b/homeassistant/components/sofar/strings.json index 5e9c01bda521..632fca515627 100644 --- a/homeassistant/components/sofar/strings.json +++ b/homeassistant/components/sofar/strings.json @@ -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" } } } diff --git a/tests/components/sofar/test_services.py b/tests/components/sofar/test_services.py new file mode 100644 index 000000000000..8ce3c719ef19 --- /dev/null +++ b/tests/components/sofar/test_services.py @@ -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, + )