[api] Let a wizard input pick multiple entities (#20537)

This commit is contained in:
Jesse Hills
2026-10-12 11:52:31 +13:00
parent 60402614cd
commit 87187c5391
9 changed files with 360 additions and 22 deletions
+17 -4
View File
@@ -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 {
+49 -7
View File
@@ -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
+33 -2
View File
@@ -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_;
+48 -7
View File
@@ -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",
+1
View File
@@ -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
+117 -1
View File
@@ -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}
+2 -1
View File
@@ -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")
+20
View File
@@ -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()); });
+73
View File
@@ -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