[thermostat] Keep presets in shared flash tables (#20080)

This commit is contained in:
J. Nick Koston
2026-10-11 15:04:16 -10:00
committed by GitHub
parent 9295342c16
commit 993fd8d7a2
6 changed files with 189 additions and 110 deletions
+39 -68
View File
@@ -39,7 +39,6 @@ from esphome.const import (
CONF_HEAT_MODE,
CONF_HEAT_OVERRUN,
CONF_HUMIDITY_SENSOR,
CONF_ID,
CONF_IDLE_ACTION,
CONF_MAX_COOLING_RUN_TIME,
CONF_MAX_HEATING_RUN_TIME,
@@ -72,6 +71,7 @@ from esphome.const import (
CONF_TARGET_TEMPERATURE_CHANGE_ACTION,
CONF_VISUAL,
)
from esphome.helpers import cpp_string_escape
from esphome.types import ConfigType
CONF_DEFAULT_PRESET = "default_preset"
@@ -93,6 +93,8 @@ ThermostatClimate = thermostat_ns.class_(
ThermostatClimateTargetTempConfig = thermostat_ns.struct(
"ThermostatClimateTargetTempConfig"
)
ThermostatPresetEntry = thermostat_ns.struct("ThermostatPresetEntry")
ThermostatCustomPresetEntry = thermostat_ns.struct("ThermostatCustomPresetEntry")
OnBootRestoreFrom = thermostat_ns.enum("OnBootRestoreFrom")
ON_BOOT_RESTORE_FROM = {
"MEMORY": OnBootRestoreFrom.MEMORY,
@@ -116,6 +118,7 @@ ClimatePreset = climate_ns.enum("ClimatePreset")
PRESET_CONFIG_SCHEMA = cv.Schema(
{
# Still accepted for existing configs; presets are flash table entries, not variables.
cv.GenerateID(): cv.declare_id(ThermostatClimateTargetTempConfig),
cv.Required(CONF_NAME): cv.string_strict,
cv.Optional(CONF_MODE): validate_climate_mode,
@@ -674,7 +677,9 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_LOW): cv.temperature,
}
),
cv.Optional(CONF_PRESET): cv.ensure_list(PRESET_CONFIG_SCHEMA),
cv.Optional(CONF_PRESET): cv.All(
cv.ensure_list(PRESET_CONFIG_SCHEMA), cv.Length(max=255)
),
cv.Optional(CONF_ON_BOOT_RESTORE_FROM): validate_on_boot_restore_from,
cv.Optional(CONF_PRESET_CHANGE): automation.validate_automation(
single=True
@@ -964,85 +969,51 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_humidity_hysteresis(config[CONF_HUMIDITY_HYSTERESIS]))
if CONF_PRESET in config:
# Separate standard and custom presets, and build preset config variables
standard_presets: list[tuple[cg.MockObj, cg.MockObj]] = []
custom_presets: list[tuple[str, cg.MockObj]] = []
# Presets live in shared PROGMEM tables; the thermostat keeps a pointer and a count.
standard_presets: list[cg.StructInitializer] = []
custom_presets: list[cg.StructInitializer] = []
nan = float("nan")
unset = cg.RawExpression("std::nullopt")
for preset_config in config[CONF_PRESET]:
name = preset_config[CONF_NAME]
standard_preset = None
if name.upper() in climate.CLIMATE_PRESETS:
standard_preset = climate.CLIMATE_PRESETS[name.upper()]
low = preset_config.get(CONF_DEFAULT_TARGET_TEMPERATURE_LOW)
high = preset_config.get(CONF_DEFAULT_TARGET_TEMPERATURE_HIGH)
if two_points_available:
preset_target_config = ThermostatClimateTargetTempConfig(
preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW],
preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH],
)
elif CONF_DEFAULT_TARGET_TEMPERATURE_HIGH in preset_config:
preset_target_config = ThermostatClimateTargetTempConfig(
preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH]
)
elif CONF_DEFAULT_TARGET_TEMPERATURE_LOW in preset_config:
preset_target_config = ThermostatClimateTargetTempConfig(
preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW]
temperatures = (nan, low, high)
else:
temperatures = (high if high is not None else low, nan, nan)
target = ThermostatClimateTargetTempConfig(
*(nan if t is None else t for t in temperatures),
preset_config.get(CONF_MODE, unset),
preset_config.get(CONF_FAN_MODE, unset),
preset_config.get(CONF_SWING_MODE, unset),
)
if (standard := climate.CLIMATE_PRESETS.get(name.upper())) is not None:
standard_presets.append(
cg.StructInitializer(
ThermostatPresetEntry, ("preset", standard), ("config", target)
)
)
else:
preset_target_config = None
preset_target_variable = cg.new_variable(
preset_config[CONF_ID], preset_target_config
)
if CONF_MODE in preset_config:
cg.add(preset_target_variable.set_mode(preset_config[CONF_MODE]))
if CONF_FAN_MODE in preset_config:
cg.add(
preset_target_variable.set_fan_mode(preset_config[CONF_FAN_MODE])
)
if CONF_SWING_MODE in preset_config:
cg.add(
preset_target_variable.set_swing_mode(
preset_config[CONF_SWING_MODE]
custom_presets.append(
cg.StructInitializer(
ThermostatCustomPresetEntry,
("name", cg.RawExpression(cpp_string_escape(name))),
("config", target),
)
)
if standard_preset is not None:
standard_presets.append((standard_preset, preset_target_variable))
else:
custom_presets.append((name, preset_target_variable))
# Build initializer list for standard presets
if standard_presets:
cg.add(
var.set_preset_config(
[
cg.StructInitializer(
thermostat_ns.struct("ThermostatPresetEntry"),
("preset", preset),
("config", preset_var),
)
for preset, preset_var in standard_presets
]
)
table = cg.shared_progmem_array(
"thermostat_presets", ThermostatPresetEntry, standard_presets
)
# Build initializer list for custom presets
cg.add(var.set_preset_config(table, len(standard_presets)))
if custom_presets:
cg.add(
var.set_custom_preset_config(
[
cg.StructInitializer(
thermostat_ns.struct("ThermostatCustomPresetEntry"),
("name", cg.RawExpression(f'"{name}"')),
("config", preset_var),
)
for name, preset_var in custom_presets
]
)
table = cg.shared_progmem_array(
"thermostat_custom_presets", ThermostatCustomPresetEntry, custom_presets
)
cg.add(var.set_custom_preset_config(table, len(custom_presets)))
if CONF_DEFAULT_PRESET in config:
default_preset_name = config[CONF_DEFAULT_PRESET]
@@ -322,8 +322,8 @@ climate::ClimateTraits ThermostatClimate::traits() {
if (this->supports_swing_mode_vertical_)
traits.add_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
for (const auto &entry : this->preset_config_) {
traits.add_supported_preset(entry.preset);
for (uint8_t i = 0; i < this->preset_count_; i++) {
traits.add_supported_preset(this->preset_entry_(i).preset);
}
// Custom presets are stored on Climate base class and wired via get_traits()
@@ -1175,17 +1175,30 @@ void ThermostatClimate::dump_preset_config_(const char *preset_name, const Therm
}
}
ThermostatPresetEntry ThermostatClimate::preset_entry_(uint8_t index) const {
ThermostatPresetEntry entry;
progmem_memcpy(&entry, &this->preset_config_[index], sizeof(entry));
return entry;
}
ThermostatCustomPresetEntry ThermostatClimate::custom_preset_entry_(uint8_t index) const {
ThermostatCustomPresetEntry entry;
progmem_memcpy(&entry, &this->custom_preset_config_[index], sizeof(entry));
return entry;
}
void ThermostatClimate::change_preset_(climate::ClimatePreset preset) {
// Linear search through preset configurations
const ThermostatClimateTargetTempConfig *config = nullptr;
for (const auto &entry : this->preset_config_) {
optional<ThermostatClimateTargetTempConfig> config;
for (uint8_t i = 0; i < this->preset_count_; i++) {
auto entry = this->preset_entry_(i);
if (entry.preset == preset) {
config = &entry.config;
config = entry.config;
break;
}
}
if (config != nullptr) {
if (config.has_value()) {
ESP_LOGV(TAG, "Preset %s requested", LOG_STR_ARG(climate::climate_preset_to_string(preset)));
if (this->change_preset_internal_(*config) || (!this->preset.has_value()) || this->preset.value() != preset) {
// Fire preset changed trigger
@@ -1205,16 +1218,17 @@ void ThermostatClimate::change_preset_(climate::ClimatePreset preset) {
void ThermostatClimate::change_custom_preset_(const char *custom_preset, size_t len) {
// Linear search through custom preset configurations
const ThermostatClimateTargetTempConfig *config = nullptr;
for (const auto &entry : this->custom_preset_config_) {
optional<ThermostatClimateTargetTempConfig> config;
for (uint8_t i = 0; i < this->custom_preset_count_; i++) {
auto entry = this->custom_preset_entry_(i);
// Compare first len chars, then verify entry.name ends there (same length)
if (strncmp(entry.name, custom_preset, len) == 0 && entry.name[len] == '\0') {
config = &entry.config;
config = entry.config;
break;
}
}
if (config != nullptr) {
if (config.has_value()) {
ESP_LOGV(TAG, "Custom preset %s requested", custom_preset);
if (this->change_preset_internal_(*config) || !this->has_custom_preset() ||
this->get_custom_preset() != custom_preset) {
@@ -1279,13 +1293,14 @@ bool ThermostatClimate::change_preset_internal_(const ThermostatClimateTargetTem
return something_changed;
}
void ThermostatClimate::set_custom_preset_config(std::initializer_list<CustomPresetEntry> presets) {
void ThermostatClimate::set_custom_preset_config(const CustomPresetEntry *presets, uint8_t count) {
this->custom_preset_config_ = presets;
this->custom_preset_count_ = count;
// Populate Climate base class custom presets vector
std::vector<const char *> names;
names.reserve(presets.size());
for (const auto &entry : this->custom_preset_config_) {
names.push_back(entry.name);
names.reserve(count);
for (uint8_t i = 0; i < count; i++) {
names.push_back(this->custom_preset_entry_(i).name);
}
this->set_supported_custom_presets(names);
}
@@ -1294,7 +1309,8 @@ ThermostatClimate::ThermostatClimate() = default;
void ThermostatClimate::set_default_preset(const char *custom_preset) {
// Find the preset in custom_preset_config_ and store pointer from there
for (const auto &entry : this->custom_preset_config_) {
for (uint8_t i = 0; i < this->custom_preset_count_; i++) {
auto entry = this->custom_preset_entry_(i);
if (strcmp(entry.name, custom_preset) == 0) {
this->default_custom_preset_ = entry.name;
return;
@@ -1545,9 +1561,10 @@ void ThermostatClimate::dump_config() {
YESNO(this->supports_dehumidification_ || this->supports_humidification_),
YESNO(this->supports_dehumidification_), YESNO(this->supports_humidification_));
if (!this->preset_config_.empty()) {
if (this->preset_count_ > 0) {
ESP_LOGCONFIG(TAG, " Supported PRESETS:");
for (const auto &entry : this->preset_config_) {
for (uint8_t i = 0; i < this->preset_count_; i++) {
auto entry = this->preset_entry_(i);
const auto *preset_name = LOG_STR_ARG(climate::climate_preset_to_string(entry.preset));
ESP_LOGCONFIG(TAG, " %s:%s", preset_name,
entry.preset == this->default_preset_ ? LOG_STR_LITERAL(" (default)") : "");
@@ -1555,9 +1572,10 @@ void ThermostatClimate::dump_config() {
}
}
if (!this->custom_preset_config_.empty()) {
if (this->custom_preset_count_ > 0) {
ESP_LOGCONFIG(TAG, " Supported CUSTOM PRESETS:");
for (const auto &entry : this->custom_preset_config_) {
for (uint8_t i = 0; i < this->custom_preset_count_; i++) {
auto entry = this->custom_preset_entry_(i);
const auto *preset_name = entry.name;
ESP_LOGCONFIG(TAG, " %s:%s", preset_name,
(this->default_custom_preset_ != nullptr && strcmp(entry.name, this->default_custom_preset_) == 0)
@@ -1568,13 +1586,4 @@ void ThermostatClimate::dump_config() {
}
}
ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig() = default;
ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig(float default_temperature)
: default_temperature(default_temperature) {}
ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig(float default_temperature_low,
float default_temperature_high)
: default_temperature_low(default_temperature_low), default_temperature_high(default_temperature_high) {}
} // namespace esphome::thermostat
@@ -49,9 +49,21 @@ struct ThermostatClimateTimer {
struct ThermostatClimateTargetTempConfig {
public:
ThermostatClimateTargetTempConfig();
ThermostatClimateTargetTempConfig(float default_temperature);
ThermostatClimateTargetTempConfig(float default_temperature_low, float default_temperature_high);
constexpr ThermostatClimateTargetTempConfig() = default;
constexpr ThermostatClimateTargetTempConfig(float default_temperature) : default_temperature(default_temperature) {}
constexpr ThermostatClimateTargetTempConfig(float default_temperature_low, float default_temperature_high)
: default_temperature_low(default_temperature_low), default_temperature_high(default_temperature_high) {}
/// Full form used by the generated preset tables in flash.
constexpr ThermostatClimateTargetTempConfig(float default_temperature, float default_temperature_low,
float default_temperature_high, optional<climate::ClimateMode> mode,
optional<climate::ClimateFanMode> fan_mode,
optional<climate::ClimateSwingMode> swing_mode)
: default_temperature(default_temperature),
default_temperature_low(default_temperature_low),
default_temperature_high(default_temperature_high),
fan_mode_(fan_mode),
swing_mode_(swing_mode),
mode_(mode) {}
void set_fan_mode(climate::ClimateFanMode fan_mode) { this->fan_mode_ = fan_mode; }
void set_swing_mode(climate::ClimateSwingMode swing_mode) { this->swing_mode_ = swing_mode; }
@@ -60,10 +72,6 @@ struct ThermostatClimateTargetTempConfig {
float default_temperature{NAN};
float default_temperature_low{NAN};
float default_temperature_high{NAN};
float cool_deadband_{NAN};
float cool_overrun_{NAN};
float heat_deadband_{NAN};
float heat_overrun_{NAN};
optional<climate::ClimateFanMode> fan_mode_{};
optional<climate::ClimateSwingMode> swing_mode_{};
optional<climate::ClimateMode> mode_{};
@@ -175,8 +183,12 @@ class ThermostatClimate final : public climate::Climate, public Component {
void set_supports_humidification(bool supports_humidification);
void set_supports_two_points(bool supports_two_points) { this->supports_two_points_ = supports_two_points; }
void set_preset_config(std::initializer_list<PresetEntry> presets) { this->preset_config_ = presets; }
void set_custom_preset_config(std::initializer_list<CustomPresetEntry> presets);
/// `presets` is a PROGMEM table that outlives the thermostat.
void set_preset_config(const PresetEntry *presets, uint8_t count) {
this->preset_config_ = presets;
this->preset_count_ = count;
}
void set_custom_preset_config(const CustomPresetEntry *presets, uint8_t count);
Trigger<> *get_cool_action_trigger();
Trigger<> *get_supplemental_cool_action_trigger();
@@ -328,6 +340,9 @@ class ThermostatClimate final : public climate::Climate, public Component {
bool humidification_required_();
void dump_preset_config_(const char *preset_name, const ThermostatClimateTargetTempConfig &config);
/// Copies of table entries; the tables live in PROGMEM and hold byte-sized fields.
PresetEntry preset_entry_(uint8_t index) const;
CustomPresetEntry custom_preset_entry_(uint8_t index) const;
/// Minimum allowable duration in seconds for action timers
const uint8_t min_timer_duration_{1};
@@ -349,6 +364,9 @@ class ThermostatClimate final : public climate::Climate, public Component {
/// state will attempt to be restored if possible
OnBootRestoreFrom on_boot_restore_from_{OnBootRestoreFrom::MEMORY};
uint8_t preset_count_{0};
uint8_t custom_preset_count_{0};
/// Whether the controller supports auto/cooling/drying/fanning/heating.
///
/// A false value for any given attribute means that the controller has no such action
@@ -522,13 +540,13 @@ class ThermostatClimate final : public climate::Climate, public Component {
/// Climate action timers
std::array<ThermostatClimateTimer, THERMOSTAT_TIMER_COUNT> timer_{};
/// The set of standard preset configurations this thermostat supports (Eg. AWAY, ECO, etc)
FixedVector<PresetEntry> preset_config_{};
/// The set of custom preset configurations this thermostat supports (eg. "My Custom Preset")
FixedVector<CustomPresetEntry> custom_preset_config_{};
/// Standard preset configurations (Eg. AWAY, ECO, etc), a PROGMEM table of preset_count_ entries
const PresetEntry *preset_config_{nullptr};
/// Custom preset configurations (eg. "My Custom Preset"), a PROGMEM table of custom_preset_count_ entries
const CustomPresetEntry *custom_preset_config_{nullptr};
private:
/// Default custom preset to use on start up (pointer to entry in custom_preset_config_)
/// Default custom preset to use on start up (name pointer from an entry in custom_preset_config_)
const char *default_custom_preset_{nullptr};
};
@@ -0,0 +1,53 @@
---
esphome:
name: test
logger:
esp32:
board: esp32dev
sensor:
- platform: template
id: temp_sensor
lambda: return 21.0;
climate:
- platform: thermostat
id: thermo_a
name: Thermo A
sensor: temp_sensor
min_heating_off_time: 300s
min_heating_run_time: 300s
min_idle_time: 30s
heat_action:
- logger.log: heat
idle_action:
- logger.log: idle
preset:
- name: Away
default_target_temperature_low: 15°C
mode: HEAT
- name: Eco
default_target_temperature_low: 17°C
- name: Night Mode
default_target_temperature_low: 16°C
- platform: thermostat
id: thermo_b
name: Thermo B
sensor: temp_sensor
min_heating_off_time: 300s
min_heating_run_time: 300s
min_idle_time: 30s
heat_action:
- logger.log: heat
idle_action:
- logger.log: idle
preset:
- name: Away
default_target_temperature_low: 15°C
mode: HEAT
- name: Eco
default_target_temperature_low: 17°C
- name: Night Mode
default_target_temperature_low: 16°C
@@ -0,0 +1,28 @@
"""Tests for the thermostat preset codegen."""
from collections.abc import Callable
from pathlib import Path
import re
def test_presets_shared_progmem_tables(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Thermostats with the same presets share one PROGMEM table per kind."""
main_cpp = generate_main(component_config_path("presets.yaml"))
standard = re.findall(
r"static constexpr thermostat::ThermostatPresetEntry (\w+)\[\] PROGMEM",
main_cpp,
)
custom = re.findall(
r"static constexpr thermostat::ThermostatCustomPresetEntry (\w+)\[\] PROGMEM",
main_cpp,
)
assert len(standard) == 1
assert len(custom) == 1
for var in ("thermo_a", "thermo_b"):
assert f"{var}->set_preset_config({standard[0]}, 2);" in main_cpp
assert f"{var}->set_custom_preset_config({custom[0]}, 1);" in main_cpp
assert "climate::CLIMATE_MODE_HEAT" in main_cpp