Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions aioesphomeapi/api.proto
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,22 @@ message HomeassistantActionRequest {
repeated HomeassistantServiceMap data_template = 3;
repeated HomeassistantServiceMap variables = 4;
bool is_event = 5;
uint32 call_id = 6 [(field_ifdef) = "USE_API_HOMEASSISTANT_ACTION_RESPONSES"];
bool wants_response = 7 [(field_ifdef) = "USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON"];
string response_template = 8 [(no_zero_copy) = true, (field_ifdef) = "USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON"];
}

// Message sent by Home Assistant to ESPHome with service call response data
message HomeassistantActionResponse {
option (id) = 130;
option (source) = SOURCE_CLIENT;
option (no_delay) = true;
option (ifdef) = "USE_API_HOMEASSISTANT_ACTION_RESPONSES";

uint32 call_id = 1; // Matches the call_id from HomeassistantActionRequest
bool success = 2; // Whether the service call succeeded
string error_message = 3; // Error message if success = false
bytes response_data = 4 [(pointer_to_buffer) = true, (field_ifdef) = "USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON"];
}

// ==================== IMPORT HOME ASSISTANT STATES ====================
Expand Down
576 changes: 294 additions & 282 deletions aioesphomeapi/api_pb2.py

Large diffs are not rendered by default.

22 changes: 19 additions & 3 deletions aioesphomeapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
ExecuteServiceRequest,
FanCommandRequest,
HomeassistantActionRequest,
HomeassistantActionResponse,
HomeAssistantStateResponse,
LightCommandRequest,
ListEntitiesDoneResponse,
Expand Down Expand Up @@ -97,7 +98,7 @@
on_bluetooth_le_advertising_response,
on_bluetooth_message_types,
on_bluetooth_scanner_state_response,
on_home_assistant_service_response,
on_home_assistant_action_request,
on_state_msg,
on_subscribe_home_assistant_state_response,
on_zwave_proxy_request_message,
Expand Down Expand Up @@ -405,7 +406,7 @@ def subscribe_service_calls(
) -> None:
self._get_connection().send_message_callback_response(
SubscribeHomeassistantServicesRequest(),
partial(on_home_assistant_service_response, on_service_call),
partial(on_home_assistant_action_request, on_service_call),
(HomeassistantActionRequest,),
)

Expand Down Expand Up @@ -959,7 +960,7 @@ def subscribe_home_assistant_states_and_services(
SUBSCRIBE_STATES_MSG_TYPES,
)
connection.add_message_callback(
partial(on_home_assistant_service_response, on_service_call),
partial(on_home_assistant_action_request, on_service_call),
(HomeassistantActionRequest,),
)
connection.add_message_callback(
Expand Down Expand Up @@ -1593,3 +1594,18 @@ async def noise_encryption_set_key(
req, NoiseEncryptionSetKeyResponse
)
return NoiseEncryptionSetKeyResponseModel.from_pb(resp).success

def send_homeassistant_action_response(
self,
call_id: int,
success: bool = True,
error_message: str = "",
response_data: bytes = b"",
) -> None:
"""Send a service call response back to ESPHome."""
req = HomeassistantActionResponse()
req.call_id = call_id
req.success = success
req.error_message = error_message
req.response_data = response_data
self._get_connection().send_message(req)
6 changes: 3 additions & 3 deletions aioesphomeapi/client_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,11 @@ def on_state_msg(
on_state(CameraState(key=msg.key, data=image_data, device_id=msg.device_id)) # type: ignore[call-arg]


def on_home_assistant_service_response(
on_service_call: Callable[[HomeassistantServiceCall], None],
def on_home_assistant_action_request(
on_action: Callable[[HomeassistantServiceCall], None],
msg: HomeassistantActionRequest,
) -> None:
on_service_call(HomeassistantServiceCall.from_pb(msg))
on_action(HomeassistantServiceCall.from_pb(msg))


def on_bluetooth_le_advertising_response(
Expand Down
2 changes: 2 additions & 0 deletions aioesphomeapi/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
HelloRequest,
HelloResponse,
HomeassistantActionRequest,
HomeassistantActionResponse,
HomeAssistantStateResponse,
LightCommandRequest,
LightStateResponse,
Expand Down Expand Up @@ -450,6 +451,7 @@ def __init__(self, error: BluetoothGATTError) -> None:
127: BluetoothScannerSetModeRequest,
128: ZWaveProxyFrame,
129: ZWaveProxyRequest,
130: HomeassistantActionResponse,
}

MESSAGE_NUMBER_TO_PROTO = tuple(MESSAGE_TYPE_TO_PROTO.values())
13 changes: 13 additions & 0 deletions aioesphomeapi/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -1159,6 +1159,19 @@ class HomeassistantServiceCall(APIModelBase):
variables: dict[str, str] = converter_field(
default_factory=dict, converter=_convert_homeassistant_service_map
)
call_id: int = 0 # Call ID for response tracking
wants_response: bool = False
response_template: str = "" # Optional Jinja template for response processing


@_frozen_dataclass_decorator
class HomeassistantActionResponse(APIModelBase):
call_id: int = 0 # Call ID that matches the original request
response_data: bytes = field(
default_factory=bytes
) # Response data from Home Assistant
success: bool = False # Whether the service call was successful
error_message: str = "" # Error message if the call failed


class UserServiceArgType(APIIntEnum):
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
long_description = readme_file.read()


VERSION = "41.12.1"
VERSION = "41.12.0"
PROJECT_NAME = "aioesphomeapi"
PROJECT_PACKAGE_NAME = "aioesphomeapi"
PROJECT_LICENSE = "MIT"
Expand Down
81 changes: 81 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import contextlib
from functools import partial
import itertools
import json
import logging
import socket
from typing import Any
Expand Down Expand Up @@ -53,6 +54,7 @@
ExecuteServiceRequest,
FanCommandRequest,
HomeassistantActionRequest,
HomeassistantActionResponse,
HomeAssistantStateResponse,
LightCommandRequest,
ListEntitiesBinarySensorResponse,
Expand Down Expand Up @@ -121,6 +123,7 @@
ESPHomeBluetoothGATTServices,
FanDirection,
FanSpeed,
HomeassistantActionResponse as HomeassistantActionResponseModel,
HomeassistantServiceCall,
LegacyCoverCommand,
LightColorCapability,
Expand Down Expand Up @@ -2156,6 +2159,84 @@ async def test_subscribe_service_calls(auth_client: APIClient) -> None:
on_service_call.assert_called_with(HomeassistantServiceCall.from_pb(service_msg))


async def test_send_homeassistant_service_call_response(auth_client: APIClient) -> None:
"""Test sending a service call response back to ESPHome."""
send = patch_send(auth_client)

response_data = json.dumps({"result": "success", "value": "42"}).encode("utf-8")

# Test successful response
auth_client.send_homeassistant_action_response(
call_id=123,
response_data=response_data,
success=True,
error_message="",
)

expected_response = HomeassistantActionResponse()
expected_response.call_id = 123
expected_response.response_data = response_data
expected_response.success = True
expected_response.error_message = ""

send.assert_called_once_with(expected_response)


async def test_send_homeassistant_service_call_response_error(
auth_client: APIClient,
) -> None:
"""Test sending an error service call response."""
send = patch_send(auth_client)

# Test error response
auth_client.send_homeassistant_action_response(
call_id=456,
response_data=b"",
success=False,
error_message="Service not found",
)

expected_response = HomeassistantActionResponse()
expected_response.call_id = 456
expected_response.response_data = b""
expected_response.success = False
expected_response.error_message = "Service not found"

send.assert_called_once_with(expected_response)


async def test_homeassistant_service_call_with_new_fields(
auth_client: APIClient,
) -> None:
"""Test HomeassistantServiceCall model with new fields."""
service_call = HomeassistantServiceCall(
service="test.service",
is_event=False,
data={"param": "value"},
data_template={"template": "{{ state }}"},
variables={"var": "data"},
call_id=789,
response_template="Response: {{ response }}",
)

assert service_call.service == "test.service"
assert service_call.call_id == 789
assert service_call.response_template == "Response: {{ response }}"


async def test_homeassistant_service_call_response_model() -> None:
"""Test HomeassistantServiceCallResponse model."""
response_data = json.dumps({"result": "success", "value": 42}).encode("utf-8")
response = HomeassistantActionResponseModel(
call_id=123, response_data=response_data, success=True, error_message=""
)

assert response.call_id == 123
assert response.response_data == response_data
assert response.success is True
assert response.error_message == ""


async def test_set_debug(
api_client: tuple[
APIClient, APIConnection, asyncio.Transport, APIPlaintextFrameHelper
Expand Down