From 6c362d42c3a72ddc7309ed38636687d6c511ce66 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:28:41 +1300 Subject: [PATCH] [api] Add support for getting action responses from home-assistant --- esphome/components/api/__init__.py | 53 +++++++++++- esphome/components/api/api.proto | 15 ++++ esphome/components/api/api_connection.cpp | 6 ++ esphome/components/api/api_connection.h | 1 + esphome/components/api/api_pb2.cpp | 30 +++++++ esphome/components/api/api_pb2.h | 23 +++++- esphome/components/api/api_pb2_dump.cpp | 9 +++ esphome/components/api/api_pb2_service.cpp | 11 +++ esphome/components/api/api_pb2_service.h | 3 + esphome/components/api/api_server.cpp | 23 ++++++ esphome/components/api/api_server.h | 9 +++ .../components/api/homeassistant_service.h | 81 +++++++++++++++++++ esphome/const.py | 2 + 13 files changed, 262 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 6a0e092008..0a7a0d347b 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -16,23 +16,26 @@ from esphome.const import ( CONF_KEY, CONF_ON_CLIENT_CONNECTED, CONF_ON_CLIENT_DISCONNECTED, + CONF_ON_RESPONSE, CONF_PASSWORD, CONF_PORT, CONF_REBOOT_TIMEOUT, + CONF_RESPONSE_TEMPLATE, CONF_SERVICE, CONF_SERVICES, CONF_TAG, CONF_TRIGGER_ID, CONF_VARIABLES, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import TemplateArgsType from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) DOMAIN = "api" DEPENDENCIES = ["network"] -AUTO_LOAD = ["socket"] +AUTO_LOAD = ["socket", "json"] CODEOWNERS = ["@esphome/core"] api_ns = cg.esphome_ns.namespace("api") @@ -40,6 +43,10 @@ APIServer = api_ns.class_("APIServer", cg.Component, cg.Controller) HomeAssistantServiceCallAction = api_ns.class_( "HomeAssistantServiceCallAction", automation.Action ) +ActionResponse = api_ns.class_("ActionResponse") +HomeAssistantActionResponseTrigger = api_ns.class_( + "HomeAssistantActionResponseTrigger", automation.Trigger +) APIConnectedCondition = api_ns.class_("APIConnectedCondition", Condition) UserServiceTrigger = api_ns.class_("UserServiceTrigger", automation.Trigger) @@ -244,6 +251,14 @@ async def to_code(config): KEY_VALUE_SCHEMA = cv.Schema({cv.string: cv.templatable(cv.string_strict)}) +def _validate_response_config(config): + if CONF_RESPONSE_TEMPLATE in config and not config.get(CONF_ON_RESPONSE): + raise cv.Invalid( + "`{CONF_RESPONSE_TEMPLATE}` requires `{CONF_ON_RESPONSE}` to be set." + ) + return config + + HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( cv.Schema( { @@ -259,10 +274,20 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( cv.Optional(CONF_VARIABLES, default={}): cv.Schema( {cv.string: cv.returning_lambda} ), + cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string), + cv.Optional(CONF_ON_RESPONSE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + HomeAssistantActionResponseTrigger + ), + }, + single=True, + ), } ), cv.has_exactly_one_key(CONF_SERVICE, CONF_ACTION), cv.rename_key(CONF_SERVICE, CONF_ACTION), + _validate_response_config, ) @@ -276,7 +301,12 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( HomeAssistantServiceCallAction, HOMEASSISTANT_ACTION_ACTION_SCHEMA, ) -async def homeassistant_service_to_code(config, action_id, template_arg, args): +async def homeassistant_service_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +): cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, False) @@ -291,6 +321,23 @@ async def homeassistant_service_to_code(config, action_id, template_arg, args): for key, value in config[CONF_VARIABLES].items(): templ = await cg.templatable(value, args, None) cg.add(var.add_variable(key, templ)) + + if response_template := config.get(CONF_RESPONSE_TEMPLATE): + templ = await cg.templatable(response_template, args, cg.std_string) + cg.add(var.set_response_template(templ)) + + if on_response := config.get(CONF_ON_RESPONSE): + trigger = cg.new_Pvariable( + on_response[CONF_TRIGGER_ID], + template_arg, + var, + ) + await automation.build_automation( + trigger, + [(cg.std_shared_ptr.template(ActionResponse), "response"), *args], + on_response, + ) + return var diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 0e385c4a17..b37344f566 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -780,6 +780,21 @@ message HomeassistantActionRequest { repeated HomeassistantServiceMap data_template = 3; repeated HomeassistantServiceMap variables = 4; bool is_event = 5; + uint32 call_id = 6; // Call ID for response tracking + string response_template = 7 [(no_zero_copy) = true]; // Optional Jinja template for response processing +} + +// 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_SERVICES"; + + 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 + string response_data = 4; // Service response data } // ==================== IMPORT HOME ASSISTANT STATES ==================== diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 30b98803d1..9d76adf98e 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1549,6 +1549,12 @@ void APIConnection::execute_service(const ExecuteServiceRequest &msg) { } } #endif + +#ifdef USE_API_HOMEASSISTANT_SERVICES +void APIConnection::on_homeassistant_action_response(const HomeassistantActionResponse &msg) { + this->parent_->handle_action_response(msg.call_id, msg.success, msg.error_message, msg.response_data); +}; +#endif #ifdef USE_API_NOISE bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryptionSetKeyRequest &msg) { NoiseEncryptionSetKeyResponse resp; diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index cc7e4d6895..401fef28dc 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -137,6 +137,7 @@ class APIConnection final : public APIServerConnection { return; this->send_message(call, HomeassistantActionRequest::MESSAGE_TYPE); } + void on_homeassistant_action_response(const HomeassistantActionResponse &msg) override; #endif #ifdef USE_BLUETOOTH_PROXY void subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) override; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 0140c60e5b..210b6505ce 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -884,6 +884,8 @@ void HomeassistantActionRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_message(4, it, true); } buffer.encode_bool(5, this->is_event); + buffer.encode_uint32(6, this->call_id); + buffer.encode_string(7, this->response_template); } void HomeassistantActionRequest::calculate_size(ProtoSize &size) const { size.add_length(1, this->service_ref_.size()); @@ -891,6 +893,34 @@ void HomeassistantActionRequest::calculate_size(ProtoSize &size) const { size.add_repeated_message(1, this->data_template); size.add_repeated_message(1, this->variables); size.add_bool(1, this->is_event); + size.add_uint32(1, this->call_id); + size.add_length(1, this->response_template.size()); +} +bool HomeassistantActionResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 1: + this->call_id = value.as_uint32(); + break; + case 2: + this->success = value.as_bool(); + break; + default: + return false; + } + return true; +} +bool HomeassistantActionResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 3: + this->error_message = value.as_string(); + break; + case 4: + this->response_data = value.as_string(); + break; + default: + return false; + } + return true; } #endif #ifdef USE_API_HOMEASSISTANT_STATES diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index d71ee9777d..aa5ef155ea 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1104,7 +1104,7 @@ class HomeassistantServiceMap final : public ProtoMessage { class HomeassistantActionRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 35; - static constexpr uint8_t ESTIMATED_SIZE = 113; + static constexpr uint8_t ESTIMATED_SIZE = 126; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "homeassistant_action_request"; } #endif @@ -1114,6 +1114,8 @@ class HomeassistantActionRequest final : public ProtoMessage { std::vector data_template{}; std::vector variables{}; bool is_event{false}; + uint32_t call_id{0}; + std::string response_template{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1122,6 +1124,25 @@ class HomeassistantActionRequest final : public ProtoMessage { protected: }; +class HomeassistantActionResponse final : public ProtoDecodableMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 130; + static constexpr uint8_t ESTIMATED_SIZE = 24; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "homeassistant_action_response"; } +#endif + uint32_t call_id{0}; + bool success{false}; + std::string error_message{}; + std::string response_data{}; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; #endif #ifdef USE_API_HOMEASSISTANT_STATES class SubscribeHomeAssistantStatesRequest final : public ProtoMessage { diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index c5f1d99dd4..9b655cc1a2 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1122,6 +1122,15 @@ void HomeassistantActionRequest::dump_to(std::string &out) const { out.append("\n"); } dump_field(out, "is_event", this->is_event); + dump_field(out, "call_id", this->call_id); + dump_field(out, "response_template", this->response_template); +} +void HomeassistantActionResponse::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "HomeassistantActionResponse"); + dump_field(out, "call_id", this->call_id); + dump_field(out, "success", this->success); + dump_field(out, "error_message", this->error_message); + dump_field(out, "response_data", this->response_data); } #endif #ifdef USE_API_HOMEASSISTANT_STATES diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index ccbd781431..6f596a2edc 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -610,6 +610,17 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_z_wave_proxy_request(msg); break; } +#endif +#ifdef USE_API_HOMEASSISTANT_SERVICES + case HomeassistantActionResponse::MESSAGE_TYPE: { + HomeassistantActionResponse msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + ESP_LOGVV(TAG, "on_homeassistant_action_response: %s", msg.dump().c_str()); +#endif + this->on_homeassistant_action_response(msg); + break; + } #endif default: break; diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 1afcba6664..f3f39d48ec 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -66,6 +66,9 @@ class APIServerConnectionBase : public ProtoService { virtual void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &value){}; #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES + virtual void on_homeassistant_action_response(const HomeassistantActionResponse &value){}; +#endif #ifdef USE_API_HOMEASSISTANT_STATES virtual void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &value){}; #endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index dd6eb950a6..254bdcd509 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -9,6 +9,9 @@ #include "esphome/core/log.h" #include "esphome/core/util.h" #include "esphome/core/version.h" +#ifdef USE_API_HOMEASSISTANT_SERVICES +#include "homeassistant_service.h" +#endif #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -387,6 +390,26 @@ void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call client->send_homeassistant_action(call); } } + +void APIServer::register_action_response_callback(uint32_t call_id, ActionResponseCallback callback) { + this->action_response_callbacks_[call_id] = callback; +} + +void APIServer::handle_action_response(uint32_t call_id, bool success, const std::string &error_message, + const std::string &response_data) { + auto it = this->action_response_callbacks_.find(call_id); + if (it != this->action_response_callbacks_.end()) { + // Create the response object + auto response = std::make_shared(success, error_message); + response->set_data(response_data); + + // Call the callback + it->second(response); + + // Remove the callback as it's one-time use + this->action_response_callbacks_.erase(it); + } +} #endif #ifdef USE_API_HOMEASSISTANT_STATES diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 627870af1d..d6fca08cff 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -16,6 +16,7 @@ #include "user_services.h" #endif +#include #include namespace esphome::api { @@ -109,6 +110,11 @@ class APIServer : public Component, public Controller { #ifdef USE_API_HOMEASSISTANT_SERVICES void send_homeassistant_action(const HomeassistantActionRequest &call); + // Action response handling + using ActionResponseCallback = std::function)>; + void register_action_response_callback(uint32_t call_id, ActionResponseCallback callback); + void handle_action_response(uint32_t call_id, bool success, const std::string &error_message, + const std::string &response_data); #endif #ifdef USE_API_SERVICES void register_user_service(UserServiceDescriptor *descriptor) { this->user_services_.push_back(descriptor); } @@ -185,6 +191,9 @@ class APIServer : public Component, public Controller { #ifdef USE_API_SERVICES std::vector user_services_; #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES + std::map action_response_callbacks_; +#endif // Group smaller types together uint16_t port_{6053}; diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 4026741ee4..ac568e6518 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -3,8 +3,10 @@ #include "api_server.h" #ifdef USE_API #ifdef USE_API_HOMEASSISTANT_SERVICES +#include #include #include "api_pb2.h" +#include "esphome/components/json/json_util.h" #include "esphome/core/automation.h" #include "esphome/core/helpers.h" @@ -44,6 +46,43 @@ template class TemplatableKeyValuePair { TemplatableStringValue value; }; +// Represents the response data from a Home Assistant action +class ActionResponse { + public: + ActionResponse(bool success, const std::string &error_message = "") + : success_(success), error_message_(error_message) {} + + bool is_success() const { return this->success_; } + const std::string &get_error_message() const { return this->error_message_; } + const std::string &get_data() const { return this->data_; } + // Get data as parsed JSON object + // Returns unbound JsonObject if data is empty or invalid JSON + JsonObject get_json() { + if (this->data_.empty()) + return JsonObject(); // Return unbound JsonObject if no data + + if (!this->parsed_json_) { + this->json_document_ = json::parse_json(this->data_); + this->json_ = this->json_document_.as(); + this->parsed_json_ = true; + } + return this->json_; + } + + void set_data(const std::string &data) { this->data_ = data; } + + protected: + bool success_; + std::string error_message_; + std::string data_; + JsonDocument json_document_; + JsonObject json_; + bool parsed_json_{false}; +}; + +// Callback type for action responses +template using ActionResponseCallback = std::function, Ts...)>; + template class HomeAssistantServiceCallAction : public Action { public: explicit HomeAssistantServiceCallAction(APIServer *parent, bool is_event) : parent_(parent), is_event_(is_event) {} @@ -61,6 +100,16 @@ template class HomeAssistantServiceCallAction : public Actionvariables_.emplace_back(std::move(key), value); } + template void set_response_template(T response_template) { + this->response_template_ = response_template; + this->has_response_template_ = true; + } + + void set_response_callback(ActionResponseCallback callback) { + this->wants_response_ = true; + this->response_callback_ = callback; + } + void play(Ts... x) override { HomeassistantActionRequest resp; std::string service_value = this->service_.value(x...); @@ -84,6 +133,25 @@ template class HomeAssistantServiceCallAction : public Actionwants_response_) { + // Generate a unique call ID for this service call + static uint32_t call_id_counter = 1; + uint32_t call_id = call_id_counter++; + resp.call_id = call_id; + // Set response template if provided + if (this->has_response_template_) { + std::string response_template_value = this->response_template_.value(x...); + resp.response_template = response_template_value; + } + + auto captured_args = std::make_tuple(x...); + this->parent_->register_action_response_callback(call_id, [this, captured_args]( + std::shared_ptr response) { + std::apply([this, &response](auto &&...args) { this->response_callback_(response, args...); }, captured_args); + }); + } + this->parent_->send_homeassistant_action(resp); } @@ -94,6 +162,19 @@ template class HomeAssistantServiceCallAction : public Action> data_; std::vector> data_template_; std::vector> variables_; + TemplatableStringValue response_template_{""}; + ActionResponseCallback response_callback_; + bool wants_response_{false}; + bool has_response_template_{false}; +}; + +template +class HomeAssistantActionResponseTrigger : public Trigger, Ts...> { + public: + HomeAssistantActionResponseTrigger(HomeAssistantServiceCallAction *action) { + action->set_response_callback( + [this](std::shared_ptr response, Ts... x) { this->trigger(response, x...); }); + } }; } // namespace esphome::api diff --git a/esphome/const.py b/esphome/const.py index 3e93200f14..ee424d095e 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -671,6 +671,7 @@ CONF_ON_PRESET_SET = "on_preset_set" CONF_ON_PRESS = "on_press" CONF_ON_RAW_VALUE = "on_raw_value" CONF_ON_RELEASE = "on_release" +CONF_ON_RESPONSE = "on_response" CONF_ON_SHUTDOWN = "on_shutdown" CONF_ON_SPEED_SET = "on_speed_set" CONF_ON_STATE = "on_state" @@ -816,6 +817,7 @@ CONF_RESET_DURATION = "reset_duration" CONF_RESET_PIN = "reset_pin" CONF_RESIZE = "resize" CONF_RESOLUTION = "resolution" +CONF_RESPONSE_TEMPLATE = "response_template" CONF_RESTART = "restart" CONF_RESTORE = "restore" CONF_RESTORE_MODE = "restore_mode"