[core] [select] Keep select options in flash with a shared ConstVector (#20117)

This commit is contained in:
J. Nick Koston
2026-10-05 19:24:31 -05:00
committed by GitHub
parent 40af90ad25
commit 80dc93d6ce
12 changed files with 336 additions and 21 deletions
+1 -1
View File
@@ -1439,7 +1439,7 @@ message ListEntitiesSelectResponse {
reserved 4; // Deprecated: was string unique_id
string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
repeated string options = 6 [(container_pointer_no_template) = "FixedVector<const char *>"];
repeated string options = 6 [(container_pointer_no_template) = "std::span<const char *const>"];
bool disabled_by_default = 7;
EntityCategory entity_category = 8;
uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"];
+3 -1
View File
@@ -990,7 +990,9 @@ uint16_t APIConnection::try_send_select_state(EntityBase *entity, APIConnection
uint16_t APIConnection::try_send_select_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
auto *select = static_cast<select::Select *>(entity);
ListEntitiesSelectResponse msg;
msg.options = &select->traits.get_options();
const auto &opts = select->traits.get_options();
const std::span<const char *const> options(opts.data(), opts.size());
msg.options = &options;
return fill_and_encode_entity_info(select, msg, conn, remaining_size);
}
void APIConnection::on_select_command_request(const SelectCommandRequest &msg) {
+1 -1
View File
@@ -1909,7 +1909,7 @@ class ListEntitiesSelectResponse final : public InfoResponseProtoMessage {
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_select_response"); }
#endif
const FixedVector<const char *> *options{};
const std::span<const char *const> *options{};
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);
@@ -28,6 +28,7 @@
// Standard library includes that might be needed
#include <set>
#include <span>
#include <vector>
#include <string>
+3 -1
View File
@@ -95,7 +95,9 @@ def select_schema(
@setup_entity("select")
async def setup_select_core_(var, config, *, options: list[str]):
cg.add(var.traits.set_options(options))
if options:
table = cg.shared_progmem_array("select_options", cg.const_char_ptr, options)
cg.add(var.traits.set_options_static(table, len(options)))
for conf in config.get(CONF_ON_VALUE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
+10 -5
View File
@@ -2,13 +2,18 @@
namespace esphome::select {
void SelectTraits::set_options(const std::initializer_list<const char *> &options) { this->options_ = options; }
// Runtime option lists are copied, since the argument may not outlive the select; one
// out of line copy keeps a single instance of the copy code.
void SelectTraits::set_options_copy_(const char *const *options, size_t count) {
this->options_.assign_copy(options, count);
}
void SelectTraits::set_options(const std::initializer_list<const char *> &options) {
this->set_options_copy_(options.begin(), options.size());
}
void SelectTraits::set_options(const FixedVector<const char *> &options) {
this->options_.init(options.size());
for (const auto &opt : options) {
this->options_.push_back(opt);
}
this->set_options_copy_(options.begin(), options.size());
}
} // namespace esphome::select
+16 -2
View File
@@ -5,14 +5,28 @@
namespace esphome::select {
/// Option strings: a shared codegen table, or a copy of a runtime list.
using SelectOptions = ConstVector<const char *, true>;
class SelectTraits {
public:
SelectTraits() = default;
SelectTraits(const SelectTraits &) = delete;
SelectTraits &operator=(const SelectTraits &) = delete;
/// Codegen only: points at a table that outlives the select. Call before any runtime set_options;
/// it does not free a previous copy (generated setup() runs before any lambda or automation).
void set_options_static(const char *const *options, size_t count) { this->options_.assign_static(options, count); }
/// Runtime lists: the pointer list is copied, as before; the strings must still outlive the select.
void set_options(const SelectOptions &options) { this->set_options_copy_(options.data(), options.size()); }
void set_options(const std::initializer_list<const char *> &options);
void set_options(const FixedVector<const char *> &options);
const FixedVector<const char *> &get_options() const { return this->options_; }
const SelectOptions &get_options() const { return this->options_; }
protected:
FixedVector<const char *> options_;
void set_options_copy_(const char *const *options, size_t count);
SelectOptions options_;
};
} // namespace esphome::select
+69 -10
View File
@@ -129,23 +129,82 @@ template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n);
/// @name Container utilities
///@{
/// Lightweight read-only view over a const array stored in RODATA (will typically be in flash memory)
/// Avoids copying data from flash to RAM by keeping a pointer to the flash data.
/// Similar to std::span but with minimal overhead for embedded systems.
template<typename T> class ConstVector {
/// Lightweight read-only view over a const array stored in RODATA (will typically be in flash memory).
/// Iterators are raw pointers like FixedVector. With Owning = true it can also hold a heap copy it
/// owns (see the specialization below); the default view never frees and has no extra cost.
template<typename T, bool Owning = false> class ConstVector {
public:
using value_type = T;
constexpr ConstVector() = default;
constexpr ConstVector(const T *data, size_t size) : data_(data), size_(size) {}
const constexpr T &operator[](size_t i) const { return data_[i]; }
constexpr size_t size() const { return size_; }
constexpr bool empty() const { return size_ == 0; }
const T *begin() const { return this->data_; }
const T *end() const { return this->data_ + this->size_; }
const T *data() const { return this->data_; }
constexpr size_t size() const { return this->size_; }
constexpr bool empty() const { return this->size_ == 0; }
const constexpr T &operator[](size_t i) const { return this->data_[i]; }
const T &at(size_t i) const { return this->data_[i]; }
protected:
const T *data_;
size_t size_;
const T *data_{nullptr};
size_t size_{0};
};
/// Owning variant: a codegen table that outlives it, or a heap copy of a runtime list it owns.
/// Ownership is the top bit of the size; it is not copyable, so a copy can never outlive the owner.
/// Elements must be whole words so ESP8266 can read a codegen table from flash.
template<typename T> class ConstVector<T, true> {
static_assert(std::is_trivially_copyable_v<T> && sizeof(T) % sizeof(uint32_t) == 0,
"ConstVector elements must be whole words so ESP8266 can read them from flash");
public:
using value_type = T;
constexpr ConstVector() = default;
constexpr ConstVector(const T *data, size_t size) : data_(data), size_(size) {}
ConstVector(const ConstVector &) = delete;
ConstVector &operator=(const ConstVector &) = delete;
~ConstVector() { this->release_(); }
const T *begin() const { return this->data_; }
const T *end() const { return this->data_ + this->size(); }
const T *data() const { return this->data_; }
size_t size() const { return this->size_ & ~OWNED_BIT; }
bool empty() const { return this->size() == 0; }
const T &operator[](size_t index) const { return this->data_[index]; }
const T &at(size_t index) const { return this->data_[index]; }
/// Codegen only: call before any runtime copy; it does not free a previous owned copy
/// (generated setup() runs before any lambda or automation can call set_options).
void assign_static(const T *data, size_t size) {
this->data_ = data;
this->size_ = size;
}
/// Copies the list into a heap array this owns, freeing a previous owned copy.
void assign_copy(const T *data, size_t size) {
auto *table = new T[size]; // NOLINT(cppcoreguidelines-owning-memory)
std::copy(data, data + size, table);
this->release_();
this->data_ = table;
this->size_ = size | OWNED_BIT;
}
protected:
static constexpr size_t OWNED_BIT = size_t{1} << (sizeof(size_t) * 8 - 1);
void release_() {
if (this->size_ & OWNED_BIT)
delete[] this->data_; // NOLINT(cppcoreguidelines-owning-memory)
}
const T *data_{nullptr};
size_t size_{0}; // top bit set when data_ is an owned heap copy
};
static_assert(sizeof(ConstVector<const char *, true>) == 2 * sizeof(void *),
"ConstVector must stay a pointer and a size");
/// Small buffer optimization - stores data inline when small, heap-allocates for large data
/// This avoids heap fragmentation for common small allocations while supporting arbitrary sizes.
/// Memory management is encapsulated - callers just use set() and data().
@@ -0,0 +1,26 @@
esphome:
name: test
esp8266:
board: esp01_1m
select:
- platform: template
id: fan_a
name: Fan A
optimistic: true
options: [low, medium, high]
- platform: template
id: fan_b
name: Fan B
optimistic: true
options: [low, medium, high]
- platform: template
id: mode
name: Mode
optimistic: true
options: [auto, manual]
- platform: copy
id: fan_copy
name: Fan Copy
source_id: fan_a
@@ -0,0 +1,27 @@
"""Select options are shared PROGMEM tables of option pointers."""
from collections.abc import Callable
from pathlib import Path
import re
def test_select_options_use_shared_tables(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("select_options.yaml"))
calls = {
var: table
for var, table, _ in re.findall(
r"(\w+)->traits\.set_options_static\((\w+), (\d+)\);", main_cpp
)
}
assert set(calls) == {"fan_a", "fan_b", "mode"}
assert calls["fan_a"] == calls["fan_b"] != calls["mode"]
assert (
f'static constexpr const char * {calls["fan_a"]}[] PROGMEM = {{"low", "medium", "high"}};'
in main_cpp
)
# The copy select takes its options from the source at setup
assert "fan_copy->traits.set_options_static(" not in main_cpp
@@ -0,0 +1,96 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <type_traits>
#include "esphome/core/helpers.h"
namespace esphome::testing {
static constexpr const char *const TABLE[] = {"a", "b", "c"};
// Exposes the owned flag, which is protected.
class ProbeVector : public ConstVector<const char *, true> {
public:
using ConstVector::ConstVector;
bool owned() const { return (this->size_ & OWNED_BIT) != 0; }
};
TEST(ConstVector, StaticTableIsViewedNotOwned) {
ProbeVector list;
EXPECT_TRUE(list.empty());
list.assign_static(TABLE, 3);
EXPECT_EQ(list.data(), TABLE);
EXPECT_EQ(list.size(), 3U);
EXPECT_FALSE(list.owned());
EXPECT_STREQ(list[1], "b");
EXPECT_STREQ(list.at(2), "c");
}
TEST(ConstVector, CopyIsOwnedAndSizeMasksTheFlag) {
ProbeVector list;
list.assign_copy(TABLE, 3);
EXPECT_NE(list.data(), TABLE);
EXPECT_TRUE(list.owned());
EXPECT_EQ(list.size(), 3U);
EXPECT_STREQ(list[0], "a");
const char *const next[] = {"x", "y"};
list.assign_copy(next, 2); // frees the previous copy
EXPECT_TRUE(list.owned());
EXPECT_EQ(list.size(), 2U);
EXPECT_STREQ(list[1], "y");
}
TEST(ConstVector, StaticThenRuntimeCopiesNeverFreeTheTable) {
ProbeVector list;
list.assign_static(TABLE, 3);
EXPECT_FALSE(list.owned());
list.assign_copy(TABLE, 2); // the static table is not owned, so nothing is freed
EXPECT_TRUE(list.owned());
EXPECT_NE(list.data(), TABLE);
const char *const next[] = {"x"};
list.assign_copy(next, 1); // frees the previous copy
EXPECT_EQ(list.size(), 1U);
EXPECT_STREQ(list[0], "x");
EXPECT_STREQ(TABLE[0], "a");
}
TEST(ConstVector, EmptyCopyIsEmptyAndFreedOnNextSet) {
ProbeVector list;
list.assign_copy(TABLE, 3);
list.assign_copy(TABLE, 0);
EXPECT_TRUE(list.empty());
list.assign_copy(TABLE, 2); // frees the empty copy
EXPECT_EQ(list.size(), 2U);
}
TEST(ConstVector, OwningVariantIsNotCopyable) {
static_assert(!std::is_copy_constructible_v<ConstVector<const char *, true>>);
static_assert(!std::is_copy_assignable_v<ConstVector<const char *, true>>);
}
TEST(ConstVector, CopyFromItsOwnStorage) {
ProbeVector list;
list.assign_copy(TABLE, 3);
list.assign_copy(list.data(), list.size());
EXPECT_EQ(list.size(), 3U);
EXPECT_STREQ(list[2], "c");
}
TEST(ConstVector, PlainViewHasNoOwnershipCost) {
static_assert(std::is_trivially_copyable_v<ConstVector<const char *>>);
static_assert(std::is_trivially_destructible_v<ConstVector<const char *>>);
ConstVector<const char *> list(TABLE, 3);
EXPECT_EQ(list.size(), 3U);
EXPECT_STREQ(list[2], "c");
}
TEST(ConstVector, IteratorsAreRawPointers) {
ConstVector<const char *, true> list(TABLE, 3);
static_assert(std::is_same_v<decltype(list.begin()), const char *const *>);
const auto *it = std::find(list.begin(), list.end(), TABLE[1]);
EXPECT_EQ(it - list.begin(), 1);
}
} // namespace esphome::testing
@@ -0,0 +1,83 @@
#include <gtest/gtest.h>
#include <string>
#include <vector>
#include "esphome/components/select/select_traits.h"
namespace esphome::select::testing {
static constexpr const char *const OPTIONS[] = {"low", "medium", "high"};
TEST(SelectTraits, ViewsTheTableWithoutCopying) {
SelectTraits traits;
EXPECT_TRUE(traits.get_options().empty());
traits.set_options_static(OPTIONS, 3);
const auto &options = traits.get_options();
EXPECT_EQ(options.size(), 3U);
EXPECT_FALSE(options.empty());
EXPECT_EQ(options.data(), OPTIONS);
EXPECT_STREQ(options[1], "medium");
EXPECT_STREQ(options.at(2), "high");
std::vector<std::string> seen;
for (const char *option : options)
seen.emplace_back(option);
EXPECT_EQ(seen, (std::vector<std::string>{"low", "medium", "high"}));
}
TEST(SelectTraits, RuntimeListsAreCopied) {
SelectTraits traits;
traits.set_options({"a", "b"});
EXPECT_EQ(traits.get_options().size(), 2U);
EXPECT_STREQ(traits.get_options()[1], "b");
FixedVector<const char *> list;
list.init(3);
list.push_back("x");
list.push_back("y");
list.push_back("z");
traits.set_options(list);
EXPECT_NE(traits.get_options().data(), list.begin());
EXPECT_EQ(traits.get_options().size(), 3U);
EXPECT_STREQ(traits.get_options().at(2), "z");
// A later runtime list replaces the earlier copy
traits.set_options({"only"});
EXPECT_EQ(traits.get_options().size(), 1U);
EXPECT_STREQ(traits.get_options()[0], "only");
}
TEST(SelectTraits, CopyingAnotherSelectSurvivesItsNextRuntimeList) {
SelectTraits source;
source.set_options({"a", "b"});
SelectTraits copy;
copy.set_options(source.get_options());
EXPECT_NE(copy.get_options().data(), source.get_options().data());
source.set_options({"c"});
ASSERT_EQ(copy.get_options().size(), 2U);
EXPECT_STREQ(copy.get_options()[0], "a");
EXPECT_STREQ(copy.get_options()[1], "b");
}
TEST(SelectTraits, StaticTablesAreNeverOwned) {
SelectTraits traits;
traits.set_options_static(OPTIONS, 3);
EXPECT_EQ(traits.get_options().data(), OPTIONS);
EXPECT_EQ(traits.get_options().size(), 3U);
// A runtime list after a static one copies and leaves the static table alone
traits.set_options({"x"});
EXPECT_NE(traits.get_options().data(), OPTIONS);
EXPECT_EQ(traits.get_options().size(), 1U);
EXPECT_STREQ(OPTIONS[0], "low");
}
TEST(SelectTraits, CopyOfItsOwnOptionsStaysValid) {
SelectTraits traits;
traits.set_options({"a", "b"});
traits.set_options(traits.get_options());
ASSERT_EQ(traits.get_options().size(), 2U);
EXPECT_STREQ(traits.get_options()[1], "b");
}
} // namespace esphome::select::testing