From 6f940a486b946eae3b0ffb30c5a95d1aa6a49ed9 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:49:31 +1300 Subject: [PATCH] [api] Add a device wizard for Home Assistant onboarding (#19966) Co-authored-by: J. Nick Koston --- esphome/codegen.py | 1 + esphome/components/api/__init__.py | 13 + esphome/components/api/api.proto | 91 ++ esphome/components/api/api_connection.cpp | 18 +- esphome/components/api/api_connection.h | 16 + esphome/components/api/api_pb2.cpp | 51 + esphome/components/api/api_pb2.h | 66 +- esphome/components/api/api_pb2_dump.cpp | 27 + esphome/components/api/api_pb2_service.cpp | 20 + esphome/components/api/api_pb2_service.h | 7 + esphome/components/api/api_wizard.cpp | 104 ++ esphome/components/api/api_wizard.h | 62 ++ esphome/components/api/wizard.py | 461 +++++++++ esphome/components/homeassistant/__init__.py | 38 +- .../homeassistant/binary_sensor/__init__.py | 3 + .../homeassistant/button/__init__.py | 3 + .../button/homeassistant_button.cpp | 7 + .../homeassistant/number/__init__.py | 3 + .../number/homeassistant_number.cpp | 7 + .../homeassistant/select/__init__.py | 3 + .../select/homeassistant_select.cpp | 7 + .../homeassistant/sensor/__init__.py | 3 + .../homeassistant/switch/__init__.py | 3 + .../switch/homeassistant_switch.cpp | 7 + .../components/homeassistant/text/__init__.py | 3 + .../homeassistant/text/homeassistant_text.cpp | 7 + .../homeassistant/text_sensor/__init__.py | 3 + esphome/core/defines.h | 6 + esphome/cpp_generator.py | 17 + requirements.txt | 1 + tests/component_tests/api/test_wizard.py | 942 ++++++++++++++++++ tests/components/api/__init__.py | 12 + tests/components/api/common-wizard.yaml | 108 ++ .../components/api/test-wizard.esp32-idf.yaml | 6 + .../api/test-wizard.esp8266-ard.yaml | 6 + tests/components/api/test-wizard.host.yaml | 4 + tests/components/api/test_wizard.cpp | 150 +++ .../components/api/test_api_proto.py | 87 ++ tests/unit_tests/test_cpp_generator.py | 8 + 39 files changed, 2372 insertions(+), 9 deletions(-) create mode 100644 esphome/components/api/api_wizard.cpp create mode 100644 esphome/components/api/api_wizard.h create mode 100644 esphome/components/api/wizard.py create mode 100644 tests/component_tests/api/test_wizard.py create mode 100644 tests/components/api/common-wizard.yaml create mode 100644 tests/components/api/test-wizard.esp32-idf.yaml create mode 100644 tests/components/api/test-wizard.esp8266-ard.yaml create mode 100644 tests/components/api/test-wizard.host.yaml create mode 100644 tests/components/api/test_wizard.cpp diff --git a/esphome/codegen.py b/esphome/codegen.py index daf53db43f..e7c08048b9 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -31,6 +31,7 @@ from esphome.cpp_generator import ( # noqa: F401 add_global, add_library, add_platformio_option, + extern_progmem_array, get_variable, get_variable_with_full_id, is_template, diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 9b65f052cc..ff3e215a40 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -57,6 +57,8 @@ from esphome.helpers import fnv1_hash from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigFragmentType, ConfigType +from . import wizard + # Compat alias: downstream consumers (e.g. device-builder) referenced the # schema by its old private name before it moved to the noise component _encryption_schema = encryption_schema @@ -389,6 +391,7 @@ CONFIG_SCHEMA = cv.All( ): ACTIONS_SCHEMA, cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA, cv.Optional(CONF_ENCRYPTION): encryption_schema, + cv.Optional(wizard.CONF_WIZARD): wizard.WIZARD_SCHEMA, cv.Optional(CONF_OUTGOING_CONNECTION): _outgoing_connection_schema, cv.Optional(CONF_BATCH_DELAY, default=DEFAULT_BATCH_DELAY): cv.All( cv.positive_time_period_milliseconds, @@ -520,9 +523,15 @@ def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType: return config +def _validate_wizard(config: ConfigType) -> ConfigType: + wizard.final_validate(config) + return config + + FINAL_VALIDATE_SCHEMA = cv.All( _validate_esp8266_action_strings, _validate_outgoing_host_ipv6, + _validate_wizard, ) @@ -678,6 +687,9 @@ async def to_code(config: ConfigType) -> None: # Stack buffer that list-entities copies PROGMEM strings into, sized for the largest action cg.add_define("API_USER_ACTION_STRINGS_SCRATCH_SIZE", max(scratch_size, 1)) + if (wizard_config := config.get(wizard.CONF_WIZARD)) is not None: + await wizard.to_code(wizard_config) + if CONF_ON_CLIENT_CONNECTED in config: cg.add_define("USE_API_CLIENT_CONNECTED_TRIGGER") await automation.build_automation( @@ -1106,6 +1118,7 @@ _define_filter = filter_source_files_from_defines( "user_services.cpp": "USE_API_USER_DEFINED_ACTIONS", "api_frame_helper_noise.cpp": "USE_API_NOISE", "api_frame_helper_plaintext.cpp": "USE_API_PLAINTEXT", + "api_wizard.cpp": "USE_API_WIZARD", "api_outgoing_connection.cpp": "USE_API_OUTGOING_CONNECTION", } ) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4fce8d7b7f..2648b5d0d8 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -20,6 +20,8 @@ service APIConnection { option (needs_authentication) = false; } rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {} + rpc device_wizard (DeviceWizardRequest) returns (DeviceWizardResponse) {} + rpc wizard_input_set (WizardInputSetRequest) returns (void) {} rpc list_entities (ListEntitiesRequest) returns (void) {} rpc subscribe_states (SubscribeStatesRequest) returns (void) {} rpc subscribe_logs (SubscribeLogsRequest) returns (void) {} @@ -391,6 +393,11 @@ message ZWaveProxyCapabilities { uint32 home_id = 2; } +message WizardCapabilities { + // True when the device has a wizard configured, so DeviceWizardRequest will be answered + bool configured = 1; +} + message DeviceCapabilitiesResponse { option (id) = 150; option (source) = SOURCE_SERVER; @@ -400,6 +407,90 @@ message DeviceCapabilitiesResponse { ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"]; repeated SerialProxyInfo serial_proxies = 4 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; + WizardCapabilities wizard = 5 [(field_ifdef) = "USE_API_WIZARD"]; +} + +// ==================== DEVICE WIZARD ==================== + +// Asks the device for the onboarding wizard that Home Assistant presents when the +// device is added. +// +// The wizard is read once, when the device is added, whereas capabilities are read +// on every connect, so it is a separate message rather than part of +// DeviceCapabilitiesResponse. It is only served on an authenticated connection. +// +// A device without a wizard does not have this message at all and never answers it, +// as a device ignores messages it does not know. Clients therefore check +// DeviceCapabilitiesResponse.wizard.configured before sending it. +message DeviceWizardRequest { + option (id) = 156; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_API_WIZARD"; + // Empty +} + +// The wizard, as UTF-8 JSON compressed into a single zstd frame. The device builds it +// when it is compiled and sends it unchanged, so its size varies with the wizard and +// its format can grow without new fields here. Clients decompress it, then read: +// +// {"version": 1, +// "pages": [{"title": "...", "description": "...", +// "entities": [{"key": 123, "device_id": 456, "description": "..."}], +// "inputs": [{"key": 789, "description": "...", +// "entity_filters": [{"integration": "...", "domain": ["..."], +// "device_class": ["..."], "supported_features": ["..."]}]}]}]} +// +// Values that are empty or unset are left out, and so are empty lists. Strings may be +// Home Assistant translation placeholders such as "[%key:component::domain::name%]", +// passed through verbatim. The pages are shown in the order listed. A client that does +// not know the version must not use the wizard. +// +// An entity is an entity of this device that the page shows to the user (for example to +// turn it on or off). Its key is the key ListEntitiesResponse sends for it. Its +// device_id is the device_id ListEntitiesResponse sends, and is left out when that is 0. +// +// An input is filled in by the user with the id of a Home Assistant entity. It either +// stands for one entity of the device's homeassistant platform (sensor, binary_sensor, +// text_sensor, number, switch, text, select or button) that sets no entity_id of its own, +// as Home Assistant supplies it, or it is a standalone input that only stores the entity +// id for the device's own automations. Its key is the FNV-1 hash +// of the input's ESPHome id, not an entity key. The client sends the chosen entity id to +// the device with a WizardInputSetRequest. entity_filters says which Home Assistant +// entities the user may choose, and mirrors Home Assistant's EntityFilterSelectorConfig: +// an entity matches a filter when it satisfies every field that is set, and an entity +// that matches any one of the filters is accepted. +// +// The device keeps the entity id of an input in RAM only and does not store it across +// restarts. The client therefore stores the choice itself and, after every connection +// and before it sends SubscribeHomeAssistantStatesRequest, sends a WizardInputSetRequest +// for each input. It sends one again whenever the user changes the choice. +message DeviceWizardResponse { + option (id) = 157; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_API_WIZARD"; + // Not logged: the data is in flash, which ESP8266 cannot read for a dump + option (log) = false; + + // The data is in flash, and the device sends it from there + bytes data = 1 [(pointer_to_buffer) = true]; +} + +// Sets the entity id of a wizard input (see WizardInputField.key). Clients send +// this only when DeviceCapabilitiesResponse.wizard.configured is set. There is no +// reply. The device ignores a request whose key matches no input or whose entity_id +// is empty, longer than 255 bytes or has no '.'. Otherwise it keeps the id in RAM, +// not across restarts, and when the input belongs to a homeassistant platform entity +// it subscribes to that entity's state. Clients send this for every input right +// after connecting, before SubscribeHomeAssistantStatesRequest. A request that +// arrives later still works: the device then sends the affected state subscriptions +// again. +message WizardInputSetRequest { + option (id) = 158; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_API_WIZARD_INPUTS"; + + fixed32 key = 1; + string entity_id = 2 [(max_data_length) = 255]; } message ListEntitiesRequest { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index a5d91de551..c5457d9482 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2085,6 +2085,9 @@ bool APIConnection::send_device_capabilities_response_() { info.port_type = proxy->get_port_type(); info.configured_line_states = proxy->get_configured_modem_pins(); } +#endif +#ifdef USE_API_WIZARD + resp.wizard.configured = true; #endif return this->send_message(resp); } @@ -2299,7 +2302,13 @@ void APIConnection::on_noise_encryption_set_key_request(const NoiseEncryptionSet } #endif #ifdef USE_API_HOMEASSISTANT_STATES -void APIConnection::on_subscribe_home_assistant_states_request() { state_subs_at_ = 0; } +void APIConnection::on_subscribe_home_assistant_states_request() { +#ifdef USE_API_WIZARD_LINKED_INPUTS + // Remember it, as a client that subscribed also gets the subscriptions again when a wizard input is set + this->flags_.home_assistant_states = true; +#endif + state_subs_at_ = 0; +} #endif bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) { delay(0); @@ -2742,6 +2751,13 @@ void APIConnection::process_state_subscriptions_() { } const auto &it = subs[this->state_subs_at_]; +#ifdef USE_API_WIZARD_LINKED_INPUTS + // An entity id that is not set yet (a wizard input) has nothing to subscribe to; it is sent once it is set + if (it.entity_id[0] == '\0') { + this->state_subs_at_++; + return; + } +#endif SubscribeHomeAssistantStateResponse resp; resp.entity_id = StringRef(it.entity_id); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index df9089f623..84f8ec9597 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -279,6 +279,12 @@ class APIConnection final : public APIServerConnectionBase { void on_ping_request(); void on_device_info_request(); void on_device_capabilities_request(); +#ifdef USE_API_WIZARD + void on_device_wizard_request(); +#endif +#ifdef USE_API_WIZARD_INPUTS + void on_wizard_input_set_request(const WizardInputSetRequest &msg); +#endif void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } void on_subscribe_states_request() { this->flags_.state_subscription = true; @@ -308,6 +314,10 @@ class APIConnection final : public APIServerConnectionBase { #endif #ifdef USE_API_HOMEASSISTANT_STATES void on_subscribe_home_assistant_states_request(); +#ifdef USE_API_WIZARD_LINKED_INPUTS + /// Tell this client about the subscriptions whose entity id is stored in the given buffer, as the buffer changed + void resend_state_subscriptions(const char *entity_id); +#endif #endif #ifdef USE_API_USER_DEFINED_ACTIONS void on_execute_service_request(const ExecuteServiceRequest &msg); @@ -413,6 +423,9 @@ class APIConnection final : public APIServerConnectionBase { bool send_ping_response_(); bool send_device_info_response_(); bool send_device_capabilities_response_(); +#ifdef USE_API_WIZARD + bool send_device_wizard_response_(); +#endif #ifdef USE_API_NOISE bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg); #endif @@ -757,6 +770,9 @@ class APIConnection final : public APIServerConnectionBase { uint8_t batch_first_message : 1; // For batch buffer allocation uint8_t should_try_send_immediately : 1; // True after initial states are sent uint8_t may_have_remaining_data : 1; // Read loop hit limit, retry without ready check +#ifdef USE_API_WIZARD_LINKED_INPUTS + uint8_t home_assistant_states : 1; // Client subscribed to Home Assistant states +#endif #ifdef USE_API_OUTGOING_CONNECTION uint8_t outgoing_connection_target : 1; // Client declared itself a dial-back target in its hello #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index b64b349246..58ae9184e2 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -302,6 +302,20 @@ uint32_t ZWaveProxyCapabilities::calc_size_msg(const void *self) { return size; } #endif +#ifdef USE_API_WIZARD +uint8_t *WizardCapabilities::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + uint8_t *__restrict__ pos = buffer.get_pos(); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.configured); + return pos; +} +uint32_t WizardCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_bool(1, msg.configured); + return size; +} +#endif uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); @@ -318,6 +332,9 @@ uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, ProtoWriteBuff for (const auto &it : msg.serial_proxies) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); } +#endif +#ifdef USE_API_WIZARD + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 5, msg.wizard); #endif return pos; } @@ -337,9 +354,40 @@ uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) { for (const auto &it : msg.serial_proxies) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } +#endif +#ifdef USE_API_WIZARD + size += ProtoSize::calc_message(1, msg.wizard.calculate_size()); #endif return size; } +#ifdef USE_API_WIZARD +uint8_t *DeviceWizardResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + uint8_t *__restrict__ pos = buffer.get_pos(); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); + return pos; +} +uint32_t DeviceWizardResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_length(1, msg.data_len); + return size; +} +#endif +#ifdef USE_API_WIZARD_INPUTS +void WizardInputSetRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.entity_id = StringRef(value.data(), value.size()); + break; + } +} +#endif #ifdef USE_BINARY_SENSOR uint8_t *ListEntitiesBinarySensorResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { @@ -4284,6 +4332,9 @@ uint32_t BluetoothSetConnectionParamsResponse::calc_size_msg(const void *self) { #ifndef HAS_PROTO_MESSAGE_DUMP static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#ifdef USE_API_WIZARD_INPUTS +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif #ifdef USE_COVER static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); #endif diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 91c218b16c..faa9c979fc 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -720,10 +720,27 @@ class ZWaveProxyCapabilities final : public ProtoMessage { protected: }; #endif +#ifdef USE_API_WIZARD +class WizardCapabilities final : public ProtoMessage { + public: + bool configured{false}; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif class DeviceCapabilitiesResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 150; - static constexpr uint8_t ESTIMATED_SIZE = 102; + static constexpr uint8_t ESTIMATED_SIZE = 119; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } #endif @@ -738,6 +755,9 @@ class DeviceCapabilitiesResponse final : public ProtoMessage { #endif #ifdef USE_SERIAL_PROXY std::array serial_proxies{}; +#endif +#ifdef USE_API_WIZARD + WizardCapabilities wizard{}; #endif static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -751,6 +771,50 @@ class DeviceCapabilitiesResponse final : public ProtoMessage { protected: }; +#ifdef USE_API_WIZARD +class DeviceWizardResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 157; + static constexpr uint8_t ESTIMATED_SIZE = 19; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("device_wizard_response"); } +#endif + const uint8_t *data{nullptr}; + uint16_t data_len{0}; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif +#ifdef USE_API_WIZARD_INPUTS +class WizardInputSetRequest final : public ProtoDecodableMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 158; + static constexpr uint8_t ESTIMATED_SIZE = 14; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("wizard_input_set_request"); } +#endif + uint32_t key{0}; + StringRef entity_id{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); +}; +#endif class ListEntitiesDoneResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 19; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 79f9f360bd..966ba8f2bf 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1078,6 +1078,13 @@ const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif +#ifdef USE_API_WIZARD +const char *WizardCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("WizardCapabilities")); + dump_field(out, ESPHOME_PSTR("configured"), this->configured); + return out.c_str(); +} +#endif const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse")); #ifdef USE_BLUETOOTH_PROXY @@ -1101,9 +1108,29 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { it.dump_to(out); out.append("\n"); } +#endif +#ifdef USE_API_WIZARD + out.append(2, ' ').append_p(ESPHOME_PSTR("wizard")).append(": "); + this->wizard.dump_to(out); + out.append("\n"); #endif return out.c_str(); } +#ifdef USE_API_WIZARD +const char *DeviceWizardResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceWizardResponse")); + dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len); + return out.c_str(); +} +#endif +#ifdef USE_API_WIZARD_INPUTS +const char *WizardInputSetRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("WizardInputSetRequest")); + dump_field(out, ESPHOME_PSTR("key"), this->key); + dump_field(out, ESPHOME_PSTR("entity_id"), this->entity_id); + return out.c_str(); +} +#endif const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const { out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}")); return out.c_str(); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index afc99f93c8..1ef70f1fd4 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -731,6 +731,26 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_subscribe_serial_proxy_identity_request(); break; } +#endif +#ifdef USE_API_WIZARD + case 156 /* DeviceWizardRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_device_wizard_request")); +#endif + this->on_device_wizard_request(); + break; + } +#endif +#ifdef USE_API_WIZARD_INPUTS + case WizardInputSetRequest::MESSAGE_TYPE: { + WizardInputSetRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_wizard_input_set_request"), msg); +#endif + this->on_wizard_input_set_request(msg); + break; + } #endif default: break; diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index ea89984ac5..13fc03e03e 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -29,6 +29,13 @@ class APIServerConnectionBase { void on_device_capabilities_request(){}; +#ifdef USE_API_WIZARD + void on_device_wizard_request(){}; +#endif + +#ifdef USE_API_WIZARD_INPUTS + void on_wizard_input_set_request(const WizardInputSetRequest &value){}; +#endif void on_list_entities_request(){}; void on_subscribe_states_request(){}; diff --git a/esphome/components/api/api_wizard.cpp b/esphome/components/api/api_wizard.cpp new file mode 100644 index 0000000000..7d4e8fdcea --- /dev/null +++ b/esphome/components/api/api_wizard.cpp @@ -0,0 +1,104 @@ +#include "api_wizard.h" + +#ifdef USE_API_WIZARD + +#include + +#include "api_connection.h" +#include "api_pb2.h" +#include "api_server.h" +#include "esphome/core/log.h" + +namespace esphome::api { + +static const char *const TAG = "api.wizard"; + +uint8_t *wizard_encode_response(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + uint8_t *__restrict__ pos = buffer.get_pos(); + if (msg.data_len == 0) + return pos; + pos = ProtoEncode::encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, 1, 2); // type 2: Length-delimited + pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.data_len); + PROTO_ENCODE_CHECK_BOUNDS(pos, msg.data_len); + progmem_memcpy(pos, msg.data, msg.data_len); + return pos + msg.data_len; +} + +#ifdef USE_API_WIZARD_INPUTS +static bool wizard_entity_id_valid(const char *entity_id, size_t length) { + return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr; +} + +const char *wizard_set_input(const WizardInputSetRequest &msg) { + if (!wizard_entity_id_valid(msg.entity_id.c_str(), msg.entity_id.size())) { + ESP_LOGW(TAG, "Ignoring an invalid entity id for wizard input"); + return nullptr; + } + for (size_t i = 0; i < API_WIZARD_INPUT_COUNT; i++) { + // The table is in flash, which ESP8266 can only read through progmem_memcpy + WizardInputEntry entry; + progmem_memcpy(&entry, &API_WIZARD_INPUTS[i], sizeof(entry)); + if (entry.key != msg.key) + continue; + memcpy(entry.entity_id, msg.entity_id.c_str(), msg.entity_id.size()); + entry.entity_id[msg.entity_id.size()] = '\0'; + return entry.entity_id; + } + ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input"); + return nullptr; +} +#endif // USE_API_WIZARD_INPUTS + +bool APIConnection::send_device_wizard_response_() { + DeviceWizardResponse resp; + resp.data = API_WIZARD_DATA; + resp.data_len = API_WIZARD_DATA_SIZE; + // Not send_message: the data is in flash, so wizard_encode_response copies it out + return this->send_message_(DeviceWizardResponse::calc_size_msg(&resp), DeviceWizardResponse::MESSAGE_TYPE, + &wizard_encode_response, &resp); +} + +void APIConnection::on_device_wizard_request() { + if (!this->send_device_wizard_response_()) { + this->on_fatal_error(); + } +} + +#ifdef USE_API_WIZARD_INPUTS +void APIConnection::on_wizard_input_set_request(const WizardInputSetRequest &msg) { + const char *entity_id = wizard_set_input(msg); + if (entity_id == nullptr) + return; +#ifdef USE_API_WIZARD_LINKED_INPUTS + // Entities subscribed to the buffer before it held an entity id, so every client needs to learn of it now + for (auto &client : this->parent_->active_clients()) { + client->resend_state_subscriptions(entity_id); + } +#endif +} +#endif + +#ifdef USE_API_WIZARD_LINKED_INPUTS +void APIConnection::resend_state_subscriptions(const char *entity_id) { + if (!this->flags_.home_assistant_states) + return; + for (const auto &it : this->parent_->get_state_subs()) { + if (it.entity_id != entity_id) + continue; + SubscribeHomeAssistantStateResponse resp; + resp.entity_id = StringRef(it.entity_id); + resp.attribute = it.attribute != nullptr ? StringRef(it.attribute) : StringRef(""); + resp.once = it.once; + if (!this->send_message(resp)) { + // Could not send now: send every subscription again from the loop + this->state_subs_at_ = 0; + return; + } + } +} +#endif + +} // namespace esphome::api + +#endif // USE_API_WIZARD diff --git a/esphome/components/api/api_wizard.h b/esphome/components/api/api_wizard.h new file mode 100644 index 0000000000..0ba204d3b7 --- /dev/null +++ b/esphome/components/api/api_wizard.h @@ -0,0 +1,62 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_API_WIZARD + +#include +#include + +#include "esphome/core/hal.h" +#include "proto.h" +#include "esphome/core/string_ref.h" + +namespace esphome::api { + +class WizardInputSetRequest; + +/// Size of the buffer holding the entity id of a wizard input. Home Assistant entity ids are at most 255 bytes. +static constexpr size_t WIZARD_ENTITY_ID_BUFFER_SIZE = 256; + +/// The wizard as zstd compressed JSON (see DeviceWizardResponse), built by the generated code +/// (components/api/wizard.py) and kept in flash. API_WIZARD_DATA_SIZE bytes long. +extern const uint8_t API_WIZARD_DATA[] PROGMEM; + +/// Encodes a DeviceWizardResponse like the generated encoder would. The data is in flash, which ESP8266 can only read +/// with progmem_memcpy, so the generated encoder (a plain memcpy) cannot be used. Plain memcpy elsewhere. +uint8_t *wizard_encode_response(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + +#ifdef USE_API_WIZARD_INPUTS +/// Where the entity id of an input is kept, found by the key the client uses for it. +struct WizardInputEntry { + uint32_t key; // FNV-1 hash of the ESPHome id of the input + char *entity_id; // RAM buffer of WIZARD_ENTITY_ID_BUFFER_SIZE bytes, shared with the homeassistant entity +}; + +/// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long. +extern const WizardInputEntry API_WIZARD_INPUTS[] PROGMEM; + +/// Apply a WizardInputSetRequest: validate it and copy the entity id into the input's buffer. Nothing is stored +/// across restarts, so the client sends the choices again after every connection. +/// Returns the buffer, or nullptr when the request was ignored. +const char *wizard_set_input(const WizardInputSetRequest &msg); +#endif + +#ifdef USE_API_WIZARD_STANDALONE_INPUTS +/// An input of the wizard that is not tied to an entity of the device. The entity ID the user picks is only ever read +/// by lambdas, for example `id(input).entity_id()`. It lives in the same RAM buffer a linked input uses. +class WizardInput { + public: + explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {} + /// The Home Assistant entity ID, empty until the wizard sets one. + StringRef entity_id() const { return StringRef(this->entity_id_); } + bool has_entity_id() const { return this->entity_id_[0] != '\0'; } + + protected: + const char *entity_id_; +}; +#endif + +} // namespace esphome::api + +#endif // USE_API_WIZARD diff --git a/esphome/components/api/wizard.py b/esphome/components/api/wizard.py new file mode 100644 index 0000000000..314b907e4d --- /dev/null +++ b/esphome/components/api/wizard.py @@ -0,0 +1,461 @@ +"""The device wizard of the api component: schema, validation and code generation. + +Home Assistant shows the wizard when the device is added. See api_wizard.h for the C++ side. +""" + +from collections.abc import Callable +import importlib +import json +from types import ModuleType +from typing import Any + +from esphome import automation +import esphome.codegen as cg +from esphome.components.const import CONF_DESCRIPTION +import esphome.config_validation as cv +from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_ID, + CONF_INTERNAL, + CONF_NAME, + CONF_PAGES, + CONF_PLATFORM, + CONF_TARGET, +) +from esphome.core import CORE, ID +import esphome.final_validate as fv +from esphome.helpers import fnv1_hash, fnv1_hash_object_id, fnv1a_32bit_hash +from esphome.types import ConfigType + +API_DOMAIN = "api" + +CONF_ENTITIES = "entities" +CONF_ENTITY = "entity" +CONF_INPUTS = "inputs" +CONF_INTEGRATION = "integration" +CONF_SUPPORTED_FEATURES = "supported_features" +CONF_TITLE = "title" +CONF_WIZARD = "wizard" + +_API = cg.esphome_ns.namespace("api") +WizardInput = _API.class_("WizardInput") + +WIZARD_ENTITY_ID_BUFFER_SIZE = ( + 256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes +) +# One API message must fit APIBuffer::MAX_SIZE (65535) together with the largest frame header (7 bytes, +# Noise) and footer (16 bytes, Noise MAC) +WIZARD_RESPONSE_MAX_SIZE = 65535 - 7 - 16 +# Version of the JSON the wizard is sent as, see wizard_document() +WIZARD_JSON_VERSION = 1 +# zstd level the JSON is compressed with. Output is deterministic for a given zstd version. +WIZARD_ZSTD_LEVEL = 19 + +# Wizard string limits; api.proto documents the same values +WIZARD_TITLE_MAX_LENGTH = 127 +WIZARD_DESCRIPTION_MAX_LENGTH = 255 +WIZARD_FILTER_MAX_LENGTH = 63 +WIZARD_SUPPORTED_FEATURE_MAX_LENGTH = 127 + + +def _wizard_text(max_length: int) -> Callable[[Any], str]: + """A string passed to Home Assistant verbatim, so it may be a [%key:...%] translation placeholder.""" + return cv.All(cv.string_strict, cv.Length(max=max_length)) + + +def _wizard_strings(max_length: int) -> Callable[[Any], list[str]]: + """A single string or a list of strings, always validated to a non-empty list.""" + return cv.All( + cv.ensure_list(cv.All(cv.string_strict, cv.Length(min=1, max=max_length))), + cv.Length(min=1), + ) + + +# Mirrors Home Assistant's EntityFilterSelectorConfig +WIZARD_ENTITY_FILTER_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_INTEGRATION): cv.All( + cv.string_strict, cv.Length(min=1, max=WIZARD_FILTER_MAX_LENGTH) + ), + cv.Optional(CONF_DOMAIN): _wizard_strings(WIZARD_FILTER_MAX_LENGTH), + cv.Optional(CONF_DEVICE_CLASS): _wizard_strings(WIZARD_FILTER_MAX_LENGTH), + cv.Optional(CONF_SUPPORTED_FEATURES): _wizard_strings( + WIZARD_SUPPORTED_FEATURE_MAX_LENGTH + ), + } + ), + cv.has_at_least_one_key( + CONF_INTEGRATION, CONF_DOMAIN, CONF_DEVICE_CLASS, CONF_SUPPORTED_FEATURES + ), +) + +WIZARD_ENTITY_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(cg.EntityBase), + cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH), + } +) + + +# An input is either standalone (id declares a new WizardInput) or linked to a homeassistant entity that has no +# entity_id of its own: Home Assistant sets it +WIZARD_INPUT_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_ID): cv.declare_id(WizardInput), + cv.Optional(CONF_ENTITY): cv.use_id(cg.EntityBase), + cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH), + cv.Optional(CONF_TARGET): cv.Schema( + { + cv.Required(CONF_ENTITY): cv.All( + cv.ensure_list(WIZARD_ENTITY_FILTER_SCHEMA), cv.Length(min=1) + ), + } + ), + } + ), + cv.has_exactly_one_key(CONF_ID, CONF_ENTITY), +) + +WIZARD_PAGE_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_TITLE): _wizard_text(WIZARD_TITLE_MAX_LENGTH), + cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH), + cv.Optional(CONF_ENTITIES): cv.All( + cv.ensure_list(WIZARD_ENTITY_SCHEMA), cv.Length(min=1) + ), + cv.Optional(CONF_INPUTS): cv.All( + cv.ensure_list(WIZARD_INPUT_SCHEMA), cv.Length(min=1) + ), + } + ), + cv.has_at_least_one_key(CONF_ENTITIES, CONF_INPUTS), +) + + +def _wizard_inputs(wizard: ConfigType) -> list[ConfigType]: + return [conf for page in wizard[CONF_PAGES] for conf in page.get(CONF_INPUTS, [])] + + +def _wizard_input_id(conf: ConfigType) -> ID: + """The ID that names an input: the one a standalone input declares, or its linked entity.""" + return conf[CONF_ID] if CONF_ID in conf else conf[CONF_ENTITY] + + +def _validate_unique_wizard_inputs(wizard: ConfigType) -> ConfigType: + """An input is identified on the wire by a hash of its ID, so IDs and hashes must be unique.""" + seen: dict[int, str] = {} + for conf in _wizard_inputs(wizard): + input_id = _wizard_input_id(conf).id + if (key := fnv1_hash(input_id)) in seen: + if seen[key] == input_id: + raise cv.Invalid(f"Wizard input '{input_id}' is used more than once") + raise cv.Invalid( + f"Wizard inputs '{seen[key]}' and '{input_id}' have the same hash, rename one" + ) + seen[key] = input_id + return wizard + + +WIZARD_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_PAGES): cv.All( + cv.ensure_list(WIZARD_PAGE_SCHEMA), cv.Length(min=1) + ), + } + ), + _validate_unique_wizard_inputs, +) + +# Platforms of the homeassistant component that a wizard input can stand for +WIZARD_INPUT_DOMAINS = ( + "binary_sensor", + "button", + "number", + "select", + "sensor", + "switch", + "text", + "text_sensor", +) +# Platforms that act on one family of Home Assistant domains, which the input's filters must stay within +WIZARD_DOMAIN_LIMITED_PLATFORMS = ("button", "number", "select", "switch", "text") + + +def wizard_input_ids(api_config: ConfigType) -> set[str]: + """The IDs of the entities that are linked inputs of the wizard in the given api config.""" + if (wizard := api_config.get(CONF_WIZARD)) is None: + return set() + return { + conf[CONF_ENTITY].id for conf in _wizard_inputs(wizard) if CONF_ENTITY in conf + } + + +def _wizard_buffer_name(entity_id: ID) -> str: + return f"api_wizard_input_{entity_id.id}" + + +def wizard_input_buffer(entity_id: ID) -> str | None: + """Name of the RAM buffer holding the Home Assistant entity ID of a wizard input. + + Returns None when the entity is not an input of the wizard. The buffer is defined by the api + codegen, and the homeassistant entity of the input is given it in place of a constant. + """ + if entity_id.id not in wizard_input_ids(CORE.config.get(API_DOMAIN, {})): + return None + return _wizard_buffer_name(entity_id) + + +def _wizard_input_declaration( + config: fv.FinalValidateConfig, entity_id: ID +) -> tuple[str, ConfigType]: + """The domain (like sensor) and the config an input ID is declared in.""" + path = config.get_path_for_id(entity_id)[:-1] + return path[0], config.get_config_for_path(path) + + +def _wizard_input_filters( + conf: ConfigType, config: fv.FinalValidateConfig +) -> list[ConfigType]: + """The entity filters of an input, with the defaults of a domain limited linked platform.""" + if (filters := conf.get(CONF_TARGET, {}).get(CONF_ENTITY)) is not None: + return filters + if CONF_ENTITY in conf: + domain, _ = _wizard_input_declaration(config, conf[CONF_ENTITY]) + if domains := _wizard_default_domains(domain): + return [{CONF_DOMAIN: domains}] + return [] + + +def _wizard_defines(wizard: ConfigType) -> set[str]: + """The defines for the parts of the wizard the configuration uses, so the rest is not compiled.""" + inputs = _wizard_inputs(wizard) + defines = {"USE_API_WIZARD"} + if inputs: + defines.add("USE_API_WIZARD_INPUTS") + if any(CONF_ENTITY in conf for conf in inputs): + defines.add("USE_API_WIZARD_LINKED_INPUTS") + if any(CONF_ID in conf for conf in inputs): + defines.add("USE_API_WIZARD_STANDALONE_INPUTS") + return defines + + +def _wizard_default_domains(domain: str) -> list[str] | None: + """The domains Home Assistant entities can be picked from when the input sets no target.""" + if domain in WIZARD_DOMAIN_LIMITED_PLATFORMS: + platform = importlib.import_module(f"esphome.components.homeassistant.{domain}") + return list(platform.SUPPORTED_DOMAINS) + return None + + +def _validate_wizard_input(conf: ConfigType) -> ConfigType: + if CONF_ENTITY not in conf: + return conf + domain, declaration = _wizard_input_declaration( + fv.full_config.get(), conf[CONF_ENTITY] + ) + if ( + declaration.get(CONF_PLATFORM) != "homeassistant" + or domain not in WIZARD_INPUT_DOMAINS + ): + raise cv.Invalid( + f"Wizard input '{conf[CONF_ENTITY].id}' must be a homeassistant " + f"{', '.join(WIZARD_INPUT_DOMAINS)} entity" + ) + if CONF_ENTITY_ID in declaration: + # An entity_id in the configuration is a static entry, which is kept apart from the dynamic ones + raise cv.Invalid( + f"'{conf[CONF_ENTITY].id}' has an entity_id set in its configuration, so it cannot be a " + "wizard input. Remove entity_id to let Home Assistant set it through the wizard." + ) + if domain in WIZARD_DOMAIN_LIMITED_PLATFORMS: + supported = _wizard_default_domains(domain) + for entity_filter in conf.get(CONF_TARGET, {}).get(CONF_ENTITY, []): + if not (domains := entity_filter.get(CONF_DOMAIN)): + raise cv.Invalid( + f"Every filter of a homeassistant {domain} input must set domain" + ) + if unsupported := [d for d in domains if d not in supported]: + raise cv.Invalid( + f"The homeassistant {domain} does not support the domain(s) " + f"{', '.join(unsupported)}. Supported: {', '.join(supported)}" + ) + return conf + + +def _validate_wizard_entity_exposed(value: ID) -> ID: + """Reject entities that are internal, as they are not exposed over the API, or have no name. + + The key a client knows an entity by is a hash of its name. Without a name of its own, the + device works the name out at runtime from its friendly name, which can add the MAC address, + so the key cannot be known when the wizard is built. + """ + _, declaration = _wizard_input_declaration(fv.full_config.get(), value) + if declaration.get(CONF_INTERNAL, False): + raise cv.Invalid( + f"Entity '{value.id}' is internal, so it is not exposed over the API " + "and cannot be used in the wizard" + ) + if not declaration.get(CONF_NAME): + raise cv.Invalid( + f"Entity '{value.id}' has no name of its own, so its key is not known " + "when the wizard is built. Give it a name to use it in the wizard" + ) + return value + + +_WIZARD_FINAL_VALIDATE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_WIZARD): { + cv.Optional(CONF_PAGES): [ + { + cv.Optional(CONF_ENTITIES): [ + {cv.Optional(CONF_ID): _validate_wizard_entity_exposed} + ], + cv.Optional(CONF_INPUTS): [_validate_wizard_input], + } + ] + } + }, + extra=cv.ALLOW_EXTRA, +) + + +def final_validate(config: ConfigType) -> None: + """Final validation of the wizard in the given api config, if it has one.""" + _WIZARD_FINAL_VALIDATE_SCHEMA(config) + if (wizard := config.get(CONF_WIZARD)) is not None: + size = len(wizard_blob(wizard, fv.full_config.get())) + if size > WIZARD_RESPONSE_MAX_SIZE: + raise cv.Invalid( + f"The compressed wizard is {size} bytes, {size - WIZARD_RESPONSE_MAX_SIZE} " + f"bytes over the {WIZARD_RESPONSE_MAX_SIZE} bytes one API message can hold. " + "Shorten the texts or use fewer pages, entities or filters", + path=[CONF_WIZARD], + ) + + +WIZARD_INPUT_IS_SET_SCHEMA = cv.maybe_simple_value( + {cv.Required(CONF_ID): cv.use_id(WizardInput)}, key=CONF_ID +) + +# Only for standalone inputs: a linked input is read through its homeassistant entity +automation.register_apply_condition( + "api.wizard.input_is_set", WIZARD_INPUT_IS_SET_SCHEMA, "has_entity_id()" +) + + +def zstd_module() -> ModuleType: + """The zstd module: the standard library one from Python 3.14, otherwise the backport.""" + try: + return importlib.import_module("compression.zstd") + except ImportError: + return importlib.import_module("backports.zstd") + + +def _entity_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType: + """An entity of the device that the page shows, keyed as ListEntitiesResponse keys it.""" + _, declaration = _wizard_input_declaration(config, conf[CONF_ID]) + document: ConfigType = {"key": fnv1_hash_object_id(declaration[CONF_NAME])} + if (device := declaration.get(CONF_DEVICE_ID)) is not None: + document["device_id"] = fnv1a_32bit_hash(device.id) + if description := conf.get(CONF_DESCRIPTION): + document[CONF_DESCRIPTION] = description + return document + + +def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType: + document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)} + if description := conf.get(CONF_DESCRIPTION): + document[CONF_DESCRIPTION] = description + if filters := _wizard_input_filters(conf, config): + document["entity_filters"] = [dict(entity_filter) for entity_filter in filters] + return document + + +def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> ConfigType: + """The wizard as the JSON document the device sends, before it is serialised. + + This is the format Home Assistant reads, and api.proto documents it for clients. Version 1: + + {"version": 1, + "pages": [{"title": "...", "description": "...", + "entities": [{"key": 123, "device_id": 456, "description": "..."}], + "inputs": [{"key": 789, "description": "...", + "entity_filters": [{"integration": "...", "domain": ["..."], + "device_class": ["..."], "supported_features": ["..."]}]}]}]} + + Anything empty or unset, and every empty list, is left out. Strings are passed through as + written, so they may be Home Assistant translation placeholders. + + - An entity key is the key ListEntitiesResponse sends for the entity: the FNV-1 hash of the + object id made from its name (entity_helpers). device_id is the hash of the ESPHome id of + the device it belongs to (esphome/core/config.py), and is left out for the main device. + - An input key is the FNV-1 hash of the ESPHome id of the input, or of the linked entity. A linked + entity must not set entity_id, as that is a static entry kept apart from the ones the wizard sets. + - entity_filters are the filters of the input, or the default filters of a linked switch, + number, text, select or button. + """ + pages: list[ConfigType] = [] + for page in wizard[CONF_PAGES]: + document: ConfigType = {} + for key in (CONF_TITLE, CONF_DESCRIPTION): + if value := page.get(key): + document[key] = value + if entities := [ + _entity_document(e, config) for e in page.get(CONF_ENTITIES, []) + ]: + document[CONF_ENTITIES] = entities + if inputs := [_input_document(i, config) for i in page.get(CONF_INPUTS, [])]: + document[CONF_INPUTS] = inputs + pages.append(document) + return {"version": WIZARD_JSON_VERSION, CONF_PAGES: pages} + + +def wizard_blob(wizard: ConfigType, config: fv.FinalValidateConfig) -> bytes: + """The wizard document as compact, sorted UTF-8 JSON in a single zstd frame.""" + text = json.dumps( + wizard_document(wizard, config), + separators=(",", ":"), + sort_keys=True, + ensure_ascii=False, + ) + return zstd_module().compress(text.encode("utf-8"), level=WIZARD_ZSTD_LEVEL) + + +async def to_code(wizard: ConfigType) -> None: + """Emit the compressed wizard, the table of inputs and the defines. + + The API reads both tables from its own sources, so they are externally linked PROGMEM arrays. + """ + blob = wizard_blob(wizard, CORE.config) + cg.extern_progmem_array("esphome::api::API_WIZARD_DATA", cg.uint8, list(blob)) + cg.add_define("API_WIZARD_DATA_SIZE", len(blob)) + for define in sorted(_wizard_defines(wizard)): + cg.add_define(define) + entries: list[cg.RawExpression] = [] + for conf in _wizard_inputs(wizard): + input_id = _wizard_input_id(conf) + # Every buffer starts empty, until the wizard sets it. A linked homeassistant entity uses it as its entity id. + buffer = _wizard_buffer_name(input_id) + cg.add_global( + cg.RawStatement( + f'static char {buffer}[{WIZARD_ENTITY_ID_BUFFER_SIZE}] = "";' + ) + ) + if CONF_ID in conf: + cg.new_Pvariable(input_id, cg.RawExpression(buffer)) + entries.append(cg.RawExpression(f"{{{fnv1_hash(input_id.id)}u, {buffer}}}")) + if entries: + cg.extern_progmem_array( + "esphome::api::API_WIZARD_INPUTS", + cg.esphome_ns.namespace("api").struct("WizardInputEntry"), + entries, + ) + cg.add_define("API_WIZARD_INPUT_COUNT", len(entries)) diff --git a/esphome/components/homeassistant/__init__.py b/esphome/components/homeassistant/__init__.py index 1b66842f1e..9fead561af 100644 --- a/esphome/components/homeassistant/__init__.py +++ b/esphome/components/homeassistant/__init__.py @@ -2,8 +2,9 @@ from collections.abc import Callable, Iterable import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_INTERNAL +from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_ID, CONF_INTERNAL from esphome.cpp_generator import MockObj +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter", "@esphome/core"] @@ -14,10 +15,12 @@ def validate_entity_domain( platform: str, supported_domains: Iterable[str] ) -> Callable[[ConfigType], ConfigType]: def validator(config: ConfigType) -> ConfigType: - domain = config[CONF_ENTITY_ID].split(".", 1)[0] - if domain not in supported_domains: + # A wizard input supplies the entity ID later; the wizard checks its domains + if (entity_id := config.get(CONF_ENTITY_ID)) is None: + return config + if entity_id.split(".", 1)[0] not in supported_domains: raise cv.Invalid( - f"Entity ID {config[CONF_ENTITY_ID]} is not supported by the {platform} platform." + f"Entity ID {entity_id} is not supported by the {platform} platform." ) return config @@ -26,7 +29,7 @@ def validate_entity_domain( HOME_ASSISTANT_IMPORT_SCHEMA = cv.Schema( { - cv.Required(CONF_ENTITY_ID): cv.entity_id, + cv.Optional(CONF_ENTITY_ID): cv.entity_id, cv.Optional(CONF_ATTRIBUTE): cv.string, cv.Optional(CONF_INTERNAL, default=True): cv.boolean, } @@ -34,14 +37,35 @@ HOME_ASSISTANT_IMPORT_SCHEMA = cv.Schema( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA = cv.Schema( { - cv.Required(CONF_ENTITY_ID): cv.entity_id, + cv.Optional(CONF_ENTITY_ID): cv.entity_id, cv.Optional(CONF_INTERNAL, default=True): cv.boolean, } ) +def final_validate_entity_id(config: ConfigType) -> ConfigType: + """Without an entity_id, the entity must be a wizard input, as Home Assistant then supplies it.""" + if CONF_ENTITY_ID in config: + return config + from esphome.components.api import wizard + + if config[CONF_ID].id not in wizard.wizard_input_ids( + fv.full_config.get()[wizard.API_DOMAIN] + ): + raise cv.Invalid( + f"{CONF_ENTITY_ID} is required unless this entity is a wizard input" + ) + return config + + def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None: - cg.add(var.set_entity_id(config[CONF_ENTITY_ID])) + from esphome.components.api import wizard + + if (buffer := wizard.wizard_input_buffer(config[CONF_ID])) is not None: + # The entity ID is chosen in the wizard and lives in a buffer the API owns + cg.add(var.set_entity_id(cg.RawExpression(buffer))) + else: + cg.add(var.set_entity_id(config[CONF_ENTITY_ID])) if CONF_ATTRIBUTE in config: cg.add(var.set_attribute(config[CONF_ATTRIBUTE])) cg.add_define("USE_API_HOMEASSISTANT_STATES") diff --git a/esphome/components/homeassistant/binary_sensor/__init__.py b/esphome/components/homeassistant/binary_sensor/__init__.py index 6ea17b6831..310ade27a8 100644 --- a/esphome/components/homeassistant/binary_sensor/__init__.py +++ b/esphome/components/homeassistant/binary_sensor/__init__.py @@ -4,6 +4,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, ) @@ -18,6 +19,8 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(HomeassistantBinarySensor).ex HOME_ASSISTANT_IMPORT_SCHEMA ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) diff --git a/esphome/components/homeassistant/button/__init__.py b/esphome/components/homeassistant/button/__init__.py index 660c59164e..f486a84126 100644 --- a/esphome/components/homeassistant/button/__init__.py +++ b/esphome/components/homeassistant/button/__init__.py @@ -5,6 +5,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, validate_entity_domain, @@ -29,6 +30,8 @@ CONFIG_SCHEMA = cv.All( validate_entity_domain("button", SUPPORTED_DOMAINS), ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") diff --git a/esphome/components/homeassistant/button/homeassistant_button.cpp b/esphome/components/homeassistant/button/homeassistant_button.cpp index d8d2faa37a..87280c2b5b 100644 --- a/esphome/components/homeassistant/button/homeassistant_button.cpp +++ b/esphome/components/homeassistant/button/homeassistant_button.cpp @@ -24,6 +24,13 @@ void HomeassistantButton::press_action() { return; } +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + return; + } +#endif + static constexpr auto SERVICE_BUTTON = StringRef::from_lit("button.press"); static constexpr auto SERVICE_INPUT_BUTTON = StringRef::from_lit("input_button.press"); static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); diff --git a/esphome/components/homeassistant/number/__init__.py b/esphome/components/homeassistant/number/__init__.py index 9658d3dc84..6288db6880 100644 --- a/esphome/components/homeassistant/number/__init__.py +++ b/esphome/components/homeassistant/number/__init__.py @@ -5,6 +5,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, validate_entity_domain, @@ -26,6 +27,8 @@ CONFIG_SCHEMA = cv.All( validate_entity_domain("number", SUPPORTED_DOMAINS), ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index 553c2466f8..e8a679e91c 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -80,6 +80,13 @@ void HomeassistantNumber::control(float value) { return; } +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + return; + } +#endif + this->publish_state(value); static constexpr auto NUMBER_SERVICE_NAME = StringRef::from_lit("number.set_value"); diff --git a/esphome/components/homeassistant/select/__init__.py b/esphome/components/homeassistant/select/__init__.py index 8be0a4545c..e8ad0fc23b 100644 --- a/esphome/components/homeassistant/select/__init__.py +++ b/esphome/components/homeassistant/select/__init__.py @@ -5,6 +5,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, validate_entity_domain, @@ -40,6 +41,8 @@ CONFIG_SCHEMA = cv.All( validate_entity_domain("select", SUPPORTED_DOMAINS), ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") diff --git a/esphome/components/homeassistant/select/homeassistant_select.cpp b/esphome/components/homeassistant/select/homeassistant_select.cpp index 82571c768d..d3401de7f0 100644 --- a/esphome/components/homeassistant/select/homeassistant_select.cpp +++ b/esphome/components/homeassistant/select/homeassistant_select.cpp @@ -230,6 +230,13 @@ void HomeassistantSelect::control(size_t index) { return; } +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + return; + } +#endif + static constexpr auto SERVICE_SELECT = StringRef::from_lit("select.select_option"); static constexpr auto SERVICE_INPUT_SELECT = StringRef::from_lit("input_select.select_option"); static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); diff --git a/esphome/components/homeassistant/sensor/__init__.py b/esphome/components/homeassistant/sensor/__init__.py index abee957fda..1417c57bb5 100644 --- a/esphome/components/homeassistant/sensor/__init__.py +++ b/esphome/components/homeassistant/sensor/__init__.py @@ -4,6 +4,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, ) @@ -18,6 +19,8 @@ CONFIG_SCHEMA = sensor.sensor_schema(HomeassistantSensor, accuracy_decimals=1).e HOME_ASSISTANT_IMPORT_SCHEMA ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) diff --git a/esphome/components/homeassistant/switch/__init__.py b/esphome/components/homeassistant/switch/__init__.py index 55854cd659..495779b6ed 100644 --- a/esphome/components/homeassistant/switch/__init__.py +++ b/esphome/components/homeassistant/switch/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, validate_entity_domain, @@ -36,6 +37,8 @@ CONFIG_SCHEMA = cv.All( validate_entity_domain("switch", SUPPORTED_DOMAINS), ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.cpp b/esphome/components/homeassistant/switch/homeassistant_switch.cpp index 8a4ea19f2e..79d75c9133 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -40,6 +40,13 @@ void HomeassistantSwitch::write_state(bool state) { return; } +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + return; + } +#endif + static constexpr auto SERVICE_ON = StringRef::from_lit("homeassistant.turn_on"); static constexpr auto SERVICE_OFF = StringRef::from_lit("homeassistant.turn_off"); static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); diff --git a/esphome/components/homeassistant/text/__init__.py b/esphome/components/homeassistant/text/__init__.py index 6ca920bac7..67377e02d0 100644 --- a/esphome/components/homeassistant/text/__init__.py +++ b/esphome/components/homeassistant/text/__init__.py @@ -5,6 +5,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, validate_entity_domain, @@ -29,6 +30,8 @@ CONFIG_SCHEMA = cv.All( validate_entity_domain("text", SUPPORTED_DOMAINS), ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") diff --git a/esphome/components/homeassistant/text/homeassistant_text.cpp b/esphome/components/homeassistant/text/homeassistant_text.cpp index a8fabd5e14..31e4919700 100644 --- a/esphome/components/homeassistant/text/homeassistant_text.cpp +++ b/esphome/components/homeassistant/text/homeassistant_text.cpp @@ -79,6 +79,13 @@ void HomeassistantText::control(const std::string &value) { return; } +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + return; + } +#endif + static constexpr auto SERVICE_TEXT = StringRef::from_lit("text.set_value"); static constexpr auto SERVICE_INPUT_TEXT = StringRef::from_lit("input_text.set_value"); static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); diff --git a/esphome/components/homeassistant/text_sensor/__init__.py b/esphome/components/homeassistant/text_sensor/__init__.py index 265250c695..f57b398111 100644 --- a/esphome/components/homeassistant/text_sensor/__init__.py +++ b/esphome/components/homeassistant/text_sensor/__init__.py @@ -4,6 +4,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, ) @@ -18,6 +19,8 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema(HomeassistantTextSensor).extend( HOME_ASSISTANT_IMPORT_SCHEMA ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0a1d16a2b9..12f61467d6 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -272,6 +272,10 @@ #define API_OUTGOING_CONNECTION_PORT 6054 #define API_OUTGOING_CONNECTION_DELAY 60000 #define USE_API_VARINT64 +#define USE_API_WIZARD +#define USE_API_WIZARD_INPUTS +#define USE_API_WIZARD_LINKED_INPUTS +#define USE_API_WIZARD_STANDALONE_INPUTS #define USE_API_PLAINTEXT #define USE_API_USER_DEFINED_ACTIONS #define USE_API_CUSTOM_SERVICES @@ -280,6 +284,8 @@ #define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #define API_MAX_SEND_QUEUE 8 #define API_USER_ACTION_STRINGS_SCRATCH_SIZE 64 +#define API_WIZARD_DATA_SIZE 1 +#define API_WIZARD_INPUT_COUNT 1 #define MAX_API_CONNECTIONS 6 // The Improv library is not in the Zephyr tidy environment #define USE_IMPROV_SERIAL diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index a5ffaefe0b..267e86c2ed 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -468,6 +468,23 @@ def progmem_array(id_, rhs) -> "MockObj": return obj +class ExternProgmemAssignmentExpression(ProgmemAssignmentExpression): + __slots__ = () + + def __str__(self): + return f"const {self.type} {self.name}[] PROGMEM = {self.rhs}" + + +def extern_progmem_array( + qualified_name: str, type_: "MockObjClass", rhs: SafeExpType +) -> "MockObj": + """Emit an externally linked PROGMEM table that a component declares extern and reads itself.""" + CORE.add_global( + ExternProgmemAssignmentExpression(type_, qualified_name, safe_exp(rhs)) + ) + return MockObj(qualified_name, ".") + + def shared_progmem_array( name: str, type_: "MockObjClass", rhs: SafeExpType, *, share: bool = True ) -> "MockObj": diff --git a/requirements.txt b/requirements.txt index cd952b5752..91a36ecbf7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,7 @@ puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 +backports.zstd==1.7.0; python_version < "3.14" # api wizard; compression.zstd is in the standard library from 3.14 pillow==12.3.0 resvg-py==0.5.0 freetype-py==2.5.1 diff --git a/tests/component_tests/api/test_wizard.py b/tests/component_tests/api/test_wizard.py new file mode 100644 index 0000000000..276f42d624 --- /dev/null +++ b/tests/component_tests/api/test_wizard.py @@ -0,0 +1,942 @@ +"""Tests for the api wizard: schema, final validation and generated tables.""" + +from collections.abc import Callable +import json +from pathlib import Path +import random +import re +import string +import textwrap + +import pytest + +from esphome.components.api import wizard +from esphome.components.homeassistant.switch import SUPPORTED_DOMAINS as SWITCH_DOMAINS +from esphome.config import load_config +from esphome.core import CORE +from esphome.helpers import fnv1_hash, fnv1a_32bit_hash +from tests.component_tests.helpers import get_define_value + +ESP32_HEADER = """ + esphome: + name: test + + esp32: + variant: esp32 + + wifi: + ssid: test + password: testtest + + logger: + """ + +ESP8266_HEADER = """ + esphome: + name: test + + esp8266: + board: d1_mini + + wifi: + ssid: test + password: testtest + + logger: + """ + +ENTITIES = """ + switch: + - platform: template + id: sw + name: Switch + optimistic: true + - platform: template + id: internal_sw + name: Internal switch + internal: true + optimistic: true + - platform: template + id: unnamed_sw + optimistic: true + - platform: homeassistant + id: ha_switch + %(light)s + sensor: + - platform: template + id: template_sensor + name: Template sensor + - platform: homeassistant + id: ha_plain_sensor + entity_id: sensor.plain + - platform: homeassistant + id: ha_sensor + %(sensor)s + binary_sensor: + - platform: homeassistant + id: ha_binary + %(binary)s + text_sensor: + - platform: homeassistant + id: ha_text + %(text)s + number: + - platform: homeassistant + id: ha_number + %(number)s + text: + - platform: homeassistant + id: ha_txt + %(text_entity)s + select: + - platform: homeassistant + id: ha_select + %(select)s + button: + - platform: homeassistant + id: ha_button + %(button)s + """ + + +def build_entities(inputs: set[str]) -> str: + """The test entities. The homeassistant ones that are wizard inputs set no entity_id, the others do.""" + ids = { + "light": ("ha_switch", "entity_id: light.lamp"), + "sensor": ("ha_sensor", "entity_id: sensor.a"), + "binary": ("ha_binary", "entity_id: binary_sensor.a"), + "text": ("ha_text", "entity_id: sensor.b"), + "number": ("ha_number", "entity_id: number.a"), + "text_entity": ("ha_txt", "entity_id: text.a"), + "select": ("ha_select", "entity_id: select.a"), + "button": ("ha_button", "entity_id: button.a"), + } + return ENTITIES % { + key: "" if name in inputs else yaml for key, (name, yaml) in ids.items() + } + + +GOOD_PAGES = """ + api: + wizard: + pages: + - title: Setup + description: "[%key:component::domain::section::name%]" + entities: + - id: sw + description: Enable + - id: sw + - inputs: + - entity: ha_sensor + description: Weather + target: + entity: + - integration: met + domain: sensor + device_class: [temperature, humidity] + supported_features: weather.WeatherEntityFeature.FORECAST_DAILY + - domain: [sensor, number] + - entity: ha_binary + - entity: ha_text + - entity: ha_number + - entity: ha_switch + - entity: ha_txt + - entity: ha_select + - entity: ha_button + - id: weather_input + target: + entity: + - domain: weather + - id: unset_input + """ + + +def write_config( + tmp_path: Path, header: str, api: str, yaml_entities: str | None = None +) -> Path: + """Write a config. By default the homeassistant entities that the wizard does not use as inputs set an entity_id.""" + path = tmp_path / "test.yaml" + if yaml_entities is None: + yaml_entities = build_entities(set(re.findall(r"entity: (\w+)", api))) + path.write_text( + textwrap.dedent(header) + textwrap.dedent(yaml_entities) + textwrap.dedent(api) + ) + return path + + +def write_input_config(tmp_path: Path, header: str) -> Path: + """Write a config with GOOD_PAGES.""" + return write_config(tmp_path, header, GOOD_PAGES) + + +def config_errors(path: Path) -> list[str]: + CORE.config_path = path + return [str(err.msg) for err in load_config({}).errors] + + +def wizard_api(pages: str) -> str: + return "api:\n wizard:\n pages:\n" + textwrap.indent( + textwrap.dedent(pages).strip("\n"), " " + ) + + +def test_no_wizard_emits_nothing( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main( + write_config( + tmp_path, + ESP32_HEADER, + "api:\n", + "sensor:\n - platform: homeassistant\n id: s\n entity_id: sensor.s\n", + ) + ) + + assert "API_WIZARD_PAGES" not in main_cpp + assert 's->set_entity_id("sensor.s");' in main_cpp + assert not any(define.name == "USE_API_WIZARD" for define in CORE.defines) + + +def test_valid_wizard_has_no_errors(tmp_path: Path) -> None: + assert config_errors(write_input_config(tmp_path, ESP32_HEADER)) == [] + + +def wizard_inputs(inputs: str) -> str: + return wizard_api("- inputs:\n" + textwrap.indent(textwrap.dedent(inputs), " ")) + + +def test_switch_input_with_supported_domains_is_valid(tmp_path: Path) -> None: + api = wizard_inputs( + """ + - entity: ha_switch + target: + entity: + - domain: [light, fan] + """ + ) + + assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == [] + + +@pytest.mark.parametrize( + ("api", "message"), + [ + pytest.param( + wizard_inputs("- entity: template_sensor"), + "must be a homeassistant", + id="input-not-homeassistant", + ), + pytest.param( + wizard_inputs("- entity: sw"), + "must be a homeassistant", + id="input-not-homeassistant-switch", + ), + pytest.param( + wizard_inputs( + """ + - entity: ha_switch + target: + entity: + - domain: [light, valve] + """ + ), + "does not support the domain(s) valve", + id="switch-unsupported-domain", + ), + pytest.param( + wizard_inputs( + """ + - entity: ha_switch + target: + entity: + - integration: hue + """ + ), + "must set domain", + id="switch-filter-without-domain", + ), + pytest.param( + wizard_inputs("- entity: ha_sensor\n- entity: ha_sensor"), + "'ha_sensor' is used more than once", + id="duplicate-input", + ), + ], +) +def test_invalid_inputs_are_rejected(tmp_path: Path, api: str, message: str) -> None: + errors = config_errors(write_config(tmp_path, ESP32_HEADER, api)) + + assert any(message in error for error in errors), errors + + +def test_hash_collision_is_rejected(tmp_path: Path) -> None: + entities = "".join( + f"sensor:\n - platform: homeassistant\n id: {name}\n" + if index == 0 + else f" - platform: homeassistant\n id: {name}\n" + for index, name in enumerate(("a179599", "a362382")) + ) + api = wizard_inputs("- entity: a179599\n- entity: a362382") + errors = config_errors(write_config(tmp_path, ESP32_HEADER, api, entities)) + + assert any("have the same hash" in error for error in errors), errors + + +@pytest.mark.parametrize( + "extra", + [ + "", + " entity_id: sensor.default\n", + ], +) +def test_entity_id_is_optional_only_for_wizard_inputs( + tmp_path: Path, extra: str +) -> None: + entities = f"sensor:\n - platform: homeassistant\n id: lonely\n{extra}" + errors = config_errors(write_config(tmp_path, ESP32_HEADER, "api:\n", entities)) + + if extra: + assert errors == [] + else: + assert any( + "entity_id is required unless this entity is a wizard input" in error + for error in errors + ), errors + + +def test_entity_id_is_optional_for_an_input_and_its_domain_is_checked( + tmp_path: Path, +) -> None: + entities = "switch:\n - platform: homeassistant\n id: ha_switch\n entity_id: climate.heater\n" + errors = config_errors( + write_config( + tmp_path, ESP32_HEADER, wizard_inputs("- entity: ha_switch"), entities + ) + ) + + # The YAML entity ID is still checked against the platform's domains + assert any("not supported by the switch platform" in error for error in errors) + + +@pytest.mark.parametrize( + ("pages", "message"), + [ + pytest.param( + "- title: Empty", + "at least one of entities, inputs", + id="page-without-fields", + ), + pytest.param( + """ + - entities: [] + """, + "length of value must be at least 1", + id="empty-entities-list", + ), + pytest.param( + """ + - entities: [{id: internal_sw}] + """, + "Entity 'internal_sw' is internal", + id="internal-entity", + ), + pytest.param( + """ + - entities: [{id: unnamed_sw}] + """, + "Entity 'unnamed_sw' is internal", + id="unnamed-entity-is-internal", + ), + pytest.param( + """ + - inputs: + - entity: ha_sensor + target: + entity: + - {} + """, + "at least one of", + id="empty-filter", + ), + pytest.param( + """ + - inputs: + - entity: ha_sensor + target: + entity: [] + """, + "length of value must be at least 1", + id="no-filters", + ), + pytest.param( + """ + - inputs: + - entity: ha_sensor + target: + entity: + - domain: [] + """, + "length of value must be at least 1", + id="empty-domain-list", + ), + pytest.param( + """ + - inputs: + - entity: ha_sensor + target: + entity: + - supported_features: FEATURE + """.replace("FEATURE", "x" * 128), + "length of value must be at most 127", + id="long-feature", + ), + pytest.param( + """ + - description: TEXT + entities: [{id: sw}] + """.replace("TEXT", "x" * 256), + "length of value must be at most 255", + id="long-description", + ), + ], +) +def test_invalid_wizards_are_rejected(tmp_path: Path, pages: str, message: str) -> None: + errors = config_errors(write_config(tmp_path, ESP32_HEADER, wizard_api(pages))) + + assert any(message in error for error in errors), errors + + +def test_no_pages_is_rejected(tmp_path: Path) -> None: + errors = config_errors( + write_config(tmp_path, ESP32_HEADER, "api:\n wizard:\n pages: []\n") + ) + + assert any("length of value must be at least 1" in error for error in errors), ( + errors + ) + + +def test_input_is_set_condition( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + interval = """ + interval: + - interval: 1h + then: + - if: + condition: + api.wizard.input_is_set: weather_input + then: + - logger.log: set + - if: + condition: + api.wizard.input_is_set: + id: unset_input + then: + - logger.log: set + """ + path = write_input_config(tmp_path, ESP32_HEADER) + path.write_text(path.read_text() + textwrap.dedent(interval)) + main_cpp = generate_main(path) + + assert "weather_input->has_entity_id()" in main_cpp + assert "unset_input->has_entity_id()" in main_cpp + + +@pytest.mark.parametrize( + ("inputs", "message"), + [ + pytest.param( + "- description: Neither", + "exactly one of id, entity", + id="neither-id-nor-entity", + ), + pytest.param( + "- id: standalone\n entity: ha_sensor", + "more than one of id, entity", + id="both-id-and-entity", + ), + pytest.param( + "- entity: ha_sensor\n entity_id: sensor.a", + "extra keys not allowed", + id="entity-id-on-linked-input", + ), + pytest.param( + "- id: standalone\n entity_id: weather.home", + "extra keys not allowed", + id="entity-id-on-standalone-input", + ), + pytest.param( + "- entity: ha_sensor\n- entity: ha_sensor", + "used more than once", + id="duplicate-linked-input", + ), + ], +) +def test_standalone_and_linked_input_rules( + tmp_path: Path, inputs: str, message: str +) -> None: + errors = config_errors(write_config(tmp_path, ESP32_HEADER, wizard_inputs(inputs))) + + assert any(message in error for error in errors), errors + + +def test_standalone_input_is_valid(tmp_path: Path) -> None: + api = wizard_inputs("- id: standalone") + + assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == [] + + +WIZARD_DEFINES = { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", +} + + +@pytest.mark.parametrize( + ("pages", "expected"), + [ + pytest.param( + "- entities: [{id: sw}]", + {"USE_API_WIZARD"}, + id="entity-only", + ), + pytest.param( + "- inputs: [{id: standalone}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + }, + id="standalone-input-without-filter", + ), + pytest.param( + "- inputs: [{id: standalone, target: {entity: [{domain: weather}]}}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + }, + id="standalone-input-with-filter", + ), + pytest.param( + "- inputs: [{entity: ha_sensor}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_LINKED_INPUTS", + }, + id="linked-sensor-without-filter", + ), + pytest.param( + "- inputs: [{entity: ha_sensor, target: {entity: [{domain: sensor}]}}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_LINKED_INPUTS", + }, + id="linked-sensor-with-filter", + ), + pytest.param( + # A switch without a target gets the default filter + "- inputs: [{entity: ha_switch}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_LINKED_INPUTS", + }, + id="linked-switch-default-filter", + ), + pytest.param( + "- entities: [{id: sw}]\n inputs: [{entity: ha_sensor, target: {entity: [{domain: sensor}]}}, {id: standalone}]", + WIZARD_DEFINES, + id="everything", + ), + ], +) +def test_only_the_defines_the_wizard_needs_are_emitted( + tmp_path: Path, + generate_main: Callable[[str | Path], str], + pages: str, + expected: set[str], +) -> None: + api = wizard_api(pages) + generate_main(write_config(tmp_path, ESP32_HEADER, api)) + + assert {d.name for d in CORE.defines} & WIZARD_DEFINES == expected + + +@pytest.mark.parametrize( + ("entity", "bad_domain"), + [ + ("ha_txt", "select"), + ("ha_select", "text"), + ("ha_button", "switch"), + ("ha_number", "sensor"), + ], +) +def test_text_select_and_button_inputs_keep_to_their_domains( + tmp_path: Path, entity: str, bad_domain: str +) -> None: + api = wizard_inputs( + f"- entity: {entity}\n target:\n entity:\n - domain: {bad_domain}" + ) + errors = config_errors(write_config(tmp_path, ESP32_HEADER, api)) + + assert any( + f"does not support the domain(s) {bad_domain}" in error for error in errors + ), errors + + +@pytest.mark.parametrize("entity", ["ha_txt", "ha_select", "ha_button", "ha_number"]) +def test_text_select_and_button_filters_must_set_a_domain( + tmp_path: Path, entity: str +) -> None: + api = wizard_inputs( + f"- entity: {entity}\n target:\n entity:\n - integration: hue" + ) + errors = config_errors(write_config(tmp_path, ESP32_HEADER, api)) + + assert any("must set domain" in error for error in errors), errors + + +def test_number_input_accepts_input_number(tmp_path: Path) -> None: + api = wizard_inputs( + "- entity: ha_number\n target:\n entity:\n - domain: input_number" + ) + + assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == [] + + +def test_text_select_and_button_default_to_their_domains( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main(write_input_config(tmp_path, ESP32_HEADER)) + document = json.loads(wizard.zstd_module().decompress(blob_in(main_cpp))) + filters = { + entry["key"]: entry.get("entity_filters") + for page in document["pages"] + for entry in page.get("inputs", []) + } + + # ha_txt and ha_button have no target, so they take every supported domain + assert filters[fnv1_hash("ha_txt")] == [{"domain": ["input_text", "text"]}] + assert filters[fnv1_hash("ha_button")] == [{"domain": ["button", "input_button"]}] + assert filters[fnv1_hash("ha_select")] == [{"domain": ["input_select", "select"]}] + # A number takes number and input_number entities, and the other sensors take anything + assert filters[fnv1_hash("ha_number")] == [{"domain": ["input_number", "number"]}] + assert filters[fnv1_hash("ha_binary")] is None + for entity in ("ha_txt", "ha_select", "ha_button"): + assert f"{entity}->set_entity_id(api_wizard_input_{entity});" in main_cpp + + +@pytest.mark.parametrize( + "entity_yaml", + [ + "text:\n - platform: homeassistant\n id: lonely\n", + "select:\n - platform: homeassistant\n id: lonely\n", + "button:\n - platform: homeassistant\n id: lonely\n", + ], +) +def test_new_platforms_need_an_entity_id_unless_they_are_inputs( + tmp_path: Path, entity_yaml: str +) -> None: + errors = config_errors(write_config(tmp_path, ESP32_HEADER, "api:\n", entity_yaml)) + + assert any( + "entity_id is required unless this entity is a wizard input" in error + for error in errors + ), errors + + +DEVICES_HEADER = """ + esphome: + name: test + devices: + - id: kitchen_dev + name: Kitchen + + esp32: + variant: esp32 + + wifi: + ssid: test + password: testtest + + logger: + """ + +JSON_ENTITIES = """ + switch: + - platform: template + id: sw + name: Switch + optimistic: true + - platform: template + id: sw2 + name: Kitchen Switch + device_id: kitchen_dev + optimistic: true + - platform: homeassistant + id: ha_switch + sensor: + - platform: homeassistant + id: ha_sensor + """ + +JSON_PAGES = """ + - title: Audio + description: Pick + entities: + - id: sw + description: Enable + - id: sw2 + inputs: + - id: weather + description: Weather + target: + entity: + - integration: met + domain: weather + device_class: [temperature, humidity] + - domain: [weather, sensor] + - entity: ha_switch + - inputs: [{entity: ha_sensor}] + """ + + +def blob_in(main_cpp: str) -> bytes: + """The compressed wizard that the generated code puts in flash.""" + match = re.search( + r"const uint8_t esphome::api::API_WIZARD_DATA\[\] PROGMEM = \{([^}]*)\};", + main_cpp, + ) + assert match is not None + return bytes(int(byte) for byte in match.group(1).split(", ")) + + +def entity_hash(main_cpp: str, variable: str) -> int: + """The key the generated code passes to configure_entity_, which ListEntities then sends.""" + match = re.search( + rf'App\.register_switch\({variable}, "[^"]*", (\d+)UL, \d+\)', main_cpp + ) + assert match is not None, variable + return int(match.group(1)) + + +def test_the_blob_is_the_exact_json_document( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main( + write_config(tmp_path, DEVICES_HEADER, wizard_api(JSON_PAGES), JSON_ENTITIES) + ) + blob = blob_in(main_cpp) + + text = wizard.zstd_module().decompress(blob).decode("utf-8") + expected = { + "version": 1, + "pages": [ + { + "title": "Audio", + "description": "Pick", + "entities": [ + # No device_id for the main device, and no description when unset + {"key": entity_hash(main_cpp, "sw"), "description": "Enable"}, + { + "key": entity_hash(main_cpp, "sw2"), + "device_id": fnv1a_32bit_hash("kitchen_dev"), + }, + ], + "inputs": [ + { + "key": fnv1_hash("weather"), + "description": "Weather", + "entity_filters": [ + { + "integration": "met", + "domain": ["weather"], + "device_class": ["temperature", "humidity"], + }, + {"domain": ["weather", "sensor"]}, + ], + }, + { + "key": fnv1_hash("ha_switch"), + # The default filter of a switch + "entity_filters": [{"domain": SWITCH_DOMAINS}], + }, + ], + }, + {"inputs": [{"key": fnv1_hash("ha_sensor")}]}, + ], + } + assert text == json.dumps( + expected, separators=(",", ":"), sort_keys=True, ensure_ascii=False + ) + # The device id is the one the generated code gives the device + assert f"set_device_id({fnv1a_32bit_hash('kitchen_dev')})" in main_cpp + assert get_define_value("API_WIZARD_DATA_SIZE") == str(len(blob)) + + +def test_the_blob_is_deterministic_and_the_same_on_every_platform( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + api = wizard_api(JSON_PAGES) + first = blob_in( + generate_main(write_config(tmp_path, DEVICES_HEADER, api, JSON_ENTITIES)) + ) + # Compressing the same document again gives the same bytes + document = wizard.wizard_document(CORE.config["api"]["wizard"], CORE.config) + again = wizard.zstd_module().compress( + json.dumps( + document, separators=(",", ":"), sort_keys=True, ensure_ascii=False + ).encode("utf-8"), + level=wizard.WIZARD_ZSTD_LEVEL, + ) + + assert again == first + assert first[:4] == b"\x28\xb5\x2f\xfd" # a zstd frame + + +def test_the_blob_is_in_flash_on_esp8266( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main(write_input_config(tmp_path, ESP8266_HEADER)) + + assert "esphome::api::API_WIZARD_DATA[] PROGMEM = {" in main_cpp + assert ( + "const api::WizardInputEntry esphome::api::API_WIZARD_INPUTS[] PROGMEM = {" + in main_cpp + ) + # Only the compressed data and the input table are emitted, no strings or row tables + assert "api_wizard_str" not in main_cpp + assert "WizardEntityRow" not in main_cpp + + +def test_inputs_have_buffers_hashes_and_a_table( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main(write_input_config(tmp_path, ESP32_HEADER)) + + # The buffer holds the YAML entity ID until the wizard sets another + assert 'static char api_wizard_input_ha_sensor[256] = "";' in main_cpp + entities = ( + "ha_sensor", + "ha_binary", + "ha_text", + "ha_number", + "ha_switch", + "ha_txt", + "ha_select", + "ha_button", + "weather_input", + "unset_input", + ) + for entity in entities: + assert f"{{{fnv1_hash(entity)}u, api_wizard_input_{entity}}}" in main_cpp + assert get_define_value("API_WIZARD_INPUT_COUNT") == str(len(entities)) + for entity in entities[:-2]: + assert f"{entity}->set_entity_id(api_wizard_input_{entity});" in main_cpp + # The standalone inputs are objects that read the same buffer + assert "WizardInput(api_wizard_input_weather_input)" in main_cpp + assert "WizardInput(api_wizard_input_unset_input)" in main_cpp + # Not an input, so it keeps its constant + assert 'ha_plain_sensor->set_entity_id("sensor.plain");' in main_cpp + + +def test_an_entity_without_a_name_cannot_be_in_the_wizard(tmp_path: Path) -> None: + yaml_entities = 'switch:\n - platform: template\n id: nameless\n name: ""\n optimistic: true\n' + errors = config_errors( + write_config( + tmp_path, + ESP32_HEADER, + wizard_api("- entities: [{id: nameless}]"), + yaml_entities, + ) + ) + + assert any("has no name of its own" in error for error in errors), errors + + +def test_zstd_falls_back_to_the_backport(monkeypatch: pytest.MonkeyPatch) -> None: + """Before Python 3.14 the standard library has no zstd, so the backport is used.""" + backport = object() + + def import_module(name: str) -> object: + if name == "compression.zstd": + raise ImportError(name) + assert name == "backports.zstd" + return backport + + monkeypatch.setattr(wizard.importlib, "import_module", import_module) + + assert wizard.zstd_module() is backport + + +def test_a_wizard_too_big_for_one_message_is_rejected(tmp_path: Path) -> None: + # Random text does not compress, so this needs more than the limit even compressed + rng = random.Random(1) + alphabet = string.ascii_letters + string.digits + entities = ",\n".join( + "{id: sw, description: " + + "".join(rng.choice(alphabet) for _ in range(255)) + + "}" + for _ in range(400) + ) + api = wizard_api(f"- entities: [\n{entities}\n]") + errors = config_errors(write_config(tmp_path, ESP32_HEADER, api)) + + assert any( + "The compressed wizard is" in error and "bytes over the 65512 bytes" in error + for error in errors + ), errors + + +def test_repeated_text_compresses_well_inside_the_limit(tmp_path: Path) -> None: + # 400 identical entities are far over the limit as JSON, but compress to very little + entities = ",\n".join(["{id: sw, description: " + "x" * 255 + "}"] * 400) + api = wizard_api(f"- entities: [\n{entities}\n]") + + assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == [] + + +@pytest.mark.parametrize( + ("platform", "entity_id"), + [ + ("sensor", "sensor.a"), + ("switch", "light.a"), + ("text", "text.a"), + ("button", "button.a"), + ], +) +def test_a_linked_entity_must_not_set_an_entity_id( + tmp_path: Path, platform: str, entity_id: str +) -> None: + yaml_entities = ( + f"{platform}:\n - platform: homeassistant\n id: fixed\n" + f" entity_id: {entity_id}\n" + ) + errors = config_errors( + write_config( + tmp_path, ESP32_HEADER, wizard_inputs("- entity: fixed"), yaml_entities + ) + ) + + assert any( + "'fixed' has an entity_id set in its configuration" in error + and "Remove entity_id" in error + for error in errors + ), errors + + +def test_an_entity_with_an_entity_id_that_is_no_input_is_valid( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main(write_input_config(tmp_path, ESP32_HEADER)) + + # Static entries keep their literal and are kept apart from the wizard buffers + assert 'ha_plain_sensor->set_entity_id("sensor.plain");' in main_cpp + assert "api_wizard_input_ha_plain_sensor" not in main_cpp + # Every buffer starts empty + assert '] = "sensor.' not in main_cpp.split("API_WIZARD_INPUTS")[0] diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py index a5e3731c75..4a66287c33 100644 --- a/tests/components/api/__init__.py +++ b/tests/components/api/__init__.py @@ -8,6 +8,18 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config): cg.add_define("USE_API") cg.add_define("USE_API_PLAINTEXT") + # Linked wizard inputs only exist next to homeassistant entities, which need this + cg.add_define("USE_API_HOMEASSISTANT_STATES") + # test_wizard.cpp supplies the tables that codegen emits for a real build + for define in ( + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + ): + cg.add_define(define) + cg.add_define("API_WIZARD_DATA_SIZE", 200) + cg.add_define("API_WIZARD_INPUT_COUNT", 2) cg.add_define("API_MAX_SEND_QUEUE", 8) cg.add_define("MAX_API_CONNECTIONS", 1) cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") diff --git a/tests/components/api/common-wizard.yaml b/tests/components/api/common-wizard.yaml new file mode 100644 index 0000000000..5e1936a042 --- /dev/null +++ b/tests/components/api/common-wizard.yaml @@ -0,0 +1,108 @@ +api: + homeassistant_services: true + wizard: + pages: + - title: Features + description: Choose which features to enable + entities: + - id: feature_enable_switch + description: "[%key:component::example::feature::enable_switch%]" + - id: guest_mode_switch + description: Guest Mode + - id: plain_switch + - title: Sources + inputs: + - entity: outdoor_temp + description: Outdoor temperature + target: + entity: + - integration: met + domain: sensor + device_class: [temperature, humidity] + supported_features: weather.WeatherEntityFeature.FORECAST_DAILY + - domain: [sensor, number] + - entity: default_temp + - entity: door_open + - entity: house_mode + - entity: target_temp + - entity: ha_light_switch + target: + entity: + - domain: light + - entity: ha_text + - entity: ha_select + target: + entity: + - domain: input_select + - entity: ha_button + - id: weather_input + description: Weather source + target: + entity: + - domain: weather + - id: unset_input + +switch: + - platform: template + id: feature_enable_switch + name: Feature Enable + optimistic: true + - platform: template + id: guest_mode_switch + name: Guest Mode + optimistic: true + - platform: template + id: plain_switch + name: Plain Switch + optimistic: true + - platform: homeassistant + id: ha_light_switch + +sensor: + - platform: homeassistant + id: outdoor_temp + - platform: homeassistant + id: default_temp + - platform: homeassistant + id: fixed_temp + entity_id: sensor.fixed_temperature + +binary_sensor: + - platform: homeassistant + id: door_open + +text_sensor: + - platform: homeassistant + id: house_mode + +number: + - platform: homeassistant + id: target_temp + +text: + - platform: homeassistant + id: ha_text + +select: + - platform: homeassistant + id: ha_select + +button: + - platform: homeassistant + id: ha_button + - platform: homeassistant + id: fixed_button + entity_id: button.restart + +interval: + - interval: 1h + then: + - if: + condition: + api.wizard.input_is_set: weather_input + then: + - homeassistant.action: + action: weather.get_forecasts + data: + type: daily + entity_id: !lambda 'return id(weather_input).entity_id().c_str();' diff --git a/tests/components/api/test-wizard.esp32-idf.yaml b/tests/components/api/test-wizard.esp32-idf.yaml new file mode 100644 index 0000000000..4f33f5b30d --- /dev/null +++ b/tests/components/api/test-wizard.esp32-idf.yaml @@ -0,0 +1,6 @@ +packages: + wizard: !include common-wizard.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/api/test-wizard.esp8266-ard.yaml b/tests/components/api/test-wizard.esp8266-ard.yaml new file mode 100644 index 0000000000..4f33f5b30d --- /dev/null +++ b/tests/components/api/test-wizard.esp8266-ard.yaml @@ -0,0 +1,6 @@ +packages: + wizard: !include common-wizard.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/api/test-wizard.host.yaml b/tests/components/api/test-wizard.host.yaml new file mode 100644 index 0000000000..ffbcfcd36b --- /dev/null +++ b/tests/components/api/test-wizard.host.yaml @@ -0,0 +1,4 @@ +packages: + wizard: !include common-wizard.yaml + +network: diff --git a/tests/components/api/test_wizard.cpp b/tests/components/api/test_wizard.cpp new file mode 100644 index 0000000000..4e257e2e6a --- /dev/null +++ b/tests/components/api/test_wizard.cpp @@ -0,0 +1,150 @@ +#ifdef USE_HOST +#include + +#include +#include +#include +#include + +#include "esphome/components/api/api_buffer.h" +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_wizard.h" +#include "esphome/components/api/proto.h" + +namespace esphome::api { + +// RAM buffers of the inputs, as codegen defines them +static char *wizard_input_weather() { + static char buffer[WIZARD_ENTITY_ID_BUFFER_SIZE] = "sensor.default"; + return buffer; +} +static char *wizard_input_other() { + static char buffer[WIZARD_ENTITY_ID_BUFFER_SIZE] = ""; + return buffer; +} + +static constexpr uint32_t WEATHER_KEY = 0x0a0b0c0d; +static constexpr uint32_t OTHER_KEY = 0x11223344; + +// The same shapes the generated code emits. The data is 200 bytes (API_WIZARD_DATA_SIZE), so its length takes two +// bytes as a varint. +const uint8_t API_WIZARD_DATA[API_WIZARD_DATA_SIZE] = { + 0x03, 0x0a, 0x11, 0x18, 0x1f, 0x26, 0x2d, 0x34, 0x3b, 0x42, 0x49, 0x50, 0x57, 0x5e, 0x65, 0x6c, 0x73, 0x7a, 0x81, + 0x88, 0x8f, 0x96, 0x9d, 0xa4, 0xab, 0xb2, 0xb9, 0xc0, 0xc7, 0xce, 0xd5, 0xdc, 0xe3, 0xea, 0xf1, 0xf8, 0xff, 0x06, + 0x0d, 0x14, 0x1b, 0x22, 0x29, 0x30, 0x37, 0x3e, 0x45, 0x4c, 0x53, 0x5a, 0x61, 0x68, 0x6f, 0x76, 0x7d, 0x84, 0x8b, + 0x92, 0x99, 0xa0, 0xa7, 0xae, 0xb5, 0xbc, 0xc3, 0xca, 0xd1, 0xd8, 0xdf, 0xe6, 0xed, 0xf4, 0xfb, 0x02, 0x09, 0x10, + 0x17, 0x1e, 0x25, 0x2c, 0x33, 0x3a, 0x41, 0x48, 0x4f, 0x56, 0x5d, 0x64, 0x6b, 0x72, 0x79, 0x80, 0x87, 0x8e, 0x95, + 0x9c, 0xa3, 0xaa, 0xb1, 0xb8, 0xbf, 0xc6, 0xcd, 0xd4, 0xdb, 0xe2, 0xe9, 0xf0, 0xf7, 0xfe, 0x05, 0x0c, 0x13, 0x1a, + 0x21, 0x28, 0x2f, 0x36, 0x3d, 0x44, 0x4b, 0x52, 0x59, 0x60, 0x67, 0x6e, 0x75, 0x7c, 0x83, 0x8a, 0x91, 0x98, 0x9f, + 0xa6, 0xad, 0xb4, 0xbb, 0xc2, 0xc9, 0xd0, 0xd7, 0xde, 0xe5, 0xec, 0xf3, 0xfa, 0x01, 0x08, 0x0f, 0x16, 0x1d, 0x24, + 0x2b, 0x32, 0x39, 0x40, 0x47, 0x4e, 0x55, 0x5c, 0x63, 0x6a, 0x71, 0x78, 0x7f, 0x86, 0x8d, 0x94, 0x9b, 0xa2, 0xa9, + 0xb0, 0xb7, 0xbe, 0xc5, 0xcc, 0xd3, 0xda, 0xe1, 0xe8, 0xef, 0xf6, 0xfd, 0x04, 0x0b, 0x12, 0x19, 0x20, 0x27, 0x2e, + 0x35, 0x3c, 0x43, 0x4a, 0x51, 0x58, 0x5f, 0x66, 0x6d, 0x74}; +const WizardInputEntry API_WIZARD_INPUTS[API_WIZARD_INPUT_COUNT] = { + {WEATHER_KEY, wizard_input_weather()}, + {OTHER_KEY, wizard_input_other()}, +}; + +using Bytes = std::vector; + +static Bytes encode(const ProtoMessage &msg, uint32_t (*calc)(const void *), + uint8_t *(*enc)(const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) { + APIBuffer buffer; + uint32_t size = calc(&msg); + EXPECT_TRUE(buffer.resize(size)); + ProtoWriteBuffer writer(&buffer, 0); +#ifdef ESPHOME_DEBUG_API + uint8_t *proto_debug_end_ = buffer.data() + buffer.size(); +#endif + uint8_t *end = enc(&msg, writer PROTO_ENCODE_DEBUG_ARG); + EXPECT_EQ(static_cast(end - buffer.data()), size); + return Bytes(buffer.data(), buffer.data() + size); +} + +TEST(DeviceWizard, ResponseSendsTheDataUnchanged) { + DeviceWizardResponse resp; + resp.data = API_WIZARD_DATA; + resp.data_len = API_WIZARD_DATA_SIZE; + + // Field 1, length delimited, then the 200 byte length as a two byte varint, then the data + Bytes expected{0x0a, 0xc8, 0x01}; + expected.insert(expected.end(), API_WIZARD_DATA, API_WIZARD_DATA + API_WIZARD_DATA_SIZE); + + EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response), expected); +} + +TEST(DeviceWizard, ShortDataHasAOneByteLength) { + DeviceWizardResponse resp; + resp.data = API_WIZARD_DATA; + resp.data_len = 3; + + Bytes expected{0x0a, 0x03, API_WIZARD_DATA[0], API_WIZARD_DATA[1], API_WIZARD_DATA[2]}; + EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response), expected); +} + +TEST(DeviceWizard, NoDataEncodesNothing) { + DeviceWizardResponse resp; + EXPECT_EQ(resp.calculate_size(), 0u); + EXPECT_TRUE(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response).empty()); +} + +TEST(DeviceWizard, CapabilitiesAnnounceTheWizard) { + DeviceCapabilitiesResponse resp; + EXPECT_TRUE( + encode(resp, &DeviceCapabilitiesResponse::calc_size_msg, &DeviceCapabilitiesResponse::encode_msg).empty()); + resp.wizard.configured = true; + // Field 5 (the wizard), length delimited, holding field 1 (configured) set to 1 + EXPECT_EQ(encode(resp, &DeviceCapabilitiesResponse::calc_size_msg, &DeviceCapabilitiesResponse::encode_msg), + (Bytes{0x2a, 0x02, 0x08, 0x01})); +} + +// A request for the input with the given key and entity id +static const char *set_input(uint32_t key, const char *entity_id) { + WizardInputSetRequest request; + request.key = key; + request.entity_id = StringRef(entity_id); + return wizard_set_input(request); +} + +TEST(DeviceWizard, SetInputStoresTheEntityIdInTheBuffer) { + EXPECT_EQ(set_input(OTHER_KEY, "sensor.outdoor"), wizard_input_other()); + EXPECT_STREQ(wizard_input_other(), "sensor.outdoor"); + // A shorter id replaces a longer one completely + EXPECT_EQ(set_input(OTHER_KEY, "light.a"), wizard_input_other()); + EXPECT_STREQ(wizard_input_other(), "light.a"); + // The longest valid id fills the buffer + std::string longest = "sensor." + std::string(WIZARD_ENTITY_ID_BUFFER_SIZE - 1 - 7, 'x'); + EXPECT_EQ(set_input(OTHER_KEY, longest.c_str()), wizard_input_other()); + EXPECT_EQ(std::string(wizard_input_other()), longest); +} + +TEST(DeviceWizard, SetInputFindsTheInputByItsKey) { + EXPECT_EQ(set_input(WEATHER_KEY, "weather.home"), wizard_input_weather()); + EXPECT_STREQ(wizard_input_weather(), "weather.home"); + EXPECT_EQ(set_input(OTHER_KEY, "weather.away"), wizard_input_other()); + EXPECT_STREQ(wizard_input_weather(), "weather.home"); +} + +TEST(DeviceWizard, SetInputIgnoresWhatIsNotAnEntityId) { + std::string before = wizard_input_weather(); + EXPECT_EQ(set_input(WEATHER_KEY, ""), nullptr); + EXPECT_EQ(set_input(WEATHER_KEY, "nodot"), nullptr); + std::string too_long = "sensor." + std::string(WIZARD_ENTITY_ID_BUFFER_SIZE, 'x'); + EXPECT_EQ(set_input(WEATHER_KEY, too_long.c_str()), nullptr); + EXPECT_EQ(set_input(0xdeadbeef, "sensor.a"), nullptr); + EXPECT_EQ(std::string(wizard_input_weather()), before); +} + +TEST(DeviceWizard, StandaloneInputReadsTheBufferTheWizardWrites) { + strcpy(wizard_input_other(), ""); + WizardInput input(wizard_input_other()); + EXPECT_FALSE(input.has_entity_id()); + EXPECT_TRUE(input.entity_id().empty()); + + ASSERT_EQ(set_input(OTHER_KEY, "weather.home"), wizard_input_other()); + EXPECT_TRUE(input.has_entity_id()); + EXPECT_EQ(input.entity_id(), "weather.home"); +} + +} // namespace esphome::api +#endif // USE_HOST diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py index 3e2f37e16c..0f25de7c08 100644 --- a/tests/unit_tests/components/api/test_api_proto.py +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -40,6 +40,7 @@ API_DIR = Path(esphome.__file__).parent / "components" / "api" PROTO_TEXT = (API_DIR / "api.proto").read_text(encoding="utf-8") HEADER_TEXT = (API_DIR / "api_pb2.h").read_text(encoding="utf-8") CPP_TEXT = (API_DIR / "api_pb2.cpp").read_text(encoding="utf-8") +SERVICE_TEXT = (API_DIR / "api_pb2_service.cpp").read_text(encoding="utf-8") API_CONNECTION_TEXT = (API_DIR / "api_connection.cpp").read_text(encoding="utf-8") # Fields on DeviceInfoResponse that were superseded by DeviceCapabilitiesResponse @@ -66,7 +67,9 @@ NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = { "voice_assistant": 2, "zwave_proxy": 3, "serial_proxies": 4, + "wizard": 5, }, + "WizardCapabilities": {"configured": 1}, "BluetoothProxyCapabilities": { "feature_flags": 1, "mac_address": 2, @@ -359,6 +362,90 @@ def test_device_capabilities_rpc_requires_authentication() -> None: ) +def test_device_wizard_messages_keep_their_wire_ids() -> None: + """Message ids are part of the wire protocol and must not change.""" + for message, expected in ( + ("DeviceWizardRequest", 156), + ("DeviceWizardResponse", 157), + ("WizardInputSetRequest", 158), + ): + body = _extract_proto_message(PROTO_TEXT, message) + match = re.search(r"option \(id\) = (\d+);", body) + assert match is not None, f"{message} is missing `option (id)`" + assert int(match.group(1)) == expected, ( + f"{message} has id {match.group(1)}, expected {expected}" + ) + + +def test_device_wizard_message_fields_keep_their_wire_numbers() -> None: + """Clients decode the wizard purely by field number.""" + fields: dict[str, dict[str, int]] = { + "WizardInputSetRequest": {"key": 1, "entity_id": 2}, + "DeviceWizardResponse": {"data": 1}, + } + for message, message_fields in fields.items(): + body = _extract_proto_message(PROTO_TEXT, message) + for field_name, number in message_fields.items(): + line = _field_declaration_line(body, field_name) + assert re.search(rf"\b{field_name}\s*=\s*{number}\b", line), ( + f"{field_name} in {message} is no longer field number {number}" + ) + + +def _enclosing_ifdefs(text: str, anchor: str) -> list[str]: + """The conditions of the #if blocks that enclose the first line starting with anchor.""" + stack: list[str] = [] + for line in text.splitlines(): + if line.startswith(anchor): + return stack + if line.startswith("#if"): + stack.append(line) + elif line.startswith("#endif"): + stack.pop() + raise AssertionError(f"{anchor} not found") + + +# The define that compiles each wizard message in, so a device only pays for what its wizard uses +WIZARD_MESSAGE_DEFINES: dict[str, str] = { + "DeviceWizardRequest": "USE_API_WIZARD", + "DeviceWizardResponse": "USE_API_WIZARD", + "WizardInputSetRequest": "USE_API_WIZARD_INPUTS", +} +# Not marked in api.proto: the generator gives it the guard of the field that uses it +NESTED_WIZARD_MESSAGE_DEFINES: dict[str, str] = {"WizardCapabilities": "USE_API_WIZARD"} + + +def test_wizard_messages_are_compiled_out_without_a_wizard() -> None: + """A device must pay nothing for wizard parts it does not use: every wizard + message carries its define in api.proto, and the generated code guards the + nested message with the define of the field that holds it. + """ + for message, define in WIZARD_MESSAGE_DEFINES.items(): + body = _extract_proto_message(PROTO_TEXT, message) + assert f'option (ifdef) = "{define}";' in body, message + # The request is empty, so it has no class, only its dispatch case + assert "#ifdef USE_API_WIZARD\n case 156 /* DeviceWizardRequest" in SERVICE_TEXT + classes = {**WIZARD_MESSAGE_DEFINES, **NESTED_WIZARD_MESSAGE_DEFINES} + for message, define in classes.items(): + if message == "DeviceWizardRequest": + continue + assert any( + define in cond + for cond in _enclosing_ifdefs(HEADER_TEXT, f"class {message} final") + ), f"{message} is not guarded by {define} in api_pb2.h" + assert "#ifdef USE_API_WIZARD\n resp.wizard.configured = true;" in ( + API_CONNECTION_TEXT + ) + + +def test_device_wizard_rpc_requires_authentication() -> None: + """The wizard is only served on an authenticated connection, so the rpc + must not set `needs_authentication` and inherits the default of true. + """ + body = _extract_rpc_body(PROTO_TEXT, "device_wizard") + assert "needs_authentication" not in body + + # ==================== Group C: advertised API version ==================== diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 06b7c56bae..b6f8fd1ec1 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -845,3 +845,11 @@ class TestSharedProgmemArray: CORE.register_variable(ID("table", is_declaration=True), cg.MockObj("table")) array = cg.shared_progmem_array("table", ct.uint8, [1]) assert str(array) == "table_2" + + +def test_extern_progmem_array_is_an_externally_linked_global() -> None: + array = cg.extern_progmem_array("esphome::api::API_TABLE", ct.uint8, [1, 2]) + assert str(array) == "esphome::api::API_TABLE" + assert [str(st) for st in CORE.global_statements if "PROGMEM" in str(st)] == [ + "const uint8_t esphome::api::API_TABLE[] PROGMEM = {1, 2};" + ]