Skip to content

Commit 71362b5

Browse files
authored
Add device defined action responses (#1436)
1 parent 272f0ee commit 71362b5

File tree

9 files changed

+706
-270
lines changed

9 files changed

+706
-270
lines changed

aioesphomeapi/api.proto

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,14 @@ enum ServiceArgType {
855855
SERVICE_ARG_TYPE_FLOAT_ARRAY = 6;
856856
SERVICE_ARG_TYPE_STRING_ARRAY = 7;
857857
}
858+
enum SupportsResponseType {
859+
SUPPORTS_RESPONSE_NONE = 0;
860+
SUPPORTS_RESPONSE_OPTIONAL = 1;
861+
SUPPORTS_RESPONSE_ONLY = 2;
862+
// Status-only response - reports success/error without data payload
863+
// Value is higher to avoid conflicts with future Home Assistant values
864+
SUPPORTS_RESPONSE_STATUS = 100;
865+
}
858866
message ListEntitiesServicesArgument {
859867
option (ifdef) = "USE_API_USER_DEFINED_ACTIONS";
860868
string name = 1;
@@ -868,6 +876,7 @@ message ListEntitiesServicesResponse {
868876
string name = 1;
869877
fixed32 key = 2;
870878
repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true];
879+
SupportsResponseType supports_response = 4;
871880
}
872881
message ExecuteServiceArgument {
873882
option (ifdef) = "USE_API_USER_DEFINED_ACTIONS";
@@ -890,6 +899,21 @@ message ExecuteServiceRequest {
890899

891900
fixed32 key = 1;
892901
repeated ExecuteServiceArgument args = 2 [(fixed_vector) = true];
902+
uint32 call_id = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_RESPONSES"];
903+
bool return_response = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_RESPONSES"];
904+
}
905+
906+
// Message sent by ESPHome to Home Assistant with service execution response data
907+
message ExecuteServiceResponse {
908+
option (id) = 131;
909+
option (source) = SOURCE_SERVER;
910+
option (no_delay) = true;
911+
option (ifdef) = "USE_API_USER_DEFINED_ACTION_RESPONSES";
912+
913+
uint32 call_id = 1; // Matches the call_id from ExecuteServiceRequest
914+
bool success = 2; // Whether the service execution succeeded
915+
string error_message = 3; // Error message if success = false
916+
bytes response_data = 4 [(pointer_to_buffer) = true, (field_ifdef) = "USE_API_USER_DEFINED_ACTION_RESPONSES_JSON"];
893917
}
894918

895919
// ==================== CAMERA ====================

aioesphomeapi/api_pb2.py

Lines changed: 280 additions & 268 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

aioesphomeapi/client.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
DeviceInfoResponse,
4646
ExecuteServiceArgument,
4747
ExecuteServiceRequest,
48+
ExecuteServiceResponse,
4849
FanCommandRequest,
4950
HomeassistantActionRequest,
5051
HomeassistantActionResponse,
@@ -134,6 +135,7 @@
134135
EntityInfo,
135136
EntityState,
136137
ESPHomeBluetoothGATTServices,
138+
ExecuteServiceResponse as ExecuteServiceResponseModel,
137139
FanDirection,
138140
FanSpeed,
139141
HomeassistantServiceCall,
@@ -1312,10 +1314,21 @@ def update_command(
13121314
)
13131315

13141316
def execute_service(
1315-
self, service: UserService, data: ExecuteServiceDataType
1317+
self,
1318+
service: UserService,
1319+
data: ExecuteServiceDataType,
1320+
*,
1321+
on_response: Callable[[ExecuteServiceResponseModel], None] | None = None,
1322+
return_response: bool = False,
13161323
) -> None:
13171324
connection = self._get_connection()
1318-
req = ExecuteServiceRequest(key=service.key)
1325+
# Generate call_id when response callback is provided
1326+
call_id = next(self._call_id_counter) if on_response is not None else 0
1327+
req = ExecuteServiceRequest(
1328+
key=service.key,
1329+
call_id=call_id,
1330+
return_response=return_response,
1331+
)
13191332
args = []
13201333
apiv = self.api_version
13211334
if TYPE_CHECKING:
@@ -1339,6 +1352,21 @@ def execute_service(
13391352
# pylint: disable=no-member
13401353
req.args.extend(args)
13411354

1355+
# Register callback for response if provided
1356+
if on_response is not None:
1357+
unsub: Callable[[], None] | None = None
1358+
1359+
def _on_response(msg: ExecuteServiceResponse) -> None:
1360+
if msg.call_id == call_id:
1361+
on_response(ExecuteServiceResponseModel.from_pb(msg))
1362+
if unsub is not None:
1363+
unsub()
1364+
1365+
unsub = connection.add_message_callback(
1366+
_on_response,
1367+
(ExecuteServiceResponse,),
1368+
)
1369+
13421370
connection.send_message(req)
13431371

13441372
def _request_image(self, *, single: bool = False, stream: bool = False) -> None:

aioesphomeapi/client_base.pxd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ cdef str _stringify_or_none(object value)
2626
cdef class APIClientBase:
2727

2828
cdef public set _background_tasks
29+
cdef public object _call_id_counter
2930
cdef public APIConnection _connection
3031
cdef public bint _debug_enabled
3132
cdef public object _loop

aioesphomeapi/client_base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import asyncio
55
from collections.abc import Callable, Coroutine
6+
import itertools
67
import logging
78
from typing import TYPE_CHECKING, Any
89

@@ -218,6 +219,7 @@ class APIClientBase:
218219

219220
__slots__ = (
220221
"_background_tasks",
222+
"_call_id_counter",
221223
"_connection",
222224
"_debug_enabled",
223225
"_loop",
@@ -286,6 +288,7 @@ def __init__(
286288
self.cached_name: str | None = None
287289
self._background_tasks: set[asyncio.Task[Any]] = set()
288290
self._loop = asyncio.get_running_loop()
291+
self._call_id_counter = itertools.count(1)
289292
self._set_log_name()
290293

291294
def set_debug(self, enabled: bool) -> None:

aioesphomeapi/core.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
DisconnectResponse,
5252
EventResponse,
5353
ExecuteServiceRequest,
54+
ExecuteServiceResponse,
5455
FanCommandRequest,
5556
FanStateResponse,
5657
GetTimeRequest,
@@ -491,6 +492,7 @@ def __init__(self, error: BluetoothGATTError) -> None:
491492
128: ZWaveProxyFrame,
492493
129: ZWaveProxyRequest,
493494
130: HomeassistantActionResponse,
495+
131: ExecuteServiceResponse,
494496
}
495497

496498
MESSAGE_NUMBER_TO_PROTO = tuple(MESSAGE_TYPE_TO_PROTO.values())

aioesphomeapi/model.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,6 +1213,13 @@ class UserServiceArgType(APIIntEnum):
12131213
STRING_ARRAY = 7
12141214

12151215

1216+
class SupportsResponseType(APIIntEnum):
1217+
NONE = 0
1218+
OPTIONAL = 1
1219+
ONLY = 2
1220+
STATUS = 100
1221+
1222+
12161223
@_frozen_dataclass_decorator
12171224
class UserServiceArg(APIModelBase):
12181225
name: str = ""
@@ -1240,6 +1247,19 @@ class UserService(APIModelBase):
12401247
args: list[UserServiceArg] = converter_field(
12411248
default_factory=list, converter=UserServiceArg.convert_list
12421249
)
1250+
supports_response: SupportsResponseType | None = converter_field(
1251+
default=SupportsResponseType.NONE, converter=SupportsResponseType.convert
1252+
)
1253+
1254+
1255+
@_frozen_dataclass_decorator
1256+
class ExecuteServiceResponse(APIModelBase):
1257+
call_id: int = 0 # Call ID that matches the original request
1258+
success: bool = False # Whether the service execution succeeded
1259+
error_message: str = "" # Error message if success = false
1260+
response_data: bytes = field(
1261+
default_factory=bytes
1262+
) # JSON response data from ESPHome
12431263

12441264

12451265
# ==================== BLUETOOTH ====================

0 commit comments

Comments
 (0)