mirror of
https://github.com/esphome/esphome.git
synced 2026-10-08 12:04:03 +00:00
[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:
co-authored by
J. Nick Koston
parent
82cffe81f3
commit
6f940a486b
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(){};
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
@@ -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 ====================
|
||||
|
||||
|
||||
|
||||
@@ -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};"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user