mirror of
https://github.com/home-assistant/core.git
synced 2026-05-20 23:40:15 +01:00
b9575ee881
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: balloob <1444314+balloob@users.noreply.github.com> Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
220 lines
6.9 KiB
Python
220 lines
6.9 KiB
Python
"""Services for Indevolt integration."""
|
|
|
|
import asyncio
|
|
from typing import Final, Never
|
|
|
|
from indevolt_api import (
|
|
IndevoltRealtimeAction,
|
|
PowerExceedsMaxError,
|
|
SocBelowMinimumError,
|
|
)
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.core import HomeAssistant, ServiceCall
|
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.helpers.service import async_extract_config_entry_ids
|
|
|
|
from .const import DOMAIN
|
|
from .coordinator import IndevoltCoordinator
|
|
|
|
RT_ACTION_SERVICE_SCHEMA: Final = vol.Schema(
|
|
{
|
|
vol.Required("device_id"): vol.All(
|
|
cv.ensure_list,
|
|
[cv.string],
|
|
),
|
|
vol.Required("target_soc"): vol.All(
|
|
vol.Coerce(int),
|
|
vol.Range(min=0, max=100),
|
|
),
|
|
vol.Required("power"): vol.All(
|
|
vol.Coerce(int),
|
|
vol.Range(min=1, max=2400),
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
async def async_setup_services(hass: HomeAssistant) -> None:
|
|
"""Set up services for Indevolt integration."""
|
|
|
|
async def charge(call: ServiceCall) -> None:
|
|
"""Handle the service call to start charging."""
|
|
await _async_handle_realtime_action(hass, call, IndevoltRealtimeAction.CHARGE)
|
|
|
|
async def discharge(call: ServiceCall) -> None:
|
|
"""Handle the service call to start discharging."""
|
|
await _async_handle_realtime_action(
|
|
hass, call, IndevoltRealtimeAction.DISCHARGE
|
|
)
|
|
|
|
hass.services.async_register(
|
|
DOMAIN, "charge", charge, schema=RT_ACTION_SERVICE_SCHEMA
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN, "discharge", discharge, schema=RT_ACTION_SERVICE_SCHEMA
|
|
)
|
|
|
|
|
|
async def _async_handle_realtime_action(
|
|
hass: HomeAssistant,
|
|
call: ServiceCall,
|
|
action: IndevoltRealtimeAction,
|
|
) -> None:
|
|
"""Validate and execute a realtime action for one or more coordinators."""
|
|
coordinators = await _async_get_coordinators_from_call(hass, call)
|
|
|
|
power: int = call.data["power"]
|
|
target_soc: int = call.data["target_soc"]
|
|
|
|
_validate_realtime_action(coordinators, action, power, target_soc)
|
|
await _execute_realtime_action(coordinators, action, power, target_soc)
|
|
|
|
|
|
async def _async_get_coordinators_from_call(
|
|
hass: HomeAssistant,
|
|
call: ServiceCall,
|
|
) -> list[IndevoltCoordinator]:
|
|
"""Resolve coordinator(s) targeted by a service call."""
|
|
entry_ids = await async_extract_config_entry_ids(call)
|
|
|
|
coordinators: list[IndevoltCoordinator] = [
|
|
entry.runtime_data
|
|
for entry in hass.config_entries.async_loaded_entries(DOMAIN)
|
|
if entry.entry_id in entry_ids
|
|
]
|
|
|
|
if not coordinators:
|
|
_raise_no_target_entries()
|
|
|
|
return coordinators
|
|
|
|
|
|
def _validate_realtime_action(
|
|
coordinators: list[IndevoltCoordinator],
|
|
action: IndevoltRealtimeAction,
|
|
power: int,
|
|
target_soc: int,
|
|
) -> None:
|
|
"""Validate parameters prior to calling `_execute_realtime_action`."""
|
|
|
|
errors: list[str] = []
|
|
|
|
for coordinator in coordinators:
|
|
try:
|
|
try:
|
|
match action:
|
|
case IndevoltRealtimeAction.CHARGE:
|
|
coordinator.api.check_charge_limits(
|
|
power, target_soc, coordinator.generation
|
|
)
|
|
case IndevoltRealtimeAction.DISCHARGE:
|
|
coordinator.api.check_discharge_limits(
|
|
power, target_soc, coordinator.generation
|
|
)
|
|
|
|
except PowerExceedsMaxError as err:
|
|
_raise_power_exceeds_max(err.power, err.max_power, err.generation)
|
|
|
|
except SocBelowMinimumError as err:
|
|
_raise_soc_below_minimum(err.target_soc, err.minimum_soc)
|
|
|
|
# Validate target SOC against known emergency SOC (soft limit)
|
|
emergency_soc = coordinator.get_emergency_soc()
|
|
if target_soc < emergency_soc:
|
|
_raise_soc_below_emergency(target_soc, emergency_soc)
|
|
|
|
except ServiceValidationError as err:
|
|
if len(coordinators) == 1:
|
|
raise
|
|
|
|
errors.append(f"{coordinator.friendly_name}: {err}")
|
|
|
|
if errors:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="multi_device_errors",
|
|
translation_placeholders={"errors": "; ".join(errors)},
|
|
)
|
|
|
|
|
|
async def _execute_realtime_action(
|
|
coordinators: list[IndevoltCoordinator],
|
|
action: IndevoltRealtimeAction,
|
|
power: int,
|
|
target_soc: int,
|
|
) -> None:
|
|
"""Execute async_execute_realtime_action on all coordinators concurrently."""
|
|
results: list[None | BaseException] = await asyncio.gather(
|
|
*(
|
|
coordinator.async_realtime_action(action, power, target_soc)
|
|
for coordinator in coordinators
|
|
),
|
|
return_exceptions=True,
|
|
)
|
|
|
|
errors: list[str] = []
|
|
|
|
for coordinator, result in zip(coordinators, results, strict=True):
|
|
if isinstance(result, BaseException):
|
|
if len(coordinators) == 1 or not isinstance(result, Exception):
|
|
raise result
|
|
|
|
errors.append(f"{coordinator.friendly_name}: {result}")
|
|
|
|
if errors:
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="multi_device_errors",
|
|
translation_placeholders={"errors": "; ".join(errors)},
|
|
)
|
|
|
|
|
|
def _raise_power_exceeds_max(power: int, max_power: int, generation: int) -> Never:
|
|
"""Raise a translated validation error for out-of-range power."""
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="power_exceeds_max",
|
|
translation_placeholders={
|
|
"power": str(power),
|
|
"max_power": str(max_power),
|
|
"generation": str(generation),
|
|
},
|
|
)
|
|
|
|
|
|
def _raise_soc_below_minimum(target_soc: int, minimum_soc: int) -> Never:
|
|
"""Raise a translated validation error.
|
|
|
|
Called when SOC is below the device's hard minimum.
|
|
"""
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="soc_below_minimum",
|
|
translation_placeholders={
|
|
"target": str(target_soc),
|
|
"minimum_soc": str(minimum_soc),
|
|
},
|
|
)
|
|
|
|
|
|
def _raise_soc_below_emergency(target: int, emergency_soc: int) -> Never:
|
|
"""Raise a translated validation error for out-of-range SOC."""
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="soc_below_emergency",
|
|
translation_placeholders={
|
|
"target": str(target),
|
|
"emergency_soc": str(emergency_soc),
|
|
},
|
|
)
|
|
|
|
|
|
def _raise_no_target_entries() -> Never:
|
|
"""Raise a translated validation error for missing/invalid service targets."""
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="no_matching_target_entries",
|
|
)
|