[api] Add a device wizard for Home Assistant onboarding (#19966)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Jesse Hills
2026-10-07 09:49:31 -10:00
committed by GitHub
co-authored by J. Nick Koston
parent 82cffe81f3
commit 6f940a486b
39 changed files with 2372 additions and 9 deletions
+1
View File
@@ -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,
+13
View File
@@ -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",
}
)
+91
View File
@@ -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 {
+17 -1
View File
@@ -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);
+16
View File
@@ -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
+51
View File
@@ -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<const WizardCapabilities *>(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<const WizardCapabilities *>(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<const DeviceCapabilitiesResponse *>(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<const DeviceWizardResponse *>(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<const DeviceWizardResponse *>(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<WizardInputSetRequest *>(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<HelloRequest>, "decodable messages carry no vtable");
static_assert(!std::is_polymorphic_v<DisconnectRequest>, "decodable messages carry no vtable");
#ifdef USE_API_WIZARD_INPUTS
static_assert(!std::is_polymorphic_v<WizardInputSetRequest>, "decodable messages carry no vtable");
#endif
#ifdef USE_COVER
static_assert(!std::is_polymorphic_v<CoverCommandRequest>, "decodable messages carry no vtable");
#endif
+65 -1
View File
@@ -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<SerialProxyInfo, SERIAL_PROXY_COUNT> 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;
+27
View File
@@ -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();
@@ -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;
+7
View File
@@ -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(){};
+104
View File
@@ -0,0 +1,104 @@
#include "api_wizard.h"
#ifdef USE_API_WIZARD
#include <cstring>
#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<const DeviceWizardResponse *>(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
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_API_WIZARD
#include <cstddef>
#include <cstdint>
#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
+461
View File
@@ -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))
+31 -7
View File
@@ -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")
@@ -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)
@@ -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")
@@ -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");
@@ -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")
@@ -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");
@@ -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")
@@ -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");
@@ -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)
@@ -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")
@@ -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");
@@ -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")
@@ -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");
@@ -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)
+6
View File
@@ -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
+17
View File
@@ -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":
+1
View File
@@ -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
+942
View File
@@ -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]
+12
View File
@@ -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")
+108
View File
@@ -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();'
@@ -0,0 +1,6 @@
packages:
wizard: !include common-wizard.yaml
wifi:
ssid: MySSID
password: password1
@@ -0,0 +1,6 @@
packages:
wizard: !include common-wizard.yaml
wifi:
ssid: MySSID
password: password1
@@ -0,0 +1,4 @@
packages:
wizard: !include common-wizard.yaml
network:
+150
View File
@@ -0,0 +1,150 @@
#ifdef USE_HOST
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
#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<uint8_t>;
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<size_t>(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
@@ -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 ====================
+8
View File
@@ -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};"
]