mirror of
https://github.com/home-assistant/core.git
synced 2026-04-20 08:29:39 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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
2
requirements_all.txt
generated
@@ -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
|
||||
|
||||
2
requirements_test_all.txt
generated
2
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user