mirror of
https://github.com/esphome/esphome.git
synced 2026-10-13 17:52:51 +00:00
[api] Let a wizard input pick multiple entities (#20537)
This commit is contained in:
@@ -444,7 +444,7 @@ message DeviceWizardRequest {
|
||||
// {"version": 1,
|
||||
// "pages": [{"title": "...", "description": "...",
|
||||
// "entities": [{"key": 123, "device_id": 456, "description": "..."}],
|
||||
// "inputs": [{"key": 789, "description": "...",
|
||||
// "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
|
||||
// "entity_filters": [{"integration": "...", "domain": ["..."],
|
||||
// "device_class": ["..."], "supported_features": ["..."]}]}]}]}
|
||||
//
|
||||
@@ -468,6 +468,14 @@ message DeviceWizardRequest {
|
||||
// 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.
|
||||
//
|
||||
// An input with "multiple": true lets the user pick several entities. The client sends
|
||||
// them in one WizardInputSetRequest as a comma separated list with no spaces
|
||||
// ("calendar.a,calendar.b"); the device also accepts spaces around each id and stores the
|
||||
// list without them. "max_length" (only on a multiple input, 255 when left out) is the
|
||||
// longest list in bytes the device accepts. The version stays 1: a client that does not
|
||||
// know "multiple" ignores it, shows a single picker and sends one entity id, which a
|
||||
// multiple input accepts.
|
||||
//
|
||||
// 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
|
||||
@@ -483,8 +491,13 @@ message DeviceWizardResponse {
|
||||
|
||||
// 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,
|
||||
// reply. The device ignores a request whose key matches no input. A single input is
|
||||
// ignored when the entity_id is empty, longer than 255 bytes or has no '.'. A multiple
|
||||
// input takes a comma separated list ("calendar.a,calendar.b"); the client sends no
|
||||
// spaces, the device trims spaces around each id and stores the list without them. The
|
||||
// whole request is ignored when any id is empty or has no '.', or when the list without
|
||||
// spaces does not fit the input's max_length (255 by default). Otherwise it keeps the id
|
||||
// or list 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
|
||||
@@ -496,7 +509,7 @@ message WizardInputSetRequest {
|
||||
option (ifdef) = "USE_API_WIZARD_INPUTS";
|
||||
|
||||
fixed32 key = 1;
|
||||
string entity_id = 2 [(max_data_length) = 255];
|
||||
string entity_id = 2 [(max_data_length) = 4095];
|
||||
}
|
||||
|
||||
message ListEntitiesRequest {
|
||||
|
||||
@@ -14,26 +14,68 @@ namespace esphome::api {
|
||||
static const char *const TAG = "api.wizard";
|
||||
|
||||
#ifdef USE_API_WIZARD_INPUTS
|
||||
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
// Walks the comma separated entity ids in value, without the spaces around each. Returns the length they take joined
|
||||
// by ',', or 0 when one is empty or has no '.'. Writes the joined list to out unless it is nullptr.
|
||||
static size_t wizard_join_entity_ids(const char *value, size_t length, char *out) {
|
||||
size_t joined = 0;
|
||||
const char *end = value + length;
|
||||
while (true) {
|
||||
const char *comma = static_cast<const char *>(memchr(value, ',', end - value));
|
||||
const char *id_end = comma != nullptr ? comma : end;
|
||||
while (value < id_end && *value == ' ')
|
||||
value++;
|
||||
while (id_end > value && id_end[-1] == ' ')
|
||||
id_end--;
|
||||
size_t id_length = id_end - value;
|
||||
if (id_length == 0 || memchr(value, '.', id_length) == nullptr)
|
||||
return 0;
|
||||
if (joined != 0) {
|
||||
if (out != nullptr)
|
||||
out[joined] = ',';
|
||||
joined++;
|
||||
}
|
||||
if (out != nullptr)
|
||||
memcpy(out + joined, value, id_length);
|
||||
joined += id_length;
|
||||
if (comma == nullptr)
|
||||
return joined;
|
||||
value = comma + 1;
|
||||
}
|
||||
}
|
||||
#endif // USE_API_WIZARD_MULTIPLE_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;
|
||||
}
|
||||
const char *value = msg.entity_id.c_str();
|
||||
const size_t length = msg.entity_id.size();
|
||||
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';
|
||||
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
if (entry.size != 0) {
|
||||
// Two passes, validate and measure then write, so a rejected request leaves the buffer unchanged
|
||||
const size_t joined = wizard_join_entity_ids(value, length, nullptr);
|
||||
if (joined == 0 || joined >= entry.size)
|
||||
break;
|
||||
wizard_join_entity_ids(value, length, entry.entity_id);
|
||||
entry.entity_id[joined] = '\0';
|
||||
return entry.entity_id;
|
||||
}
|
||||
#endif
|
||||
if (!wizard_entity_id_valid(value, length))
|
||||
break;
|
||||
memcpy(entry.entity_id, value, length);
|
||||
entry.entity_id[length] = '\0';
|
||||
return entry.entity_id;
|
||||
}
|
||||
ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input");
|
||||
ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input, or one that is not valid");
|
||||
return nullptr;
|
||||
}
|
||||
#endif // USE_API_WIZARD_INPUTS
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
#include <cstring>
|
||||
#endif
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "proto.h"
|
||||
@@ -26,7 +29,10 @@ extern const uint8_t API_WIZARD_DATA[] PROGMEM;
|
||||
/// 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
|
||||
char *entity_id; // RAM buffer, WIZARD_ENTITY_ID_BUFFER_SIZE bytes or size, shared with the homeassistant entity
|
||||
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
uint16_t size; // Buffer size of a multiple input, 0 for a single input
|
||||
#endif
|
||||
};
|
||||
|
||||
/// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long.
|
||||
@@ -44,9 +50,34 @@ const char *wizard_set_input(const WizardInputSetRequest &msg);
|
||||
class WizardInput {
|
||||
public:
|
||||
explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {}
|
||||
/// The Home Assistant entity ID, empty until the wizard sets one.
|
||||
/// The Home Assistant entity ID, empty until the wizard sets one. For a multiple input this is the whole
|
||||
/// comma separated list, which homeassistant.action data can use as it is.
|
||||
StringRef entity_id() const { return StringRef(this->entity_id_); }
|
||||
bool has_entity_id() const { return this->entity_id_[0] != '\0'; }
|
||||
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
/// The number of entity IDs in the list, 0 until the wizard sets one.
|
||||
size_t entity_id_count() const {
|
||||
if (!this->has_entity_id())
|
||||
return 0;
|
||||
size_t count = 1;
|
||||
for (const char *p = this->entity_id_; (p = strchr(p, ',')) != nullptr; p++)
|
||||
count++;
|
||||
return count;
|
||||
}
|
||||
/// Call callback(StringRef) for each entity ID in the list, in order. The StringRef points into the list and is
|
||||
/// not null terminated, so print it with "%.*s".
|
||||
template<typename F> void for_each_entity_id(F &&callback) const {
|
||||
// The device stores the list without spaces or empty entries
|
||||
const char *start = this->entity_id_;
|
||||
while (*start != '\0') {
|
||||
const char *end = strchr(start, ',');
|
||||
if (end == nullptr)
|
||||
end = start + strlen(start);
|
||||
callback(StringRef(start, end - start));
|
||||
start = *end == ',' ? end + 1 : end;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
const char *entity_id_;
|
||||
|
||||
@@ -20,6 +20,8 @@ from esphome.const import (
|
||||
CONF_ENTITY_ID,
|
||||
CONF_ID,
|
||||
CONF_INTERNAL,
|
||||
CONF_MAX_LENGTH,
|
||||
CONF_MULTIPLE,
|
||||
CONF_NAME,
|
||||
CONF_PAGES,
|
||||
CONF_PLATFORM,
|
||||
@@ -46,6 +48,10 @@ WizardInput = _API.class_("WizardInput")
|
||||
WIZARD_ENTITY_ID_BUFFER_SIZE = (
|
||||
256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes
|
||||
)
|
||||
WIZARD_ENTITY_ID_MAX_LENGTH = WIZARD_ENTITY_ID_BUFFER_SIZE - 1
|
||||
# The value of a multiple input fits one WizardInputSetRequest, which the device accepts up to 8 KiB on ESP8266;
|
||||
# api.proto max_data_length documents the same value
|
||||
WIZARD_MULTIPLE_MAX_LENGTH = 4095
|
||||
# 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
|
||||
@@ -101,6 +107,21 @@ WIZARD_ENTITY_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
def _validate_multiple(conf: ConfigType) -> ConfigType:
|
||||
if conf[CONF_MULTIPLE] and CONF_ENTITY in conf:
|
||||
raise cv.Invalid(
|
||||
"A linked input follows the state of exactly one Home Assistant entity, so multiple "
|
||||
"is only for standalone inputs (id)",
|
||||
path=[CONF_MULTIPLE],
|
||||
)
|
||||
if CONF_MAX_LENGTH in conf and not conf[CONF_MULTIPLE]:
|
||||
raise cv.Invalid(
|
||||
"max_length only applies to an input with multiple: true",
|
||||
path=[CONF_MAX_LENGTH],
|
||||
)
|
||||
return conf
|
||||
|
||||
|
||||
# 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(
|
||||
@@ -109,6 +130,10 @@ WIZARD_INPUT_SCHEMA = cv.All(
|
||||
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_MULTIPLE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_MAX_LENGTH): cv.int_range(
|
||||
min=WIZARD_ENTITY_ID_MAX_LENGTH, max=WIZARD_MULTIPLE_MAX_LENGTH
|
||||
),
|
||||
cv.Optional(CONF_TARGET): cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ENTITY): cv.All(
|
||||
@@ -119,6 +144,7 @@ WIZARD_INPUT_SCHEMA = cv.All(
|
||||
}
|
||||
),
|
||||
cv.has_exactly_one_key(CONF_ID, CONF_ENTITY),
|
||||
_validate_multiple,
|
||||
)
|
||||
|
||||
WIZARD_PAGE_SCHEMA = cv.All(
|
||||
@@ -243,6 +269,8 @@ def _wizard_defines(wizard: ConfigType) -> set[str]:
|
||||
defines.add("USE_API_WIZARD_LINKED_INPUTS")
|
||||
if any(CONF_ID in conf for conf in inputs):
|
||||
defines.add("USE_API_WIZARD_STANDALONE_INPUTS")
|
||||
if any(conf.get(CONF_MULTIPLE) for conf in inputs):
|
||||
defines.add("USE_API_WIZARD_MULTIPLE_INPUTS")
|
||||
return defines
|
||||
|
||||
|
||||
@@ -374,6 +402,10 @@ def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigT
|
||||
document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)}
|
||||
if description := conf.get(CONF_DESCRIPTION):
|
||||
document[CONF_DESCRIPTION] = description
|
||||
if conf.get(CONF_MULTIPLE):
|
||||
document["multiple"] = True
|
||||
if (max_length := conf.get(CONF_MAX_LENGTH)) is not None:
|
||||
document["max_length"] = max_length
|
||||
if filters := _wizard_input_filters(conf, config):
|
||||
document["entity_filters"] = [dict(entity_filter) for entity_filter in filters]
|
||||
return document
|
||||
@@ -387,7 +419,7 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi
|
||||
{"version": 1,
|
||||
"pages": [{"title": "...", "description": "...",
|
||||
"entities": [{"key": 123, "device_id": 456, "description": "..."}],
|
||||
"inputs": [{"key": 789, "description": "...",
|
||||
"inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
|
||||
"entity_filters": [{"integration": "...", "domain": ["..."],
|
||||
"device_class": ["..."], "supported_features": ["..."]}]}]}]}
|
||||
|
||||
@@ -399,6 +431,14 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi
|
||||
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.
|
||||
- multiple means the user may pick several entities. The client sends them in one
|
||||
WizardInputSetRequest as a comma separated list ("calendar.a,calendar.b") with no spaces; the
|
||||
device trims spaces around each id and stores the list without them. The whole request is
|
||||
ignored when any id is empty or has no '.', or when the list without spaces does not fit
|
||||
max_length (only on a multiple input, 255 when left out). A single input is ignored when the id
|
||||
is empty, longer than 255 bytes or has no '.'. The version stays 1: a client that does not know
|
||||
multiple ignores it, shows a single picker and sends one entity id, which a multiple input
|
||||
accepts.
|
||||
- entity_filters are the filters of the input, or the default filters of a linked switch,
|
||||
number, text, select or button.
|
||||
"""
|
||||
@@ -444,14 +484,15 @@ async def to_code(wizard: ConfigType) -> None:
|
||||
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}] = "";'
|
||||
)
|
||||
)
|
||||
size = conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1
|
||||
cg.add_global(cg.RawStatement(f'static char {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}}}"))
|
||||
key = fnv1_hash(input_id.id)
|
||||
if conf[CONF_MULTIPLE]:
|
||||
entries.append(cg.RawExpression(f"{{{key}u, {buffer}, {size}}}"))
|
||||
else:
|
||||
entries.append(cg.RawExpression(f"{{{key}u, {buffer}}}"))
|
||||
if entries:
|
||||
cg.extern_progmem_array(
|
||||
"esphome::api::API_WIZARD_INPUTS",
|
||||
|
||||
@@ -275,6 +275,7 @@
|
||||
#define USE_API_WIZARD
|
||||
#define USE_API_WIZARD_INPUTS
|
||||
#define USE_API_WIZARD_LINKED_INPUTS
|
||||
#define USE_API_WIZARD_MULTIPLE_INPUTS
|
||||
#define USE_API_WIZARD_STANDALONE_INPUTS
|
||||
#define USE_API_PLAINTEXT
|
||||
#define USE_API_USER_DEFINED_ACTIONS
|
||||
|
||||
@@ -490,6 +490,7 @@ WIZARD_DEFINES = {
|
||||
"USE_API_WIZARD",
|
||||
"USE_API_WIZARD_INPUTS",
|
||||
"USE_API_WIZARD_LINKED_INPUTS",
|
||||
"USE_API_WIZARD_MULTIPLE_INPUTS",
|
||||
"USE_API_WIZARD_STANDALONE_INPUTS",
|
||||
}
|
||||
|
||||
@@ -550,9 +551,28 @@ WIZARD_DEFINES = {
|
||||
),
|
||||
pytest.param(
|
||||
"- entities: [{id: sw}]\n inputs: [{entity: ha_sensor, target: {entity: [{domain: sensor}]}}, {id: standalone}]",
|
||||
WIZARD_DEFINES,
|
||||
WIZARD_DEFINES - {"USE_API_WIZARD_MULTIPLE_INPUTS"},
|
||||
id="everything",
|
||||
),
|
||||
pytest.param(
|
||||
"- inputs: [{id: calendars, multiple: true}]",
|
||||
{
|
||||
"USE_API_WIZARD",
|
||||
"USE_API_WIZARD_INPUTS",
|
||||
"USE_API_WIZARD_STANDALONE_INPUTS",
|
||||
"USE_API_WIZARD_MULTIPLE_INPUTS",
|
||||
},
|
||||
id="multiple-input",
|
||||
),
|
||||
pytest.param(
|
||||
"- inputs: [{id: calendars, multiple: false}]",
|
||||
{
|
||||
"USE_API_WIZARD",
|
||||
"USE_API_WIZARD_INPUTS",
|
||||
"USE_API_WIZARD_STANDALONE_INPUTS",
|
||||
},
|
||||
id="multiple-false",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_only_the_defines_the_wizard_needs_are_emitted(
|
||||
@@ -940,3 +960,99 @@ def test_an_entity_with_an_entity_id_that_is_no_input_is_valid(
|
||||
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]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_conf",
|
||||
[
|
||||
"{id: cals, multiple: true}",
|
||||
"{id: cals, multiple: true, max_length: 255}",
|
||||
"{id: cals, multiple: true, max_length: 4095}",
|
||||
"{id: cals, multiple: false}",
|
||||
],
|
||||
)
|
||||
def test_multiple_input_is_valid(tmp_path: Path, input_conf: str) -> None:
|
||||
api = wizard_inputs(f"- {input_conf}")
|
||||
|
||||
assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("inputs", "message"),
|
||||
[
|
||||
pytest.param(
|
||||
"- {entity: ha_sensor, multiple: true}",
|
||||
"multiple is only for standalone inputs",
|
||||
id="multiple-linked",
|
||||
),
|
||||
pytest.param(
|
||||
"- {id: cals, max_length: 1023}",
|
||||
"max_length only applies to an input with multiple: true",
|
||||
id="max-length-without-multiple",
|
||||
),
|
||||
pytest.param(
|
||||
"- {id: cals, multiple: false, max_length: 1023}",
|
||||
"max_length only applies to an input with multiple: true",
|
||||
id="max-length-multiple-false",
|
||||
),
|
||||
pytest.param(
|
||||
"- {id: cals, multiple: true, max_length: 254}",
|
||||
"value must be at least 255",
|
||||
id="max-length-too-small",
|
||||
),
|
||||
pytest.param(
|
||||
"- {id: cals, multiple: true, max_length: 4096}",
|
||||
"value must be at most 4095",
|
||||
id="max-length-too-big",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_invalid_multiple_inputs_are_rejected(
|
||||
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_multiple_input_document_and_table(
|
||||
tmp_path: Path, generate_main: Callable[[str | Path], str]
|
||||
) -> None:
|
||||
api = wizard_inputs(
|
||||
"""
|
||||
- {id: cals, multiple: true, max_length: 1023}
|
||||
- {id: cals_default, multiple: true}
|
||||
- {id: single}
|
||||
"""
|
||||
)
|
||||
main_cpp = generate_main(write_config(tmp_path, ESP32_HEADER, api))
|
||||
|
||||
document = json.loads(zstd_module().decompress(blob_in(main_cpp)))
|
||||
inputs = document["pages"][0]["inputs"]
|
||||
# multiple and max_length are only there when set
|
||||
assert inputs == [
|
||||
{"key": fnv1_hash("cals"), "multiple": True, "max_length": 1023},
|
||||
{"key": fnv1_hash("cals_default"), "multiple": True},
|
||||
{"key": fnv1_hash("single")},
|
||||
]
|
||||
# Only a multiple entry names its buffer size
|
||||
assert 'static char api_wizard_input_cals[1024] = "";' in main_cpp
|
||||
assert 'static char api_wizard_input_cals_default[256] = "";' in main_cpp
|
||||
assert 'static char api_wizard_input_single[256] = "";' in main_cpp
|
||||
assert f"{{{fnv1_hash('cals')}u, api_wizard_input_cals, 1024}}" in main_cpp
|
||||
assert (
|
||||
f"{{{fnv1_hash('cals_default')}u, api_wizard_input_cals_default, 256}}"
|
||||
in main_cpp
|
||||
)
|
||||
assert f"{{{fnv1_hash('single')}u, api_wizard_input_single}}" in main_cpp
|
||||
|
||||
|
||||
def test_inputs_without_multiple_keep_the_two_field_table(
|
||||
tmp_path: Path, generate_main: Callable[[str | Path], str]
|
||||
) -> None:
|
||||
main_cpp = generate_main(
|
||||
write_config(tmp_path, ESP32_HEADER, wizard_inputs("- id: single"))
|
||||
)
|
||||
|
||||
assert f"{{{fnv1_hash('single')}u, api_wizard_input_single}}" in main_cpp
|
||||
assert "MULTIPLE_INPUTS" not in {d.name for d in CORE.defines}
|
||||
|
||||
@@ -23,11 +23,12 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
"USE_API_WIZARD",
|
||||
"USE_API_WIZARD_INPUTS",
|
||||
"USE_API_WIZARD_LINKED_INPUTS",
|
||||
"USE_API_WIZARD_MULTIPLE_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_WIZARD_INPUT_COUNT", 3)
|
||||
cg.add_define("API_MAX_SEND_QUEUE", 8)
|
||||
cg.add_define("MAX_API_CONNECTIONS", 1)
|
||||
cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS")
|
||||
|
||||
@@ -41,6 +41,13 @@ api:
|
||||
entity:
|
||||
- domain: weather
|
||||
- id: unset_input
|
||||
- id: calendars_input
|
||||
description: Calendars
|
||||
multiple: true
|
||||
max_length: 1023
|
||||
target:
|
||||
entity:
|
||||
- domain: calendar
|
||||
|
||||
switch:
|
||||
- platform: template
|
||||
@@ -106,3 +113,16 @@ interval:
|
||||
data:
|
||||
type: daily
|
||||
entity_id: !lambda 'return id(weather_input).entity_id().c_str();'
|
||||
- if:
|
||||
condition:
|
||||
api.wizard.input_is_set: calendars_input
|
||||
then:
|
||||
- homeassistant.action:
|
||||
action: calendar.get_events
|
||||
data:
|
||||
duration: "24:00:00"
|
||||
entity_id: !lambda 'return id(calendars_input).entity_id().c_str();'
|
||||
- lambda: |-
|
||||
ESP_LOGD("wizard", "%u calendars", (unsigned) id(calendars_input).entity_id_count());
|
||||
id(calendars_input).for_each_entity_id(
|
||||
[](StringRef calendar) { ESP_LOGD("wizard", "Calendar %.*s", (int) calendar.size(), calendar.c_str()); });
|
||||
|
||||
@@ -23,8 +23,15 @@ static char *wizard_input_other() {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
static constexpr size_t CALENDARS_SIZE = 64;
|
||||
static char *wizard_input_calendars() {
|
||||
static char buffer[CALENDARS_SIZE] = "";
|
||||
return buffer;
|
||||
}
|
||||
|
||||
static constexpr uint32_t WEATHER_KEY = 0x0a0b0c0d;
|
||||
static constexpr uint32_t OTHER_KEY = 0x11223344;
|
||||
static constexpr uint32_t CALENDARS_KEY = 0x55667788;
|
||||
|
||||
// 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.
|
||||
@@ -43,6 +50,7 @@ const uint8_t API_WIZARD_DATA[API_WIZARD_DATA_SIZE] = {
|
||||
const WizardInputEntry API_WIZARD_INPUTS[API_WIZARD_INPUT_COUNT] = {
|
||||
{WEATHER_KEY, wizard_input_weather()},
|
||||
{OTHER_KEY, wizard_input_other()},
|
||||
{CALENDARS_KEY, wizard_input_calendars(), CALENDARS_SIZE},
|
||||
};
|
||||
|
||||
using Bytes = std::vector<uint8_t>;
|
||||
@@ -144,5 +152,70 @@ TEST(DeviceWizard, StandaloneInputReadsTheBufferTheWizardWrites) {
|
||||
EXPECT_EQ(input.entity_id(), "weather.home");
|
||||
}
|
||||
|
||||
TEST(DeviceWizard, MultipleInputStoresTheList) {
|
||||
EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,calendar.b"), wizard_input_calendars());
|
||||
EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b");
|
||||
// One id is a list of one
|
||||
EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.c"), wizard_input_calendars());
|
||||
EXPECT_STREQ(wizard_input_calendars(), "calendar.c");
|
||||
}
|
||||
|
||||
TEST(DeviceWizard, MultipleInputStoresTheListWithoutSpaces) {
|
||||
EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a , calendar.b"), wizard_input_calendars());
|
||||
EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b");
|
||||
EXPECT_EQ(set_input(CALENDARS_KEY, " calendar.a , calendar.b ,calendar.c "), wizard_input_calendars());
|
||||
EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b,calendar.c");
|
||||
}
|
||||
|
||||
TEST(DeviceWizard, MultipleInputIgnoresTheWholeRequestForABadEntry) {
|
||||
ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.keep"), wizard_input_calendars());
|
||||
EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,nodot"), nullptr);
|
||||
EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,,calendar.b"), nullptr);
|
||||
EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,"), nullptr);
|
||||
EXPECT_EQ(set_input(CALENDARS_KEY, ",calendar.a"), nullptr);
|
||||
EXPECT_EQ(set_input(CALENDARS_KEY, " "), nullptr);
|
||||
EXPECT_EQ(set_input(CALENDARS_KEY, ""), nullptr);
|
||||
EXPECT_STREQ(wizard_input_calendars(), "calendar.keep");
|
||||
}
|
||||
|
||||
TEST(DeviceWizard, MultipleInputIsLimitedByItsBuffer) {
|
||||
// Pad the last id so the list fills the buffer
|
||||
std::string fits = "calendar.a,calendar.b,calendar." + std::string(CALENDARS_SIZE - 1 - 31, 'x');
|
||||
ASSERT_EQ(fits.size(), CALENDARS_SIZE - 1);
|
||||
EXPECT_EQ(set_input(CALENDARS_KEY, fits.c_str()), wizard_input_calendars());
|
||||
EXPECT_EQ(std::string(wizard_input_calendars()), fits);
|
||||
|
||||
std::string too_long = fits + "x";
|
||||
EXPECT_EQ(set_input(CALENDARS_KEY, too_long.c_str()), nullptr);
|
||||
EXPECT_EQ(std::string(wizard_input_calendars()), fits);
|
||||
// Spaces do not count towards the limit
|
||||
std::string spaced = fits;
|
||||
spaced.replace(spaced.find(','), 1, " , ");
|
||||
EXPECT_EQ(set_input(CALENDARS_KEY, spaced.c_str()), wizard_input_calendars());
|
||||
EXPECT_EQ(std::string(wizard_input_calendars()), fits);
|
||||
}
|
||||
|
||||
TEST(DeviceWizard, MultipleInputHelpersWalkTheList) {
|
||||
strcpy(wizard_input_calendars(), "");
|
||||
WizardInput input(wizard_input_calendars());
|
||||
EXPECT_EQ(input.entity_id_count(), 0u);
|
||||
std::vector<std::string> seen;
|
||||
input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); });
|
||||
EXPECT_TRUE(seen.empty());
|
||||
|
||||
ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.a"), wizard_input_calendars());
|
||||
EXPECT_EQ(input.entity_id_count(), 1u);
|
||||
input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); });
|
||||
EXPECT_EQ(seen, (std::vector<std::string>{"calendar.a"}));
|
||||
|
||||
seen.clear();
|
||||
ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.a, calendar.b,calendar.c"), wizard_input_calendars());
|
||||
EXPECT_EQ(input.entity_id_count(), 3u);
|
||||
input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); });
|
||||
EXPECT_EQ(seen, (std::vector<std::string>{"calendar.a", "calendar.b", "calendar.c"}));
|
||||
// The raw list is what homeassistant.action data receives
|
||||
EXPECT_EQ(input.entity_id(), "calendar.a,calendar.b,calendar.c");
|
||||
}
|
||||
|
||||
} // namespace esphome::api
|
||||
#endif // USE_HOST
|
||||
|
||||
Reference in New Issue
Block a user