Compare commits

..
32 changed files with 353 additions and 583 deletions
+2 -3
View File
@@ -338,9 +338,8 @@ class ApplyField:
Double a literal brace in a template. ``conf_key`` may be a path into nested sections.
``type_`` may be a C++ type string using ``{parent}`` when the type is only known per
instance. ``const_fn(config, value)`` renders a constant's argument text when ``cg.safe_exp``
is not the right spelling, or the value depends on another entity's config, such as the unit
a climate reports in; a lambda or an id bypasses it, so the target must also take a plain
``type_``. An absent key emits nothing.
is not the right spelling (unit conversion belongs in the validator); a lambda or an id
bypasses it, so the target must also take a plain ``type_``. An absent key emits nothing.
"""
conf_key: str | tuple[str, ...]
+20 -61
View File
@@ -46,15 +46,12 @@ from esphome.const import (
CONF_TARGET_TEMPERATURE_LOW_STATE_TOPIC,
CONF_TARGET_TEMPERATURE_STATE_TOPIC,
CONF_TEMPERATURE_STEP,
CONF_UNIT_OF_MEASUREMENT,
CONF_VISUAL,
CONF_WEB_SERVER,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
entity_temperature_unit,
entity_temperature_unit_of_id,
queue_entity_register,
setup_entity,
)
@@ -140,10 +137,13 @@ CONF_MIN_HUMIDITY = "min_humidity"
CONF_MAX_HUMIDITY = "max_humidity"
CONF_TARGET_HUMIDITY = "target_humidity"
visual_temperature = cv.float_with_unit("visual_temperature", "(°|(° ?)?[CKF])?")
VISUAL_TEMPERATURE_STEP_SCHEMA = cv.Schema(
{
cv.Required(CONF_TARGET_TEMPERATURE): cv.temperature_with_unit,
cv.Required(CONF_CURRENT_TEMPERATURE): cv.temperature_with_unit,
cv.Required(CONF_TARGET_TEMPERATURE): visual_temperature,
cv.Required(CONF_CURRENT_TEMPERATURE): visual_temperature,
}
)
@@ -154,30 +154,13 @@ def visual_temperature_step(value: Any) -> ConfigType:
return VISUAL_TEMPERATURE_STEP_SCHEMA(value)
# Otherwise, use the single value for both properties
value = cv.temperature_with_unit(value)
return {CONF_TARGET_TEMPERATURE: value, CONF_CURRENT_TEMPERATURE: value}
def temperature_in_entity_unit(
config: ConfigType, value: tuple[float, str | None]
) -> str:
"""Render a static action temperature in the unit of the climate the action targets."""
unit = entity_temperature_unit_of_id(config[CONF_ID])
return str(cg.safe_exp(cv.convert_temperature(*value, unit)))
def _visual_temperatures_in_entity_unit(config: ConfigType) -> ConfigType:
"""Convert the visual temperatures into the entity's unit."""
unit = entity_temperature_unit(config)
visual = config[CONF_VISUAL]
for key in (CONF_MIN_TEMPERATURE, CONF_MAX_TEMPERATURE):
if isinstance(value := visual.get(key), tuple):
visual[key] = cv.convert_temperature(*value, unit)
if (step := visual.get(CONF_TEMPERATURE_STEP)) is not None:
for key in (CONF_TARGET_TEMPERATURE, CONF_CURRENT_TEMPERATURE):
if isinstance(value := step[key], tuple):
step[key] = cv.convert_temperature(*value, unit, delta=True)
return config
value = visual_temperature(value)
return VISUAL_TEMPERATURE_STEP_SCHEMA(
{
CONF_TARGET_TEMPERATURE: value,
CONF_CURRENT_TEMPERATURE: value,
}
)
_CLIMATE_SCHEMA = (
@@ -188,8 +171,8 @@ _CLIMATE_SCHEMA = (
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTClimateComponent),
cv.Optional(CONF_VISUAL, default={}): cv.Schema(
{
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature_with_unit,
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature_with_unit,
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature,
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature,
cv.Optional(CONF_TEMPERATURE_STEP): visual_temperature_step,
cv.Optional(CONF_MIN_HUMIDITY): cv.percentage_int,
cv.Optional(CONF_MAX_HUMIDITY): cv.percentage_int,
@@ -266,7 +249,6 @@ _CLIMATE_SCHEMA = (
_CLIMATE_SCHEMA.add_extra(entity_duplicate_validator("climate"))
_CLIMATE_SCHEMA.add_extra(_visual_temperatures_in_entity_unit)
def climate_schema(
@@ -274,18 +256,10 @@ def climate_schema(
*,
entity_category: str = cv.UNDEFINED,
icon: str = cv.UNDEFINED,
temperature_unit: bool = False,
) -> cv.Schema:
"""Build the schema of a climate platform.
``temperature_unit`` adds ``unit_of_measurement`` for platforms whose temperatures are all
in the unit the user picks; the class then needs ``set_temperature_unit(TemperatureUnit)``.
"""
schema = {
cv.GenerateID(): cv.declare_id(class_),
}
if temperature_unit:
schema[cv.Optional(CONF_UNIT_OF_MEASUREMENT)] = cv.temperature_unit
for key, default, validator in [
(CONF_ENTITY_CATEGORY, entity_category, cv.entity_category),
@@ -486,13 +460,9 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(Climate),
cv.Optional(CONF_MODE): cv.templatable(validate_climate_mode),
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature_with_unit),
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(
cv.temperature_with_unit
),
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(
cv.temperature_with_unit
),
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_AWAY): cv.invalid("Use preset instead"),
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): cv.templatable(
@@ -512,23 +482,12 @@ automation.register_apply_action(
"climate.control",
CLIMATE_CONTROL_ACTION_SCHEMA,
automation.ApplyField(CONF_MODE, "set_mode", ClimateMode),
automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_),
automation.ApplyField(
CONF_TARGET_TEMPERATURE,
"set_target_temperature",
cg.float_,
temperature_in_entity_unit,
CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_
),
automation.ApplyField(
CONF_TARGET_TEMPERATURE_LOW,
"set_target_temperature_low",
cg.float_,
temperature_in_entity_unit,
),
automation.ApplyField(
CONF_TARGET_TEMPERATURE_HIGH,
"set_target_temperature_high",
cg.float_,
temperature_in_entity_unit,
CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_
),
automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_),
automation.ApplyField(CONF_FAN_MODE, "set_fan_mode", ClimateFanMode),
+53 -27
View File
@@ -5,6 +5,7 @@
#include "display_color_utils.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::display {
@@ -52,40 +53,65 @@ void Display::line_at_angle(int x, int y, int angle, int start_radius, int stop_
this->line(x1, y1, x2, y2, color);
}
void Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
size_t line_stride = x_offset + w + x_pad; // length of each source line in pixels
uint32_t color_value;
// flatten keeps to_color() inlined in the pixel loop; in PSRAM builds GCC at
// -Os stops inlining it once there are two call sites, which costs a call and
// spills per pixel. Without PSRAM there is one call site and it changes
// nothing, and draw_pixel_at() stays a virtual call either way.
template<ColorBitness BITNESS>
static void __attribute__((flatten))
draw_packed_pixels(Display *display, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
bool big_endian, int x_offset, int y_offset, int x_pad) {
const size_t line_stride = x_offset + w + x_pad; // length of each source line in pixels
// BITNESS is a constant here, so to_color()'s three per pixel divisions
// become a multiply and shift and its bitness switch folds.
const auto draw_source_pixel = [&](int x, int y, size_t source_idx) ESPHOME_ALWAYS_INLINE {
display->draw_pixel_at(
x + x_start, y + y_start,
ColorUtil::to_color(ColorUtil::read_packed<BITNESS>(ptr, source_idx, big_endian), order, BITNESS));
};
// The source and frame buffers are row-major, so walking rows keeps the read
// and the write sequential. When the display swaps the axes before writing,
// walking columns keeps the write sequential instead; with the frame buffer
// in PSRAM a strided write costs more than a strided read.
#ifdef USE_PSRAM
if (display->pixel_axes_swapped()) {
size_t column_idx = y_offset * line_stride + x_offset;
for (int x = 0; x != w; x++, column_idx++) {
size_t source_idx = column_idx;
for (int y = 0; y != h; y++, source_idx += line_stride) {
draw_source_pixel(x, y, source_idx);
}
}
return;
}
#endif
// Row walk: builds without PSRAM, and PSRAM builds whose axes are not swapped.
for (int y = 0; y != h; y++) {
size_t source_idx = (y_offset + y) * line_stride + x_offset;
size_t source_idx_mod;
for (int x = 0; x != w; x++, source_idx++) {
switch (bitness) {
default:
color_value = ptr[source_idx];
break;
case COLOR_BITNESS_565:
source_idx_mod = source_idx * 2;
if (big_endian) {
color_value = (ptr[source_idx_mod] << 8) + ptr[source_idx_mod + 1];
} else {
color_value = ptr[source_idx_mod] + (ptr[source_idx_mod + 1] << 8);
}
break;
case COLOR_BITNESS_888:
source_idx_mod = source_idx * 3;
if (big_endian) {
color_value = (ptr[source_idx_mod + 0] << 16) + (ptr[source_idx_mod + 1] << 8) + ptr[source_idx_mod + 2];
} else {
color_value = ptr[source_idx_mod + 0] + (ptr[source_idx_mod + 1] << 8) + (ptr[source_idx_mod + 2] << 16);
}
break;
}
this->draw_pixel_at(x + x_start, y + y_start, ColorUtil::to_color(color_value, order, bitness));
draw_source_pixel(x, y, source_idx);
}
}
}
void Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
switch (bitness) {
case COLOR_BITNESS_565:
draw_packed_pixels<COLOR_BITNESS_565>(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset,
x_pad);
break;
case COLOR_BITNESS_888:
draw_packed_pixels<COLOR_BITNESS_888>(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset,
x_pad);
break;
default:
draw_packed_pixels<COLOR_BITNESS_332>(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset,
x_pad);
break;
}
}
void HOT Display::horizontal_line(int x, int y, int width, Color color) {
// Future: Could be made more efficient by manipulating buffer directly in certain rotations.
for (int i = x; i < x + width; i++)
+11
View File
@@ -139,6 +139,11 @@ enum DisplayRotation {
DISPLAY_ROTATION_270_DEGREES = 270,
};
/// Whether a rotation swaps the x and y axes.
constexpr bool rotation_swaps_axes(DisplayRotation rotation) {
return rotation == DISPLAY_ROTATION_90_DEGREES || rotation == DISPLAY_ROTATION_270_DEGREES;
}
const int EDGES_TRIGON = 3;
const int EDGES_TRIANGLE = 3;
const int EDGES_TETRAGON = 4;
@@ -717,6 +722,12 @@ class Display : public PollingComponent {
virtual DisplayRotation get_rotation() const { return this->rotation_; }
/// True when draw_pixel_at() swaps x and y before writing, so pixels that are
/// neighbours along x land a row apart in the frame buffer. Not the same as
/// get_width()/get_height() swapping: a display that rotates in hardware
/// reports swapped dimensions but writes unswapped pixels.
virtual bool pixel_axes_swapped() const { return rotation_swaps_axes(this->rotation_); }
/** Get the type of display that the buffer corresponds to. In case of dynamically configurable displays,
* returns the type the display is currently configured to.
*/
@@ -2,6 +2,7 @@
#include <array>
#include <cstdint>
#include "esphome/core/color.h"
#include "esphome/core/helpers.h"
namespace esphome::display {
enum ColorOrder : uint8_t { COLOR_ORDER_RGB = 0, COLOR_ORDER_BGR = 1, COLOR_ORDER_GRB = 2 };
@@ -10,6 +11,27 @@ inline static uint8_t esp_scale(uint8_t i, uint8_t scale, uint8_t max_value = 25
class ColorUtil {
public:
/// Reads one packed pixel of the given bitness from a source buffer into the
/// value to_color() takes. BITNESS is a template argument so the byte layout
/// folds; pass it on to to_color() as a constant too, and its channel scaling
/// divides by a constant, which the compiler turns into a multiply and shift.
template<ColorBitness BITNESS>
static ESPHOME_ALWAYS_INLINE uint32_t read_packed(const uint8_t *ptr, size_t index, bool big_endian) {
if constexpr (BITNESS == COLOR_BITNESS_565) {
const size_t idx = index * 2;
if (big_endian)
return (ptr[idx] << 8) + ptr[idx + 1];
return ptr[idx] + (ptr[idx + 1] << 8);
} else if constexpr (BITNESS == COLOR_BITNESS_888) {
const size_t idx = index * 3;
if (big_endian)
return (ptr[idx + 0] << 16) + (ptr[idx + 1] << 8) + ptr[idx + 2];
return ptr[idx + 0] + (ptr[idx + 1] << 8) + (ptr[idx + 2] << 16);
} else {
return ptr[index];
}
}
static Color to_color(uint32_t colorcode, ColorOrder color_order,
ColorBitness color_bitness = ColorBitness::COLOR_BITNESS_888, bool right_bit_aligned = true) {
uint8_t first_color, second_color, third_color;
@@ -106,6 +106,7 @@ class EPaperBase : public Display,
int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; }
int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; }
bool pixel_axes_swapped() const override { return this->effective_transform_ & SWAP_XY; }
void draw_pixel_at(int x, int y, Color color) override;
void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override;
+1
View File
@@ -204,6 +204,7 @@ class IT8951Display : public Display,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override;
int get_width() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->height_ : this->width_; }
int get_height() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->width_ : this->height_; }
bool pixel_axes_swapped() const override { return this->effective_transform_ & TRANSFORM_SWAP_XY; }
protected:
int get_height_internal() override { return this->height_; }
+36 -38
View File
@@ -1,15 +1,42 @@
#include "lsm6ds.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
#include "esphome/core/progmem.h"
namespace esphome::lsm6ds {
ESPHOME_LOG_TAG(TAG, "lsm6ds");
static const struct {
uint8_t who_am_i;
const char *const name;
} CHIP_IDS[] = {{0x69, "LSMDSO"}, {0x6A, "LSM6DS3"}};
// Indexed by the accel range field, which is not in ascending order (datasheet Table 3)
PROGMEM_STRING_TABLE(AccelRangeStrings, "±2g", "±16g", "±4g", "±8g");
static const LogString *gyro_range_to_str(LSM6DSGyroRange range) {
switch (range) {
case LSM6DS_GYRO_RANGE_125:
return LOG_STR("±125dps");
case LSM6DS_GYRO_RANGE_250:
return LOG_STR("±250dps");
case LSM6DS_GYRO_RANGE_500:
return LOG_STR("±500dps");
case LSM6DS_GYRO_RANGE_1000:
return LOG_STR("±1000dps");
case LSM6DS_GYRO_RANGE_2000:
return LOG_STR("±2000dps");
default:
return LOG_STR("unknown");
}
}
static const LogString *chip_name_from_who_am_i(uint8_t who_am_i) {
switch (who_am_i) {
case 0x69:
return LOG_STR("LSMDSO");
case 0x6A:
return LOG_STR("LSM6DS3");
default:
return nullptr;
}
}
void LSM6DSComponent::setup() {
MotionComponent::setup();
@@ -19,19 +46,13 @@ void LSM6DSComponent::setup() {
this->mark_failed();
return;
}
const char *chip_name = nullptr;
for (const auto &chip : CHIP_IDS) {
if (chip.who_am_i == who_am_i) {
chip_name = chip.name;
break;
}
}
const LogString *chip_name = chip_name_from_who_am_i(who_am_i);
if (chip_name == nullptr) {
ESP_LOGE(TAG, "Unknown WHO_AM_I: 0x%02X", who_am_i);
this->mark_failed(LOG_STR("Unknown WHO_AM_I value"));
return;
}
ESP_LOGD(TAG, "Found %s (WHO_AM_I = 0x%02X)", chip_name, who_am_i);
ESP_LOGD(TAG, "Found %s (WHO_AM_I = 0x%02X)", LOG_STR_ARG(chip_name), who_am_i);
this->chip_name_ = chip_name;
// 2. Software reset — clears all registers to defaults
@@ -85,34 +106,10 @@ void LSM6DSComponent::dump_config() {
ESP_LOGCONFIG(TAG,
"LSM6DS IMU:\n"
" Chip type: %s\n",
this->chip_name_);
LOG_STR_ARG(this->chip_name_ != nullptr ? this->chip_name_ : LOG_STR("Unknown")));
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
// Accel range — index into the sensitivity table (datasheet Table 3)
static const char *const ACCEL_RANGE_STR[] = {"±2g", "±16g", "±4g", "±8g"};
const char *gyro_str;
switch (this->gyro_range_) {
case LSM6DS_GYRO_RANGE_125:
gyro_str = "±125dps";
break;
case LSM6DS_GYRO_RANGE_250:
gyro_str = "±250dps";
break;
case LSM6DS_GYRO_RANGE_500:
gyro_str = "±500dps";
break;
case LSM6DS_GYRO_RANGE_1000:
gyro_str = "±1000dps";
break;
case LSM6DS_GYRO_RANGE_2000:
gyro_str = "±2000dps";
break;
default:
gyro_str = "unknown";
break;
}
auto accel_odr = this->accel_odr_ == 0 ? 0 : 13 * (1 << (this->accel_odr_ - 1));
auto gyro_odr = this->gyro_odr_ == 0 ? 0 : 13 * (1 << (this->gyro_odr_ - 1));
ESP_LOGCONFIG(TAG,
@@ -120,7 +117,8 @@ void LSM6DSComponent::dump_config() {
" Accel data rate : %dHz\n"
" Gyro range : %s\n"
" Gyro data rate : %dHz",
ACCEL_RANGE_STR[this->accel_range_], accel_odr, gyro_str, gyro_odr);
LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_, AccelRangeStrings::LAST_INDEX)),
accel_odr, LOG_STR_ARG(gyro_range_to_str(this->gyro_range_)), gyro_odr);
}
// update_data()
+2 -1
View File
@@ -1,6 +1,7 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/log.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/motion/motion_component.h"
@@ -97,7 +98,7 @@ class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDev
template<typename F> void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward<F>(cb)); }
protected:
const char *chip_name_{"Unknown"};
const LogString *chip_name_{nullptr};
bool update_data(motion::MotionData &data) override;
LSM6DSAccelRange accel_range_{LSM6DS_ACCEL_RANGE_4G};
+5 -13
View File
@@ -130,19 +130,8 @@ class MipiSpi : public display::Display,
}
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; }
int get_width() override {
if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES ||
this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES)
return HEIGHT;
return WIDTH;
}
int get_height() override {
if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES ||
this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES)
return WIDTH;
return HEIGHT;
}
int get_width() override { return display::rotation_swaps_axes(this->rotation_) ? HEIGHT : WIDTH; }
int get_height() override { return display::rotation_swaps_axes(this->rotation_) ? WIDTH : HEIGHT; }
void set_init_sequence(const uint8_t *sequence, size_t len) {
this->init_sequence_ = sequence;
@@ -621,6 +610,9 @@ class MipiSpiBuffer
#endif
}
// With hardware rotation the buffer is written in drawn coordinates.
bool pixel_axes_swapped() const override { return !HAS_HARDWARE_ROTATION && display::Display::pixel_axes_swapped(); }
// Draw a pixel at the given coordinates.
void draw_pixel_at(int x, int y, Color color) override {
if (this->is_point_clipped(x, y))
+12 -50
View File
@@ -27,10 +27,7 @@ from esphome.const import (
CONF_TARGET_TEMPERATURE,
CONF_TARGET_TEMPERATURE_HIGH,
CONF_TARGET_TEMPERATURE_LOW,
CONF_UNIT_OF_MEASUREMENT,
)
from esphome.core.entity_helpers import entity_temperature_unit
from esphome.cpp_types import TEMPERATURE_UNITS
from esphome.types import ConfigType
from .. import template_ns
@@ -165,13 +162,6 @@ def _resolve_supports(config: ConfigType) -> ConfigType:
return config
TEMPERATURE_KEYS = (
CONF_TARGET_TEMPERATURE,
CONF_TARGET_TEMPERATURE_LOW,
CONF_TARGET_TEMPERATURE_HIGH,
)
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.
@@ -201,10 +191,6 @@ def _validate_initial_state(config: ConfigType) -> ConfigType:
f"'{CONF_TARGET_HUMIDITY}' requires '{CONF_SUPPORTS_TARGET_HUMIDITY}' to be enabled",
path=[CONF_INITIAL_STATE, CONF_TARGET_HUMIDITY],
)
unit = entity_temperature_unit(config)
for key in TEMPERATURE_KEYS:
if isinstance(value := initial_state.get(key), tuple):
initial_state[key] = cv.convert_temperature(*value, unit)
return config
@@ -214,9 +200,9 @@ INITIAL_STATE_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_MODE): climate.validate_climate_mode,
cv.Optional(CONF_TARGET_TEMPERATURE): cv.temperature_with_unit,
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature_with_unit,
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature_with_unit,
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(
@@ -231,7 +217,7 @@ INITIAL_STATE_SCHEMA = cv.All(
)
CONFIG_SCHEMA = cv.All(
climate.climate_schema(TemplateClimate, temperature_unit=True)
climate.climate_schema(TemplateClimate)
.extend(
{
cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor),
@@ -309,8 +295,6 @@ 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 (unit := config.get(CONF_UNIT_OF_MEASUREMENT)) is not None:
cg.add(var.set_temperature_unit(TEMPERATURE_UNITS[unit]))
if (sens := config.get(CONF_SENSOR)) is not None:
cg.add(var.set_sensor(await cg.get_variable(sens)))
@@ -397,19 +381,11 @@ CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.use_id(TemplateClimate),
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(
cv.temperature_with_unit
),
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_with_unit
),
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(
cv.temperature_with_unit
),
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(
cv.temperature_with_unit
),
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),
@@ -438,29 +414,15 @@ automation.register_apply_action(
"climate.template.publish",
CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA,
automation.ApplyField(
CONF_CURRENT_TEMPERATURE,
"current_temperature = {}",
cg.float_,
climate.temperature_in_entity_unit,
CONF_CURRENT_TEMPERATURE, "current_temperature = {}", cg.float_
),
automation.ApplyField(CONF_CURRENT_HUMIDITY, "current_humidity = {}", cg.float_),
automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_),
automation.ApplyField(
CONF_TARGET_TEMPERATURE,
"set_target_temperature",
cg.float_,
climate.temperature_in_entity_unit,
CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_
),
automation.ApplyField(
CONF_TARGET_TEMPERATURE_LOW,
"set_target_temperature_low",
cg.float_,
climate.temperature_in_entity_unit,
),
automation.ApplyField(
CONF_TARGET_TEMPERATURE_HIGH,
"set_target_temperature_high",
cg.float_,
climate.temperature_in_entity_unit,
CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_
),
automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_),
automation.ApplyField(CONF_MODE, "set_mode", climate.ClimateMode),
@@ -22,7 +22,6 @@ class TemplateClimate final : public climate::Climate, public Component {
climate::ClimateTraits traits() override { return this->traits_; }
void add_feature_flags(uint32_t flags) { this->traits_.add_feature_flags(flags); }
void set_temperature_unit(TemperatureUnit unit) { this->traits_.set_temperature_unit(unit); }
#ifdef USE_SENSOR
// The matching feature flag is added from codegen, so the configuration alone decides it.
@@ -12,12 +12,9 @@ from esphome.const import (
CONF_SET_ACTION,
CONF_SUPPORTED_MODES,
CONF_TARGET_TEMPERATURE,
CONF_UNIT_OF_MEASUREMENT,
)
from esphome.core import ID
from esphome.core.entity_helpers import entity_temperature_unit_of_id
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.cpp_types import TEMPERATURE_UNITS
from esphome.types import ConfigType
from .. import template_ns
@@ -42,7 +39,7 @@ RESTORE_MODES = {
}
CONFIG_SCHEMA = (
water_heater.water_heater_schema(TemplateWaterHeater, temperature_unit=True)
water_heater.water_heater_schema(TemplateWaterHeater)
.extend(
{
cv.Optional(CONF_OPTIMISTIC, default=True): cv.boolean,
@@ -68,8 +65,6 @@ async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await water_heater.register_water_heater(var, config)
if (unit := config.get(CONF_UNIT_OF_MEASUREMENT)) is not None:
cg.add(var.set_temperature_unit(TEMPERATURE_UNITS[unit]))
cg.add(var.set_optimistic(config[CONF_OPTIMISTIC]))
@@ -130,12 +125,8 @@ async def to_code(config: ConfigType) -> None:
cv.Schema(
{
cv.GenerateID(): cv.use_id(TemplateWaterHeater),
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(
cv.temperature_with_unit
),
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(
cv.temperature_with_unit
),
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
cv.Optional(CONF_MODE): cv.templatable(
water_heater.validate_water_heater_mode
),
@@ -154,17 +145,13 @@ async def water_heater_template_publish_to_code(
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
# A static temperature is in the unit of the water heater it is published to
unit = entity_temperature_unit_of_id(config[CONF_ID])
for key, setter in (
(CONF_CURRENT_TEMPERATURE, var.set_current_temperature),
(CONF_TARGET_TEMPERATURE, var.set_target_temperature),
):
if (value := config.get(key)) is None:
continue
if isinstance(value, tuple):
value = cv.convert_temperature(*value, unit)
cg.add(setter(await cg.templatable(value, args, cg.float_)))
if current_temp := config.get(CONF_CURRENT_TEMPERATURE):
template_ = await cg.templatable(current_temp, args, cg.float_)
cg.add(var.set_current_temperature(template_))
if target_temp := config.get(CONF_TARGET_TEMPERATURE):
template_ = await cg.templatable(target_temp, args, cg.float_)
cg.add(var.set_target_temperature(template_))
if mode := config.get(CONF_MODE):
template_ = await cg.templatable(mode, args, water_heater.WaterHeaterMode)
@@ -32,7 +32,6 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() {
}
traits.set_supports_current_temperature(true);
traits.set_temperature_unit(this->temperature_unit_);
if (this->target_temperature_f_.has_value()) {
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE);
}
@@ -29,7 +29,6 @@ class TemplateWaterHeater final : public Component, public water_heater::WaterHe
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
void set_restore_mode(TemplateWaterHeaterRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
void set_temperature_unit(TemperatureUnit unit) { this->temperature_unit_ = unit; }
void set_supported_modes(const std::initializer_list<water_heater::WaterHeaterMode> &modes) {
this->supported_modes_ = modes;
}
@@ -55,7 +54,6 @@ class TemplateWaterHeater final : public Component, public water_heater::WaterHe
TemplateLambda<bool> away_f_;
TemplateLambda<bool> is_on_f_;
TemplateWaterHeaterRestoreMode restore_mode_{WATER_HEATER_NO_RESTORE};
TemperatureUnit temperature_unit_{TemperatureUnit::CELSIUS};
water_heater::WaterHeaterModeMask supported_modes_;
bool optimistic_{true};
};
+3 -29
View File
@@ -6,13 +6,11 @@ from esphome.const import (
CONF_ID,
CONF_MAX_TEMPERATURE,
CONF_MIN_TEMPERATURE,
CONF_UNIT_OF_MEASUREMENT,
CONF_VISUAL,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
entity_temperature_unit,
queue_entity_register,
setup_entity,
)
@@ -43,35 +41,19 @@ WATER_HEATER_MODES = {
}
validate_water_heater_mode = cv.enum(WATER_HEATER_MODES, upper=True)
def _visual_temperatures_in_entity_unit(config: ConfigType) -> ConfigType:
"""Convert the visual temperatures into the entity's unit."""
unit = entity_temperature_unit(config)
visual = config[CONF_VISUAL]
for key in (CONF_MIN_TEMPERATURE, CONF_MAX_TEMPERATURE):
if isinstance(value := visual.get(key), tuple):
visual[key] = cv.convert_temperature(*value, unit)
if isinstance(value := visual.get(CONF_TARGET_TEMPERATURE_STEP), tuple):
visual[CONF_TARGET_TEMPERATURE_STEP] = cv.convert_temperature(
*value, unit, delta=True
)
return config
_WATER_HEATER_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend(
{
cv.Optional(CONF_VISUAL, default={}): cv.Schema(
{
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature_with_unit,
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature_with_unit,
cv.Optional(CONF_TARGET_TEMPERATURE_STEP): cv.temperature_with_unit,
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature,
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature,
cv.Optional(CONF_TARGET_TEMPERATURE_STEP): cv.float_,
}
),
}
)
_WATER_HEATER_SCHEMA.add_extra(entity_duplicate_validator("water_heater"))
_WATER_HEATER_SCHEMA.add_extra(_visual_temperatures_in_entity_unit)
def water_heater_schema(
@@ -79,16 +61,8 @@ def water_heater_schema(
*,
icon: str = cv.UNDEFINED,
entity_category: str = cv.UNDEFINED,
temperature_unit: bool = False,
) -> cv.Schema:
"""Build the schema of a water heater platform.
``temperature_unit`` adds ``unit_of_measurement`` for platforms whose temperatures are all
in the unit the user picks; the class then needs ``set_temperature_unit(TemperatureUnit)``.
"""
schema = {cv.GenerateID(): cv.declare_id(class_)}
if temperature_unit:
schema[cv.Optional(CONF_UNIT_OF_MEASUREMENT)] = cv.temperature_unit
for key, default, validator in [
(CONF_ICON, icon, cv.icon),
+39 -64
View File
@@ -77,9 +77,6 @@ from esphome.const import (
SCHEDULER_DONT_RUN,
TYPE_GIT,
TYPE_LOCAL,
UNIT_CELSIUS,
UNIT_FAHRENHEIT,
UNIT_KELVIN,
Framework,
Toolchain,
__version__ as ESPHOME_VERSION,
@@ -1419,10 +1416,7 @@ def float_with_unit(quantity, regex_suffix, optional_unit=False):
if match is None:
raise Invalid(f"Expected {quantity} with unit, got {value}")
try:
mantissa = float(match.group(1))
except ValueError as e:
raise Invalid(f"Expected {quantity} with unit, got {value}") from e
mantissa = float(match.group(1))
if match.group(2) not in METRIC_SUFFIXES:
raise Invalid(f"Invalid {quantity} suffix {match.group(2)}")
@@ -1441,72 +1435,53 @@ distance = float_with_unit("distance", "(m)")
framerate = float_with_unit("framerate", "(FPS|fps|Fps|FpS|Hz)")
angle = float_with_unit("angle", "(°|deg)", optional_unit=True)
_temperature_c = float_with_unit("temperature", "(°C|° C|C|°)?")
_temperature_c_strict = float_with_unit("temperature", "(°C|° C|C)")
_temperature_k = float_with_unit("temperature", "(°K|° K|K)")
_temperature_f = float_with_unit("temperature", "(°F|° F|F)")
_temperature_k = float_with_unit("temperature", "(°K|° K|K)?")
_temperature_f = float_with_unit("temperature", "(°F|° F|F)?")
decibel = float_with_unit("decibel", "(dB|dBm|db|dbm)", optional_unit=True)
pressure = float_with_unit("pressure", "(bar|Bar)", optional_unit=True)
def temperature_unit(value: Any) -> str:
"""Validate the unit an entity reports its temperatures in."""
return one_of(UNIT_CELSIUS, UNIT_FAHRENHEIT, UNIT_KELVIN)(value)
def temperature(value):
err = None
try:
return _temperature_c(value)
except Invalid as orig_err:
err = orig_err
try:
kelvin = _temperature_k(value)
return kelvin - 273.15
except Invalid:
pass
try:
fahrenheit = _temperature_f(value)
return (fahrenheit - 32) * (5 / 9)
except Invalid:
pass
raise err
def temperature_with_unit(value: Any) -> tuple[float, str | None]:
"""Parse a temperature and keep the unit written with it; the unit is None when there is none."""
for validator, unit in (
(_temperature_k, UNIT_KELVIN),
(_temperature_f, UNIT_FAHRENHEIT),
(_temperature_c_strict, UNIT_CELSIUS),
):
try:
return validator(value), unit
except Invalid:
pass
return _temperature_c(value), None
def temperature_delta(value):
err = None
try:
return _temperature_c(value)
except Invalid as orig_err:
err = orig_err
try:
return _temperature_k(value)
except Invalid:
pass
def _temperature_to_celsius(value: float, unit: str, delta: bool) -> float:
if unit == UNIT_FAHRENHEIT:
return value * (5 / 9) if delta else (value - 32) * (5 / 9)
if unit == UNIT_KELVIN:
return value if delta else value - 273.15
if unit != UNIT_CELSIUS:
raise ValueError(f"Unknown temperature unit {unit!r}")
return value
try:
fahrenheit = _temperature_f(value)
return fahrenheit * (5 / 9)
except Invalid:
pass
def _temperature_from_celsius(value: float, unit: str, delta: bool) -> float:
if unit == UNIT_FAHRENHEIT:
return value * (9 / 5) if delta else value * (9 / 5) + 32
if unit == UNIT_KELVIN:
return value if delta else value + 273.15
return value
def convert_temperature(
value: float, unit: str | None, to_unit: str, *, delta: bool = False
) -> float:
"""Convert a temperature, or with ``delta`` a temperature difference, between units.
A value written without a unit is taken to be in ``to_unit`` already.
"""
if unit is None or unit == to_unit:
return value
return _temperature_from_celsius(
_temperature_to_celsius(value, unit, delta), to_unit, delta
)
def temperature(value: Any) -> float:
"""Parse a temperature in °C, K or °F into degrees Celsius."""
return convert_temperature(*temperature_with_unit(value), UNIT_CELSIUS)
def temperature_delta(value: Any) -> float:
"""Parse a temperature difference in °C, K or °F into Celsius degrees."""
return convert_temperature(*temperature_with_unit(value), UNIT_CELSIUS, delta=True)
raise err
_color_temperature_mireds = float_with_unit("Color Temperature", r"(mireds|Mireds)")
-1
View File
@@ -1260,7 +1260,6 @@ UNIT_DECIBEL_MILLIWATT = "dBm"
UNIT_DEGREE_PER_SECOND = "°/s"
UNIT_DEGREES = "°"
UNIT_EMPTY = ""
UNIT_FAHRENHEIT = "°F"
UNIT_G = "G"
UNIT_GRAMS_PER_CUBIC_METER = "g/m³"
UNIT_HECTOPASCAL = "hPa"
+1 -15
View File
@@ -16,7 +16,6 @@ from esphome.const import (
CONF_INTERNAL,
CONF_NAME,
CONF_UNIT_OF_MEASUREMENT,
UNIT_CELSIUS,
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.config import (
@@ -25,7 +24,7 @@ from esphome.core.config import (
UNIT_OF_MEASUREMENT_MAX_LENGTH,
)
from esphome.cpp_generator import MockObj, RawStatement, add, get_variable
from esphome.cpp_types import TEMPERATURE_UNITS, App
from esphome.cpp_types import App
import esphome.final_validate as fv
from esphome.helpers import (
cpp_string_escape,
@@ -551,19 +550,6 @@ def inherit_property_from(property_to_inherit, parent_id_property, transform=Non
return inherit_property
def entity_temperature_unit(config: ConfigType) -> str:
"""The unit a climate or water heater reports in: ``unit_of_measurement`` when the platform took
the option through its schema factory, else Celsius (anova has an unrelated option of that name)."""
unit = config.get(CONF_UNIT_OF_MEASUREMENT)
return unit if unit in TEMPERATURE_UNITS else UNIT_CELSIUS
def entity_temperature_unit_of_id(entity_id: ID) -> str:
"""The unit of the entity with this id, for actions that take static temperatures."""
path = CORE.config.get_path_for_id(entity_id)[:-1]
return entity_temperature_unit(CORE.config.get_config_for_path(path))
def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigType]:
"""Create a validator function to check for duplicate entity names.
-7
View File
@@ -1,4 +1,3 @@
from esphome.const import UNIT_CELSIUS, UNIT_FAHRENHEIT, UNIT_KELVIN
from esphome.cpp_generator import MockObj
global_ns = MockObj("", "")
@@ -43,12 +42,6 @@ InternalGPIOPin = esphome_ns.class_("InternalGPIOPin", GPIOPin)
gpio_ns = esphome_ns.namespace("gpio")
gpio_Flags = gpio_ns.enum("Flags", is_class=True)
EntityCategory = esphome_ns.enum("EntityCategory")
TemperatureUnit = esphome_ns.enum("TemperatureUnit", is_class=True)
TEMPERATURE_UNITS = {
UNIT_CELSIUS: TemperatureUnit.CELSIUS,
UNIT_FAHRENHEIT: TemperatureUnit.FAHRENHEIT,
UNIT_KELVIN: TemperatureUnit.KELVIN,
}
Parented = esphome_ns.class_("Parented")
ESPTime = esphome_ns.struct("ESPTime")
StringRef = esphome_ns.class_("StringRef")
@@ -1,81 +0,0 @@
"""Tests for the climate visual temperature conversion."""
import pytest
from esphome.components.climate import (
_visual_temperatures_in_entity_unit,
visual_temperature_step,
)
from esphome.const import (
CONF_CURRENT_TEMPERATURE,
CONF_MAX_TEMPERATURE,
CONF_MIN_TEMPERATURE,
CONF_TARGET_TEMPERATURE,
CONF_TEMPERATURE_STEP,
CONF_UNIT_OF_MEASUREMENT,
CONF_VISUAL,
UNIT_CELSIUS,
UNIT_FAHRENHEIT,
)
from esphome.core.entity_helpers import entity_temperature_unit
def test_visual_temperature_step_scalar_applies_to_both() -> None:
step = visual_temperature_step("1°F")
assert step == {
CONF_TARGET_TEMPERATURE: (1.0, UNIT_FAHRENHEIT),
CONF_CURRENT_TEMPERATURE: (1.0, UNIT_FAHRENHEIT),
}
def test_visual_temperatures_default_to_celsius() -> None:
config = _visual_temperatures_in_entity_unit(
{
CONF_VISUAL: {
CONF_MIN_TEMPERATURE: (50.0, UNIT_FAHRENHEIT),
CONF_MAX_TEMPERATURE: (30.0, None),
CONF_TEMPERATURE_STEP: visual_temperature_step("1.8°F"),
}
}
)
visual = config[CONF_VISUAL]
assert visual[CONF_MIN_TEMPERATURE] == pytest.approx(10.0)
assert visual[CONF_MAX_TEMPERATURE] == 30.0
assert visual[CONF_TEMPERATURE_STEP][CONF_TARGET_TEMPERATURE] == pytest.approx(1.0)
def test_visual_temperatures_follow_unit_of_measurement() -> None:
config = _visual_temperatures_in_entity_unit(
{
CONF_UNIT_OF_MEASUREMENT: UNIT_FAHRENHEIT,
CONF_VISUAL: {
CONF_MIN_TEMPERATURE: (60.0, None),
CONF_MAX_TEMPERATURE: (30.0, UNIT_CELSIUS),
CONF_TEMPERATURE_STEP: {
CONF_TARGET_TEMPERATURE: (1.0, UNIT_FAHRENHEIT),
CONF_CURRENT_TEMPERATURE: (0.5, UNIT_CELSIUS),
},
},
}
)
visual = config[CONF_VISUAL]
assert visual[CONF_MIN_TEMPERATURE] == 60.0
assert visual[CONF_MAX_TEMPERATURE] == pytest.approx(86.0)
assert visual[CONF_TEMPERATURE_STEP][CONF_TARGET_TEMPERATURE] == 1.0
assert visual[CONF_TEMPERATURE_STEP][CONF_CURRENT_TEMPERATURE] == pytest.approx(0.9)
def test_platform_with_its_own_unit_option_keeps_celsius() -> None:
# anova validates unit_of_measurement as c|f for the device, not for the entity
assert entity_temperature_unit({CONF_UNIT_OF_MEASUREMENT: "f"}) == UNIT_CELSIUS
assert (
entity_temperature_unit({CONF_UNIT_OF_MEASUREMENT: UNIT_FAHRENHEIT})
== UNIT_FAHRENHEIT
)
def test_visual_temperatures_converted_once() -> None:
config = {CONF_VISUAL: {CONF_MIN_TEMPERATURE: (50.0, UNIT_FAHRENHEIT)}}
_visual_temperatures_in_entity_unit(config)
_visual_temperatures_in_entity_unit(config)
assert config[CONF_VISUAL][CONF_MIN_TEMPERATURE] == pytest.approx(10.0)
@@ -24,8 +24,6 @@ from esphome.const import (
CONF_TARGET_TEMPERATURE,
CONF_TARGET_TEMPERATURE_HIGH,
CONF_TARGET_TEMPERATURE_LOW,
CONF_UNIT_OF_MEASUREMENT,
UNIT_FAHRENHEIT,
)
from esphome.types import ConfigType
@@ -145,14 +143,3 @@ def test_initial_state_matching_two_point_accepted() -> None:
},
}
assert _validate_initial_state(config) is config
def test_initial_state_temperature_follows_unit_of_measurement() -> None:
config: ConfigType = {
CONF_UNIT_OF_MEASUREMENT: UNIT_FAHRENHEIT,
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False,
CONF_SUPPORTS_TARGET_HUMIDITY: False,
CONF_INITIAL_STATE: {CONF_TARGET_TEMPERATURE: (21.0, "°C")},
}
_validate_initial_state(config)
assert config[CONF_INITIAL_STATE][CONF_TARGET_TEMPERATURE] == pytest.approx(69.8)
@@ -1,49 +0,0 @@
"""Tests for the water heater visual temperature conversion."""
import pytest
from esphome.components.water_heater import (
CONF_TARGET_TEMPERATURE_STEP,
_visual_temperatures_in_entity_unit,
)
from esphome.const import (
CONF_MAX_TEMPERATURE,
CONF_MIN_TEMPERATURE,
CONF_UNIT_OF_MEASUREMENT,
CONF_VISUAL,
UNIT_CELSIUS,
UNIT_FAHRENHEIT,
)
def test_visual_temperatures_default_to_celsius() -> None:
config = _visual_temperatures_in_entity_unit(
{
CONF_VISUAL: {
CONF_MIN_TEMPERATURE: (100.0, UNIT_FAHRENHEIT),
CONF_MAX_TEMPERATURE: (85.0, None),
CONF_TARGET_TEMPERATURE_STEP: (1.8, UNIT_FAHRENHEIT),
}
}
)
visual = config[CONF_VISUAL]
assert visual[CONF_MIN_TEMPERATURE] == pytest.approx(37.78, abs=0.01)
assert visual[CONF_MAX_TEMPERATURE] == 85.0
assert visual[CONF_TARGET_TEMPERATURE_STEP] == pytest.approx(1.0)
def test_visual_temperatures_follow_unit_of_measurement() -> None:
config = _visual_temperatures_in_entity_unit(
{
CONF_UNIT_OF_MEASUREMENT: UNIT_FAHRENHEIT,
CONF_VISUAL: {
CONF_MIN_TEMPERATURE: (100.0, None),
CONF_MAX_TEMPERATURE: (60.0, UNIT_CELSIUS),
CONF_TARGET_TEMPERATURE_STEP: (1.0, UNIT_CELSIUS),
},
}
)
visual = config[CONF_VISUAL]
assert visual[CONF_MIN_TEMPERATURE] == 100.0
assert visual[CONF_MAX_TEMPERATURE] == pytest.approx(140.0)
assert visual[CONF_TARGET_TEMPERATURE_STEP] == pytest.approx(1.8)
+20
View File
@@ -0,0 +1,20 @@
from pathlib import Path
import esphome.codegen as cg
from esphome.const import CONF_ESPHOME, CONF_INCLUDES
from esphome.core import CORE
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
TESTS_DIR = Path(__file__).parent
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The column walk in Display::draw_pixels_at() is only built with PSRAM; define it
# when display's own tests are built so both walk orders run, and leave other
# components' test binaries on the plain build.
async def to_code_testing(config: ConfigType) -> None:
if any(Path(p) == TESTS_DIR for p in CORE.config[CONF_ESPHOME][CONF_INCLUDES]):
cg.add_define("USE_PSRAM")
manifest.to_code = to_code_testing
@@ -0,0 +1,109 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <initializer_list>
#include <tuple>
#include <vector>
#include "esphome/components/display/display.h"
namespace esphome::display::testing {
// Records every pixel write; pixel_axes_swapped() picks the walk order.
class RecordingDisplay : public Display {
public:
void set_axes_swapped(bool swapped) { this->axes_swapped_ = swapped; }
bool pixel_axes_swapped() const override { return this->axes_swapped_; }
void draw_pixel_at(int x, int y, Color color) override { this->pixels_.emplace_back(x, y, color.raw_32); }
void update() override {}
DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; }
int get_width_internal() override { return 64; }
int get_height_internal() override { return 64; }
std::vector<std::tuple<int, int, uint32_t>> sorted_pixels() const {
auto sorted = this->pixels_;
std::sort(sorted.begin(), sorted.end());
return sorted;
}
size_t count() const { return this->pixels_.size(); }
const std::vector<std::tuple<int, int, uint32_t>> &pixels() const { return this->pixels_; }
protected:
bool axes_swapped_{false};
std::vector<std::tuple<int, int, uint32_t>> pixels_;
};
// A 7x5 region with a 2 pixel left offset, a 3 line top offset and 4 pixels of
// right padding, so the source index arithmetic of both walks is exercised.
static constexpr int W = 7, H = 5, X_OFFSET = 2, Y_OFFSET = 3, X_PAD = 4;
static constexpr int LINE_STRIDE = X_OFFSET + W + X_PAD;
static constexpr int LINES = Y_OFFSET + H;
static std::vector<uint8_t> make_source(int bytes_per_pixel) {
std::vector<uint8_t> source(LINE_STRIDE * LINES * bytes_per_pixel);
uint8_t value = 1;
for (auto &byte : source)
byte = value++;
return source;
}
static void expect_walks_match(ColorBitness bitness, int bytes_per_pixel, bool big_endian) {
const auto source = make_source(bytes_per_pixel);
RecordingDisplay rows;
RecordingDisplay columns;
columns.set_axes_swapped(true);
rows.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD);
columns.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD);
EXPECT_EQ(rows.count(), static_cast<size_t>(W * H));
EXPECT_EQ(columns.count(), static_cast<size_t>(W * H));
EXPECT_EQ(rows.sorted_pixels(), columns.sorted_pixels());
}
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk332) { expect_walks_match(COLOR_BITNESS_332, 1, true); }
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565BigEndian) { expect_walks_match(COLOR_BITNESS_565, 2, true); }
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565LittleEndian) { expect_walks_match(COLOR_BITNESS_565, 2, false); }
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888BigEndian) { expect_walks_match(COLOR_BITNESS_888, 3, true); }
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888LittleEndian) { expect_walks_match(COLOR_BITNESS_888, 3, false); }
// One known pixel per bitness and byte order, so a decode that swapped bytes or channels
// could not pass the walk comparison above by being wrong the same way twice.
static void expect_golden(ColorBitness bitness, std::initializer_list<uint8_t> bytes, bool big_endian, Color expected) {
const std::vector<uint8_t> source(bytes);
RecordingDisplay display;
display.draw_pixels_at(3, 4, 1, 1, source.data(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0);
ASSERT_EQ(display.count(), 1u);
EXPECT_EQ(display.pixels()[0], std::make_tuple(3, 4, expected.raw_32));
}
TEST(DrawPixelsAt, Golden565BigEndian) {
expect_golden(COLOR_BITNESS_565, {0xE0, 0x07}, true, Color(230, 0, 57)); // 0xE007: r 28, g 0, b 7
}
TEST(DrawPixelsAt, Golden565LittleEndian) {
expect_golden(COLOR_BITNESS_565, {0xE0, 0x07}, false, Color(0, 255, 0)); // 0x07E0: pure green
}
TEST(DrawPixelsAt, Golden888BigEndian) {
expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, true, Color(0x10, 0x20, 0x30));
}
TEST(DrawPixelsAt, Golden888LittleEndian) {
expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, false, Color(0x30, 0x20, 0x10));
}
TEST(DrawPixelsAt, Golden332) {
expect_golden(COLOR_BITNESS_332, {0xE3}, true, Color(255, 0, 255)); // r 7 of 7, g 0, b 3 of 3
}
// The column walk really is a different order, otherwise the tests above prove nothing.
TEST(DrawPixelsAt, ColumnWalkChangesOrder) {
const auto source = make_source(1);
RecordingDisplay rows;
RecordingDisplay columns;
columns.set_axes_swapped(true);
rows.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, X_PAD);
columns.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET,
X_PAD);
ASSERT_GT(rows.count(), 1u);
ASSERT_GT(columns.count(), 1u);
EXPECT_EQ(std::get<0>(rows.pixels()[1]), 1); // the row walk steps x first
EXPECT_EQ(std::get<1>(columns.pixels()[1]), 1); // the column walk steps y first
}
} // namespace esphome::display::testing
@@ -41,17 +41,6 @@ esphome:
mode: !lambda "return climate::CLIMATE_MODE_COOL;"
target_temperature: !lambda "return 23.0f;"
- climate.template.publish:
id: template_climate_fahrenheit
current_temperature: 20°C
target_temperature: 70
- climate.control:
id: template_climate_fahrenheit
target_temperature: 22°C
- water_heater.template.publish:
id: template_water_heater_fahrenheit
current_temperature: 45°C
target_temperature: 120
- climate.template.publish:
id: template_climate_custom_modes
custom_fan_mode: "turbo"
@@ -603,25 +592,6 @@ alarm_control_panel:
- "1234"
climate:
# unit_of_measurement keeps the visual temperatures in that unit; a value written in
# another unit is converted into it
- platform: template
id: template_climate_fahrenheit
name: "Template Climate Fahrenheit"
unit_of_measurement: °F
optimistic: true
supported_modes:
- "OFF"
- HEAT
initial_state:
mode: HEAT
target_temperature: 21°C
visual:
min_temperature: 60
max_temperature: 30°C
temperature_step:
target_temperature: 1°F
current_temperature: 0.5°C
- platform: template
id: template_climate
name: "Template Climate"
@@ -714,17 +684,6 @@ climate:
temperature_step: 0.5
water_heater:
- platform: template
id: template_water_heater_fahrenheit
name: "Template Water Heater Fahrenheit"
unit_of_measurement: °F
optimistic: true
current_temperature: !lambda "return 110.0f;"
target_temperature: !lambda "return 120.0f;"
visual:
min_temperature: 100
max_temperature: 60°C
target_temperature_step: 1°C
- platform: template
id: template_water_heater
name: "Template Water Heater"
+6 -2
View File
@@ -2,11 +2,15 @@ water_heater:
- platform: template
id: my_boiler
name: "Test Boiler"
min_temperature: 10
max_temperature: 85
target_temperature_step: 0.5
current_temperature_step: 0.1
optimistic: true
current_temperature: !lambda "return 45.0f;"
target_temperature: !lambda "return 60.0f;"
current_temperature: 45.0
mode: !lambda "return water_heater::WATER_HEATER_MODE_ECO;"
visual:
min_temperature: 10
max_temperature: 85
target_temperature_step: 0.5
current_temperature_step: 0.1
@@ -1,2 +0,0 @@
packages:
water_heater: !include common.yaml
@@ -1,2 +0,0 @@
packages:
water_heater: !include common.yaml
@@ -51,9 +51,6 @@ from esphome.const import (
SCHEDULER_DONT_RUN,
TYPE_GIT,
TYPE_LOCAL,
UNIT_CELSIUS,
UNIT_FAHRENHEIT,
UNIT_KELVIN,
Framework,
Toolchain,
)
@@ -2148,60 +2145,6 @@ def test_temperature_delta_invalid() -> None:
cv.temperature_delta("5x")
@pytest.mark.parametrize(
("value", "expected"),
[
("100°F", (100.0, UNIT_FAHRENHEIT)),
("100 °F", (100.0, UNIT_FAHRENHEIT)),
("100F", (100.0, UNIT_FAHRENHEIT)),
("25°C", (25.0, UNIT_CELSIUS)),
("25C", (25.0, UNIT_CELSIUS)),
("300K", (300.0, UNIT_KELVIN)),
("300°K", (300.0, UNIT_KELVIN)),
("25", (25.0, None)),
("25°", (25.0, None)),
(25, (25.0, None)),
],
)
def test_temperature_with_unit(
value: str | int, expected: tuple[float, str | None]
) -> None:
assert cv.temperature_with_unit(value) == expected
@pytest.mark.parametrize("value", ["5x", "abc°F", None])
def test_temperature_with_unit__invalid(value: str | None) -> None:
with pytest.raises(Invalid):
cv.temperature_with_unit(value)
def test_temperature_unit() -> None:
assert cv.temperature_unit("°F") == UNIT_FAHRENHEIT
with pytest.raises(Invalid):
cv.temperature_unit("F")
@pytest.mark.parametrize(
("value", "unit", "to_unit", "delta", "expected"),
[
(60.0, None, UNIT_FAHRENHEIT, False, 60.0),
(30.0, UNIT_CELSIUS, UNIT_FAHRENHEIT, False, 86.0),
(212.0, UNIT_FAHRENHEIT, UNIT_CELSIUS, False, 100.0),
(300.0, UNIT_KELVIN, UNIT_FAHRENHEIT, False, 80.33),
(86.0, UNIT_FAHRENHEIT, UNIT_KELVIN, False, 303.15),
(0.5, UNIT_CELSIUS, UNIT_FAHRENHEIT, True, 0.9),
(9.0, UNIT_FAHRENHEIT, UNIT_CELSIUS, True, 5.0),
(1.0, UNIT_KELVIN, UNIT_CELSIUS, True, 1.0),
],
)
def test_convert_temperature(
value: float, unit: str | None, to_unit: str, delta: bool, expected: float
) -> None:
assert cv.convert_temperature(value, unit, to_unit, delta=delta) == pytest.approx(
expected
)
def test_color_temperature_mireds() -> None:
assert cv.color_temperature("153 mireds") == pytest.approx(153.0)