From 1b39063e9e37395d278332bd63ad6cf77d084d21 Mon Sep 17 00:00:00 2001 From: Thijs W Date: Tue, 25 Jul 2023 07:38:02 +0000 Subject: [PATCH] Fix services when integration is setup multiple times Fixes #429 --- __init__.py | 10 +- const.py | 1 + services.py | 689 +++++++++++++++++++++++++++++--------------------- services.yaml | 6 +- 4 files changed, 404 insertions(+), 302 deletions(-) diff --git a/__init__.py b/__init__.py index c228946..7c210f8 100644 --- a/__init__.py +++ b/__init__.py @@ -29,6 +29,7 @@ CONF_ENABLE_PARAMETER_CONFIGURATION, CONF_SLAVE_IDS, CONFIGURATION_UPDATE_INTERVAL, + DATA_BRIDGES_WITH_DEVICEINFOS, DATA_CONFIGURATION_UPDATE_COORDINATORS, DATA_OPTIMIZER_UPDATE_COORDINATORS, DATA_UPDATE_COORDINATORS, @@ -37,7 +38,7 @@ SERVICES, UPDATE_INTERVAL, ) -from .services import async_setup_services +from .services import async_setup_services, async_cleanup_services _LOGGER = logging.getLogger(__name__) @@ -180,6 +181,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: optimizers_device_infos = {} hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + DATA_BRIDGES_WITH_DEVICEINFOS: bridges_with_device_infos, DATA_UPDATE_COORDINATORS: update_coordinators, DATA_CONFIGURATION_UPDATE_COORDINATORS: configuration_update_coordinators, DATA_OPTIMIZER_UPDATE_COORDINATORS: optimizer_update_coordinators, @@ -199,7 +201,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise err await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await async_setup_services(hass, entry, bridges_with_device_infos) + await async_setup_services(hass, entry) return True @@ -213,9 +215,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for update_coordinator in update_coordinators: await update_coordinator.bridge.stop() - for service in SERVICES: - if hass.services.has_service(DOMAIN, service): - hass.services.async_remove(DOMAIN, service) + await async_cleanup_services(hass) hass.data[DOMAIN].pop(entry.entry_id) diff --git a/const.py b/const.py index aaa990c..4ae3c64 100644 --- a/const.py +++ b/const.py @@ -12,6 +12,7 @@ CONF_ENABLE_PARAMETER_CONFIGURATION = "enable_parameter_configuration" +DATA_BRIDGES_WITH_DEVICEINFOS = "bridges" DATA_UPDATE_COORDINATORS = "update_coordinators" DATA_CONFIGURATION_UPDATE_COORDINATORS = "configuration_update_coordinators" DATA_OPTIMIZER_UPDATE_COORDINATORS = "optimizer_update_coordinators" diff --git a/services.py b/services.py index 37c3662..8ce4c42 100644 --- a/services.py +++ b/services.py @@ -1,6 +1,7 @@ """The Huawei Solar services.""" from __future__ import annotations +from functools import partial import logging import re from typing import TYPE_CHECKING, Any @@ -8,7 +9,7 @@ import homeassistant.helpers.config_validation as cv import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import async_get_hass, callback, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr from huawei_solar import HuaweiSolarBridge @@ -24,6 +25,7 @@ from .const import ( CONF_ENABLE_PARAMETER_CONFIGURATION, + DATA_BRIDGES_WITH_DEVICEINFOS, DOMAIN, SERVICE_FORCIBLE_CHARGE, SERVICE_FORCIBLE_CHARGE_SOC, @@ -43,6 +45,23 @@ if TYPE_CHECKING: from . import HuaweiInverterBridgeDeviceInfos + +ALL_SERVICES = [ + SERVICE_FORCIBLE_CHARGE, + SERVICE_FORCIBLE_CHARGE_SOC, + SERVICE_FORCIBLE_DISCHARGE, + SERVICE_FORCIBLE_DISCHARGE_SOC, + SERVICE_RESET_MAXIMUM_FEED_GRID_POWER, + SERVICE_SET_CAPACITY_CONTROL_PERIODS, + SERVICE_SET_DI_ACTIVE_POWER_SCHEDULING, + SERVICE_SET_FIXED_CHARGE_PERIODS, + SERVICE_SET_MAXIMUM_FEED_GRID_POWER, + SERVICE_SET_MAXIMUM_FEED_GRID_POWER_PERCENT, + SERVICE_SET_TOU_PERIODS, + SERVICE_SET_ZERO_POWER_GRID_CONNECTION, + SERVICE_STOP_FORCIBLE_CHARGE, +] + DATA_DEVICE_ID = "device_id" DATA_POWER = "power" DATA_POWER_PERCENTAGE = "power_percentage" @@ -50,13 +69,35 @@ DATA_TARGET_SOC = "target_soc" DATA_PERIODS = "periods" -DEVICE_SCHEMA = vol.Schema( - { - vol.Required(DATA_DEVICE_ID): cv.string, - } +def validate_battery_device_id(device_id: str) -> str: + """Validate whether the device_id refers to a 'Connected Energy Storage' device""" + hass = async_get_hass() + try: + _get_battery_bridge(hass, device_id) + return device_id + except HuaweiSolarServiceException as err: + raise vol.Invalid(str(err)) + +def validate_inverter_device_id(device_id: str) -> str: + """Validate whether the device_id refers to an 'Inverter' device""" + hass = async_get_hass() + try: + _get_inverter_bridge(hass, device_id) + return device_id + except HuaweiSolarServiceException as err: + raise vol.Invalid(str(err)) + + +INVERTER_DEVICE_SCHEMA = vol.Schema( + {DATA_DEVICE_ID: vol.All(cv.string, validate_inverter_device_id)} +) + +BATTERY_DEVICE_SCHEMA = vol.Schema( + {DATA_DEVICE_ID: vol.All(cv.string, validate_battery_device_id)} ) -FORCIBLE_CHARGE_BASE_SCHEMA = DEVICE_SCHEMA.extend( + +FORCIBLE_CHARGE_BASE_SCHEMA = BATTERY_DEVICE_SCHEMA.extend( { vol.Required(DATA_POWER): cv.positive_int, } @@ -74,13 +115,13 @@ } ) -MAXIMUM_FEED_GRID_POWER_SCHEMA = DEVICE_SCHEMA.extend( +MAXIMUM_FEED_GRID_POWER_SCHEMA = INVERTER_DEVICE_SCHEMA.extend( { vol.Required(DATA_POWER): vol.All(vol.Coerce(int), vol.Range(min=-1000)), } ) -MAXIMUM_FEED_GRID_POWER_PERCENTAGE_SCHEMA = DEVICE_SCHEMA.extend( +MAXIMUM_FEED_GRID_POWER_PERCENTAGE_SCHEMA = INVERTER_DEVICE_SCHEMA.extend( { vol.Required(DATA_POWER_PERCENTAGE): vol.All( vol.Coerce(int), vol.Range(min=0, max=100) @@ -91,7 +132,7 @@ HUAWEI_LUNA2000_TOU_PATTERN = r"([0-2]\d:\d\d-[0-2]\d:\d\d/[1-7]{1,7}/[+-]\n?){0,14}" LG_RESU_TOU_PATTERN = r"([0-2]\d:\d\d-[0-2]\d:\d\d/\d+\.?\d*\n?){0,14}" -TOU_PERIODS_SCHEMA = DEVICE_SCHEMA.extend( +TOU_PERIODS_SCHEMA = BATTERY_DEVICE_SCHEMA.extend( { vol.Required(DATA_PERIODS): vol.All( cv.string, @@ -104,7 +145,7 @@ r"([0-2]\d:\d\d-[0-2]\d:\d\d/[1-7]{1,7}/\d+W\n?){0,14}" ) -CAPACITY_CONTROL_PERIODS_SCHEMA = DEVICE_SCHEMA.extend( +CAPACITY_CONTROL_PERIODS_SCHEMA = BATTERY_DEVICE_SCHEMA.extend( { vol.Required(DATA_PERIODS): vol.All( cv.string, @@ -115,7 +156,7 @@ FIXED_CHARGE_PERIODS_PATTERN = r"([0-2]\d:\d\d-[0-2]\d:\d\d/\d+W\n?){0,10}" -FIXED_CHARGE_PERIODS_SCHEMA = DEVICE_SCHEMA.extend( +FIXED_CHARGE_PERIODS_SCHEMA = BATTERY_DEVICE_SCHEMA.extend( { vol.Required(DATA_PERIODS): vol.All( cv.string, @@ -149,22 +190,16 @@ def _parse_time(value: str): return minutes_since_midnight -async def async_setup_services( # noqa: C901 - hass: HomeAssistant, - entry: ConfigEntry, - bridges_with_device_infos: list[ - tuple[HuaweiSolarBridge, HuaweiInverterBridgeDeviceInfos] - ], -): - """Huawei Solar Services Setup.""" +@callback +def _get_battery_bridge(hass: HomeAssistant, device_id: str): + dev_reg = dr.async_get(hass) + device_entry = dev_reg.async_get(device_id) - def get_battery_bridge(service_call: ServiceCall): - dev_reg = dr.async_get(hass) - device_entry = dev_reg.async_get(service_call.data[DATA_DEVICE_ID]) - - if not device_entry: - raise HuaweiSolarServiceException("No such device found") + if not device_entry: + raise HuaweiSolarServiceException("No such device found") + for entry_data in hass.data[DOMAIN].values(): + bridges_with_device_infos = entry_data[DATA_BRIDGES_WITH_DEVICEINFOS] for bridge, device_infos in bridges_with_device_infos: if device_infos["connected_energy_storage"] is None: continue @@ -175,399 +210,458 @@ def get_battery_bridge(service_call: ServiceCall): for device_identifier in device_entry.identifiers: if ces_identifier == device_identifier: return bridge + _LOGGER.error("The provided device is not a Connected Energy Storage") + raise HuaweiSolarServiceException("Not a valid 'Connected Energy Storage' device") - _LOGGER.error("The provided device is not a Connected Energy Storage") - raise HuaweiSolarServiceException( - "Not a valid 'Connected Energy Storage' device" - ) - def get_inverter_bridge(service_call: ServiceCall): - dev_reg = dr.async_get(hass) - device_entry = dev_reg.async_get(service_call.data[DATA_DEVICE_ID]) +@callback +def get_battery_bridge( + hass: HomeAssistant, service_call: ServiceCall +) -> HuaweiSolarBridge: + """Returns the HuaweiSolarBridge associated with the battery device_id in the service call""" + device_id = service_call.data[DATA_DEVICE_ID] + return _get_battery_bridge(hass, device_id) - if not device_entry: - raise HuaweiSolarServiceException("No such device found") +@callback +def _get_inverter_bridge(hass: HomeAssistant, device_id: str): + dev_reg = dr.async_get(hass) + device_entry = dev_reg.async_get(device_id) + + if not device_entry: + raise HuaweiSolarServiceException("No such device found") + for entry_data in hass.data[DOMAIN].values(): + bridges_with_device_infos = entry_data[DATA_BRIDGES_WITH_DEVICEINFOS] for bridge, device_infos in bridges_with_device_infos: for identifier in device_infos["inverter"]["identifiers"]: for device_identifier in device_entry.identifiers: if identifier == device_identifier: return bridge - _LOGGER.error("The provided device is not an inverter") - raise HuaweiSolarServiceException("Not a valid 'Inverter' device") + _LOGGER.error("The provided device is not an inverter") + raise HuaweiSolarServiceException("Not a valid 'Inverter' device") - async def _validate_power_value( - power: Any, bridge: HuaweiSolarBridge, max_value_key - ): - # these are already checked by voluptuous: - assert isinstance(power, int) - assert power >= 0 - maximum_active_power = ( - await bridge.client.get(max_value_key, bridge.slave_id) - ).value +@callback +def get_inverter_bridge(hass: HomeAssistant, service_call: ServiceCall): + """Returns the HuaweiSolarBridge associated with the inverter device_id in the service call""" + device_id = service_call.data[DATA_DEVICE_ID] + return _get_inverter_bridge(hass, device_id) - if not (0 <= power <= maximum_active_power): - raise ValueError(f"Power must be between 0 and {maximum_active_power}") - return power +async def _validate_power_value(power: Any, bridge: HuaweiSolarBridge, max_value_key): + # these are already checked by voluptuous: + assert isinstance(power, int) + assert power >= 0 - async def forcible_charge(service_call: ServiceCall) -> None: - """Start a forcible charge on the battery.""" - bridge = get_battery_bridge(service_call) - power = await _validate_power_value( - service_call.data[DATA_POWER], bridge, rn.STORAGE_MAXIMUM_CHARGE_POWER - ) + maximum_active_power = ( + await bridge.client.get(max_value_key, bridge.slave_id) + ).value - duration = service_call.data[DATA_DURATION] - if duration > 1440: - raise ValueError("Maximum duration is 1440 minutes") + if not (0 <= power <= maximum_active_power): + raise ValueError(f"Power must be between 0 and {maximum_active_power}") - await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_POWER, power) - await bridge.set( - rn.STORAGE_FORCED_CHARGING_AND_DISCHARGING_PERIOD, - duration, - ) - await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_SETTING_MODE, 0) - await bridge.set( - rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_WRITE, - rv.StorageForcibleChargeDischarge.CHARGE, - ) + return power - async def forcible_discharge(service_call: ServiceCall) -> None: - """Start a forcible charge on the battery.""" - bridge = get_battery_bridge(service_call) - power = await _validate_power_value( - service_call.data[DATA_POWER], bridge, rn.STORAGE_MAXIMUM_DISCHARGE_POWER - ) - duration = service_call.data[DATA_DURATION] - if duration > 1440: - raise ValueError("Maximum duration is 1440 minutes") +async def forcible_charge(hass: HomeAssistant, service_call: ServiceCall) -> None: + """Start a forcible charge on the battery.""" + bridge = get_battery_bridge(hass, service_call) + power = await _validate_power_value( + service_call.data[DATA_POWER], bridge, rn.STORAGE_MAXIMUM_CHARGE_POWER + ) - await bridge.set(rn.STORAGE_FORCIBLE_DISCHARGE_POWER, power) - await bridge.set( - rn.STORAGE_FORCED_CHARGING_AND_DISCHARGING_PERIOD, - duration, - ) - await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_SETTING_MODE, 0) - await bridge.set( - rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_WRITE, - rv.StorageForcibleChargeDischarge.DISCHARGE, - ) + duration = service_call.data[DATA_DURATION] + if duration > 1440: + raise ValueError("Maximum duration is 1440 minutes") - async def forcible_charge_soc(service_call: ServiceCall) -> None: - """Start a forcible charge on the battery until the target SOC is hit.""" + await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_POWER, power) + await bridge.set( + rn.STORAGE_FORCED_CHARGING_AND_DISCHARGING_PERIOD, + duration, + ) + await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_SETTING_MODE, 0) + await bridge.set( + rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_WRITE, + rv.StorageForcibleChargeDischarge.CHARGE, + ) - bridge = get_battery_bridge(service_call) - target_soc = service_call.data[DATA_TARGET_SOC] - power = await _validate_power_value( - service_call.data[DATA_POWER], bridge, rn.STORAGE_MAXIMUM_CHARGE_POWER - ) - await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_POWER, power) - await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_SOC, target_soc) - await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_SETTING_MODE, 1) - await bridge.set( - rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_WRITE, - rv.StorageForcibleChargeDischarge.CHARGE, - ) +async def forcible_discharge(hass: HomeAssistant, service_call: ServiceCall) -> None: + """Start a forcible charge on the battery.""" + bridge = get_battery_bridge(hass, service_call) + power = await _validate_power_value( + service_call.data[DATA_POWER], bridge, rn.STORAGE_MAXIMUM_DISCHARGE_POWER + ) - async def forcible_discharge_soc(service_call: ServiceCall) -> None: - """Start a forcible discharge on the battery until the target SOC is hit.""" + duration = service_call.data[DATA_DURATION] + if duration > 1440: + raise ValueError("Maximum duration is 1440 minutes") - bridge = get_battery_bridge(service_call) - target_soc = service_call.data[DATA_TARGET_SOC] - power = await _validate_power_value( - service_call.data[DATA_POWER], bridge, rn.STORAGE_MAXIMUM_DISCHARGE_POWER - ) + await bridge.set(rn.STORAGE_FORCIBLE_DISCHARGE_POWER, power) + await bridge.set( + rn.STORAGE_FORCED_CHARGING_AND_DISCHARGING_PERIOD, + duration, + ) + await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_SETTING_MODE, 0) + await bridge.set( + rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_WRITE, + rv.StorageForcibleChargeDischarge.DISCHARGE, + ) - await bridge.set(rn.STORAGE_FORCIBLE_DISCHARGE_POWER, power) - await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_SOC, target_soc) - await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_SETTING_MODE, 1) - await bridge.set( - rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_WRITE, - rv.StorageForcibleChargeDischarge.DISCHARGE, - ) - async def stop_forcible_charge(service_call: ServiceCall) -> None: - """Stop a forcible charge or discharge.""" +async def forcible_charge_soc(hass: HomeAssistant, service_call: ServiceCall) -> None: + """Start a forcible charge on the battery until the target SOC is hit.""" - bridge = get_battery_bridge(service_call) - await bridge.set( - rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_WRITE, - rv.StorageForcibleChargeDischarge.STOP, - ) - await bridge.set(rn.STORAGE_FORCIBLE_DISCHARGE_POWER, 0) - await bridge.set( - rn.STORAGE_FORCED_CHARGING_AND_DISCHARGING_PERIOD, - 0, - ) - await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_SETTING_MODE, 0) + bridge = get_battery_bridge(hass, service_call) + target_soc = service_call.data[DATA_TARGET_SOC] + power = await _validate_power_value( + service_call.data[DATA_POWER], bridge, rn.STORAGE_MAXIMUM_CHARGE_POWER + ) - async def reset_maximum_feed_grid_power(service_call: ServiceCall) -> None: - """Set Active Power Control to 'Unlimited'.""" + await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_POWER, power) + await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_SOC, target_soc) + await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_SETTING_MODE, 1) + await bridge.set( + rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_WRITE, + rv.StorageForcibleChargeDischarge.CHARGE, + ) - bridge = get_inverter_bridge(service_call) - await bridge.set( - rn.ACTIVE_POWER_CONTROL_MODE, - rv.ActivePowerControlMode.UNLIMITED, - ) - await bridge.set(rn.MAXIMUM_FEED_GRID_POWER_WATT, 0) - await bridge.set( - rn.MAXIMUM_FEED_GRID_POWER_PERCENT, - 0, - ) - async def set_di_active_power_scheduling(service_call: ServiceCall) -> None: - """Set Active Power Control to 'DI active scheduling'.""" +async def forcible_discharge_soc( + hass: HomeAssistant, service_call: ServiceCall +) -> None: + """Start a forcible discharge on the battery until the target SOC is hit.""" - bridge = get_inverter_bridge(service_call) - await bridge.set( - rn.ACTIVE_POWER_CONTROL_MODE, - rv.ActivePowerControlMode.DI_ACTIVE_SCHEDULING, - ) - await bridge.set(rn.MAXIMUM_FEED_GRID_POWER_WATT, 0) - await bridge.set( - rn.MAXIMUM_FEED_GRID_POWER_PERCENT, - 0, - ) + bridge = get_battery_bridge(hass, service_call) + target_soc = service_call.data[DATA_TARGET_SOC] + power = await _validate_power_value( + service_call.data[DATA_POWER], bridge, rn.STORAGE_MAXIMUM_DISCHARGE_POWER + ) - async def set_zero_power_grid_connection(service_call: ServiceCall) -> None: - """Set Active Power Control to 'Zero-Power Grid Connection'.""" + await bridge.set(rn.STORAGE_FORCIBLE_DISCHARGE_POWER, power) + await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_SOC, target_soc) + await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_SETTING_MODE, 1) + await bridge.set( + rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_WRITE, + rv.StorageForcibleChargeDischarge.DISCHARGE, + ) - bridge = get_inverter_bridge(service_call) - await bridge.set( - rn.ACTIVE_POWER_CONTROL_MODE, - rv.ActivePowerControlMode.ZERO_POWER_GRID_CONNECTION, - ) - await bridge.set(rn.MAXIMUM_FEED_GRID_POWER_WATT, 0) - await bridge.set( - rn.MAXIMUM_FEED_GRID_POWER_PERCENT, - 0, - ) - async def set_maximum_feed_grid_power(service_call: ServiceCall) -> None: - """Set Active Power Control to 'Power-limited grid connection' with the given wattage.""" +async def stop_forcible_charge(hass: HomeAssistant, service_call: ServiceCall) -> None: + """Stop a forcible charge or discharge.""" - bridge = get_inverter_bridge(service_call) - power = await _validate_power_value( - service_call.data[DATA_POWER], bridge, rn.P_MAX - ) + bridge = get_battery_bridge(hass, service_call) + await bridge.set( + rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_WRITE, + rv.StorageForcibleChargeDischarge.STOP, + ) + await bridge.set(rn.STORAGE_FORCIBLE_DISCHARGE_POWER, 0) + await bridge.set( + rn.STORAGE_FORCED_CHARGING_AND_DISCHARGING_PERIOD, + 0, + ) + await bridge.set(rn.STORAGE_FORCIBLE_CHARGE_DISCHARGE_SETTING_MODE, 0) - await bridge.set(rn.MAXIMUM_FEED_GRID_POWER_WATT, power) - await bridge.set( - rn.ACTIVE_POWER_CONTROL_MODE, - rv.ActivePowerControlMode.POWER_LIMITED_GRID_CONNECTION_WATT, - ) - async def set_maximum_feed_grid_power_percentage(service_call: ServiceCall) -> None: - """Set Active Power Control to 'Power-limited grid connection' with the given percentage.""" +async def reset_maximum_feed_grid_power( + hass: HomeAssistant, service_call: ServiceCall +) -> None: + """Set Active Power Control to 'Unlimited'.""" - bridge = get_inverter_bridge(service_call) - power_percentage = service_call.data[DATA_POWER_PERCENTAGE] + bridge = get_inverter_bridge(hass, service_call) + await bridge.set( + rn.ACTIVE_POWER_CONTROL_MODE, + rv.ActivePowerControlMode.UNLIMITED, + ) + await bridge.set(rn.MAXIMUM_FEED_GRID_POWER_WATT, 0) + await bridge.set( + rn.MAXIMUM_FEED_GRID_POWER_PERCENT, + 0, + ) - await bridge.set(rn.MAXIMUM_FEED_GRID_POWER_PERCENT, power_percentage) - await bridge.set( - rn.ACTIVE_POWER_CONTROL_MODE, - rv.ActivePowerControlMode.POWER_LIMITED_GRID_CONNECTION_PERCENT, - ) - async def set_tou_periods(service_call: ServiceCall) -> None: - def _parse_huawei_luna2000_periods( - text, - ) -> list[HUAWEI_LUNA2000_TimeOfUsePeriod]: - result = [] - for line in text.split("\n"): - start_end_time_str, days_effective_str, charge_flag_str = line.split( - "/" - ) - start_time_str, end_time_str = start_end_time_str.split("-") - - result.append( - HUAWEI_LUNA2000_TimeOfUsePeriod( - _parse_time(start_time_str), - _parse_time(end_time_str), - ChargeFlag.CHARGE - if charge_flag_str == "+" - else ChargeFlag.DISCHARGE, - _parse_days_effective(days_effective_str), - ) - ) +async def set_di_active_power_scheduling( + hass: HomeAssistant, service_call: ServiceCall +) -> None: + """Set Active Power Control to 'DI active scheduling'.""" - return result + bridge = get_inverter_bridge(hass, service_call) + await bridge.set( + rn.ACTIVE_POWER_CONTROL_MODE, + rv.ActivePowerControlMode.DI_ACTIVE_SCHEDULING, + ) + await bridge.set(rn.MAXIMUM_FEED_GRID_POWER_WATT, 0) + await bridge.set( + rn.MAXIMUM_FEED_GRID_POWER_PERCENT, + 0, + ) - def _parse_lg_resu_periods(text) -> list[LG_RESU_TimeOfUsePeriod]: - result = [] - for line in text.split("\n"): - start_end_time_str, energy_price = line.split("/") - start_time_str, end_time_str = start_end_time_str.split("-") - result.append( - LG_RESU_TimeOfUsePeriod( - _parse_time(start_time_str), - _parse_time(end_time_str), - float(energy_price), - ) - ) +async def set_zero_power_grid_connection( + hass: HomeAssistant, service_call: ServiceCall +) -> None: + """Set Active Power Control to 'Zero-Power Grid Connection'.""" - return result + bridge = get_inverter_bridge(hass, service_call) + await bridge.set( + rn.ACTIVE_POWER_CONTROL_MODE, + rv.ActivePowerControlMode.ZERO_POWER_GRID_CONNECTION, + ) + await bridge.set(rn.MAXIMUM_FEED_GRID_POWER_WATT, 0) + await bridge.set( + rn.MAXIMUM_FEED_GRID_POWER_PERCENT, + 0, + ) - bridge = get_battery_bridge(service_call) - if bridge.battery_type == rv.StorageProductModel.HUAWEI_LUNA2000: - if not re.fullmatch( - HUAWEI_LUNA2000_TOU_PATTERN, service_call.data[DATA_PERIODS] - ): - raise ValueError("Invalid periods") - await bridge.set( - rn.STORAGE_TIME_OF_USE_CHARGING_AND_DISCHARGING_PERIODS, - _parse_huawei_luna2000_periods(service_call.data[DATA_PERIODS]), - ) - elif bridge.battery_type == rv.StorageProductModel.LG_RESU: - if not re.fullmatch(LG_RESU_TOU_PATTERN, service_call.data[DATA_PERIODS]): - raise ValueError("Invalid periods") - await bridge.set( - rn.STORAGE_TIME_OF_USE_CHARGING_AND_DISCHARGING_PERIODS, - _parse_lg_resu_periods(service_call.data[DATA_PERIODS]), +async def set_maximum_feed_grid_power( + hass: HomeAssistant, service_call: ServiceCall +) -> None: + """Set Active Power Control to 'Power-limited grid connection' with the given wattage.""" + + bridge = get_inverter_bridge(hass, service_call) + power = await _validate_power_value(service_call.data[DATA_POWER], bridge, rn.P_MAX) + + await bridge.set(rn.MAXIMUM_FEED_GRID_POWER_WATT, power) + await bridge.set( + rn.ACTIVE_POWER_CONTROL_MODE, + rv.ActivePowerControlMode.POWER_LIMITED_GRID_CONNECTION_WATT, + ) + + +async def set_maximum_feed_grid_power_percentage( + hass: HomeAssistant, service_call: ServiceCall +) -> None: + """Set Active Power Control to 'Power-limited grid connection' with the given percentage.""" + + bridge = get_inverter_bridge(hass, service_call) + power_percentage = service_call.data[DATA_POWER_PERCENTAGE] + + await bridge.set(rn.MAXIMUM_FEED_GRID_POWER_PERCENT, power_percentage) + await bridge.set( + rn.ACTIVE_POWER_CONTROL_MODE, + rv.ActivePowerControlMode.POWER_LIMITED_GRID_CONNECTION_PERCENT, + ) + + +async def set_tou_periods(hass: HomeAssistant, service_call: ServiceCall) -> None: + """Set the TOU periods of the battery""" + + def _parse_huawei_luna2000_periods( + text, + ) -> list[HUAWEI_LUNA2000_TimeOfUsePeriod]: + result = [] + for line in text.split("\n"): + start_end_time_str, days_effective_str, charge_flag_str = line.split("/") + start_time_str, end_time_str = start_end_time_str.split("-") + + result.append( + HUAWEI_LUNA2000_TimeOfUsePeriod( + _parse_time(start_time_str), + _parse_time(end_time_str), + ChargeFlag.CHARGE + if charge_flag_str == "+" + else ChargeFlag.DISCHARGE, + _parse_days_effective(days_effective_str), + ) ) - async def set_capacity_control_periods(service_call: ServiceCall) -> None: - def _parse_periods(text) -> list[PeakSettingPeriod]: - result = [] - for line in text.split("\n"): - start_end_time_str, days_str, wattage_str = line.split("/") - start_time_str, end_time_str = start_end_time_str.split("-") - - result.append( - PeakSettingPeriod( - _parse_time(start_time_str), - _parse_time(end_time_str), - int(wattage_str[:-1]), - _parse_days_effective(days_str), - ) + return result + + def _parse_lg_resu_periods(text) -> list[LG_RESU_TimeOfUsePeriod]: + result = [] + for line in text.split("\n"): + start_end_time_str, energy_price = line.split("/") + start_time_str, end_time_str = start_end_time_str.split("-") + + result.append( + LG_RESU_TimeOfUsePeriod( + _parse_time(start_time_str), + _parse_time(end_time_str), + float(energy_price), ) - return result + ) - bridge = get_battery_bridge(service_call) + return result + bridge = get_battery_bridge(hass, service_call) + + if bridge.battery_type == rv.StorageProductModel.HUAWEI_LUNA2000: if not re.fullmatch( - CAPACITY_CONTROL_PERIODS_PATTERN, service_call.data[DATA_PERIODS] + HUAWEI_LUNA2000_TOU_PATTERN, service_call.data[DATA_PERIODS] ): raise ValueError("Invalid periods") - await bridge.set( - rn.STORAGE_CAPACITY_CONTROL_PERIODS, - _parse_periods(service_call.data[DATA_PERIODS]), + rn.STORAGE_TIME_OF_USE_CHARGING_AND_DISCHARGING_PERIODS, + _parse_huawei_luna2000_periods(service_call.data[DATA_PERIODS]), + ) + elif bridge.battery_type == rv.StorageProductModel.LG_RESU: + if not re.fullmatch(LG_RESU_TOU_PATTERN, service_call.data[DATA_PERIODS]): + raise ValueError("Invalid periods") + await bridge.set( + rn.STORAGE_TIME_OF_USE_CHARGING_AND_DISCHARGING_PERIODS, + _parse_lg_resu_periods(service_call.data[DATA_PERIODS]), ) - async def set_fixed_charge_periods(service_call: ServiceCall) -> None: - def _parse_periods(text) -> list[ChargeDischargePeriod]: - result = [] - for line in text.split("\n"): - start_end_time_str, wattage_str = line.split("/") - start_time_str, end_time_str = start_end_time_str.split("-") - - result.append( - ChargeDischargePeriod( - _parse_time(start_time_str), - _parse_time(end_time_str), - int(wattage_str[:-1]), - ) + +async def set_capacity_control_periods( + hass: HomeAssistant, service_call: ServiceCall +) -> None: + """Set the Capacity Control Periods of the battery""" + + def _parse_periods(text) -> list[PeakSettingPeriod]: + result = [] + for line in text.split("\n"): + start_end_time_str, days_str, wattage_str = line.split("/") + start_time_str, end_time_str = start_end_time_str.split("-") + + result.append( + PeakSettingPeriod( + _parse_time(start_time_str), + _parse_time(end_time_str), + int(wattage_str[:-1]), + _parse_days_effective(days_str), ) - return result + ) + return result - bridge = get_battery_bridge(service_call) + bridge = get_battery_bridge(hass, service_call) - if not re.fullmatch( - FIXED_CHARGE_PERIODS_PATTERN, service_call.data[DATA_PERIODS] - ): - raise ValueError("Invalid periods") + if not re.fullmatch( + CAPACITY_CONTROL_PERIODS_PATTERN, service_call.data[DATA_PERIODS] + ): + raise ValueError("Invalid periods") - await bridge.set( - rn.STORAGE_FIXED_CHARGING_AND_DISCHARGING_PERIODS, - _parse_periods(service_call.data[DATA_PERIODS]), - ) + await bridge.set( + rn.STORAGE_CAPACITY_CONTROL_PERIODS, + _parse_periods(service_call.data[DATA_PERIODS]), + ) + + +async def set_fixed_charge_periods( + hass: HomeAssistant, service_call: ServiceCall +) -> None: + """Set the fixed charging periods of the battery""" + + def _parse_periods(text) -> list[ChargeDischargePeriod]: + result = [] + for line in text.split("\n"): + start_end_time_str, wattage_str = line.split("/") + start_time_str, end_time_str = start_end_time_str.split("-") + + result.append( + ChargeDischargePeriod( + _parse_time(start_time_str), + _parse_time(end_time_str), + int(wattage_str[:-1]), + ) + ) + return result + bridge = get_battery_bridge(hass, service_call) + + if not re.fullmatch(FIXED_CHARGE_PERIODS_PATTERN, service_call.data[DATA_PERIODS]): + raise ValueError("Invalid periods") + + await bridge.set( + rn.STORAGE_FIXED_CHARGING_AND_DISCHARGING_PERIODS, + _parse_periods(service_call.data[DATA_PERIODS]), + ) + + +async def async_setup_services( # noqa: C901 + hass: HomeAssistant, + entry: ConfigEntry, +): + """Huawei Solar Services Setup.""" if not entry.data.get(CONF_ENABLE_PARAMETER_CONFIGURATION, False): return + hass.services.async_register( DOMAIN, SERVICE_RESET_MAXIMUM_FEED_GRID_POWER, - reset_maximum_feed_grid_power, - schema=DEVICE_SCHEMA, + partial(reset_maximum_feed_grid_power, hass), + schema=INVERTER_DEVICE_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_SET_DI_ACTIVE_POWER_SCHEDULING, - set_di_active_power_scheduling, - schema=DEVICE_SCHEMA, + partial(set_di_active_power_scheduling, hass), + schema=INVERTER_DEVICE_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_SET_ZERO_POWER_GRID_CONNECTION, - set_zero_power_grid_connection, - schema=DEVICE_SCHEMA, + partial(set_zero_power_grid_connection, hass), + schema=INVERTER_DEVICE_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_SET_MAXIMUM_FEED_GRID_POWER, - set_maximum_feed_grid_power, + partial(set_maximum_feed_grid_power, hass), schema=MAXIMUM_FEED_GRID_POWER_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_SET_MAXIMUM_FEED_GRID_POWER_PERCENT, - set_maximum_feed_grid_power_percentage, + partial(set_maximum_feed_grid_power_percentage, hass), schema=MAXIMUM_FEED_GRID_POWER_PERCENTAGE_SCHEMA, ) + bridges_with_device_infos = hass.data[DOMAIN][entry.entry_id][DATA_BRIDGES_WITH_DEVICEINFOS] + if any( bridge.battery_type != rv.StorageProductModel.NONE for bridge, _ in bridges_with_device_infos ): hass.services.async_register( - DOMAIN, SERVICE_FORCIBLE_CHARGE, forcible_charge, schema=DURATION_SCHEMA + DOMAIN, + SERVICE_FORCIBLE_CHARGE, + partial(forcible_charge, hass), + schema=DURATION_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_FORCIBLE_DISCHARGE, - forcible_discharge, + partial(forcible_discharge, hass), schema=DURATION_SCHEMA, ) hass.services.async_register( - DOMAIN, SERVICE_FORCIBLE_CHARGE_SOC, forcible_charge_soc, schema=SOC_SCHEMA + DOMAIN, + SERVICE_FORCIBLE_CHARGE_SOC, + partial(forcible_charge_soc, hass), + schema=SOC_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_FORCIBLE_DISCHARGE_SOC, - forcible_discharge_soc, + partial(forcible_discharge_soc, hass), schema=SOC_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_STOP_FORCIBLE_CHARGE, - stop_forcible_charge, - schema=DEVICE_SCHEMA, + partial(stop_forcible_charge, hass), + schema=BATTERY_DEVICE_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_SET_TOU_PERIODS, - set_tou_periods, + partial(set_tou_periods, hass), schema=TOU_PERIODS_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_SET_FIXED_CHARGE_PERIODS, - set_fixed_charge_periods, + partial(set_fixed_charge_periods, hass), schema=FIXED_CHARGE_PERIODS_SCHEMA, ) @@ -577,6 +671,13 @@ def _parse_periods(text) -> list[ChargeDischargePeriod]: hass.services.async_register( DOMAIN, SERVICE_SET_CAPACITY_CONTROL_PERIODS, - set_capacity_control_periods, + partial(set_capacity_control_periods, hass), schema=CAPACITY_CONTROL_PERIODS_SCHEMA, ) + +async def async_cleanup_services( hass: HomeAssistant): + """Cleanup all Huawei Solar service (if all config entries unloaded)""" + if len(hass.data[DOMAIN] == 1): + for service in ALL_SERVICES: + if hass.services.has_service(DOMAIN, service): + hass.services.async_remove(DOMAIN, service) \ No newline at end of file diff --git a/services.yaml b/services.yaml index f9d3ec1..352ef03 100644 --- a/services.yaml +++ b/services.yaml @@ -129,7 +129,7 @@ stop_forcible_charge: integration: huawei_solar reset_maximum_feed_grid_power: - name: Set Active Power Control to Unlimited mode + name: Set Active Power Control to Maximum Feed Grid Power description: Set Active Power Control to the default Unlimited mode fields: device_id: @@ -141,7 +141,7 @@ reset_maximum_feed_grid_power: integration: huawei_solar set_di_active_power_scheduling: - name: Set Active Power Control to Unlimited mode + name: Set Active Power Control to 'DI active scheduling' description: Set Active Power Control to 'DI active scheduling' fields: device_id: @@ -153,7 +153,7 @@ set_di_active_power_scheduling: integration: huawei_solar set_zero_power_grid_connection: - name: Set Active Power Control to Unlimited mode + name: Set Active Power Control to 'Zero power grid connection' description: Set Active Power Control to 'Zero power grid connection' fields: device_id: