Compare commits

..
41 changed files with 2386 additions and 210 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.1
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.3
RUN \
platformio settings set enable_telemetry No \
@@ -10,7 +10,6 @@
#include "proto.h"
#include <cstring>
#include <cinttypes>
#include <sodium.h>
#ifdef USE_ESP8266
#include <pgmspace.h>
@@ -549,10 +548,7 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
* @return 0 on success, -1 on error (check errno)
*/
APIError APINoiseFrameHelper::init_handshake_() {
noise::ephemeral_keypair_t spare;
const uint8_t *ephemeral = noise::take_spare_ephemeral(spare) ? spare.data() : nullptr;
int err = this->handshake_.init(this->ctx_.get_psk(), prologue_.data(), prologue_.size(), ephemeral);
sodium_memzero(spare.data(), spare.size());
int err = this->handshake_.init(this->ctx_.get_psk(), prologue_.data(), prologue_.size());
APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED);
if (aerr != APIError::OK)
return aerr;
-25
View File
@@ -138,9 +138,6 @@ void APIServer::setup() {
}
void APIServer::loop() {
#ifdef USE_API_NOISE
this->prepare_spare_ephemeral_();
#endif
// Accept new clients only if the socket exists and has incoming connections
if (this->socket_ && this->socket_->ready()) {
this->accept_new_connections_();
@@ -191,28 +188,6 @@ void APIServer::loop() {
}
}
#ifdef USE_API_NOISE
// Refill the spare ephemeral key while nobody is waiting for it: not before
// the network is up, and not while an api client is still in its handshake
// (an OTA handshake is not visible here; it took the previous spare and
// pays the refill instead, no worse than generating its own key). The
// generation blocks the loop for about 60 ms on ESP8266, over the 50 ms
// blocking warning, so the api component's warning threshold ratchets up
// on the first refill; the same generation used to run inside the
// handshake, where it tripped the warning for longer.
void APIServer::prepare_spare_ephemeral_() {
if (noise::has_spare_ephemeral() || !network::is_connected()) {
return;
}
for (auto &client : this->active_clients()) {
if (!client->is_connection_setup()) {
return;
}
}
noise::prepare_spare_ephemeral();
}
#endif
void APIServer::remove_client_(uint8_t client_index) {
auto &client = this->clients_[client_index];
-1
View File
@@ -357,7 +357,6 @@ class APIServer final : public Component,
#endif
#ifdef USE_API_NOISE
void prepare_spare_ephemeral_();
noise::NoiseContext noise_ctx_;
ESPPreferenceObject noise_pref_;
#endif // USE_API_NOISE
+13
View File
@@ -125,6 +125,19 @@ CLIMATE_SWING_MODES = {
validate_climate_swing_mode = cv.enum(CLIMATE_SWING_MODES, upper=True)
ClimateAction = climate_ns.enum("ClimateAction")
CLIMATE_ACTIONS = {
"OFF": ClimateAction.CLIMATE_ACTION_OFF,
"COOLING": ClimateAction.CLIMATE_ACTION_COOLING,
"HEATING": ClimateAction.CLIMATE_ACTION_HEATING,
"IDLE": ClimateAction.CLIMATE_ACTION_IDLE,
"DRYING": ClimateAction.CLIMATE_ACTION_DRYING,
"FAN": ClimateAction.CLIMATE_ACTION_FAN,
"DEFROSTING": ClimateAction.CLIMATE_ACTION_DEFROSTING,
}
validate_climate_action = cv.enum(CLIMATE_ACTIONS, upper=True)
CONF_MIN_HUMIDITY = "min_humidity"
CONF_MAX_HUMIDITY = "max_humidity"
CONF_TARGET_HUMIDITY = "target_humidity"
@@ -7,7 +7,6 @@
#include <cstring>
#include <new>
#include <sodium.h>
#ifdef USE_ESP8266
#include <pgmspace.h>
@@ -72,18 +71,7 @@ bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
*p++ = ota::OTA_RESPONSE_FEATURE_FLAGS;
*p++ = server_feature_flags;
// The api server keeps the spare; without an encrypted api there is none
const uint8_t *ephemeral = nullptr;
#ifdef USE_API_NOISE
noise::ephemeral_keypair_t spare;
if (noise::take_spare_ephemeral(spare)) {
ephemeral = spare.data();
}
#endif
int err = this->noise_->handshake.init(this->noise_ctx_.get_psk(), prologue, sizeof(prologue), ephemeral);
#ifdef USE_API_NOISE
sodium_memzero(spare.data(), spare.size());
#endif
int err = this->noise_->handshake.init(this->noise_ctx_.get_psk(), prologue, sizeof(prologue));
if (err != 0) {
ESP_LOGW(TAG, "Handshake init: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
this->cleanup_connection_();
+2 -2
View File
@@ -35,8 +35,8 @@ void MipiDsi::setup() {
.bus_id = 0, // index from 0, specify the DSI host to use
.num_data_lanes =
this->lanes_, // Number of data lanes to use, can't set a value that exceeds the chip's capability
.phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT, // Clock source for the DPHY
.lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps
// phy_clk_src left at 0 to enable runtime auto-select.
.lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps
};
auto err = esp_lcd_new_dsi_bus(&bus_config, &this->bus_handle_);
if (err != ESP_OK) {
+2 -2
View File
@@ -72,12 +72,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType:
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_NOISE")
cg.add_library("esphome/noise-c", "0.1.24")
cg.add_library("esphome/noise-c", "0.1.21")
# noise-c depends on libsodium, but declaring it here too lets the
# library manager see the full set up front instead of discovering
# libsodium only after noise-c has downloaded, so the two can download
# in parallel. The version must match noise-c's library.json.
cg.add_library("esphome/libsodium", "1.10021.6")
cg.add_library("esphome/libsodium", "1.10021.4")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
-34
View File
@@ -1,13 +1,11 @@
#include "noise.h"
#ifdef USE_NOISE
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cstring>
#include <noise/protocol.h>
#include <sodium.h>
#ifdef USE_ESP8266
#include <pgmspace.h>
@@ -17,38 +15,6 @@ namespace esphome::noise {
static const char *const TAG = "noise";
#ifdef USE_API_NOISE
static uint8_t spare_ephemeral[EPHEMERAL_KEYPAIR_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static bool spare_ephemeral_ready = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
bool has_spare_ephemeral() { return spare_ephemeral_ready; }
void prepare_spare_ephemeral() {
uint8_t *private_key = spare_ephemeral;
uint8_t *public_key = spare_ephemeral + EPHEMERAL_PRIVATE_KEY_SIZE;
// Same generation as noise-c's curve25519 backend: random bytes, X25519
// clamping, then the public key. A random source failure leaves the slot
// empty; the handshake then generates its own key.
if (!random_bytes(private_key, EPHEMERAL_PRIVATE_KEY_SIZE)) {
return;
}
private_key[0] &= 0xF8;
private_key[EPHEMERAL_PRIVATE_KEY_SIZE - 1] = (private_key[EPHEMERAL_PRIVATE_KEY_SIZE - 1] & 0x7F) | 0x40;
crypto_scalarmult_curve25519_base(public_key, private_key);
spare_ephemeral_ready = true;
}
bool take_spare_ephemeral(ephemeral_keypair_t &out) {
if (!spare_ephemeral_ready) {
return false;
}
std::memcpy(out.data(), spare_ephemeral, EPHEMERAL_KEYPAIR_SIZE);
sodium_memzero(spare_ephemeral, EPHEMERAL_KEYPAIR_SIZE);
spare_ephemeral_ready = false;
return true;
}
#endif // USE_API_NOISE
const LogString *noise_err_to_logstr(int err) {
if (err == NOISE_ERROR_NO_MEMORY)
return LOG_STR("NO_MEMORY");
-21
View File
@@ -38,27 +38,6 @@ class NoiseContext {
/// Convert a noise error code to a readable error
const LogString *noise_err_to_logstr(int err);
// An X25519 key pair as the spare ephemeral hands it out: private key first
static constexpr size_t EPHEMERAL_PRIVATE_KEY_SIZE = 32;
static constexpr size_t EPHEMERAL_PUBLIC_KEY_SIZE = 32;
static constexpr size_t EPHEMERAL_KEYPAIR_SIZE = EPHEMERAL_PRIVATE_KEY_SIZE + EPHEMERAL_PUBLIC_KEY_SIZE;
using ephemeral_keypair_t = std::array<uint8_t, EPHEMERAL_KEYPAIR_SIZE>;
#ifdef USE_API_NOISE
// A responder ephemeral key pair generated ahead of time. The base point
// multiply behind one costs about 60 ms on ESP8266, so the api server fills
// the slot while idle and a connecting client does not wait for it. One slot
// serves every noise transport; a handshake that finds it empty generates
// its own key as before. The api server is the only refiller, so the slot
// only exists in builds with an encrypted api.
bool has_spare_ephemeral();
/// Generate a key pair into the slot; blocks for the base point multiply.
void prepare_spare_ephemeral();
/// Move the slot's key pair into out and empty the slot. Returns false,
/// leaving out untouched, when the slot is empty.
bool take_spare_ephemeral(ephemeral_keypair_t &out);
#endif
// Shared wire format for the noise transports (api and ota): every frame is
// FRAME_INDICATOR, a 16-bit big-endian payload length, then the payload.
// Handshake payloads start with a status byte; transport payloads end with
+1 -11
View File
@@ -20,8 +20,7 @@ NoiseResponderHandshake::~NoiseResponderHandshake() {
}
}
int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len,
const uint8_t *ephemeral_keypair) {
int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) {
if (this->handshake_ != nullptr) {
noise_handshakestate_free(this->handshake_);
this->handshake_ = nullptr;
@@ -55,15 +54,6 @@ int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, siz
HANDSHAKE_STEP_LOG("noise_handshakestate_set_prologue", err);
return this->fail_init_(err);
}
if (ephemeral_keypair != nullptr) {
err = noise_handshakestate_set_local_ephemeral(this->handshake_, ephemeral_keypair, EPHEMERAL_PRIVATE_KEY_SIZE,
ephemeral_keypair + EPHEMERAL_PRIVATE_KEY_SIZE,
EPHEMERAL_PUBLIC_KEY_SIZE);
// Not fatal: the handshake generates its own key when the spare is refused
if (err != 0) {
HANDSHAKE_STEP_LOG("noise_handshakestate_set_local_ephemeral", err);
}
}
err = noise_handshakestate_start(this->handshake_);
if (err != 0) {
HANDSHAKE_STEP_LOG("noise_handshakestate_start", err);
+1 -6
View File
@@ -38,12 +38,7 @@ class NoiseResponderHandshake {
/// Create and start the handshake with the given PSK and prologue. A
/// repeated call frees the previous handshake state and starts over.
/// ephemeral_keypair, when not null, is a key pair from
/// take_spare_ephemeral() that the handshake uses as its ephemeral key
/// instead of generating one; if noise-c refuses it the handshake
/// generates its own key and init() still succeeds.
[[nodiscard]] int init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len,
const uint8_t *ephemeral_keypair = nullptr);
[[nodiscard]] int init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len);
/// ACTION_FAILED is the catch-all: returned before init(), after split()
/// has released the state, and when noise-c reports a failed handshake.
[[nodiscard]] Action action() const;
@@ -0,0 +1,465 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import climate, sensor
from esphome.components.climate import climate_ns
import esphome.config_validation as cv
from esphome.const import (
CONF_ACTION,
CONF_CURRENT_TEMPERATURE,
CONF_CUSTOM_FAN_MODE,
CONF_CUSTOM_FAN_MODES,
CONF_CUSTOM_PRESET,
CONF_CUSTOM_PRESETS,
CONF_FAN_MODE,
CONF_HUMIDITY_SENSOR,
CONF_ID,
CONF_INITIAL_STATE,
CONF_MODE,
CONF_OPTIMISTIC,
CONF_PRESET,
CONF_RESTORE_MODE,
CONF_SENSOR,
CONF_SUPPORTED_FAN_MODES,
CONF_SUPPORTED_MODES,
CONF_SUPPORTED_PRESETS,
CONF_SUPPORTED_SWING_MODES,
CONF_SWING_MODE,
CONF_TARGET_TEMPERATURE,
CONF_TARGET_TEMPERATURE_HIGH,
CONF_TARGET_TEMPERATURE_LOW,
)
from esphome.core import ID
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
from .. import template_ns
CONF_CURRENT_HUMIDITY = "current_humidity"
CONF_TARGET_HUMIDITY = "target_humidity"
CONF_SUPPORTS_ACTION = "supports_action"
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE = "supports_two_point_target_temperature"
CONF_SUPPORTS_TARGET_HUMIDITY = "supports_target_humidity"
CONF_SUPPORTS_CURRENT_TEMPERATURE = "supports_current_temperature"
CONF_SUPPORTS_CURRENT_HUMIDITY = "supports_current_humidity"
CONF_SET_MODE_ACTION = "set_mode_action"
CONF_SET_TARGET_TEMPERATURE_ACTION = "set_target_temperature_action"
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION = "set_target_temperature_low_action"
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION = "set_target_temperature_high_action"
CONF_SET_TARGET_HUMIDITY_ACTION = "set_target_humidity_action"
CONF_SET_FAN_MODE_ACTION = "set_fan_mode_action"
CONF_SET_CUSTOM_FAN_MODE_ACTION = "set_custom_fan_mode_action"
CONF_SET_SWING_MODE_ACTION = "set_swing_mode_action"
CONF_SET_PRESET_ACTION = "set_preset_action"
CONF_SET_CUSTOM_PRESET_ACTION = "set_custom_preset_action"
TemplateClimate = template_ns.class_("TemplateClimate", climate.Climate, cg.Component)
TemplateClimatePublishAction = template_ns.class_(
"TemplateClimatePublishAction",
automation.Action,
cg.Parented.template(TemplateClimate),
)
TemplateClimateRestoreMode = template_ns.enum(
"TemplateClimateRestoreMode", is_class=True
)
CLIMATE_RESTORE_MODES = {
"NO_RESTORE": TemplateClimateRestoreMode.TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE,
"RESTORE": TemplateClimateRestoreMode.TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE,
}
# Per-field actions that forward a requested value on. The third item is the type of `x`.
SET_ACTIONS = (
(CONF_SET_MODE_ACTION, "get_set_mode_trigger", climate.ClimateMode),
(
CONF_SET_TARGET_TEMPERATURE_ACTION,
"get_set_target_temperature_trigger",
cg.float_,
),
(
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION,
"get_set_target_temperature_low_trigger",
cg.float_,
),
(
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION,
"get_set_target_temperature_high_trigger",
cg.float_,
),
(CONF_SET_TARGET_HUMIDITY_ACTION, "get_set_target_humidity_trigger", cg.float_),
(CONF_SET_FAN_MODE_ACTION, "get_set_fan_mode_trigger", climate.ClimateFanMode),
(
CONF_SET_CUSTOM_FAN_MODE_ACTION,
"get_set_custom_fan_mode_trigger",
cg.StringRef,
),
(
CONF_SET_SWING_MODE_ACTION,
"get_set_swing_mode_trigger",
climate.ClimateSwingMode,
),
(CONF_SET_PRESET_ACTION, "get_set_preset_trigger", climate.ClimatePreset),
(CONF_SET_CUSTOM_PRESET_ACTION, "get_set_custom_preset_trigger", cg.StringRef),
)
# supports_* keys have no default so that an omitted key can mean "derive it from the sensor or
# set action that makes the trait useful", which is not expressible once a default fills it in.
DERIVED_SUPPORTS = (
(CONF_SUPPORTS_CURRENT_TEMPERATURE, (CONF_SENSOR,)),
(CONF_SUPPORTS_CURRENT_HUMIDITY, (CONF_HUMIDITY_SENSOR,)),
(
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
(
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION,
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION,
),
),
(CONF_SUPPORTS_TARGET_HUMIDITY, (CONF_SET_TARGET_HUMIDITY_ACTION,)),
)
# Custom fan modes/presets are opaque user-defined strings with no build-time correctness check
# elsewhere (Climate::set_supported_custom_fan_modes()/set_supported_custom_presets() don't block
# empty entries), so reject empty ones here -- they could never be selected at runtime anyway.
validate_custom_climate_string = cv.All(cv.string_strict, cv.Length(min=1))
def _validate_two_point(config: ConfigType) -> ConfigType:
has_low = CONF_TARGET_TEMPERATURE_LOW in config
has_high = CONF_TARGET_TEMPERATURE_HIGH in config
if has_low != has_high:
raise cv.Invalid(
f"'{CONF_TARGET_TEMPERATURE_LOW}' and '{CONF_TARGET_TEMPERATURE_HIGH}' must be used together"
)
if (has_low or has_high) and CONF_TARGET_TEMPERATURE in config:
raise cv.Invalid(
f"'{CONF_TARGET_TEMPERATURE}' cannot be used together with "
f"'{CONF_TARGET_TEMPERATURE_LOW}'/'{CONF_TARGET_TEMPERATURE_HIGH}'"
)
return config
def _validate_set_actions(config: ConfigType) -> ConfigType:
has_low = CONF_SET_TARGET_TEMPERATURE_LOW_ACTION in config
has_high = CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION in config
if has_low != has_high:
raise cv.Invalid(
f"'{CONF_SET_TARGET_TEMPERATURE_LOW_ACTION}' and "
f"'{CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION}' must be used together"
)
if (has_low or has_high) and CONF_SET_TARGET_TEMPERATURE_ACTION in config:
raise cv.Invalid(
f"'{CONF_SET_TARGET_TEMPERATURE_ACTION}' cannot be used together with "
f"'{CONF_SET_TARGET_TEMPERATURE_LOW_ACTION}'/'{CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION}'"
)
return config
def _resolve_supports(config: ConfigType) -> ConfigType:
# An explicit true stays valid without either, since climate.template.publish can report the
# value; an explicit false that contradicts the configuration is an error, not a silent override.
for key, sources in DERIVED_SUPPORTS:
configured = [source for source in sources if source in config]
if key not in config:
config[key] = bool(configured)
elif not config[key] and configured:
raise cv.Invalid(
f"'{key}' cannot be false while '{configured[0]}' is configured",
path=[key],
)
return config
def _validate_initial_state(config: ConfigType) -> ConfigType:
# Climate keeps target_temperature and target_temperature_low in a union, so writing the wrong
# one of the pair corrupts the setpoint with no runtime complaint.
if (initial_state := config.get(CONF_INITIAL_STATE)) is None:
return config
two_point = config[CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE]
if two_point and CONF_TARGET_TEMPERATURE in initial_state:
raise cv.Invalid(
f"'{CONF_TARGET_TEMPERATURE}' is not available while "
f"'{CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE}' is enabled; use "
f"'{CONF_TARGET_TEMPERATURE_LOW}'/'{CONF_TARGET_TEMPERATURE_HIGH}' instead",
path=[CONF_INITIAL_STATE, CONF_TARGET_TEMPERATURE],
)
if not two_point:
for key in (CONF_TARGET_TEMPERATURE_LOW, CONF_TARGET_TEMPERATURE_HIGH):
if key in initial_state:
raise cv.Invalid(
f"'{key}' requires '{CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE}' to be enabled",
path=[CONF_INITIAL_STATE, key],
)
if (
CONF_TARGET_HUMIDITY in initial_state
and not config[CONF_SUPPORTS_TARGET_HUMIDITY]
):
raise cv.Invalid(
f"'{CONF_TARGET_HUMIDITY}' requires '{CONF_SUPPORTS_TARGET_HUMIDITY}' to be enabled",
path=[CONF_INITIAL_STATE, CONF_TARGET_HUMIDITY],
)
return config
# Same settable fields as climate.template.publish, minus current_temperature/current_humidity/
# action: those are reported values (from a sensor or the device), not meaningful static defaults.
INITIAL_STATE_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_MODE): climate.validate_climate_mode,
cv.Optional(CONF_TARGET_TEMPERATURE): cv.temperature,
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature,
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature,
cv.Optional(CONF_TARGET_HUMIDITY): cv.percentage_int,
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): climate.validate_climate_fan_mode,
cv.Exclusive(
CONF_CUSTOM_FAN_MODE, "fan_mode"
): validate_custom_climate_string,
cv.Optional(CONF_SWING_MODE): climate.validate_climate_swing_mode,
cv.Exclusive(CONF_PRESET, "preset"): climate.validate_climate_preset,
cv.Exclusive(CONF_CUSTOM_PRESET, "preset"): validate_custom_climate_string,
}
),
_validate_two_point,
)
CONFIG_SCHEMA = cv.All(
climate.climate_schema(TemplateClimate)
.extend(
{
cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor),
cv.Optional(CONF_HUMIDITY_SENSOR): cv.use_id(sensor.Sensor),
# action only ever arrives through climate.template.publish, so unlike the other
# supports_* keys there is no set action to derive it from.
cv.Optional(CONF_SUPPORTS_ACTION, default=False): cv.boolean,
cv.Optional(CONF_SUPPORTS_CURRENT_TEMPERATURE): cv.boolean,
cv.Optional(CONF_SUPPORTS_CURRENT_HUMIDITY): cv.boolean,
cv.Optional(CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE): cv.boolean,
cv.Optional(CONF_SUPPORTS_TARGET_HUMIDITY): cv.boolean,
cv.Required(CONF_SUPPORTED_MODES): cv.All(
cv.ensure_list(climate.validate_climate_mode), cv.Unique()
),
cv.Optional(CONF_SUPPORTED_FAN_MODES): cv.All(
cv.ensure_list(climate.validate_climate_fan_mode), cv.Unique()
),
cv.Optional(CONF_CUSTOM_FAN_MODES): cv.All(
cv.ensure_list(validate_custom_climate_string), cv.Unique()
),
cv.Optional(CONF_SUPPORTED_SWING_MODES): cv.All(
cv.ensure_list(climate.validate_climate_swing_mode), cv.Unique()
),
cv.Optional(CONF_SUPPORTED_PRESETS): cv.All(
cv.ensure_list(climate.validate_climate_preset), cv.Unique()
),
cv.Optional(CONF_CUSTOM_PRESETS): cv.All(
cv.ensure_list(validate_custom_climate_string), cv.Unique()
),
cv.Optional(CONF_OPTIMISTIC, default=True): cv.boolean,
cv.Optional(CONF_RESTORE_MODE, default="RESTORE"): cv.enum(
CLIMATE_RESTORE_MODES, upper=True
),
cv.Optional(CONF_INITIAL_STATE): INITIAL_STATE_SCHEMA,
cv.Optional(CONF_SET_MODE_ACTION): automation.validate_automation(
single=True
),
cv.Optional(
CONF_SET_TARGET_TEMPERATURE_ACTION
): automation.validate_automation(single=True),
cv.Optional(
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION
): automation.validate_automation(single=True),
cv.Optional(
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION
): automation.validate_automation(single=True),
cv.Optional(
CONF_SET_TARGET_HUMIDITY_ACTION
): automation.validate_automation(single=True),
cv.Optional(CONF_SET_FAN_MODE_ACTION): automation.validate_automation(
single=True
),
cv.Optional(
CONF_SET_CUSTOM_FAN_MODE_ACTION
): automation.validate_automation(single=True),
cv.Optional(CONF_SET_SWING_MODE_ACTION): automation.validate_automation(
single=True
),
cv.Optional(CONF_SET_PRESET_ACTION): automation.validate_automation(
single=True
),
cv.Optional(CONF_SET_CUSTOM_PRESET_ACTION): automation.validate_automation(
single=True
),
}
)
.extend(cv.COMPONENT_SCHEMA),
_validate_set_actions,
_resolve_supports,
_validate_initial_state,
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await climate.register_climate(var, config)
if (sens := config.get(CONF_SENSOR)) is not None:
cg.add(var.set_sensor(await cg.get_variable(sens)))
if (sens := config.get(CONF_HUMIDITY_SENSOR)) is not None:
cg.add(var.set_humidity_sensor(await cg.get_variable(sens)))
for key, flag in (
(CONF_SUPPORTS_ACTION, climate_ns.CLIMATE_SUPPORTS_ACTION),
(
CONF_SUPPORTS_CURRENT_TEMPERATURE,
climate_ns.CLIMATE_SUPPORTS_CURRENT_TEMPERATURE,
),
(CONF_SUPPORTS_CURRENT_HUMIDITY, climate_ns.CLIMATE_SUPPORTS_CURRENT_HUMIDITY),
(
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
climate_ns.CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
),
(CONF_SUPPORTS_TARGET_HUMIDITY, climate_ns.CLIMATE_SUPPORTS_TARGET_HUMIDITY),
):
if config[key]:
cg.add(var.add_feature_flags(flag))
for mode in config[CONF_SUPPORTED_MODES]:
cg.add(var.add_supported_mode(mode))
for mode in config.get(CONF_SUPPORTED_FAN_MODES, []):
cg.add(var.add_supported_fan_mode(mode))
if CONF_CUSTOM_FAN_MODES in config:
cg.add(
var.set_supported_custom_fan_modes(
cg.ArrayInitializer(*config[CONF_CUSTOM_FAN_MODES])
)
)
for mode in config.get(CONF_SUPPORTED_SWING_MODES, []):
cg.add(var.add_supported_swing_mode(mode))
for preset in config.get(CONF_SUPPORTED_PRESETS, []):
cg.add(var.add_supported_preset(preset))
if CONF_CUSTOM_PRESETS in config:
cg.add(
var.set_supported_custom_presets(
cg.ArrayInitializer(*config[CONF_CUSTOM_PRESETS])
)
)
for key, trigger_getter, arg_type in SET_ACTIONS:
if (conf := config.get(key)) is not None:
await automation.build_automation(
getattr(var, trigger_getter)(), [(arg_type, "x")], conf
)
cg.add(var.set_optimistic(config[CONF_OPTIMISTIC]))
cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE]))
if (initial_state := config.get(CONF_INITIAL_STATE)) is not None:
if (v := initial_state.get(CONF_MODE)) is not None:
cg.add(var.set_mode(v))
if (v := initial_state.get(CONF_TARGET_TEMPERATURE)) is not None:
cg.add(var.set_target_temperature(v))
if (v := initial_state.get(CONF_TARGET_TEMPERATURE_LOW)) is not None:
cg.add(var.set_target_temperature_low(v))
if (v := initial_state.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None:
cg.add(var.set_target_temperature_high(v))
if (v := initial_state.get(CONF_TARGET_HUMIDITY)) is not None:
cg.add(var.set_target_humidity(v))
if (v := initial_state.get(CONF_FAN_MODE)) is not None:
cg.add(var.set_fan_mode(v))
if (v := initial_state.get(CONF_CUSTOM_FAN_MODE)) is not None:
cg.add(var.set_custom_fan_mode(v))
if (v := initial_state.get(CONF_SWING_MODE)) is not None:
cg.add(var.set_swing_mode(v))
if (v := initial_state.get(CONF_PRESET)) is not None:
cg.add(var.set_preset(v))
if (v := initial_state.get(CONF_CUSTOM_PRESET)) is not None:
cg.add(var.set_custom_preset(v))
CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.use_id(TemplateClimate),
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
cv.Optional(CONF_CURRENT_HUMIDITY): cv.templatable(cv.percentage_int),
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int),
cv.Optional(CONF_MODE): cv.templatable(climate.validate_climate_mode),
cv.Optional(CONF_ACTION): cv.templatable(climate.validate_climate_action),
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): cv.templatable(
climate.validate_climate_fan_mode
),
cv.Exclusive(CONF_CUSTOM_FAN_MODE, "fan_mode"): cv.templatable(
validate_custom_climate_string
),
cv.Optional(CONF_SWING_MODE): cv.templatable(
climate.validate_climate_swing_mode
),
cv.Exclusive(CONF_PRESET, "preset"): cv.templatable(
climate.validate_climate_preset
),
cv.Exclusive(CONF_CUSTOM_PRESET, "preset"): cv.templatable(
validate_custom_climate_string
),
}
),
_validate_two_point,
)
@automation.register_action(
"climate.template.publish",
TemplateClimatePublishAction,
CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA,
synchronous=True,
)
async def climate_template_publish_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
if (v := config.get(CONF_CURRENT_TEMPERATURE)) is not None:
cg.add(var.set_current_temperature(await cg.templatable(v, args, cg.float_)))
if (v := config.get(CONF_CURRENT_HUMIDITY)) is not None:
cg.add(var.set_current_humidity(await cg.templatable(v, args, cg.float_)))
if (v := config.get(CONF_TARGET_TEMPERATURE)) is not None:
cg.add(var.set_target_temperature(await cg.templatable(v, args, cg.float_)))
if (v := config.get(CONF_TARGET_TEMPERATURE_LOW)) is not None:
cg.add(var.set_target_temperature_low(await cg.templatable(v, args, cg.float_)))
if (v := config.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None:
cg.add(
var.set_target_temperature_high(await cg.templatable(v, args, cg.float_))
)
if (v := config.get(CONF_TARGET_HUMIDITY)) is not None:
cg.add(var.set_target_humidity(await cg.templatable(v, args, cg.float_)))
if (v := config.get(CONF_MODE)) is not None:
cg.add(var.set_mode(await cg.templatable(v, args, climate.ClimateMode)))
if (v := config.get(CONF_ACTION)) is not None:
cg.add(var.set_action(await cg.templatable(v, args, climate.ClimateAction)))
if (v := config.get(CONF_FAN_MODE)) is not None:
cg.add(var.set_fan_mode(await cg.templatable(v, args, climate.ClimateFanMode)))
if (v := config.get(CONF_CUSTOM_FAN_MODE)) is not None:
cg.add(var.set_custom_fan_mode(await cg.templatable(v, args, cg.std_string)))
if (v := config.get(CONF_SWING_MODE)) is not None:
cg.add(
var.set_swing_mode(await cg.templatable(v, args, climate.ClimateSwingMode))
)
if (v := config.get(CONF_PRESET)) is not None:
cg.add(var.set_preset(await cg.templatable(v, args, climate.ClimatePreset)))
if (v := config.get(CONF_CUSTOM_PRESET)) is not None:
cg.add(var.set_custom_preset(await cg.templatable(v, args, cg.std_string)))
return var
@@ -0,0 +1,57 @@
#pragma once
#include "template_climate.h"
#include "esphome/core/automation.h"
namespace esphome::template_ {
template<typename... Ts>
class TemplateClimatePublishAction final : public Action<Ts...>, public Parented<TemplateClimate> {
public:
TEMPLATABLE_VALUE(float, current_temperature)
TEMPLATABLE_VALUE(float, current_humidity)
TEMPLATABLE_VALUE(float, target_temperature)
TEMPLATABLE_VALUE(float, target_temperature_low)
TEMPLATABLE_VALUE(float, target_temperature_high)
TEMPLATABLE_VALUE(float, target_humidity)
TEMPLATABLE_VALUE(climate::ClimateMode, mode)
TEMPLATABLE_VALUE(climate::ClimateAction, action)
TEMPLATABLE_VALUE(climate::ClimateFanMode, fan_mode)
TEMPLATABLE_VALUE(std::string, custom_fan_mode)
TEMPLATABLE_VALUE(climate::ClimateSwingMode, swing_mode)
TEMPLATABLE_VALUE(climate::ClimatePreset, preset)
TEMPLATABLE_VALUE(std::string, custom_preset)
void play(const Ts &...x) override {
if (this->current_temperature_.has_value())
this->parent_->current_temperature = this->current_temperature_.value(x...);
if (this->current_humidity_.has_value())
this->parent_->current_humidity = this->current_humidity_.value(x...);
if (this->target_temperature_.has_value())
this->parent_->set_target_temperature(this->target_temperature_.value(x...));
if (this->target_temperature_low_.has_value())
this->parent_->set_target_temperature_low(this->target_temperature_low_.value(x...));
if (this->target_temperature_high_.has_value())
this->parent_->set_target_temperature_high(this->target_temperature_high_.value(x...));
if (this->target_humidity_.has_value())
this->parent_->set_target_humidity(this->target_humidity_.value(x...));
if (this->mode_.has_value())
this->parent_->set_mode(this->mode_.value(x...));
if (this->action_.has_value())
this->parent_->action = this->action_.value(x...);
if (this->fan_mode_.has_value())
this->parent_->set_fan_mode(this->fan_mode_.value(x...));
if (this->custom_fan_mode_.has_value())
this->parent_->set_custom_fan_mode(StringRef(this->custom_fan_mode_.value(x...)));
if (this->swing_mode_.has_value())
this->parent_->set_swing_mode(this->swing_mode_.value(x...));
if (this->preset_.has_value())
this->parent_->set_preset(this->preset_.value(x...));
if (this->custom_preset_.has_value())
this->parent_->set_custom_preset(StringRef(this->custom_preset_.value(x...)));
this->parent_->publish_state();
}
};
} // namespace esphome::template_
@@ -0,0 +1,164 @@
#include "template_climate.h"
#include "esphome/core/log.h"
namespace esphome::template_ {
static const char *const TAG = "template.climate";
void TemplateClimate::setup() {
if (this->restore_mode_ == TemplateClimateRestoreMode::TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE) {
auto restore = this->restore_state_();
if (restore.has_value()) {
restore->apply(this);
}
}
// Sensors publish every reading, not just changes, so only re-publish when the value moved.
// NAN means the sensor went unavailable and is passed through rather than dropped; the second
// check stops an unavailable sensor re-publishing forever, since NAN never equals NAN.
#ifdef USE_SENSOR
if (this->sensor_ != nullptr) {
this->current_temperature = this->sensor_->state;
this->sensor_->add_on_state_callback([this](float state) {
if (state != this->current_temperature && !(std::isnan(state) && std::isnan(this->current_temperature))) {
this->current_temperature = state;
this->publish_state();
}
});
}
if (this->humidity_sensor_ != nullptr) {
this->current_humidity = this->humidity_sensor_->state;
this->humidity_sensor_->add_on_state_callback([this](float state) {
if (state != this->current_humidity && !(std::isnan(state) && std::isnan(this->current_humidity))) {
this->current_humidity = state;
this->publish_state();
}
});
}
#endif
}
void TemplateClimate::dump_config() {
LOG_CLIMATE("", "Template Climate", this);
ESP_LOGCONFIG(TAG, " Optimistic: %s", YESNO(this->optimistic_));
}
void TemplateClimate::control(const climate::ClimateCall &call) {
// Each field present fires its set_*_action; on_control sees the whole call. optimistic: true
// also applies the values right away, false waits for a climate.template.publish report.
if (auto mode = call.get_mode()) {
if (this->optimistic_)
this->mode = *mode;
this->set_mode_trigger_.trigger(*mode);
}
if (auto target_temp = call.get_target_temperature()) {
if (this->optimistic_)
this->target_temperature = *target_temp;
this->set_target_temperature_trigger_.trigger(*target_temp);
}
if (auto target_temp_low = call.get_target_temperature_low()) {
if (this->optimistic_)
this->target_temperature_low = *target_temp_low;
this->set_target_temperature_low_trigger_.trigger(*target_temp_low);
}
if (auto target_temp_high = call.get_target_temperature_high()) {
if (this->optimistic_)
this->target_temperature_high = *target_temp_high;
this->set_target_temperature_high_trigger_.trigger(*target_temp_high);
}
if (auto target_humidity = call.get_target_humidity()) {
if (this->optimistic_)
this->target_humidity = *target_humidity;
this->set_target_humidity_trigger_.trigger(*target_humidity);
}
if (auto fan_mode = call.get_fan_mode()) {
if (this->optimistic_)
this->set_fan_mode_(*fan_mode);
this->set_fan_mode_trigger_.trigger(*fan_mode);
}
if (call.has_custom_fan_mode()) {
if (this->optimistic_)
this->set_custom_fan_mode_(call.get_custom_fan_mode());
this->set_custom_fan_mode_trigger_.trigger(call.get_custom_fan_mode());
}
if (auto swing_mode = call.get_swing_mode()) {
if (this->optimistic_)
this->swing_mode = *swing_mode;
this->set_swing_mode_trigger_.trigger(*swing_mode);
}
if (auto preset = call.get_preset()) {
if (this->optimistic_)
this->set_preset_(*preset);
this->set_preset_trigger_.trigger(*preset);
}
if (call.has_custom_preset()) {
if (this->optimistic_)
this->set_custom_preset_(call.get_custom_preset());
this->set_custom_preset_trigger_.trigger(call.get_custom_preset());
}
if (this->optimistic_)
this->publish_state();
}
// A climate.template.publish report (and initial_state:) never goes through ClimateCall::validate_(),
// so check here instead -- otherwise a typo is published as state the receiving end will reject.
void TemplateClimate::set_mode(climate::ClimateMode mode) {
if (!this->traits_.supports_mode(mode)) {
ESP_LOGW(TAG, "'%s' - Unsupported mode %u", this->get_name().c_str(), static_cast<unsigned>(mode));
return;
}
this->mode = mode;
}
void TemplateClimate::set_swing_mode(climate::ClimateSwingMode swing_mode) {
if (!this->traits_.supports_swing_mode(swing_mode)) {
ESP_LOGW(TAG, "'%s' - Unsupported swing mode %u", this->get_name().c_str(), static_cast<unsigned>(swing_mode));
return;
}
this->swing_mode = swing_mode;
}
void TemplateClimate::set_fan_mode(climate::ClimateFanMode fan_mode) {
if (!this->traits_.supports_fan_mode(fan_mode)) {
ESP_LOGW(TAG, "'%s' - Unsupported fan mode %u", this->get_name().c_str(), static_cast<unsigned>(fan_mode));
return;
}
this->set_fan_mode_(fan_mode);
}
void TemplateClimate::set_preset(climate::ClimatePreset preset) {
if (!this->traits_.supports_preset(preset)) {
ESP_LOGW(TAG, "'%s' - Unsupported preset %u", this->get_name().c_str(), static_cast<unsigned>(preset));
return;
}
this->set_preset_(preset);
}
void TemplateClimate::set_custom_fan_mode(StringRef mode) {
if (this->find_custom_fan_mode_(mode.c_str(), mode.size()) == nullptr) {
ESP_LOGW(TAG, "'%s' - Unsupported custom fan mode '%s'", this->get_name().c_str(), mode.c_str());
return;
}
this->set_custom_fan_mode_(mode);
}
void TemplateClimate::set_custom_preset(StringRef preset) {
if (this->find_custom_preset_(preset.c_str(), preset.size()) == nullptr) {
ESP_LOGW(TAG, "'%s' - Unsupported custom preset '%s'", this->get_name().c_str(), preset.c_str());
return;
}
this->set_custom_preset_(preset);
}
} // namespace esphome::template_
@@ -0,0 +1,92 @@
#pragma once
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/components/climate/climate.h"
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
namespace esphome::template_ {
enum class TemplateClimateRestoreMode {
TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE,
TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE,
};
class TemplateClimate final : public climate::Climate, public Component {
public:
void setup() override;
void dump_config() override;
climate::ClimateTraits traits() override { return this->traits_; }
void add_feature_flags(uint32_t flags) { this->traits_.add_feature_flags(flags); }
#ifdef USE_SENSOR
// The matching feature flag is added from codegen, so the configuration alone decides it.
void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; }
void set_humidity_sensor(sensor::Sensor *sensor) { this->humidity_sensor_ = sensor; }
#endif
void add_supported_mode(climate::ClimateMode mode) { this->traits_.add_supported_mode(mode); }
void add_supported_fan_mode(climate::ClimateFanMode mode) { this->traits_.add_supported_fan_mode(mode); }
void add_supported_swing_mode(climate::ClimateSwingMode mode) { this->traits_.add_supported_swing_mode(mode); }
void add_supported_preset(climate::ClimatePreset preset) { this->traits_.add_supported_preset(preset); }
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
void set_restore_mode(TemplateClimateRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
// Fired from control() for each field the call carries, so a device-backed config can forward
// it on. Which of these are configured also decides the two-point/target-humidity traits.
Trigger<climate::ClimateMode> *get_set_mode_trigger() { return &this->set_mode_trigger_; }
Trigger<float> *get_set_target_temperature_trigger() { return &this->set_target_temperature_trigger_; }
Trigger<float> *get_set_target_temperature_low_trigger() { return &this->set_target_temperature_low_trigger_; }
Trigger<float> *get_set_target_temperature_high_trigger() { return &this->set_target_temperature_high_trigger_; }
Trigger<float> *get_set_target_humidity_trigger() { return &this->set_target_humidity_trigger_; }
Trigger<climate::ClimateFanMode> *get_set_fan_mode_trigger() { return &this->set_fan_mode_trigger_; }
Trigger<StringRef> *get_set_custom_fan_mode_trigger() { return &this->set_custom_fan_mode_trigger_; }
Trigger<climate::ClimateSwingMode> *get_set_swing_mode_trigger() { return &this->set_swing_mode_trigger_; }
Trigger<climate::ClimatePreset> *get_set_preset_trigger() { return &this->set_preset_trigger_; }
Trigger<StringRef> *get_set_custom_preset_trigger() { return &this->set_custom_preset_trigger_; }
// Used by TemplateClimatePublishAction, which is not a Climate subclass and so cannot reach the
// protected setters, and by codegen to apply `initial_state:` before setup() runs.
void set_target_temperature(float value) { this->target_temperature = value; }
void set_target_temperature_low(float value) { this->target_temperature_low = value; }
void set_target_temperature_high(float value) { this->target_temperature_high = value; }
void set_target_humidity(float value) { this->target_humidity = value; }
void set_mode(climate::ClimateMode mode);
void set_swing_mode(climate::ClimateSwingMode mode);
void set_fan_mode(climate::ClimateFanMode mode);
void set_custom_fan_mode(const char *mode) { this->set_custom_fan_mode(StringRef(mode)); }
void set_custom_fan_mode(StringRef mode);
void set_preset(climate::ClimatePreset preset);
void set_custom_preset(const char *preset) { this->set_custom_preset(StringRef(preset)); }
void set_custom_preset(StringRef preset);
protected:
void control(const climate::ClimateCall &call) override;
climate::ClimateTraits traits_;
bool optimistic_{false};
TemplateClimateRestoreMode restore_mode_{TemplateClimateRestoreMode::TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE};
#ifdef USE_SENSOR
sensor::Sensor *sensor_{nullptr};
sensor::Sensor *humidity_sensor_{nullptr};
#endif
Trigger<climate::ClimateMode> set_mode_trigger_;
Trigger<float> set_target_temperature_trigger_;
Trigger<float> set_target_temperature_low_trigger_;
Trigger<float> set_target_temperature_high_trigger_;
Trigger<float> set_target_humidity_trigger_;
Trigger<climate::ClimateFanMode> set_fan_mode_trigger_;
Trigger<StringRef> set_custom_fan_mode_trigger_;
Trigger<climate::ClimateSwingMode> set_swing_mode_trigger_;
Trigger<climate::ClimatePreset> set_preset_trigger_;
Trigger<StringRef> set_custom_preset_trigger_;
};
} // namespace esphome::template_
+1
View File
@@ -133,6 +133,7 @@ Upper = vol.Upper
Length = vol.Length
Exclusive = vol.Exclusive
Inclusive = vol.Inclusive
Unique = vol.Unique
ALLOW_EXTRA = vol.ALLOW_EXTRA
UNDEFINED = vol.UNDEFINED
RequiredFieldInvalid = vol.RequiredFieldInvalid
+3 -3
View File
@@ -45,7 +45,7 @@ lib_deps_base =
lib_deps =
${common.lib_deps_base}
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
esphome/noise-c@0.1.24 ; noise (api, ota)
esphome/noise-c@0.1.21 ; noise (api, ota)
improv/Improv@1.2.7 ; improv_serial / esp32_improv
kikuchan98/pngle@1.1.0 ; online_image
; Using the repository directly, otherwise ESP-IDF can't use the library
@@ -244,7 +244,7 @@ lib_deps =
${common:idf-component-libs.lib_deps}
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
droscy/esp_wireguard@0.4.5 ; wireguard
esphome/noise-c@0.1.24 ; noise (api, ota)
esphome/noise-c@0.1.21 ; noise (api, ota)
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
DNSServer ; captive_portal
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
@@ -641,7 +641,7 @@ build_unflags =
extends = common
platform = platformio/native
lib_deps =
esphome/noise-c@0.1.24 ; used by noise (api, ota)
esphome/noise-c@0.1.21 ; used by noise (api, ota)
lvgl/lvgl@9.5.0 ; lvgl
build_flags =
${common.build_flags}
@@ -0,0 +1,145 @@
"""Tests for template climate config validation."""
import pytest
from esphome import config_validation as cv
from esphome.components.template.climate import (
CONF_SET_TARGET_HUMIDITY_ACTION,
CONF_SET_TARGET_TEMPERATURE_ACTION,
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION,
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION,
CONF_SUPPORTS_CURRENT_HUMIDITY,
CONF_SUPPORTS_CURRENT_TEMPERATURE,
CONF_SUPPORTS_TARGET_HUMIDITY,
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
CONF_TARGET_HUMIDITY,
_resolve_supports,
_validate_initial_state,
_validate_set_actions,
)
from esphome.const import (
CONF_HUMIDITY_SENSOR,
CONF_INITIAL_STATE,
CONF_SENSOR,
CONF_TARGET_TEMPERATURE,
CONF_TARGET_TEMPERATURE_HIGH,
CONF_TARGET_TEMPERATURE_LOW,
)
from esphome.types import ConfigType
def test_supports_current_temperature_derived_from_sensor() -> None:
config: ConfigType = {CONF_SENSOR: "some_sensor"}
assert _resolve_supports(config)[CONF_SUPPORTS_CURRENT_TEMPERATURE] is True
def test_supports_current_temperature_false_without_sensor() -> None:
assert _resolve_supports({})[CONF_SUPPORTS_CURRENT_TEMPERATURE] is False
def test_supports_current_temperature_explicit_true_without_sensor_allowed() -> None:
# The value can still be reported with climate.template.publish.
config: ConfigType = {CONF_SUPPORTS_CURRENT_TEMPERATURE: True}
assert _resolve_supports(config)[CONF_SUPPORTS_CURRENT_TEMPERATURE] is True
def test_supports_current_temperature_false_with_sensor_rejected() -> None:
config: ConfigType = {
CONF_SENSOR: "some_sensor",
CONF_SUPPORTS_CURRENT_TEMPERATURE: False,
}
with pytest.raises(cv.Invalid, match="cannot be false"):
_resolve_supports(config)
def test_supports_current_humidity_false_with_sensor_rejected() -> None:
config: ConfigType = {
CONF_HUMIDITY_SENSOR: "some_sensor",
CONF_SUPPORTS_CURRENT_HUMIDITY: False,
}
with pytest.raises(cv.Invalid, match="cannot be false"):
_resolve_supports(config)
def test_two_point_derived_from_set_actions() -> None:
config: ConfigType = {
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}],
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION: [{}],
}
assert _resolve_supports(config)[CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE] is True
def test_two_point_false_with_set_action_rejected() -> None:
config: ConfigType = {
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}],
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False,
}
with pytest.raises(cv.Invalid, match="cannot be false"):
_resolve_supports(config)
def test_target_humidity_derived_from_set_action() -> None:
config: ConfigType = {CONF_SET_TARGET_HUMIDITY_ACTION: [{}]}
assert _resolve_supports(config)[CONF_SUPPORTS_TARGET_HUMIDITY] is True
def test_set_target_temperature_low_requires_high() -> None:
config: ConfigType = {CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}]}
with pytest.raises(cv.Invalid, match="must be used together"):
_validate_set_actions(config)
def test_set_target_temperature_conflicts_with_two_point_actions() -> None:
config: ConfigType = {
CONF_SET_TARGET_TEMPERATURE_ACTION: [{}],
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}],
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION: [{}],
}
with pytest.raises(cv.Invalid, match="cannot be used together"):
_validate_set_actions(config)
def test_initial_state_target_temperature_rejected_with_two_point() -> None:
config: ConfigType = {
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: True,
CONF_SUPPORTS_TARGET_HUMIDITY: False,
CONF_INITIAL_STATE: {CONF_TARGET_TEMPERATURE: 21.0},
}
with pytest.raises(cv.Invalid, match="is not available"):
_validate_initial_state(config)
def test_initial_state_two_point_values_rejected_without_two_point() -> None:
config: ConfigType = {
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False,
CONF_SUPPORTS_TARGET_HUMIDITY: False,
CONF_INITIAL_STATE: {
CONF_TARGET_TEMPERATURE_LOW: 18.0,
CONF_TARGET_TEMPERATURE_HIGH: 24.0,
},
}
with pytest.raises(cv.Invalid, match="requires"):
_validate_initial_state(config)
def test_initial_state_target_humidity_rejected_without_support() -> None:
config: ConfigType = {
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False,
CONF_SUPPORTS_TARGET_HUMIDITY: False,
CONF_INITIAL_STATE: {CONF_TARGET_HUMIDITY: 50},
}
with pytest.raises(cv.Invalid, match="requires"):
_validate_initial_state(config)
def test_initial_state_matching_two_point_accepted() -> None:
config: ConfigType = {
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: True,
CONF_SUPPORTS_TARGET_HUMIDITY: True,
CONF_INITIAL_STATE: {
CONF_TARGET_TEMPERATURE_LOW: 18.0,
CONF_TARGET_TEMPERATURE_HIGH: 24.0,
CONF_TARGET_HUMIDITY: 50,
},
}
assert _validate_initial_state(config) is config
+1 -2
View File
@@ -30,8 +30,7 @@ climate:
- switch.turn_on: climate_heater_switch
- switch.turn_off: climate_cooler_switch
# Thermostat-based climate so climate.control: action variants get build
# coverage (bang_bang doesn't support fan modes, presets, etc.). Climate
# has no template platform, so thermostat is the right vehicle.
# coverage (bang_bang doesn't support fan modes, presets, etc.).
- platform: thermostat
id: climate_test_thermostat
name: Test Thermostat
-9
View File
@@ -1,4 +1,3 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
@@ -6,11 +5,3 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
# to_code must run: it defines USE_NOISE and adds the noise-c library
# the component sources under test need.
manifest.enable_codegen()
real_to_code = manifest.to_code
async def to_code_testing(config):
await real_to_code(config)
# The spare ephemeral slot only exists in builds with an encrypted api
cg.add_define("USE_API_NOISE")
manifest.to_code = to_code_testing
@@ -3,7 +3,6 @@
#include <cstring>
#include <noise/protocol.h>
#include <sodium.h>
#include "esphome/components/noise/noise.h"
#include "esphome/components/noise/noise_handshake.h"
@@ -150,61 +149,6 @@ TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
noise_cipherstate_free(recv_cipher);
}
TEST(SpareEphemeralTest, EmptySlotHandsOutNothing) {
ephemeral_keypair_t out;
// Drain whatever an earlier test left behind, then the slot must stay empty
take_spare_ephemeral(out);
EXPECT_FALSE(has_spare_ephemeral());
EXPECT_FALSE(take_spare_ephemeral(out));
}
TEST(SpareEphemeralTest, KeyPairIsHandedOutExactlyOnce) {
ephemeral_keypair_t out;
take_spare_ephemeral(out);
prepare_spare_ephemeral();
ASSERT_TRUE(has_spare_ephemeral());
ASSERT_TRUE(take_spare_ephemeral(out));
// Taken once: the slot is empty and a second take gets nothing
EXPECT_FALSE(has_spare_ephemeral());
EXPECT_FALSE(take_spare_ephemeral(out));
// The pair is consistent: the public half is the base point multiple of the private half
uint8_t check[EPHEMERAL_PUBLIC_KEY_SIZE];
ASSERT_EQ(crypto_scalarmult_curve25519_base(check, out.data()), 0);
EXPECT_EQ(std::memcmp(check, out.data() + EPHEMERAL_PRIVATE_KEY_SIZE, EPHEMERAL_PUBLIC_KEY_SIZE), 0);
}
TEST(SpareEphemeralTest, SuppliedKeyPairCompletesHandshakeAndIsTheKeyOnTheWire) {
ephemeral_keypair_t spare;
take_spare_ephemeral(spare);
prepare_spare_ephemeral();
ASSERT_TRUE(take_spare_ephemeral(spare));
const psk_t psk = make_psk(7);
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE), spare.data()), 0);
Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_EQ(responder.read_message(msg, msg_len), 0);
size_t reply_len = 0;
ASSERT_EQ(responder.write_message(msg, sizeof(msg), reply_len), 0);
// The responder's message starts with its ephemeral public key
ASSERT_GE(reply_len, static_cast<size_t>(EPHEMERAL_PUBLIC_KEY_SIZE));
EXPECT_EQ(std::memcmp(msg, spare.data() + EPHEMERAL_PRIVATE_KEY_SIZE, EPHEMERAL_PUBLIC_KEY_SIZE), 0);
ASSERT_EQ(initiator.read_message(msg, reply_len), 0);
initiator.split();
NoiseCipherState *send_cipher = nullptr;
NoiseCipherState *recv_cipher = nullptr;
ASSERT_EQ(responder.split(send_cipher, recv_cipher), 0);
ASSERT_NE(send_cipher, nullptr);
noise_cipherstate_free(send_cipher);
noise_cipherstate_free(recv_cipher);
}
TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
// The documented retry shape: a repeated init() frees the previous state
// and starts over. The first message under the new key authenticating
+113
View File
@@ -25,6 +25,27 @@ esphome:
away: !lambda "return true;"
is_on: !lambda "return false;"
- climate.template.publish:
id: template_climate
current_temperature: 21.0
mode: HEAT
fan_mode: AUTO
swing_mode: "OFF"
preset: NONE
target_temperature: 22.0
# Templated
- climate.template.publish:
id: template_climate
current_temperature: !lambda "return 21.5f;"
mode: !lambda "return climate::CLIMATE_MODE_COOL;"
target_temperature: !lambda "return 23.0f;"
- climate.template.publish:
id: template_climate_custom_modes
custom_fan_mode: "turbo"
custom_preset: "eco_plus"
# Test C++ API: set_template() with stateless lambda (no captures)
# NOTE: set_template() is not intended to be a public API, but we test it to ensure it doesn't break.
- lambda: |-
@@ -513,6 +534,98 @@ alarm_control_panel:
codes:
- "1234"
climate:
- platform: template
id: template_climate
name: "Template Climate"
optimistic: true
sensor: template_template_sens
supports_action: true
supports_current_humidity: true
restore_mode: NO_RESTORE
initial_state:
mode: HEAT
target_temperature: 21.0
fan_mode: LOW
supported_modes:
- "OFF"
- HEAT
- COOL
supported_fan_modes:
- AUTO
- LOW
- HIGH
supported_swing_modes:
- "OFF"
- VERTICAL
supported_presets:
- NONE
- ECO
visual:
min_temperature: 16.0
max_temperature: 30.0
temperature_step: 0.5
set_mode_action:
- logger.log:
format: "set_mode_action %d"
args: ["(int) x"]
set_target_temperature_action:
- logger.log:
format: "set_target_temperature_action %.1f"
args: ["x"]
set_target_humidity_action:
- logger.log:
format: "set_target_humidity_action %.1f"
args: ["x"]
set_fan_mode_action:
- logger.log:
format: "set_fan_mode_action %d"
args: ["(int) x"]
set_swing_mode_action:
- logger.log:
format: "set_swing_mode_action %d"
args: ["(int) x"]
set_preset_action:
- logger.log:
format: "set_preset_action %d"
args: ["(int) x"]
on_control:
- logger.log: "on_control fired"
on_state:
- logger.log: "on_state fired"
- platform: template
id: template_climate_custom_modes
name: "Template Climate Custom Modes"
optimistic: true
sensor: template_template_sens
supported_modes:
- "OFF"
- HEAT
custom_fan_modes:
- turbo
- silent
- eco
custom_presets:
- eco_plus
- power_save
- max
set_custom_fan_mode_action:
- logger.log:
format: "set_custom_fan_mode_action %s"
args: ["x.c_str()"]
set_custom_preset_action:
- logger.log:
format: "set_custom_preset_action %s"
args: ["x.c_str()"]
initial_state:
custom_fan_mode: eco
custom_preset: max
visual:
min_temperature: 16.0
max_temperature: 30.0
temperature_step: 0.5
water_heater:
- platform: template
id: template_water_heater
@@ -0,0 +1,72 @@
esphome:
name: tmpl-clim-basic
on_boot:
- climate.template.publish:
id: test_climate
action: IDLE
host:
api:
logger:
climate:
- platform: template
id: test_climate
name: Test Basic Climate
optimistic: true
sensor: test_climate_current_temperature
humidity_sensor: test_climate_current_humidity
supports_action: true
supported_modes:
- "OFF"
- HEAT
- COOL
supported_fan_modes:
- AUTO
- LOW
- HIGH
supported_swing_modes:
- "OFF"
- VERTICAL
supported_presets:
- NONE
- ECO
visual:
min_temperature: 16.0
max_temperature: 30.0
temperature_step: 0.5
on_control:
- lambda: |-
if (x.get_mode().has_value())
ESP_LOGD("test", "on_control mode=%d", (int) *x.get_mode());
if (x.get_target_temperature().has_value())
ESP_LOGD("test", "on_control target_temperature=%.1f", *x.get_target_temperature());
if (x.get_fan_mode().has_value())
ESP_LOGD("test", "on_control fan_mode=%d", (int) *x.get_fan_mode());
if (x.get_swing_mode().has_value())
ESP_LOGD("test", "on_control swing_mode=%d", (int) *x.get_swing_mode());
if (x.get_preset().has_value())
ESP_LOGD("test", "on_control preset=%d", (int) *x.get_preset());
sensor:
- platform: template
id: test_climate_current_temperature
name: Test Climate Current Temperature
lambda: "return 22.5f;"
update_interval: 10ms
- platform: template
id: test_climate_current_humidity
name: Test Climate Current Humidity
lambda: "return 55.0f;"
update_interval: 10ms
button:
- platform: template
id: simulate_device_report
name: Simulate Device Report
on_press:
- climate.template.publish:
id: test_climate
mode: "OFF"
fan_mode: AUTO
swing_mode: "OFF"
preset: NONE
@@ -0,0 +1,47 @@
esphome:
name: tmpl-clim-custom
host:
api:
logger:
climate:
- platform: template
id: test_climate
name: Test Custom Mode Climate
optimistic: true
sensor: test_climate_current_temperature
supported_modes:
- "OFF"
- HEAT
- COOL
custom_fan_modes:
- turbo
- silent
- eco
custom_presets:
- eco_plus
- power_save
- max
on_control:
- lambda: |-
if (x.has_custom_fan_mode())
ESP_LOGD("test", "on_control custom_fan_mode=%s", x.get_custom_fan_mode().c_str());
if (x.has_custom_preset())
ESP_LOGD("test", "on_control custom_preset=%s", x.get_custom_preset().c_str());
sensor:
- platform: template
id: test_climate_current_temperature
name: Test Climate Current Temperature
lambda: "return 22.5f;"
update_interval: 10ms
button:
- platform: template
id: simulate_device_report
name: Simulate Device Report
on_press:
- climate.template.publish:
id: test_climate
custom_fan_mode: "eco"
custom_preset: "max"
@@ -0,0 +1,56 @@
esphome:
name: tmpl-clim-nonopt
host:
api:
logger:
climate:
- platform: template
id: test_climate
name: Test Template Climate Nonoptimistic
optimistic: false
supported_modes:
- "OFF"
- HEAT
- COOL
- FAN_ONLY
supported_fan_modes:
- AUTO
- LOW
- HIGH
supported_swing_modes:
- "OFF"
- VERTICAL
supported_presets:
- NONE
- ECO
- AWAY
visual:
min_temperature: 16.0
max_temperature: 30.0
temperature_step: 0.5
on_control:
- lambda: |-
if (x.get_mode().has_value())
ESP_LOGD("test", "on_control mode=%d", (int) *x.get_mode());
if (x.get_target_temperature().has_value())
ESP_LOGD("test", "on_control target_temperature=%.1f", *x.get_target_temperature());
if (x.get_fan_mode().has_value())
ESP_LOGD("test", "on_control fan_mode=%d", (int) *x.get_fan_mode());
if (x.get_swing_mode().has_value())
ESP_LOGD("test", "on_control swing_mode=%d", (int) *x.get_swing_mode());
if (x.get_preset().has_value())
ESP_LOGD("test", "on_control preset=%d", (int) *x.get_preset());
button:
- platform: template
id: simulate_device_confirmation
name: Simulate Device Confirmation
on_press:
- climate.template.publish:
id: test_climate
mode: HEAT
target_temperature: 22.5
fan_mode: HIGH
swing_mode: VERTICAL
preset: AWAY
@@ -0,0 +1,26 @@
esphome:
name: tmpl-clim-oc-order
host:
api:
logger:
# on_control fires with the full ClimateCall (arg `x`) from the base Climate component's
# ClimateCall::perform(), before validate_()/control() run -- so when the lambda action below
# runs, the entity's own .mode is still the OLD value, even though x.get_mode() already reports
# the NEW requested value. on_state fires afterward, once control() has applied it.
climate:
- platform: template
id: test_climate
name: Test On Control Ordering
optimistic: true
supported_modes:
- "OFF"
- HEAT
on_control:
- lambda: |-
ESP_LOGD("test", "on_control requested_mode=%d current_mode_before_apply=%d",
x.get_mode().has_value() ? (int) *x.get_mode() : -1,
(int) id(test_climate).mode);
on_state:
- lambda: |-
ESP_LOGD("test", "on_state mode=%d", (int) x.mode);
@@ -0,0 +1,63 @@
esphome:
name: tmpl-clim-publish-all
host:
api:
logger:
climate:
- platform: template
id: test_climate
name: Test Publish All Fields
optimistic: true
# current_temperature/current_humidity/action are only sent over the API at all if their
# trait is advertised: current_temperature/current_humidity because a sensor/humidity_sensor
# is referenced below, action because supports_action is set. The sensors' fixed readings
# match what climate.template.publish pushes, so the sensor callback (guarded to only publish
# on an actual change) doesn't produce an extra, unexpected state update of its own.
sensor: test_climate_current_temperature
humidity_sensor: test_climate_current_humidity
supports_action: true
supported_modes:
- "OFF"
- HEAT
supported_fan_modes:
- AUTO
- HIGH
supported_swing_modes:
- "OFF"
- VERTICAL
supported_presets:
- NONE
- ECO
on_control:
# Should never fire in this test: climate.template.publish is a pure bypass and must not
# re-trigger on_control as if the entity were freshly commanded.
- logger.log: "on_control fired"
sensor:
- platform: template
id: test_climate_current_temperature
name: Test Climate Current Temperature
lambda: "return 20.0f;"
update_interval: 10ms
- platform: template
id: test_climate_current_humidity
name: Test Climate Current Humidity
lambda: "return 60.0f;"
update_interval: 10ms
button:
- platform: template
id: publish_all
name: Publish All
on_press:
- climate.template.publish:
id: test_climate
current_temperature: 20.0
current_humidity: 60.0
target_temperature: 23.0
mode: HEAT
action: HEATING
fan_mode: HIGH
swing_mode: VERTICAL
preset: ECO
@@ -0,0 +1,49 @@
esphome:
name: tmpl-clim-sensor-push
host:
api:
logger:
# No lambda/update_interval: these sensors only ever report a value when a button below
# publishes one (standing in for e.g. a BLE scan callback in a real config).
sensor:
- platform: template
id: room_temperature
name: Room Temperature
- platform: template
id: room_humidity
name: Room Humidity
climate:
- platform: template
id: test_climate
name: Test Sensor Push Climate
optimistic: true
sensor: room_temperature
humidity_sensor: room_humidity
supported_modes:
- "OFF"
- HEAT
button:
- platform: template
id: publish_temperature
name: Publish Temperature
on_press:
- sensor.template.publish:
id: room_temperature
state: 24.0
- platform: template
id: publish_temperature_same
name: Publish Temperature Same Value
on_press:
- sensor.template.publish:
id: room_temperature
state: 24.0
- platform: template
id: publish_humidity
name: Publish Humidity
on_press:
- sensor.template.publish:
id: room_humidity
state: 65.0
@@ -0,0 +1,89 @@
esphome:
name: tmpl-clim-set-act
host:
api:
logger:
# Every settable field forwards its requested value to a set_*_action. supports_two_point and
# supports_target_humidity are not declared here: they are derived from the low/high and humidity
# set actions being present.
climate:
- platform: template
id: test_climate
name: Test Set Actions
optimistic: false
restore_mode: NO_RESTORE
supported_modes:
- "OFF"
- HEAT
- COOL
supported_fan_modes:
- AUTO
- LOW
supported_swing_modes:
- "OFF"
- VERTICAL
supported_presets:
- NONE
- ECO
custom_fan_modes:
- turbo
custom_presets:
- eco_plus
visual:
min_temperature: 16.0
max_temperature: 30.0
temperature_step: 0.5
set_mode_action:
- logger.log:
format: "set_mode_action %d"
args: ["(int) x"]
set_target_temperature_low_action:
- logger.log:
format: "set_target_temperature_low_action %.1f"
args: ["x"]
set_target_temperature_high_action:
- logger.log:
format: "set_target_temperature_high_action %.1f"
args: ["x"]
set_target_humidity_action:
- logger.log:
format: "set_target_humidity_action %.0f"
args: ["x"]
set_fan_mode_action:
- logger.log:
format: "set_fan_mode_action %d"
args: ["(int) x"]
set_custom_fan_mode_action:
- logger.log:
format: "set_custom_fan_mode_action %s"
args: ["x.c_str()"]
set_swing_mode_action:
- logger.log:
format: "set_swing_mode_action %d"
args: ["(int) x"]
set_preset_action:
- logger.log:
format: "set_preset_action %d"
args: ["(int) x"]
set_custom_preset_action:
- logger.log:
format: "set_custom_preset_action %s"
args: ["x.c_str()"]
button:
- platform: template
id: report_device_state
name: Report Device State
on_press:
- climate.template.publish:
id: test_climate
mode: HEAT
- platform: template
id: report_unsupported_mode
name: Report Unsupported Mode
on_press:
- climate.template.publish:
id: test_climate
mode: DRY
@@ -0,0 +1,52 @@
esphome:
name: tmpl-clim-two-point
host:
api:
logger:
climate:
- platform: template
id: test_climate
name: Test Two-Point Heatpump
optimistic: true
sensor: test_climate_current_temperature
supports_two_point_target_temperature: true
supports_target_humidity: true
supported_modes:
- "OFF"
- HEAT_COOL
- HEAT
- COOL
visual:
min_temperature: 16.0
max_temperature: 30.0
temperature_step: 0.5
on_control:
- lambda: |-
if (x.get_mode().has_value())
ESP_LOGD("test", "on_control mode=%d", (int) *x.get_mode());
if (x.get_target_temperature_low().has_value())
ESP_LOGD("test", "on_control target_temperature_low=%.1f", *x.get_target_temperature_low());
if (x.get_target_temperature_high().has_value())
ESP_LOGD("test", "on_control target_temperature_high=%.1f", *x.get_target_temperature_high());
if (x.get_target_humidity().has_value())
ESP_LOGD("test", "on_control target_humidity=%.1f", *x.get_target_humidity());
sensor:
- platform: template
id: test_climate_current_temperature
name: Test Climate Current Temperature
lambda: "return 21.0f;"
update_interval: 10ms
button:
- platform: template
id: simulate_device_report
name: Simulate Device Report
on_press:
- climate.template.publish:
id: test_climate
mode: HEAT_COOL
target_temperature_low: 18.0
target_temperature_high: 24.0
target_humidity: 50.0
@@ -0,0 +1,146 @@
"""Integration test for template climate: sensor-pushed measured values, on_control + publish
for the settable ones.
current_temperature/current_humidity are pushed by a referenced sensor/humidity_sensor (no
polling); action is set once at boot via climate.template.publish, since it has no sensor
equivalent. mode/target_temperature/fan_mode/swing_mode/preset are plain internal state:
on_control fires exactly once per command (never before the first one), and
climate.template.publish simulates the device reporting its own state independent of any prior
command -- that report is authoritative, overriding whatever was optimistically applied earlier.
"""
from __future__ import annotations
import asyncio
import aioesphomeapi
from aioesphomeapi import (
ButtonInfo,
ClimateAction,
ClimateFanMode,
ClimateInfo,
ClimateMode,
ClimatePreset,
ClimateSwingMode,
)
import pytest
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "tmpl-clim-basic"
@pytest.mark.asyncio
async def test_template_climate_basic(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Sensor-pushed measured values, on_control + publish for settable ones."""
clear_host_prefs(DEVICE_NAME)
log_lines: list[str] = []
def on_log_line(line: str) -> None:
if "on_control " in line:
log_lines.append(line)
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
async def wait_for_climate_state(
timeout: float = 5.0,
) -> aioesphomeapi.ClimateState:
return await wait_for_state(
client, lambda s: isinstance(s, aioesphomeapi.ClimateState), timeout
)
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
test_climate = climate_infos[0]
# Advertised capabilities come straight from the supported_*/custom_* config lists.
assert ClimateMode.OFF in test_climate.supported_modes
assert ClimateMode.HEAT in test_climate.supported_modes
assert ClimateMode.COOL in test_climate.supported_modes
assert ClimateFanMode.AUTO in test_climate.supported_fan_modes
assert ClimateFanMode.LOW in test_climate.supported_fan_modes
assert ClimateFanMode.HIGH in test_climate.supported_fan_modes
assert ClimateSwingMode.OFF in test_climate.supported_swing_modes
assert ClimateSwingMode.VERTICAL in test_climate.supported_swing_modes
assert ClimatePreset.NONE in test_climate.supported_presets
assert ClimatePreset.ECO in test_climate.supported_presets
report_button = require_entity(entities, "simulate_device_report", ButtonInfo)
client.subscribe_states(
initial_state_helper.on_state_wrapper(lambda state: None)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
initial = initial_state_helper.initial_states.get(test_climate.key)
assert initial is not None, "No initial climate state received"
assert isinstance(initial, aioesphomeapi.ClimateState)
assert initial.current_temperature == pytest.approx(22.5, abs=0.1)
assert initial.current_humidity == pytest.approx(55.0, abs=0.1)
assert initial.action == ClimateAction.IDLE
assert initial.mode == ClimateMode.OFF
# Nothing was commanded yet: on_control must not have fired.
assert not log_lines
# Commands apply optimistically and on_control fires with the same values.
client.climate_command(test_climate.key, mode=ClimateMode.HEAT)
state = await wait_for_climate_state()
assert state.mode == ClimateMode.HEAT
client.climate_command(test_climate.key, target_temperature=22.5)
state = await wait_for_climate_state()
assert state.target_temperature == pytest.approx(22.5, abs=0.1)
client.climate_command(test_climate.key, fan_mode=ClimateFanMode.HIGH)
state = await wait_for_climate_state()
assert state.fan_mode == ClimateFanMode.HIGH
client.climate_command(test_climate.key, swing_mode=ClimateSwingMode.VERTICAL)
state = await wait_for_climate_state()
assert state.swing_mode == ClimateSwingMode.VERTICAL
client.climate_command(test_climate.key, preset=ClimatePreset.ECO)
state = await wait_for_climate_state()
assert state.preset == ClimatePreset.ECO
await asyncio.sleep(0.2)
assert any(
"on_control mode=3" in line for line in log_lines
) # CLIMATE_MODE_HEAT
assert any("on_control target_temperature=22.5" in line for line in log_lines)
assert any("on_control fan_mode=" in line for line in log_lines)
assert any("on_control swing_mode=" in line for line in log_lines)
assert any("on_control preset=" in line for line in log_lines)
# Exactly one on_control log line per command, none extra (e.g. from a stray republish).
assert len(log_lines) == 5
# measured values are untouched by any of the above (no set action exists for them).
assert state.current_temperature == pytest.approx(22.5, abs=0.1)
assert state.current_humidity == pytest.approx(55.0, abs=0.1)
assert state.action == ClimateAction.IDLE
# The device's report is authoritative and overrides everything commanded above.
client.button_command(report_button.key)
state = await wait_for_climate_state()
assert state.mode == ClimateMode.OFF
assert state.fan_mode == ClimateFanMode.AUTO
assert state.swing_mode == ClimateSwingMode.OFF
assert state.preset == ClimatePreset.NONE
@@ -0,0 +1,98 @@
"""Integration test for template climate: custom fan modes and presets.
Same on_control (forward) + climate.template.publish (device report, authoritative) pattern as
the enum-based mode/preset fields, but for the custom string variants.
"""
from __future__ import annotations
import asyncio
import aioesphomeapi
from aioesphomeapi import ButtonInfo, ClimateInfo
import pytest
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "tmpl-clim-custom"
@pytest.mark.asyncio
async def test_template_climate_custom_modes(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Custom fan mode/preset: traits, on_control forwarding, and publish precedence."""
clear_host_prefs(DEVICE_NAME)
log_lines: list[str] = []
def on_log_line(line: str) -> None:
if "on_control " in line:
log_lines.append(line)
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
async def wait_for_climate_state(
timeout: float = 5.0,
) -> aioesphomeapi.ClimateState:
return await wait_for_state(
client, lambda s: isinstance(s, aioesphomeapi.ClimateState), timeout
)
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
test_climate = climate_infos[0]
assert set(test_climate.supported_custom_fan_modes) == {
"turbo",
"silent",
"eco",
}
assert set(test_climate.supported_custom_presets) == {
"eco_plus",
"power_save",
"max",
}
report_button = require_entity(entities, "simulate_device_report", ButtonInfo)
client.subscribe_states(
initial_state_helper.on_state_wrapper(lambda state: None)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
initial = initial_state_helper.initial_states.get(test_climate.key)
assert initial is not None, "No initial climate state received"
assert isinstance(initial, aioesphomeapi.ClimateState)
assert initial.custom_fan_mode == ""
assert initial.custom_preset == ""
client.climate_command(test_climate.key, custom_fan_mode="turbo")
state = await wait_for_climate_state()
assert state.custom_fan_mode == "turbo"
client.climate_command(test_climate.key, custom_preset="power_save")
state = await wait_for_climate_state()
assert state.custom_preset == "power_save"
await asyncio.sleep(0.2)
assert any("on_control custom_fan_mode=turbo" in line for line in log_lines)
assert any("on_control custom_preset=power_save" in line for line in log_lines)
# The device's report is authoritative and overrides what was commanded above.
client.button_command(report_button.key)
state = await wait_for_climate_state()
assert state.custom_fan_mode == "eco"
assert state.custom_preset == "max"
@@ -0,0 +1,107 @@
"""Integration test for template climate: optimistic: false.
A command still fires on_control (so a real device-backed config can forward it out), but must
NOT change the entity's own state -- only an explicit climate.template.publish call (standing in
for the device confirming the command actually took effect) does that.
"""
from __future__ import annotations
import asyncio
import aioesphomeapi
from aioesphomeapi import (
ButtonInfo,
ClimateFanMode,
ClimateInfo,
ClimateMode,
ClimatePreset,
ClimateSwingMode,
)
import pytest
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "tmpl-clim-nonopt"
@pytest.mark.asyncio
async def test_template_climate_nonoptimistic(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Nonoptimistic: a command doesn't change state until explicitly published."""
clear_host_prefs(DEVICE_NAME)
log_lines: list[str] = []
state_updates: list[aioesphomeapi.ClimateState] = []
def on_log_line(line: str) -> None:
if "on_control " in line:
log_lines.append(line)
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
def on_state(state: aioesphomeapi.EntityState) -> None:
if isinstance(state, aioesphomeapi.ClimateState):
state_updates.append(state)
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
test_climate = climate_infos[0]
confirm_button = require_entity(
entities, "simulate_device_confirmation", ButtonInfo
)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
initial = initial_state_helper.initial_states.get(test_climate.key)
assert initial is not None, "No initial climate state received"
assert isinstance(initial, aioesphomeapi.ClimateState)
assert initial.mode == ClimateMode.OFF
# Send every settable field in one command. on_control must fire with all of them, but
# nothing may be applied to the entity's own state -- no ClimateState update at all.
client.climate_command(
test_climate.key,
mode=ClimateMode.HEAT,
target_temperature=22.5,
fan_mode=ClimateFanMode.HIGH,
swing_mode=ClimateSwingMode.VERTICAL,
preset=ClimatePreset.AWAY,
)
await asyncio.sleep(0.3)
assert any(
"on_control mode=3" in line for line in log_lines
) # CLIMATE_MODE_HEAT
assert any("on_control target_temperature=22.5" in line for line in log_lines)
assert any("on_control fan_mode=" in line for line in log_lines)
assert any("on_control swing_mode=" in line for line in log_lines)
assert any("on_control preset=" in line for line in log_lines)
assert not state_updates, (
"optimistic: false must not publish a state until climate.template.publish reports it"
)
# The device confirms the command actually took effect.
client.button_command(confirm_button.key)
state = await wait_for_state(
client, lambda s: isinstance(s, aioesphomeapi.ClimateState)
)
assert state.mode == ClimateMode.HEAT
assert state.target_temperature == pytest.approx(22.5, abs=0.1)
assert state.fan_mode == ClimateFanMode.HIGH
assert state.swing_mode == ClimateSwingMode.VERTICAL
assert state.preset == ClimatePreset.AWAY
@@ -0,0 +1,83 @@
"""Integration test: on_control fires before control()/on_state, with the full ClimateCall.
on_control's lambda argument exposes get_mode()/etc. on the *requested* ClimateCall, while the
entity's own .mode field still reflects the state *before* control() applies the change --
proving the firing order is on_control, then control(), then on_state.
"""
from __future__ import annotations
import asyncio
import aioesphomeapi
from aioesphomeapi import ClimateInfo, ClimateMode
import pytest
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "tmpl-clim-oc-order"
@pytest.mark.asyncio
async def test_template_climate_on_control_ordering(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""on_control sees the requested value while the entity's own state is still the old one."""
clear_host_prefs(DEVICE_NAME)
log_lines: list[str] = []
def on_log_line(line: str) -> None:
if "on_control " in line or "on_state " in line:
log_lines.append(line)
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
async def wait_for_climate_state(
timeout: float = 5.0,
) -> aioesphomeapi.ClimateState:
return await wait_for_state(
client, lambda s: isinstance(s, aioesphomeapi.ClimateState), timeout
)
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
test_climate = climate_infos[0]
client.subscribe_states(
initial_state_helper.on_state_wrapper(lambda state: None)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
client.climate_command(test_climate.key, mode=ClimateMode.HEAT)
state = await wait_for_climate_state()
assert state.mode == ClimateMode.HEAT
await asyncio.sleep(0.2)
# on_control saw the new requested mode (3 == CLIMATE_MODE_HEAT) while the entity's own
# state was still the old one (0 == CLIMATE_MODE_OFF) -- proving it fired before control().
assert any(
"on_control requested_mode=3 current_mode_before_apply=0" in line
for line in log_lines
)
# on_state fired afterward, reporting the now-applied mode.
assert any("on_state mode=3" in line for line in log_lines)
control_index = next(
i for i, line in enumerate(log_lines) if "on_control " in line
)
state_index = next(i for i, line in enumerate(log_lines) if "on_state " in line)
assert control_index < state_index, "on_control must fire before on_state"
@@ -0,0 +1,96 @@
"""Integration test for template climate: climate.template.publish covering every field at once.
A single climate.template.publish call resolves into exactly one ClimateState update, and never
triggers on_control (which would misrepresent a device state report as a fresh command). This also
exercises that a sensor/humidity_sensor whose reading matches what's about to be published doesn't
sneak in an extra state update of its own (the sensor callback only re-publishes on an actual
change).
"""
from __future__ import annotations
import asyncio
import aioesphomeapi
from aioesphomeapi import (
ButtonInfo,
ClimateAction,
ClimateFanMode,
ClimateInfo,
ClimateMode,
ClimatePreset,
ClimateSwingMode,
)
import pytest
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "tmpl-clim-publish-all"
@pytest.mark.asyncio
async def test_template_climate_publish_all_fields(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""One climate.template.publish call setting every field resolves to one state update."""
clear_host_prefs(DEVICE_NAME)
state_updates: list[aioesphomeapi.ClimateState] = []
on_control_count = 0
def on_log_line(line: str) -> None:
nonlocal on_control_count
if "on_control fired" in line:
on_control_count += 1
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
def on_state(state: aioesphomeapi.EntityState) -> None:
if isinstance(state, aioesphomeapi.ClimateState):
state_updates.append(state)
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
publish_button = require_entity(entities, "publish_all", ButtonInfo)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
client.button_command(publish_button.key)
try:
state = await wait_for_state(
client, lambda s: isinstance(s, aioesphomeapi.ClimateState)
)
except TimeoutError:
pytest.fail("Timeout waiting for the published climate state")
assert state.current_temperature == pytest.approx(20.0, abs=0.1)
assert state.current_humidity == pytest.approx(60.0, abs=0.1)
assert state.target_temperature == pytest.approx(23.0, abs=0.1)
assert state.mode == ClimateMode.HEAT
assert state.action == ClimateAction.HEATING
assert state.fan_mode == ClimateFanMode.HIGH
assert state.swing_mode == ClimateSwingMode.VERTICAL
assert state.preset == ClimatePreset.ECO
# Give any stray extra update (there shouldn't be one) a moment to arrive.
await asyncio.sleep(0.2)
assert len(state_updates) == 1, (
f"Expected exactly one ClimateState update, got {len(state_updates)}"
)
assert on_control_count == 0, (
"climate.template.publish must not trigger on_control"
)
@@ -0,0 +1,88 @@
"""Integration test for template climate: current_temperature/current_humidity live sensor push.
A *later* change to a backing sensor's value -- not just its initial reading at boot -- propagates
into a new climate state via add_on_state_callback. Re-publishing the same sensor value again must
not cause a redundant climate state update.
"""
from __future__ import annotations
import asyncio
import math
import aioesphomeapi
from aioesphomeapi import ButtonInfo, ClimateInfo
import pytest
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "tmpl-clim-sensor-push"
@pytest.mark.asyncio
async def test_template_climate_sensor_push(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A later change to the backing sensor pushes a new climate state; an unchanged republish does not."""
clear_host_prefs(DEVICE_NAME)
state_updates: list[aioesphomeapi.ClimateState] = []
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
def on_state(state: aioesphomeapi.EntityState) -> None:
if isinstance(state, aioesphomeapi.ClimateState):
state_updates.append(state)
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
test_climate = climate_infos[0]
publish_temp = require_entity(entities, "publish_temperature", ButtonInfo)
publish_temp_same = require_entity(
entities, "publish_temperature_same", ButtonInfo
)
publish_humidity = require_entity(entities, "publish_humidity", ButtonInfo)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
initial = initial_state_helper.initial_states.get(test_climate.key)
assert initial is not None, "No initial climate state received"
assert isinstance(initial, aioesphomeapi.ClimateState)
# Neither backing sensor has published anything yet.
assert math.isnan(initial.current_temperature)
assert math.isnan(initial.current_humidity)
# A later sensor reading -- not the initial one -- pushes a new climate state.
client.button_command(publish_temp.key)
state = await wait_for_state(
client, lambda s: isinstance(s, aioesphomeapi.ClimateState)
)
assert state.current_temperature == pytest.approx(24.0, abs=0.1)
client.button_command(publish_humidity.key)
state = await wait_for_state(
client, lambda s: isinstance(s, aioesphomeapi.ClimateState)
)
assert state.current_humidity == pytest.approx(65.0, abs=0.1)
# Re-publishing the same temperature must not cause a redundant climate state update.
updates_before = len(state_updates)
client.button_command(publish_temp_same.key)
await asyncio.sleep(0.3)
assert len(state_updates) == updates_before, (
"Re-publishing an unchanged sensor reading must not republish the climate state"
)
@@ -0,0 +1,114 @@
"""Integration test: each settable field forwards its value to the matching set_*_action.
With optimistic: false the entity state stays put until climate.template.publish reports the
device's actual state back, so the actions are the only thing that reacts to a command.
"""
from __future__ import annotations
import asyncio
import aioesphomeapi
from aioesphomeapi import (
ButtonInfo,
ClimateFanMode,
ClimateInfo,
ClimateMode,
ClimatePreset,
ClimateSwingMode,
)
import pytest
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "tmpl-clim-set-act"
@pytest.mark.asyncio
async def test_template_climate_set_actions(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Every set_*_action fires with the requested value; state waits for a publish."""
clear_host_prefs(DEVICE_NAME)
log_lines: list[str] = []
def on_log_line(line: str) -> None:
if "_action " in line or "Unsupported" in line:
log_lines.append(line)
def logged(fragment: str) -> bool:
return any(fragment in line for line in log_lines)
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
test_climate = climate_infos[0]
report_button = require_entity(entities, "report_device_state", ButtonInfo)
unsupported_button = require_entity(
entities, "report_unsupported_mode", ButtonInfo
)
client.subscribe_states(
initial_state_helper.on_state_wrapper(lambda state: None)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Both traits are derived from the low/high and humidity set actions, not declared.
assert test_climate.supports_two_point_target_temperature
assert test_climate.supports_target_humidity
client.climate_command(test_climate.key, mode=ClimateMode.HEAT)
client.climate_command(
test_climate.key, target_temperature_low=18.0, target_temperature_high=24.0
)
client.climate_command(test_climate.key, target_humidity=55)
client.climate_command(test_climate.key, fan_mode=ClimateFanMode.LOW)
client.climate_command(test_climate.key, custom_fan_mode="turbo")
client.climate_command(test_climate.key, swing_mode=ClimateSwingMode.VERTICAL)
client.climate_command(test_climate.key, preset=ClimatePreset.ECO)
client.climate_command(test_climate.key, custom_preset="eco_plus")
for _ in range(50):
await asyncio.sleep(0.1)
if logged("set_custom_preset_action eco_plus"):
break
assert logged("set_mode_action 3") # CLIMATE_MODE_HEAT
assert logged("set_target_temperature_low_action 18.0")
assert logged("set_target_temperature_high_action 24.0")
assert logged("set_target_humidity_action 55")
assert logged("set_fan_mode_action 3") # CLIMATE_FAN_LOW
assert logged("set_custom_fan_mode_action turbo")
assert logged("set_swing_mode_action 2") # CLIMATE_SWING_VERTICAL
assert logged("set_preset_action 5") # CLIMATE_PRESET_ECO
assert logged("set_custom_preset_action eco_plus")
# optimistic: false, so none of the commands above touched the entity's own state --
# a device report is what actually moves it.
client.button_command(report_button.key)
state = await wait_for_state(
client, lambda s: isinstance(s, aioesphomeapi.ClimateState)
)
assert state.mode == ClimateMode.HEAT
# A publish naming a mode outside supported_modes warns instead of publishing it.
client.button_command(unsupported_button.key)
for _ in range(50):
await asyncio.sleep(0.1)
if logged("Unsupported mode"):
break
assert logged("Unsupported mode")
@@ -0,0 +1,118 @@
"""Integration tests for template climate: two-point target temperature + humidity.
Covers the supports_two_point_target_temperature/supports_target_humidity boolean flags plus
on_control (forwarding commands out) and climate.template.publish (the device reporting its own
authoritative state, independent of any prior command -- e.g. a device that owns its own setpoint,
changed via a physical remote).
"""
from __future__ import annotations
import asyncio
import aioesphomeapi
from aioesphomeapi import ButtonInfo, ClimateInfo, ClimateMode
import pytest
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "tmpl-clim-two-point"
@pytest.mark.asyncio
async def test_template_climate_two_point_temperature(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Two-point target temperature + humidity: booleans, on_control, and publish precedence."""
clear_host_prefs(DEVICE_NAME)
log_lines: list[str] = []
def on_log_line(line: str) -> None:
if "on_control " in line:
log_lines.append(line)
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
async def wait_for_climate_state(
timeout: float = 5.0,
) -> aioesphomeapi.ClimateState:
return await wait_for_state(
client, lambda s: isinstance(s, aioesphomeapi.ClimateState), timeout
)
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
test_climate = climate_infos[0]
assert test_climate.name == "Test Two-Point Heatpump"
assert test_climate.supports_two_point_target_temperature
assert test_climate.supports_target_humidity
report_button = require_entity(entities, "simulate_device_report", ButtonInfo)
client.subscribe_states(
initial_state_helper.on_state_wrapper(lambda state: None)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
initial = initial_state_helper.initial_states.get(test_climate.key)
assert initial is not None, "No initial climate state received"
assert isinstance(initial, aioesphomeapi.ClimateState)
# Nothing has been published yet: settable fields have no sensor to seed them from, so
# the entity starts at ESPHome's plain defaults. current_temperature is pushed by the
# referenced sensor, which has already settled by the time we get here.
assert initial.mode == ClimateMode.OFF
assert initial.current_temperature == pytest.approx(21.0, abs=0.1)
# The device reports its actual state for the first time.
client.button_command(report_button.key)
state = await wait_for_climate_state()
assert state.mode == ClimateMode.HEAT_COOL
assert state.target_temperature_low == pytest.approx(18.0, abs=0.1)
assert state.target_temperature_high == pytest.approx(24.0, abs=0.1)
assert state.target_humidity == pytest.approx(50.0, abs=0.1)
# Commands apply optimistically (settable fields are plain internal state), and on_control
# fires with the same values so a real config could forward them to the device.
client.climate_command(
test_climate.key, target_temperature_low=19.0, target_temperature_high=25.0
)
state = await wait_for_climate_state()
assert state.target_temperature_low == pytest.approx(19.0, abs=0.1)
assert state.target_temperature_high == pytest.approx(25.0, abs=0.1)
await asyncio.sleep(0.2)
assert any(
"on_control target_temperature_low=19.0" in line for line in log_lines
)
assert any(
"on_control target_temperature_high=25.0" in line for line in log_lines
)
client.climate_command(test_climate.key, target_humidity=45.0)
state = await wait_for_climate_state()
assert state.target_humidity == pytest.approx(45.0, abs=0.1)
await asyncio.sleep(0.2)
assert any("on_control target_humidity=45.0" in line for line in log_lines)
# The device's next report is authoritative and overrides whatever was optimistically
# applied above -- this is the whole point of climate.template.publish: a device that owns
# its own state (e.g. changed by a physical remote) always wins.
client.button_command(report_button.key)
state = await wait_for_climate_state()
assert state.target_temperature_low == pytest.approx(18.0, abs=0.1)
assert state.target_temperature_high == pytest.approx(24.0, abs=0.1)
assert state.target_humidity == pytest.approx(50.0, abs=0.1)
+17 -17
View File
@@ -35,8 +35,8 @@ def _load_script():
def test_spec_key_collapses_destinations() -> None:
"""Two specs delivering one package share a directory and one key."""
mod = _load_script()
assert mod.spec_key("esphome/noise-c @ 0.1.24") == "noise-c"
assert mod.spec_key("esphome/noise-c@0.1.24") == "noise-c"
assert mod.spec_key("esphome/noise-c @ 0.1.21") == "noise-c"
assert mod.spec_key("esphome/noise-c@0.1.21") == "noise-c"
assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key(
"esp32async/asynctcp @ 3.5.0"
)
@@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None:
"[env:a]\n"
"platform = fake/platform@1\n"
"lib_deps =\n"
" esphome/noise-c @ 0.1.24\n"
" esphome/noise-c @ 0.1.21\n"
" ${common.lib_deps}\n"
" internal_lib\n"
"[env:b]\n"
"lib_deps =\n"
" esphome/noise-c @ 0.1.24\n"
" esphome/noise-c @ 0.1.21\n"
)
mod = _load_script()
args = Namespace(libraries=True, platforms=True, tools=False)
libs, platforms, tools = mod.parse_specs(str(ini), args)
# exact-string duplicates collapse; distinct version pins survive
assert libs == ["esphome/noise-c @ 0.1.24"]
assert libs == ["esphome/noise-c @ 0.1.21"]
assert platforms == ["fake/platform@1"]
assert tools == []
assert mod.build_cli_args(libs, platforms, tools) == [
"-l",
"esphome/noise-c @ 0.1.24",
"esphome/noise-c @ 0.1.21",
"-p",
"fake/platform@1",
]
@@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None:
mod.parallel_install(
cls,
[
"esphome/noise-c @ 0.1.24",
"esphome/noise-c @ 0.1.24",
"esphome/noise-c @ 0.1.21",
"esphome/noise-c @ 0.1.21",
"esphome/already @ 1.0",
"https://x/framework.tar.xz",
],
)
assert cls.calls == ["esphome/noise-c @ 0.1.24"]
assert cls.calls == ["esphome/noise-c @ 0.1.21"]
assert cls.lock_events == ["lock", "unlock"]
@@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
mod = _load_script()
cls = _reset_fake(str(tmp_path))
cls.deps = {
"esphome/noise-c @ 0.1.24": [
"esphome/noise-c @ 0.1.21": [
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
{"name": "SPI"},
],
@@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
],
}
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24", "esphome/wg @ 1.0"])
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21", "esphome/wg @ 1.0"])
assert len(cls.calls) == 3 # the shared dep installs exactly once
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"}
# Wave-1 strings carry no compatibility; the dependency wave does
compats = dict(cls.compat_calls)
assert compats["esphome/noise-c @ 0.1.24"] is None
assert compats["esphome/noise-c @ 0.1.21"] is None
dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k)
assert dep_compat is not None # mirrors pio's install_dependency
@@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None:
mod = _load_script()
cls = _reset_fake(str(tmp_path))
cls.deps = {
"esphome/noise-c @ 0.1.24": [
"esphome/noise-c @ 0.1.21": [
{"name": "vendored", "version": "https://github.com/x/y.git"},
],
}
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"])
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21"])
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"}
@@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None:
"""Already-installed top-level packages still feed the dependency
wave; a warm store can be missing a transitive dep."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.24"})
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.21"})
cls.deps = {
"esphome/noise-c @ 0.1.24": [
"esphome/noise-c @ 0.1.21": [
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
],
}
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"])
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21"])
assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"]
+2 -2
View File
@@ -1576,7 +1576,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None:
{"name": "SPI"},
]
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))])
pf._preinstall(m, [("noise-c@0.1.21", _FakeSpec(name="noise-c"))])
assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out
# The dep wave carries its compatibility so _install searches qualified
dep_call = m._install.call_args_list[-1]
@@ -1596,7 +1596,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None:
m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: (
installed.append(getattr(spec, "name", str(spec)))
)
pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))])
pf._preinstall(m, [("noise-c@0.1.21", _FakeSpec(name="noise-c"))])
assert installed == ["noise-c"]