Add response support to esphome custom actions (#157393)

Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Jesse Hills
2025-12-07 04:48:20 +13:00
committed by GitHub
parent 38c5e483a8
commit f306cde3b6
6 changed files with 584 additions and 14 deletions

View File

@@ -15,12 +15,14 @@ from aioesphomeapi import (
APIVersion,
DeviceInfo as EsphomeDeviceInfo,
EncryptionPlaintextAPIError,
ExecuteServiceResponse,
HomeassistantServiceCall,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
LogLevel,
ReconnectLogic,
RequiresEncryptionAPIError,
SupportsResponseType,
UserService,
UserServiceArgType,
ZWaveProxyRequest,
@@ -44,7 +46,9 @@ from homeassistant.core import (
EventStateChangedData,
HomeAssistant,
ServiceCall,
ServiceResponse,
State,
SupportsResponse,
callback,
)
from homeassistant.exceptions import (
@@ -58,7 +62,7 @@ from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
json,
json as json_helper,
template,
)
from homeassistant.helpers.device_registry import format_mac
@@ -70,6 +74,7 @@ from homeassistant.helpers.issue_registry import (
)
from homeassistant.helpers.service import async_set_service_schema
from homeassistant.helpers.template import Template
from homeassistant.util.json import json_loads_object
from .bluetooth import async_connect_scanner
from .const import (
@@ -91,6 +96,7 @@ from .encryption_key_storage import async_get_encryption_key_storage
# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
from .enum_mapper import EsphomeEnumMapper
DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}"
UNPACK_UINT32_BE = struct.Struct(">I").unpack_from
@@ -367,7 +373,7 @@ class ESPHomeManager:
response_dict = {"response": action_response}
# JSON encode response data for ESPHome
response_data = json.json_bytes(response_dict)
response_data = json_helper.json_bytes(response_dict)
except (
ServiceNotFound,
@@ -1150,13 +1156,52 @@ ARG_TYPE_METADATA = {
}
@callback
def execute_service(
entry_data: RuntimeEntryData, service: UserService, call: ServiceCall
) -> None:
"""Execute a service on a node."""
async def execute_service(
entry_data: RuntimeEntryData,
service: UserService,
call: ServiceCall,
*,
supports_response: SupportsResponseType,
) -> ServiceResponse:
"""Execute a service on a node and optionally wait for response."""
# Determine if we should wait for a response
# NONE: fire and forget
# OPTIONAL/ONLY/STATUS: always wait for success/error confirmation
wait_for_response = supports_response != SupportsResponseType.NONE
if not wait_for_response:
# Fire and forget - no response expected
try:
await entry_data.client.execute_service(service, call.data)
except APIConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="action_call_failed",
translation_placeholders={
"call_name": service.name,
"device_name": entry_data.name,
"error": str(err),
},
) from err
else:
return None
# Determine if we need response_data from ESPHome
# ONLY: always need response_data
# OPTIONAL: only if caller requested it
# STATUS: never need response_data (just success/error)
need_response_data = supports_response == SupportsResponseType.ONLY or (
supports_response == SupportsResponseType.OPTIONAL and call.return_response
)
try:
entry_data.client.execute_service(service, call.data)
response: (
ExecuteServiceResponse | None
) = await entry_data.client.execute_service(
service,
call.data,
return_response=need_response_data,
)
except APIConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -1167,6 +1212,44 @@ def execute_service(
"error": str(err),
},
) from err
except TimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="action_call_timeout",
translation_placeholders={
"call_name": service.name,
"device_name": entry_data.name,
},
) from err
assert response is not None
if not response.success:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="action_call_failed",
translation_placeholders={
"call_name": service.name,
"device_name": entry_data.name,
"error": response.error_message,
},
)
# Parse and return response data as JSON if we requested it
if need_response_data and response.response_data:
try:
return json_loads_object(response.response_data)
except ValueError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="action_call_failed",
translation_placeholders={
"call_name": service.name,
"device_name": entry_data.name,
"error": f"Invalid JSON response: {err}",
},
) from err
return None
def build_service_name(device_info: EsphomeDeviceInfo, service: UserService) -> str:
@@ -1174,6 +1257,19 @@ def build_service_name(device_info: EsphomeDeviceInfo, service: UserService) ->
return f"{device_info.name.replace('-', '_')}_{service.name}"
# Map ESPHome SupportsResponseType to Home Assistant SupportsResponse
# STATUS (100) is ESPHome-specific: waits for success/error internally but
# doesn't return data to HA, so it maps to NONE from HA's perspective
_RESPONSE_TYPE_MAPPER = EsphomeEnumMapper[SupportsResponseType, SupportsResponse](
{
SupportsResponseType.NONE: SupportsResponse.NONE,
SupportsResponseType.OPTIONAL: SupportsResponse.OPTIONAL,
SupportsResponseType.ONLY: SupportsResponse.ONLY,
SupportsResponseType.STATUS: SupportsResponse.NONE,
}
)
@callback
def _async_register_service(
hass: HomeAssistant,
@@ -1205,11 +1301,21 @@ def _async_register_service(
"selector": metadata.selector,
}
# Get the supports_response from the service, defaulting to NONE
esphome_supports_response = service.supports_response or SupportsResponseType.NONE
ha_supports_response = _RESPONSE_TYPE_MAPPER.from_esphome(esphome_supports_response)
hass.services.async_register(
DOMAIN,
service_name,
partial(execute_service, entry_data, service),
partial(
execute_service,
entry_data,
service,
supports_response=esphome_supports_response,
),
vol.Schema(schema),
supports_response=ha_supports_response,
)
async_set_service_schema(
hass,

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==42.10.0",
"aioesphomeapi==43.0.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.4.0"
],

View File

@@ -128,6 +128,9 @@
"action_call_failed": {
"message": "Failed to execute the action call {call_name} on {device_name}: {error}"
},
"action_call_timeout": {
"message": "Timeout waiting for response from action call {call_name} on {device_name}"
},
"error_communicating_with_device": {
"message": "Error communicating with the device {device_name}: {error}"
},

2
requirements_all.txt generated
View File

@@ -252,7 +252,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==42.10.0
aioesphomeapi==43.0.0
# homeassistant.components.matrix
# homeassistant.components.slack

View File

@@ -243,7 +243,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==42.10.0
aioesphomeapi==43.0.0
# homeassistant.components.matrix
# homeassistant.components.slack

View File

@@ -12,12 +12,14 @@ from aioesphomeapi import (
AreaInfo,
DeviceInfo,
EncryptionPlaintextAPIError,
ExecuteServiceResponse,
HomeassistantServiceCall,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
LogLevel,
RequiresEncryptionAPIError,
SubDeviceInfo,
SupportsResponseType,
UserService,
UserServiceArg,
UserServiceArgType,
@@ -49,7 +51,7 @@ from homeassistant.const import (
CONF_PORT,
EVENT_HOMEASSISTANT_CLOSE,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
@@ -1456,7 +1458,7 @@ async def test_esphome_user_service_fails(
await hass.async_block_till_done()
assert hass.services.has_service(DOMAIN, "with_dash_simple_service")
mock_client.execute_service = Mock(side_effect=APIConnectionError("fail"))
mock_client.execute_service = AsyncMock(side_effect=APIConnectionError("fail"))
with pytest.raises(HomeAssistantError) as exc:
await hass.services.async_call(
DOMAIN, "with_dash_simple_service", {"arg1": True}, blocking=True
@@ -2812,3 +2814,462 @@ async def test_no_zwave_proxy_subscribe_without_feature_flags(
# Verify subscribe_zwave_proxy_request was NOT called
mock_client.subscribe_zwave_proxy_request.assert_not_called()
async def test_execute_service_response_type_none(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service with SupportsResponseType.NONE (fire and forget)."""
service = UserService(
name="fire_forget_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.NONE,
)
# For NONE type, no response is expected
mock_client.execute_service = AsyncMock(return_value=None)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
assert hass.services.has_service(DOMAIN, "test_fire_forget_service")
# Call the service - should be fire and forget
await hass.services.async_call(
DOMAIN, "test_fire_forget_service", {"arg1": True}, blocking=True
)
await hass.async_block_till_done()
# Verify execute_service was called without extra kwargs (fire and forget)
mock_client.execute_service.assert_called_once()
call_args = mock_client.execute_service.call_args
assert call_args[0][1] == {"arg1": True}
# Fire and forget - no return_response or other kwargs
assert call_args[1] == {}
async def test_execute_service_response_type_status(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service with SupportsResponseType.STATUS."""
service = UserService(
name="status_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.STATUS,
)
# Set up mock response
mock_client.execute_service = AsyncMock(
return_value=ExecuteServiceResponse(
call_id=1,
success=True,
error_message="",
response_data=b"",
)
)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
# Call the service - should wait for response but not return data
# Note: STATUS maps to SupportsResponse.NONE so we can't use return_response=True
await hass.services.async_call(
DOMAIN, "test_status_service", {"arg1": True}, blocking=True
)
await hass.async_block_till_done()
# Verify return_response was False (STATUS doesn't need response_data)
call_args = mock_client.execute_service.call_args
assert call_args[1].get("return_response") is False
async def test_execute_service_response_type_optional_without_return(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service with SupportsResponseType.OPTIONAL when caller doesn't request response."""
service = UserService(
name="optional_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.OPTIONAL,
)
# Set up mock response
mock_client.execute_service = AsyncMock(
return_value=ExecuteServiceResponse(
call_id=1,
success=True,
error_message="",
response_data=b'{"result": "data"}',
)
)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
# Call without return_response - should still wait but not return data
result = await hass.services.async_call(
DOMAIN, "test_optional_service", {"arg1": True}, blocking=True
)
await hass.async_block_till_done()
assert result is None
# Verify return_response was False (caller didn't request it)
call_args = mock_client.execute_service.call_args
assert call_args[1].get("return_response") is False
async def test_execute_service_response_type_optional_with_return(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service with SupportsResponseType.OPTIONAL when caller requests response."""
service = UserService(
name="optional_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.OPTIONAL,
)
# Set up mock response with data
mock_client.execute_service = AsyncMock(
return_value=ExecuteServiceResponse(
call_id=1,
success=True,
error_message="",
response_data=b'{"result": "data"}',
)
)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
# Call with return_response=True
result = await hass.services.async_call(
DOMAIN,
"test_optional_service",
{"arg1": True},
blocking=True,
return_response=True,
)
await hass.async_block_till_done()
# Should return parsed JSON data
assert result == {"result": "data"}
# Verify return_response was True
call_args = mock_client.execute_service.call_args
assert call_args[1].get("return_response") is True
async def test_execute_service_response_type_only(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service with SupportsResponseType.ONLY."""
service = UserService(
name="only_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.ONLY,
)
# Set up mock response
mock_client.execute_service = AsyncMock(
return_value=ExecuteServiceResponse(
call_id=1,
success=True,
error_message="",
response_data=b'{"status": "ok", "value": 42}',
)
)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
# Call the service - ONLY type always returns data
result = await hass.services.async_call(
DOMAIN, "test_only_service", {"arg1": True}, blocking=True, return_response=True
)
await hass.async_block_till_done()
assert result == {"status": "ok", "value": 42}
# Verify return_response was True
call_args = mock_client.execute_service.call_args
assert call_args[1].get("return_response") is True
async def test_execute_service_timeout(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service timeout handling."""
service = UserService(
name="slow_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.STATUS,
)
# Mock execute_service to raise TimeoutError
mock_client.execute_service = AsyncMock(side_effect=TimeoutError())
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN, "test_slow_service", {"arg1": True}, blocking=True
)
assert "Timeout" in str(exc_info.value)
async def test_execute_service_connection_error(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service connection error handling."""
service = UserService(
name="error_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.NONE,
)
mock_client.execute_service = AsyncMock(
side_effect=APIConnectionError("Connection lost")
)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN, "test_error_service", {"arg1": True}, blocking=True
)
assert "Connection lost" in str(exc_info.value)
async def test_execute_service_connection_error_with_response(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service connection error when waiting for response."""
service = UserService(
name="error_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.STATUS, # Uses response path
)
mock_client.execute_service = AsyncMock(
side_effect=APIConnectionError("Connection lost")
)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN, "test_error_service", {"arg1": True}, blocking=True
)
assert "Connection lost" in str(exc_info.value)
async def test_execute_service_failure_response(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service with failure response from device."""
service = UserService(
name="failing_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.STATUS,
)
# Set up mock failure response
mock_client.execute_service = AsyncMock(
return_value=ExecuteServiceResponse(
call_id=1,
success=False,
error_message="Device reported error: invalid argument",
response_data=b"",
)
)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN, "test_failing_service", {"arg1": True}, blocking=True
)
assert "invalid argument" in str(exc_info.value)
async def test_execute_service_invalid_json_response(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test execute_service with invalid JSON in response data."""
service = UserService(
name="bad_json_service",
key=1,
args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)],
supports_response=SupportsResponseType.ONLY,
)
# Set up mock response with invalid JSON
mock_client.execute_service = AsyncMock(
return_value=ExecuteServiceResponse(
call_id=1,
success=True,
error_message="",
response_data=b"not valid json {{{",
)
)
await mock_esphome_device(
mock_client=mock_client,
user_service=[service],
device_info={"name": "test"},
)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN,
"test_bad_json_service",
{"arg1": True},
blocking=True,
return_response=True,
)
assert "Invalid JSON response" in str(exc_info.value)
async def test_service_registration_response_types(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test that services are registered with correct SupportsResponse types."""
services = [
UserService(
name="none_service",
key=1,
args=[],
supports_response=SupportsResponseType.NONE,
),
UserService(
name="optional_service",
key=2,
args=[],
supports_response=SupportsResponseType.OPTIONAL,
),
UserService(
name="only_service",
key=3,
args=[],
supports_response=SupportsResponseType.ONLY,
),
UserService(
name="status_service",
key=4,
args=[],
supports_response=SupportsResponseType.STATUS,
),
]
await mock_esphome_device(
mock_client=mock_client,
user_service=services,
device_info={"name": "test"},
)
await hass.async_block_till_done()
# Verify all services are registered
assert hass.services.has_service(DOMAIN, "test_none_service")
assert hass.services.has_service(DOMAIN, "test_optional_service")
assert hass.services.has_service(DOMAIN, "test_only_service")
assert hass.services.has_service(DOMAIN, "test_status_service")
# Verify response types are correctly mapped using public API
# NONE -> SupportsResponse.NONE
# OPTIONAL -> SupportsResponse.OPTIONAL
# ONLY -> SupportsResponse.ONLY
# STATUS -> SupportsResponse.NONE (no data returned to HA)
assert (
hass.services.supports_response(DOMAIN, "test_none_service")
== SupportsResponse.NONE
)
assert (
hass.services.supports_response(DOMAIN, "test_optional_service")
== SupportsResponse.OPTIONAL
)
assert (
hass.services.supports_response(DOMAIN, "test_only_service")
== SupportsResponse.ONLY
)
assert (
hass.services.supports_response(DOMAIN, "test_status_service")
== SupportsResponse.NONE
)