Skip to content

Commit f306cde

Browse files
jesserockzarturpragaczbdraco
authored
Add response support to esphome custom actions (home-assistant#157393)
Co-authored-by: Artur Pragacz <[email protected]> Co-authored-by: J. Nick Koston <[email protected]>
1 parent 38c5e48 commit f306cde

File tree

6 files changed

+584
-14
lines changed

6 files changed

+584
-14
lines changed

homeassistant/components/esphome/manager.py

Lines changed: 115 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
APIVersion,
1616
DeviceInfo as EsphomeDeviceInfo,
1717
EncryptionPlaintextAPIError,
18+
ExecuteServiceResponse,
1819
HomeassistantServiceCall,
1920
InvalidAuthAPIError,
2021
InvalidEncryptionKeyAPIError,
2122
LogLevel,
2223
ReconnectLogic,
2324
RequiresEncryptionAPIError,
25+
SupportsResponseType,
2426
UserService,
2527
UserServiceArgType,
2628
ZWaveProxyRequest,
@@ -44,7 +46,9 @@
4446
EventStateChangedData,
4547
HomeAssistant,
4648
ServiceCall,
49+
ServiceResponse,
4750
State,
51+
SupportsResponse,
4852
callback,
4953
)
5054
from homeassistant.exceptions import (
@@ -58,7 +62,7 @@
5862
device_registry as dr,
5963
entity_registry as er,
6064
issue_registry as ir,
61-
json,
65+
json as json_helper,
6266
template,
6367
)
6468
from homeassistant.helpers.device_registry import format_mac
@@ -70,6 +74,7 @@
7074
)
7175
from homeassistant.helpers.service import async_set_service_schema
7276
from homeassistant.helpers.template import Template
77+
from homeassistant.util.json import json_loads_object
7378

7479
from .bluetooth import async_connect_scanner
7580
from .const import (
@@ -91,6 +96,7 @@
9196

9297
# Import config flow so that it's added to the registry
9398
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
99+
from .enum_mapper import EsphomeEnumMapper
94100

95101
DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}"
96102
UNPACK_UINT32_BE = struct.Struct(">I").unpack_from
@@ -367,7 +373,7 @@ async def _handle_service_call_with_response(
367373
response_dict = {"response": action_response}
368374

369375
# JSON encode response data for ESPHome
370-
response_data = json.json_bytes(response_dict)
376+
response_data = json_helper.json_bytes(response_dict)
371377

372378
except (
373379
ServiceNotFound,
@@ -1150,13 +1156,52 @@ class ServiceMetadata(NamedTuple):
11501156
}
11511157

11521158

1153-
@callback
1154-
def execute_service(
1155-
entry_data: RuntimeEntryData, service: UserService, call: ServiceCall
1156-
) -> None:
1157-
"""Execute a service on a node."""
1159+
async def execute_service(
1160+
entry_data: RuntimeEntryData,
1161+
service: UserService,
1162+
call: ServiceCall,
1163+
*,
1164+
supports_response: SupportsResponseType,
1165+
) -> ServiceResponse:
1166+
"""Execute a service on a node and optionally wait for response."""
1167+
# Determine if we should wait for a response
1168+
# NONE: fire and forget
1169+
# OPTIONAL/ONLY/STATUS: always wait for success/error confirmation
1170+
wait_for_response = supports_response != SupportsResponseType.NONE
1171+
1172+
if not wait_for_response:
1173+
# Fire and forget - no response expected
1174+
try:
1175+
await entry_data.client.execute_service(service, call.data)
1176+
except APIConnectionError as err:
1177+
raise HomeAssistantError(
1178+
translation_domain=DOMAIN,
1179+
translation_key="action_call_failed",
1180+
translation_placeholders={
1181+
"call_name": service.name,
1182+
"device_name": entry_data.name,
1183+
"error": str(err),
1184+
},
1185+
) from err
1186+
else:
1187+
return None
1188+
1189+
# Determine if we need response_data from ESPHome
1190+
# ONLY: always need response_data
1191+
# OPTIONAL: only if caller requested it
1192+
# STATUS: never need response_data (just success/error)
1193+
need_response_data = supports_response == SupportsResponseType.ONLY or (
1194+
supports_response == SupportsResponseType.OPTIONAL and call.return_response
1195+
)
1196+
11581197
try:
1159-
entry_data.client.execute_service(service, call.data)
1198+
response: (
1199+
ExecuteServiceResponse | None
1200+
) = await entry_data.client.execute_service(
1201+
service,
1202+
call.data,
1203+
return_response=need_response_data,
1204+
)
11601205
except APIConnectionError as err:
11611206
raise HomeAssistantError(
11621207
translation_domain=DOMAIN,
@@ -1167,13 +1212,64 @@ def execute_service(
11671212
"error": str(err),
11681213
},
11691214
) from err
1215+
except TimeoutError as err:
1216+
raise HomeAssistantError(
1217+
translation_domain=DOMAIN,
1218+
translation_key="action_call_timeout",
1219+
translation_placeholders={
1220+
"call_name": service.name,
1221+
"device_name": entry_data.name,
1222+
},
1223+
) from err
1224+
1225+
assert response is not None
1226+
1227+
if not response.success:
1228+
raise HomeAssistantError(
1229+
translation_domain=DOMAIN,
1230+
translation_key="action_call_failed",
1231+
translation_placeholders={
1232+
"call_name": service.name,
1233+
"device_name": entry_data.name,
1234+
"error": response.error_message,
1235+
},
1236+
)
1237+
1238+
# Parse and return response data as JSON if we requested it
1239+
if need_response_data and response.response_data:
1240+
try:
1241+
return json_loads_object(response.response_data)
1242+
except ValueError as err:
1243+
raise HomeAssistantError(
1244+
translation_domain=DOMAIN,
1245+
translation_key="action_call_failed",
1246+
translation_placeholders={
1247+
"call_name": service.name,
1248+
"device_name": entry_data.name,
1249+
"error": f"Invalid JSON response: {err}",
1250+
},
1251+
) from err
1252+
return None
11701253

11711254

11721255
def build_service_name(device_info: EsphomeDeviceInfo, service: UserService) -> str:
11731256
"""Build a service name for a node."""
11741257
return f"{device_info.name.replace('-', '_')}_{service.name}"
11751258

11761259

1260+
# Map ESPHome SupportsResponseType to Home Assistant SupportsResponse
1261+
# STATUS (100) is ESPHome-specific: waits for success/error internally but
1262+
# doesn't return data to HA, so it maps to NONE from HA's perspective
1263+
_RESPONSE_TYPE_MAPPER = EsphomeEnumMapper[SupportsResponseType, SupportsResponse](
1264+
{
1265+
SupportsResponseType.NONE: SupportsResponse.NONE,
1266+
SupportsResponseType.OPTIONAL: SupportsResponse.OPTIONAL,
1267+
SupportsResponseType.ONLY: SupportsResponse.ONLY,
1268+
SupportsResponseType.STATUS: SupportsResponse.NONE,
1269+
}
1270+
)
1271+
1272+
11771273
@callback
11781274
def _async_register_service(
11791275
hass: HomeAssistant,
@@ -1205,11 +1301,21 @@ def _async_register_service(
12051301
"selector": metadata.selector,
12061302
}
12071303

1304+
# Get the supports_response from the service, defaulting to NONE
1305+
esphome_supports_response = service.supports_response or SupportsResponseType.NONE
1306+
ha_supports_response = _RESPONSE_TYPE_MAPPER.from_esphome(esphome_supports_response)
1307+
12081308
hass.services.async_register(
12091309
DOMAIN,
12101310
service_name,
1211-
partial(execute_service, entry_data, service),
1311+
partial(
1312+
execute_service,
1313+
entry_data,
1314+
service,
1315+
supports_response=esphome_supports_response,
1316+
),
12121317
vol.Schema(schema),
1318+
supports_response=ha_supports_response,
12131319
)
12141320
async_set_service_schema(
12151321
hass,

homeassistant/components/esphome/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"mqtt": ["esphome/discover/#"],
1818
"quality_scale": "platinum",
1919
"requirements": [
20-
"aioesphomeapi==42.10.0",
20+
"aioesphomeapi==43.0.0",
2121
"esphome-dashboard-api==1.3.0",
2222
"bleak-esphome==3.4.0"
2323
],

homeassistant/components/esphome/strings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@
128128
"action_call_failed": {
129129
"message": "Failed to execute the action call {call_name} on {device_name}: {error}"
130130
},
131+
"action_call_timeout": {
132+
"message": "Timeout waiting for response from action call {call_name} on {device_name}"
133+
},
131134
"error_communicating_with_device": {
132135
"message": "Error communicating with the device {device_name}: {error}"
133136
},

requirements_all.txt

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

requirements_test_all.txt

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)