mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Add Sofar service actions for the paired-register controls (#180892)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user