Compare commits

..
43 changed files with 567 additions and 3190 deletions

No files matched your search

+18 -11
View File
@@ -4,11 +4,12 @@ The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack
bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build
on this component and contain no SDK calls of their own.
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253 (BLE 5.2),
and any future BLE-5.x SoC. LibreTiny 1.14 or newer is required: earlier releases
have no BLE bonding partition, so the Beken SDK erases the bootloader flash sector
at boot on BK7238 (esphome#18646, libretiny-eu/libretiny#408). Known non-5.x
families are rejected in to_code. Unknown families are capability-checked at compile time via
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7252N/BK7253 (BLE 5.2),
and any future BLE-5.x SoC. BK7238 (BLE 5.2) is blocked for now: with BLE
compiled in, the Beken SDK erases the bootloader flash sector at boot because
LibreTiny's partition table has no BLE bonding entry (esphome#18646,
libretiny-eu/libretiny#408). Known non-5.x families and BK7238 are rejected in
to_code. Unknown families are capability-checked at compile time via
`__has_include("app_ble.h")`, a header only on the BLE 5.x include path
(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build
fails with a clear #error.
@@ -28,6 +29,7 @@ from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231Q,
FAMILY_BK7231T,
FAMILY_BK7238,
FAMILY_BK7251,
)
import esphome.config_validation as cv
@@ -52,9 +54,7 @@ BEKEN_BDK_VERSION_OPTION = "custom_versions.beken-bdk"
# bootloader with BLE compiled in; the libretiny platform builds with this one
MIN_BDK_VERSION = (3, 0, 78)
# Older LibreTiny releases arm the Beken MCU sleep with wifi power save, which halts
# the device once the station stops while BLE runs (libretiny-eu/libretiny#414), and
# have no BLE bond partition, so the BK7238 bond store erases flash sector 0 and
# bricks the chip (libretiny-eu/libretiny#408)
# the device once the station stops while BLE runs (libretiny-eu/libretiny#414)
MIN_LIBRETINY_VERSION = (1, 14, 0)
_LOGGER = logging.getLogger(__name__)
@@ -86,6 +86,14 @@ def _unsupported_family_message(family: str) -> str | None:
)
if family == FAMILY_BK7231Q:
return "bk72xx_ble does not support BK7231Q: this SoC has no BLE"
if family == FAMILY_BK7238:
return (
"bk72xx_ble is disabled on BK7238: with BLE compiled in, the Beken SDK "
"erases the bootloader flash sector at boot and the device can no longer "
"start (see https://github.com/esphome/esphome/issues/18646); support "
"returns once the LibreTiny partition table fix "
"(libretiny-eu/libretiny#408) is released"
)
return None
@@ -109,15 +117,14 @@ def check_bdk_version(value: str | None) -> None:
def check_libretiny_version(version: cv.Version) -> None:
"""Reject a LibreTiny release older than the one whose WiFi sleep and bond storage are safe with BLE."""
"""Reject a LibreTiny release older than the one whose WiFi sleep is safe with BLE."""
if (version.major, version.minor, version.patch) >= MIN_LIBRETINY_VERSION:
return
minimum = ".".join(str(part) for part in MIN_LIBRETINY_VERSION)
raise cv.Invalid(
f"bk72xx_ble needs LibreTiny {minimum} or newer, not {version}: older releases "
"arm the Beken MCU sleep with wifi power save and the device halts once the "
"station stops while BLE runs, and on BK7238 the bond store erases flash "
"sector 0 and bricks the chip"
"station stops while BLE runs"
)
+2 -13
View File
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import climate_ir, remote_base
from esphome.components import climate_ir
import esphome.config_validation as cv
from esphome.const import CONF_MODEL, CONF_USE_FAHRENHEIT
from esphome.types import ConfigType
@@ -13,16 +13,7 @@ GreeClimate = gree_ns.class_("GreeClimate", climate_ir.ClimateIR)
Model = gree_ns.enum("Model")
MODELS = Model.options(
"generic",
"yan",
"yaa",
"yac",
"yac1fb9",
"yb1fa",
"yx1ff",
"yag",
"yaw1f",
prefix="GREE_",
"generic", "yan", "yaa", "yac", "yac1fb9", "yx1ff", "yag", "yaw1f", prefix="GREE_"
)
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend(
@@ -41,5 +32,3 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_use_fahrenheit(True))
if config[CONF_MODEL] == "yaw1f":
cg.add_define("USE_GREE_YAW1F")
if remote_base.CONF_RECEIVER_ID in config:
cg.add_define("USE_GREE_RECEIVER")
+212 -535
View File
@@ -4,6 +4,10 @@
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/helpers.h"
#include <algorithm>
#include <array>
#include <utility>
namespace esphome::gree {
ESPHOME_LOG_TAG(TAG, "gree.climate");
@@ -11,27 +15,51 @@ ESPHOME_LOG_TAG(TAG, "gree.climate");
static constexpr remote_base::BitTimings GREE_BIT_TIMINGS{
.bit_mark = GREE_BIT_MARK, .one_space = GREE_ONE_SPACE, .zero_space = GREE_ZERO_SPACE};
static constexpr uint8_t GREE_SWING_AUTO_MASK = 0x40;
#ifdef USE_GREE_YAW1F
// The YAW1F second message is 00 00 00 A0 | 00 00 00 A0.
static constexpr uint8_t GREE_YAW1F_FOLLOWUP_BYTE = 0xA0;
void GreeClimate::set_model(Model model) {
if (model == GREE_YAN || model == GREE_YX1FF) {
// These models only expose vertical swing; horizontal swing is not encoded on the wire.
this->swing_modes_.erase(climate::CLIMATE_SWING_HORIZONTAL);
this->swing_modes_.erase(climate::CLIMATE_SWING_BOTH);
}
if (model == GREE_YX1FF) {
this->fan_modes_.insert(climate::CLIMATE_FAN_QUIET); // YX1FF 4 speed
}
// Models with a sleep preset
if (model == GREE_YX1FF || model == GREE_YAC1FB9) {
this->presets_.insert(climate::CLIMATE_PRESET_NONE);
this->presets_.insert(climate::CLIMATE_PRESET_SLEEP);
}
this->model_ = model;
}
void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) {
assign_bits(this->mode_bits_, bit_mask, enabled);
this->transmit_state();
}
#ifdef USE_GREE_SWITCH
void GreeClimate::setup() {
const std::array<std::pair<switch_::Switch *, uint8_t>, 4> switches{{{this->turbo_switch_, GREE_MODE_BIT_TURBO},
{this->light_switch_, GREE_MODE_BIT_LIGHT},
{this->health_switch_, GREE_MODE_BIT_HEALTH},
{this->xfan_switch_, GREE_MODE_BIT_XFAN}}};
// Seed the mode bits from the restored switch states; nothing is sent at boot, they ride along with the next frame
for (auto [sw, bit] : switches) {
if (sw == nullptr)
continue;
if (auto initial = sw->get_initial_write_state())
assign_bits(this->mode_bits_, bit, *initial);
}
ClimateIR::setup();
// Publish once the climate is restored, so a switch automation sees its state
for (auto [sw, bit] : switches) {
if (sw != nullptr)
sw->publish_state((this->mode_bits_ & bit) != 0);
}
}
#endif
// The mode, fan and feature mappings use if-else: a switch becomes a lookup table in RAM on ESP8266.
static constexpr uint8_t supported_feature_mask(Model model) {
if (model == GREE_YB1FA)
return GREE_MODE_BIT_TURBO | GREE_MODE_BIT_LIGHT | GREE_MODE_BIT_XFAN;
if (model == GREE_YX1FF)
return GREE_MODE_BIT_LIGHT;
if (model == GREE_GENERIC || model == GREE_YAG || model == GREE_YAW1F)
return 0;
return GREE_MODE_BIT_TURBO | GREE_MODE_BIT_LIGHT | GREE_MODE_BIT_HEALTH | GREE_MODE_BIT_XFAN;
}
static constexpr uint8_t default_feature_bits(Model model) {
return supported_feature_mask(model) & GREE_MODE_BIT_LIGHT;
}
#ifdef USE_GREE_FAHRENHEIT
uint8_t fahrenheit_half_steps(float target_celsius) {
if (!std::isfinite(target_celsius)) {
@@ -47,292 +75,190 @@ uint8_t fahrenheit_half_steps(float target_celsius) {
}
#endif
struct GreeFrameSpaces {
uint32_t header;
uint32_t message;
};
void GreeClimate::transmit_state() {
uint8_t remote_state[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00};
const Model model = this->model_;
// YAW1F uses the YAG byte layout with different framing (see below)
const bool yag_layout = model == GREE_YAG || model == GREE_YAW1F;
static GreeFrameSpaces frame_spaces(Model model) {
if (model == GREE_YAC1FB9 || model == GREE_YAW1F)
return {GREE_YAC1FB9_HEADER_SPACE, GREE_YAC1FB9_MESSAGE_SPACE};
return {GREE_HEADER_SPACE, GREE_MESSAGE_SPACE};
}
static void write_frame(remote_base::RemoteTransmitData *data, const GreeState &frame, const GreeFrameSpaces &spaces) {
data->item(GREE_HEADER_MARK, spaces.header);
data->item_bytes<GREE_BIT_TIMINGS>(frame.data(), 4);
// Fixed 010 between the two halves, then the message gap
data->item(GREE_BIT_MARK, GREE_ZERO_SPACE);
data->item(GREE_BIT_MARK, GREE_ONE_SPACE);
data->item(GREE_BIT_MARK, GREE_ZERO_SPACE);
data->item(GREE_BIT_MARK, spaces.message);
data->item_bytes<GREE_BIT_TIMINGS>(frame.data() + 4, 4);
data->mark(GREE_BIT_MARK);
}
#ifdef USE_GREE_RECEIVER
static constexpr uint8_t GREE_MODE_MASK = 0x07;
static constexpr uint8_t GREE_POWER_MASK = 0x08;
static constexpr uint8_t GREE_FAN_MASK = 0x30;
static constexpr uint8_t GREE_SLEEP_MASK = 0x80;
static constexpr uint8_t GREE_TEMP_MASK = 0x0F;
static constexpr uint8_t GREE_TIMER_HALF_HOUR_MASK = 0x10;
static constexpr uint8_t GREE_TIMER_TENS_HOUR_MASK = 0x60;
static constexpr uint8_t GREE_TIMER_HOURS_MASK = 0x0F;
static constexpr uint8_t GREE_BYTE5_FIXED_MASK = 0xB8;
static constexpr uint8_t GREE_BYTE5_FIXED_VALUE = 0x20;
static bool read_frame(remote_base::RemoteReceiveData &data, GreeState &frame, const GreeFrameSpaces &spaces) {
return data.expect_item(GREE_HEADER_MARK, spaces.header) && data.expect_bytes<GREE_BIT_TIMINGS>(frame.data(), 4) &&
data.expect_item(GREE_BIT_MARK, GREE_ZERO_SPACE) && data.expect_item(GREE_BIT_MARK, GREE_ONE_SPACE) &&
data.expect_item(GREE_BIT_MARK, GREE_ZERO_SPACE) && data.expect_item(GREE_BIT_MARK, spaces.message) &&
data.expect_bytes<GREE_BIT_TIMINGS>(frame.data() + 4, 4) && data.expect_mark(GREE_BIT_MARK);
}
#ifdef USE_GREE_YAW1F
static bool is_yaw1f_followup(const GreeState &state) {
return state[3] == GREE_YAW1F_FOLLOWUP_BYTE && state[7] == GREE_YAW1F_FOLLOWUP_BYTE &&
(state[0] | state[1] | state[2] | state[4] | state[5] | state[6]) == 0;
}
remote_state[0] = this->fan_speed_() | this->operation_mode_();
#ifdef USE_GREE_FAHRENHEIT
const uint8_t half_steps = this->use_fahrenheit_ ? fahrenheit_half_steps(this->target_temperature) : 0;
remote_state[1] = this->use_fahrenheit_ ? half_steps >> 1 : this->temperature_();
#else
remote_state[1] = this->temperature_();
#endif
// YAC sends a 0x70 follow-up frame and YAW1F a fixed second message; neither carries climate state.
static bool is_auxiliary_frame(Model model, const GreeState &state) {
#ifdef USE_GREE_YAW1F
if (model == GREE_YAW1F && is_yaw1f_followup(state))
return true;
#endif
return model == GREE_YAC && (state[3] & 0xF0) == 0x70;
}
static bool is_valid_timer(const GreeState &state) {
const uint8_t timer_hours = state[2] & GREE_TIMER_HOURS_MASK;
const uint8_t timer_tens_hours = (state[1] & GREE_TIMER_TENS_HOUR_MASK) >> 5;
const bool timer_half_hour = state[1] & GREE_TIMER_HALF_HOUR_MASK;
if (timer_hours > 9 || timer_tens_hours > 2)
return false;
if (timer_tens_hours < 2)
return true;
return timer_hours < 4 || (timer_hours == 4 && !timer_half_hour);
}
static bool is_valid_vertical_swing(bool automatic, uint8_t position) {
if (automatic) {
return position == GREE_VDIR_SWING || position == GREE_VDIR_SWING_DOWN || position == GREE_VDIR_SWING_MIDDLE ||
position == GREE_VDIR_SWING_UP;
if (model == GREE_YAN) {
remote_state[3] = 0x50; // bits 4..7 always 0101
remote_state[4] = this->vertical_swing_();
}
return position == GREE_VDIR_MANUAL || (position >= GREE_VDIR_UP && position <= GREE_VDIR_DOWN);
}
static bool is_valid_swing(Model model, const GreeState &state) {
const bool automatic = state[0] & GREE_SWING_AUTO_MASK;
const uint8_t vertical = state[4] & 0x0F;
const uint8_t horizontal = state[4] >> 4;
if (model == GREE_YX1FF || yag_layout) {
remote_state[2] = 0x60;
remote_state[3] = 0x50;
remote_state[4] = this->vertical_swing_();
if (!is_valid_vertical_swing(automatic, vertical))
return false;
if (model == GREE_YX1FF)
return horizontal == (automatic ? GREE_HDIR_SWING : GREE_HDIR_MANUAL);
return horizontal == GREE_HDIR_MANUAL;
}
if (model == GREE_YX1FF) {
// ModelA is set only when powered on.
if (this->mode == climate::CLIMATE_MODE_OFF)
remote_state[2] &= ~0x40;
// YX1FF needs the swing flag as well as its own vane-position value
if (this->vertical_swing_() == GREE_VDIR_SWING) {
remote_state[0] |= (1 << 6);
remote_state[4] = GREE_YX1FF_VANE_SWING;
}
}
}
static climate::ClimateMode decode_operation_mode(uint8_t mode) {
if (mode == GREE_MODE_COOL)
return climate::CLIMATE_MODE_COOL;
if (mode == GREE_MODE_DRY)
return climate::CLIMATE_MODE_DRY;
if (mode == GREE_MODE_FAN)
return climate::CLIMATE_MODE_FAN_ONLY;
if (mode == GREE_MODE_HEAT)
return climate::CLIMATE_MODE_HEAT;
return climate::CLIMATE_MODE_HEAT_COOL;
}
if (yag_layout) {
remote_state[5] = model == GREE_YAW1F ? 0x80 : 0x40;
if (this->vertical_swing_() == GREE_VDIR_SWING || this->horizontal_swing_() == GREE_HDIR_SWING) {
remote_state[0] |= (1 << 6);
}
}
if (model == GREE_YAC)
remote_state[4] = this->vertical_swing_();
if (model == GREE_YAC || yag_layout) {
remote_state[4] |= (this->horizontal_swing_() << 4);
}
if (model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) {
remote_state[3] = 0x50; // bits 4..7 always 0101
remote_state[6] = 0x20; // YAA1FB, FAA1FB1, YB1F2 bits 4..7 always 0010
if (this->vertical_swing_() == GREE_VDIR_SWING) {
remote_state[0] |= (1 << 6); // Enable swing by setting bit 6
} else if (this->vertical_swing_() != GREE_VDIR_AUTO) {
remote_state[5] = this->vertical_swing_();
}
}
if (model == GREE_YAN || model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) {
// Merge the mode bits into remote_state[2]
// Clear the mode bits (bits 4-7) and OR in the current mode_bits_
remote_state[2] = (remote_state[2] & 0x0F) | this->mode_bits_;
}
// Use if-else rather than lookup tables to avoid RAM usage on ESP8266.
static climate::ClimateFanMode decode_fan_mode(Model model, uint8_t fan) {
if (fan == GREE_FAN_AUTO)
return climate::CLIMATE_FAN_AUTO;
if (model == GREE_YX1FF) {
if (fan == GREE_FAN_1)
return climate::CLIMATE_FAN_QUIET;
if (fan == GREE_FAN_2)
return climate::CLIMATE_FAN_LOW;
return climate::CLIMATE_FAN_MEDIUM;
if (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO) == climate::CLIMATE_FAN_HIGH) {
remote_state[2] |= GREE_FAN_TURBO_BIT;
}
}
if (fan == GREE_FAN_1)
return climate::CLIMATE_FAN_LOW;
if (fan == GREE_FAN_2)
return climate::CLIMATE_FAN_MEDIUM;
return climate::CLIMATE_FAN_HIGH;
}
#endif // USE_GREE_RECEIVER
void GreeProtocol::encode(remote_base::RemoteTransmitData *data, const GreeState &state) const {
const GreeFrameSpaces spaces = frame_spaces(this->model_);
data->reserve(this->model_ == GREE_YAW1F ? 280 : 140);
if (this->preset_() == GREE_PRESET_SLEEP) {
remote_state[0] |= GREE_PRESET_SLEEP_BIT;
}
#ifdef USE_GREE_FAHRENHEIT
if (this->use_fahrenheit_) {
remote_state[3] |= GREE_FAHRENHEIT_BIT;
if (half_steps & 1)
remote_state[3] |= GREE_TEMP_HALF_DEGREE_BIT;
}
#endif
remote_state[7] = frame_checksum(remote_state);
auto transmit = this->transmitter_->transmit();
auto *data = transmit.get_data();
data->set_carrier_frequency(GREE_IR_FREQUENCY);
write_frame(data, state, spaces);
const bool long_gaps = model == GREE_YAC1FB9 || model == GREE_YAW1F;
const uint32_t header_space = long_gaps ? GREE_YAC1FB9_HEADER_SPACE : GREE_HEADER_SPACE;
const uint32_t message_space = long_gaps ? GREE_YAC1FB9_MESSAGE_SPACE : GREE_MESSAGE_SPACE;
auto encode = [&](const uint8_t *state) {
data->item(GREE_HEADER_MARK, header_space);
data->item_bytes<GREE_BIT_TIMINGS>(state, 4);
// Fixed 010 between the two halves, then the message gap
data->item(GREE_BIT_MARK, GREE_ZERO_SPACE);
data->item(GREE_BIT_MARK, GREE_ONE_SPACE);
data->item(GREE_BIT_MARK, GREE_ZERO_SPACE);
data->item(GREE_BIT_MARK, message_space);
data->item_bytes<GREE_BIT_TIMINGS>(state + 4, 4);
data->mark(GREE_BIT_MARK);
};
encode(remote_state);
#ifdef USE_GREE_YAW1F
if (this->model_ == GREE_YAW1F) {
// The YAW1F remote follows every command with a fixed second message.
GreeState followup{};
followup[3] = followup[7] = GREE_YAW1F_FOLLOWUP_BYTE;
if (model == GREE_YAW1F) {
// The YAW1F remote follows every command with a second message, 00 00 00 A0 | 00 00 00 A0.
// The command buffer is reused so the payload takes no RAM on ESP8266.
std::fill_n(remote_state, sizeof(remote_state), 0);
remote_state[3] = remote_state[7] = 0xA0;
data->space(GREE_YAW1F_FOLLOWUP_SPACE);
write_frame(data, followup, spaces);
encode(remote_state);
}
#endif
data->space(0);
transmit.perform();
}
#ifdef USE_GREE_RECEIVER
optional<GreeState> GreeProtocol::decode(remote_base::RemoteReceiveData data) const {
const GreeFrameSpaces spaces = frame_spaces(this->model_);
GreeState state{};
if (!read_frame(data, state, spaces) || !GreeProtocol::valid_checksum(state))
return {};
uint8_t GreeClimate::operation_mode_() {
uint8_t operating_mode = GREE_MODE_ON;
#ifdef USE_GREE_YAW1F
// Depending on receiver idle settings, YAW1F's ~40 ms second message may arrive in
// the same capture. Validate it completely; never treat it as climate state.
if (this->model_ == GREE_YAW1F && data.is_valid(1)) {
GreeState followup{};
if (!data.expect_space(GREE_YAW1F_FOLLOWUP_SPACE) || !read_frame(data, followup, spaces) ||
!is_yaw1f_followup(followup)) {
ESP_LOGV(TAG, "YAW1F frame dropped: second message invalid");
return {};
}
}
#endif
// A receiver normally appends one final idle space. Reject any extra pulses.
if (data.is_valid(1) || (data.is_valid() && data.peek() > 0)) {
ESP_LOGV(TAG, "Frame dropped: extra pulses follow it (receiver idle may be too long)");
return {};
switch (this->mode) {
case climate::CLIMATE_MODE_COOL:
operating_mode |= GREE_MODE_COOL;
break;
case climate::CLIMATE_MODE_DRY:
operating_mode |= GREE_MODE_DRY;
break;
case climate::CLIMATE_MODE_HEAT:
operating_mode |= GREE_MODE_HEAT;
break;
case climate::CLIMATE_MODE_HEAT_COOL:
operating_mode |= GREE_MODE_AUTO;
break;
case climate::CLIMATE_MODE_FAN_ONLY:
operating_mode |= GREE_MODE_FAN;
break;
case climate::CLIMATE_MODE_OFF:
default:
operating_mode = GREE_MODE_OFF;
break;
}
return state;
}
#endif // USE_GREE_RECEIVER
uint8_t GreeProtocol::calculate_checksum(const GreeState &state) {
uint8_t sum = 0x0A;
for (uint8_t pos = 0; pos < 4; pos++)
sum += state[pos] & 0x0F;
for (uint8_t pos = 4; pos < GREE_STATE_FRAME_SIZE - 1; pos++)
sum += state[pos] >> 4;
return (sum & 0x0F) << 4;
return operating_mode;
}
#ifdef USE_GREE_RECEIVER
bool GreeProtocol::valid_checksum(const GreeState &state) {
return (state[GREE_STATE_FRAME_SIZE - 1] & 0xF0) == GreeProtocol::calculate_checksum(state);
}
#endif
GreeState GreeClimateCodec::encode(Model model, const GreeClimateData &data) {
GreeState state{0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00};
const uint8_t vertical_swing = GreeClimateCodec::encode_vertical_swing(data.swing_mode);
const uint8_t horizontal_swing = GreeClimateCodec::encode_horizontal_swing(data.swing_mode);
state[0] =
GreeClimateCodec::encode_fan_mode(model, data.fan_mode) | GreeClimateCodec::encode_operation_mode(data.mode);
const uint8_t target_temperature = clamp<uint8_t>(data.target_temperature, GREE_TEMP_MIN, GREE_TEMP_MAX);
state[1] = target_temperature - GREE_TEMP_MIN;
state[2] = data.feature_bits & supported_feature_mask(model);
if (model != GREE_GENERIC)
state[3] = 0x50;
if (model == GREE_YAN)
state[4] = vertical_swing;
if (model == GREE_YB1FA || model == GREE_YX1FF) {
if (data.mode != climate::CLIMATE_MODE_OFF)
state[2] |= GREE_MODEL_A_BIT;
if (model == GREE_YX1FF && data.fan_mode == climate::CLIMATE_FAN_HIGH)
state[2] |= GREE_FAN_TURBO_BIT;
if (vertical_swing == GREE_VDIR_SWING) {
state[0] |= GREE_SWING_AUTO_MASK;
// YX1FF repeats the automatic swing value in both nibbles, while YB1FA only uses the vertical field.
state[4] = model == GREE_YX1FF ? GREE_YX1FF_VANE_SWING : GREE_VDIR_SWING;
uint8_t GreeClimate::fan_speed_() {
// YX1FF has 4 fan speeds -- we treat low as quiet and turbo as high
if (this->model_ == GREE_YX1FF) {
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) {
case climate::CLIMATE_FAN_QUIET:
return GREE_FAN_1;
case climate::CLIMATE_FAN_LOW:
return GREE_FAN_2;
case climate::CLIMATE_FAN_MEDIUM:
case climate::CLIMATE_FAN_HIGH:
// HIGH additionally sets the separate Turbo bit in byte 2.
return GREE_FAN_3;
case climate::CLIMATE_FAN_AUTO:
default:
return GREE_FAN_AUTO;
}
}
if (model == GREE_YAG || model == GREE_YAW1F) {
state[2] |= 0x60;
state[5] = model == GREE_YAW1F ? 0x80 : 0x40;
if (vertical_swing == GREE_VDIR_SWING || horizontal_swing == GREE_HDIR_SWING)
state[0] |= GREE_SWING_AUTO_MASK;
}
// YAC, like YAG, keeps vertical swing in the low nibble of byte 4; the byte 0 swing flag alone is not enough.
if (model == GREE_YAC || model == GREE_YAG || model == GREE_YAW1F)
state[4] = vertical_swing | (horizontal_swing << 4);
if (model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) {
state[6] = 0x20;
if (vertical_swing == GREE_VDIR_SWING)
state[0] |= GREE_SWING_AUTO_MASK;
}
if ((model == GREE_YX1FF || model == GREE_YAC1FB9) && data.preset == climate::CLIMATE_PRESET_SLEEP)
state[0] |= GREE_PRESET_SLEEP_BIT;
if (data.fahrenheit) {
state[3] |= GREE_FAHRENHEIT_BIT;
if (data.half_degree)
state[3] |= GREE_TEMP_HALF_DEGREE_BIT;
}
state[GREE_STATE_FRAME_SIZE - 1] = GreeProtocol::calculate_checksum(state);
return state;
}
#ifdef USE_GREE_RECEIVER
optional<GreeClimateData> GreeClimateCodec::decode(Model model, const GreeState &state) {
if (model == GREE_YB1FA || model == GREE_YX1FF)
return GreeClimateCodec::decode_model_a(model, state);
return GreeClimateCodec::decode_legacy(model, state);
}
#endif // USE_GREE_RECEIVER
uint8_t GreeClimateCodec::encode_operation_mode(climate::ClimateMode mode) {
if (mode == climate::CLIMATE_MODE_COOL)
return GREE_MODE_ON | GREE_MODE_COOL;
if (mode == climate::CLIMATE_MODE_DRY)
return GREE_MODE_ON | GREE_MODE_DRY;
if (mode == climate::CLIMATE_MODE_HEAT)
return GREE_MODE_ON | GREE_MODE_HEAT;
if (mode == climate::CLIMATE_MODE_HEAT_COOL)
return GREE_MODE_ON | GREE_MODE_AUTO;
if (mode == climate::CLIMATE_MODE_FAN_ONLY)
return GREE_MODE_ON | GREE_MODE_FAN;
return GREE_MODE_OFF;
}
uint8_t GreeClimateCodec::encode_fan_mode(Model model, climate::ClimateFanMode fan_mode) {
if (model == GREE_YX1FF) {
if (fan_mode == climate::CLIMATE_FAN_QUIET)
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) {
case climate::CLIMATE_FAN_LOW:
return GREE_FAN_1;
if (fan_mode == climate::CLIMATE_FAN_LOW)
case climate::CLIMATE_FAN_MEDIUM:
return GREE_FAN_2;
if (fan_mode == climate::CLIMATE_FAN_MEDIUM || fan_mode == climate::CLIMATE_FAN_HIGH)
case climate::CLIMATE_FAN_HIGH:
return GREE_FAN_3;
return GREE_FAN_AUTO;
case climate::CLIMATE_FAN_AUTO:
default:
return GREE_FAN_AUTO;
}
if (fan_mode == climate::CLIMATE_FAN_LOW)
return GREE_FAN_1;
if (fan_mode == climate::CLIMATE_FAN_MEDIUM)
return GREE_FAN_2;
if (fan_mode == climate::CLIMATE_FAN_HIGH)
return GREE_FAN_3;
return GREE_FAN_AUTO;
}
uint8_t GreeClimateCodec::encode_horizontal_swing(climate::ClimateSwingMode swing_mode) {
switch (swing_mode) {
uint8_t GreeClimate::horizontal_swing_() {
switch (this->swing_mode) {
case climate::CLIMATE_SWING_HORIZONTAL:
case climate::CLIMATE_SWING_BOTH:
return GREE_HDIR_SWING;
@@ -341,8 +267,8 @@ uint8_t GreeClimateCodec::encode_horizontal_swing(climate::ClimateSwingMode swin
}
}
uint8_t GreeClimateCodec::encode_vertical_swing(climate::ClimateSwingMode swing_mode) {
switch (swing_mode) {
uint8_t GreeClimate::vertical_swing_() {
switch (this->swing_mode) {
case climate::CLIMATE_SWING_VERTICAL:
case climate::CLIMATE_SWING_BOTH:
return GREE_VDIR_SWING;
@@ -351,273 +277,24 @@ uint8_t GreeClimateCodec::encode_vertical_swing(climate::ClimateSwingMode swing_
}
}
#ifdef USE_GREE_RECEIVER
optional<GreeClimateData> GreeClimateCodec::decode_legacy(Model model, const GreeState &state) {
if (is_auxiliary_frame(model, state))
return {};
const uint8_t mode = state[0] & GREE_MODE_MASK;
const bool power = state[0] & GREE_POWER_MASK;
const uint8_t fan = state[0] & GREE_FAN_MASK;
const uint8_t temperature = state[1] & GREE_TEMP_MASK;
// Legacy GREE-branded remotes have several model and OEM variants. Decode the fields that are stable across the
// protocol, but do not require the transmitter-side fixed bytes: physical Tadiran/Tosot/GREE remotes may populate
// timer, display-temperature, I-Feel, WiFi, vane-position, or otherwise unused fields differently.
if (mode > GREE_MODE_HEAT || temperature > GREE_TEMP_MAX - GREE_TEMP_MIN)
return {};
const uint8_t vertical = state[4] & 0x0F;
const uint8_t horizontal = (state[4] >> 4) & 0x07;
bool swing_vertical = false;
bool swing_horizontal = false;
switch (model) {
case GREE_YAG:
case GREE_YAW1F:
case GREE_YAC:
// On YAC1FB bit 6 does not reliably identify vertical swing; use vane fields for both axes.
swing_vertical = is_valid_vertical_swing(true, vertical);
swing_horizontal = horizontal == GREE_HDIR_SWING;
break;
case GREE_GENERIC:
case GREE_YAN:
case GREE_YAA:
case GREE_YAC1FB9:
default:
// ESPHome's YAN transmitter stores automatic vertical swing in byte 4, while YAA/YAC1FB9 use bit 6.
// Accept either representation so branded remotes using the standard GREE layout are handled as well.
swing_vertical = (state[0] & GREE_SWING_AUTO_MASK) || is_valid_vertical_swing(true, vertical);
break;
}
GreeClimateData data{
.mode = climate::CLIMATE_MODE_OFF,
.target_temperature = static_cast<uint8_t>(GREE_TEMP_MIN + temperature),
.fan_mode = climate::CLIMATE_FAN_AUTO,
.swing_mode = climate::CLIMATE_SWING_OFF,
.preset = model == GREE_YAC1FB9 && (state[0] & GREE_SLEEP_MASK) ? climate::CLIMATE_PRESET_SLEEP
: climate::CLIMATE_PRESET_NONE,
.feature_bits = static_cast<uint8_t>(state[2] & supported_feature_mask(model)),
.fahrenheit = static_cast<bool>(state[3] & GREE_FAHRENHEIT_BIT),
.half_degree = static_cast<bool>(state[3] & GREE_TEMP_HALF_DEGREE_BIT),
};
if (swing_vertical && swing_horizontal) {
data.swing_mode = climate::CLIMATE_SWING_BOTH;
} else if (swing_vertical) {
data.swing_mode = climate::CLIMATE_SWING_VERTICAL;
} else if (swing_horizontal) {
data.swing_mode = climate::CLIMATE_SWING_HORIZONTAL;
}
if (power)
data.mode = decode_operation_mode(mode);
data.fan_mode = decode_fan_mode(model, fan);
return data;
uint8_t GreeClimate::temperature_() {
return (uint8_t) roundf(clamp<float>(this->target_temperature, GREE_TEMP_MIN, GREE_TEMP_MAX)) - GREE_TEMP_MIN;
}
optional<GreeClimateData> GreeClimateCodec::decode_model_a(Model model, const GreeState &state) {
const uint8_t mode = state[0] & GREE_MODE_MASK;
const bool power = state[0] & GREE_POWER_MASK;
const uint8_t fan = state[0] & GREE_FAN_MASK;
const bool swing = state[0] & GREE_SWING_AUTO_MASK;
const bool turbo = state[2] & GREE_FAN_TURBO_BIT;
const uint8_t temperature = state[1] & GREE_TEMP_MASK;
// Validate fields that are not represented by Climate where possible. Supported independent feature state is
// returned separately so the parent can synchronize its feature switches.
if (mode > GREE_MODE_HEAT || temperature > GREE_TEMP_MAX - GREE_TEMP_MIN || !is_valid_timer(state) ||
static_cast<bool>(state[2] & GREE_MODEL_A_BIT) != power ||
(state[3] & ~(GREE_FAHRENHEIT_BIT | GREE_TEMP_HALF_DEGREE_BIT)) != 0x50 || !is_valid_swing(model, state) ||
(state[5] & GREE_BYTE5_FIXED_MASK) != GREE_BYTE5_FIXED_VALUE || state[6] != 0x00 || (state[7] & 0x0F) != 0x00 ||
(model == GREE_YX1FF && turbo && fan != GREE_FAN_3)) {
return {};
}
GreeClimateData data{
.mode = climate::CLIMATE_MODE_OFF,
.target_temperature = static_cast<uint8_t>(GREE_TEMP_MIN + temperature),
.fan_mode = climate::CLIMATE_FAN_AUTO,
.swing_mode = swing ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF,
.preset = model == GREE_YX1FF && state[0] & GREE_SLEEP_MASK ? climate::CLIMATE_PRESET_SLEEP
: climate::CLIMATE_PRESET_NONE,
.feature_bits = static_cast<uint8_t>(state[2] & supported_feature_mask(model)),
.fahrenheit = static_cast<bool>(state[3] & GREE_FAHRENHEIT_BIT),
.half_degree = static_cast<bool>(state[3] & GREE_TEMP_HALF_DEGREE_BIT),
};
if (power)
data.mode = decode_operation_mode(mode);
data.fan_mode = turbo ? climate::CLIMATE_FAN_HIGH : decode_fan_mode(model, fan);
return data;
}
#endif // USE_GREE_RECEIVER
void GreeClimate::set_model(Model model) {
if (model == GREE_YAN || model == GREE_YB1FA || model == GREE_YX1FF) {
// These remotes only expose a vertical swing control.
this->swing_modes_.erase(climate::CLIMATE_SWING_HORIZONTAL);
this->swing_modes_.erase(climate::CLIMATE_SWING_BOTH);
}
if (model == GREE_YX1FF)
this->fan_modes_.insert(climate::CLIMATE_FAN_QUIET);
if (model == GREE_YX1FF || model == GREE_YAC1FB9) {
this->presets_.insert(climate::CLIMATE_PRESET_NONE);
this->presets_.insert(climate::CLIMATE_PRESET_SLEEP);
}
this->model_ = model;
this->feature_bits_ = default_feature_bits(model);
}
void GreeClimate::set_feature_state(GreeFeature feature, bool enabled) {
const uint8_t bit = gree_feature_bit(feature);
if (!(supported_feature_mask(this->model_) & bit)) {
ESP_LOGW(TAG, "GREE model %u does not support feature %u", static_cast<unsigned>(this->model_),
static_cast<unsigned>(feature));
#ifdef USE_GREE_FEATURE_SWITCH
this->publish_feature_state_(feature);
#endif
return;
}
// YB1FA reports Turbo as effective HIGH fan. Keep the Climate and feature entities consistent when Turbo is enabled.
const bool fan_changed = this->model_ == GREE_YB1FA && feature == GREE_FEATURE_TURBO && enabled &&
this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO) != climate::CLIMATE_FAN_HIGH;
if (fan_changed)
this->fan_mode = climate::CLIMATE_FAN_HIGH;
assign_bits(this->feature_bits_, bit, enabled);
this->transmit_state();
#ifdef USE_GREE_FEATURE_SWITCH
this->publish_feature_state_(feature);
#endif
if (fan_changed)
this->publish_state();
}
bool GreeClimate::get_feature_state(GreeFeature feature) const {
return this->feature_bits_ & gree_feature_bit(feature);
}
#ifdef USE_GREE_FEATURE_SWITCH
void GreeClimate::setup() {
climate_ir::ClimateIR::setup();
// Apply every restored switch, then send one frame. Models that receive feature state have restoring disabled,
// so their switches show the current feature bits instead.
bool restored = false;
for (size_t feature = 0; feature < this->feature_switches_.size(); feature++) {
auto *feature_switch = this->feature_switches_[feature];
if (feature_switch == nullptr)
continue;
if (auto initial = feature_switch->get_initial_state_with_restore_mode()) {
// What control() would hand the switch's apply body
assign_bits(this->feature_bits_, gree_feature_bit(static_cast<GreeFeature>(feature)),
*initial != feature_switch->is_inverted());
restored = true;
uint8_t GreeClimate::preset_() {
// YX1FF and YAC1FB9 have sleep preset
if (this->model_ == GREE_YX1FF || this->model_ == GREE_YAC1FB9) {
switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) {
case climate::CLIMATE_PRESET_NONE:
return GREE_PRESET_NONE;
case climate::CLIMATE_PRESET_SLEEP:
return GREE_PRESET_SLEEP;
default:
return GREE_PRESET_NONE;
}
this->publish_feature_state_(static_cast<GreeFeature>(feature));
}
if (restored)
this->transmit_state();
}
void GreeClimate::register_feature_switch(GreeFeature feature, switch_::Switch *feature_switch) {
this->feature_switches_[static_cast<uint8_t>(feature)] = feature_switch;
}
void GreeClimate::publish_feature_state_(GreeFeature feature) {
auto *feature_switch = this->feature_switches_[static_cast<uint8_t>(feature)];
if (feature_switch != nullptr)
feature_switch->publish_state(this->get_feature_state(feature));
}
#ifdef USE_GREE_RECEIVER
void GreeClimate::publish_feature_states_() {
for (size_t feature = 0; feature < this->feature_switches_.size(); feature++)
this->publish_feature_state_(static_cast<GreeFeature>(feature));
}
#endif
#endif // USE_GREE_FEATURE_SWITCH
void GreeClimate::control(const climate::ClimateCall &call) {
// An explicit fan selection exits YB1FA Turbo. Disabling Turbo itself leaves the effective HIGH fan selected.
const bool clear_turbo =
this->model_ == GREE_YB1FA && call.get_fan_mode().has_value() && this->get_feature_state(GREE_FEATURE_TURBO);
if (clear_turbo) {
this->feature_bits_ &= ~GREE_MODE_BIT_TURBO;
#ifdef USE_GREE_FEATURE_SWITCH
this->publish_feature_state_(GREE_FEATURE_TURBO);
#endif
}
climate_ir::ClimateIR::control(call);
}
void GreeClimate::transmit_state() {
GreeClimateData climate_data{
.mode = this->mode,
.target_temperature =
static_cast<uint8_t>(roundf(clamp<float>(this->target_temperature, GREE_TEMP_MIN, GREE_TEMP_MAX))),
.fan_mode = this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO),
.swing_mode = this->swing_mode,
.preset = this->preset.value_or(climate::CLIMATE_PRESET_NONE),
.feature_bits = this->feature_bits_,
};
#ifdef USE_GREE_FAHRENHEIT
if (this->use_fahrenheit_) {
const uint8_t half_steps = fahrenheit_half_steps(this->target_temperature);
climate_data.target_temperature = GREE_TEMP_MIN + (half_steps >> 1);
climate_data.fahrenheit = true;
climate_data.half_degree = half_steps & 1;
}
#endif
const GreeState state = GreeClimateCodec::encode(this->model_, climate_data);
auto transmit = this->transmitter_->transmit();
GreeProtocol(this->model_).encode(transmit.get_data(), state);
transmit.perform();
}
#ifdef USE_GREE_RECEIVER
bool GreeClimate::on_receive(remote_base::RemoteReceiveData data) {
auto state = GreeProtocol(this->model_).decode(data);
if (!state.has_value())
return false;
auto decoded = GreeClimateCodec::decode(this->model_, *state);
if (!decoded.has_value()) {
ESP_LOGD(TAG, "GREE frame rejected for model %u: %02X %02X %02X %02X %02X %02X %02X %02X",
static_cast<unsigned>(this->model_), (*state)[0], (*state)[1], (*state)[2], (*state)[3], (*state)[4],
(*state)[5], (*state)[6], (*state)[7]);
return false;
}
this->mode = decoded->mode;
const int half_steps = 2 * (decoded->target_temperature - GREE_TEMP_MIN) + decoded->half_degree;
float target = GREE_TEMP_MIN + 0.5f * half_steps;
// The panel shows (10 * 32 + 18 * GREE_TEMP_MIN + 9 * k) / 10 F; store that degree so it is sent back unchanged
if (decoded->fahrenheit) {
// The remote truncates the displayed Fahrenheit degree; retain that exact integer
// through subsequent feature-only and fan-only transmissions.
const int fahrenheit_degree = (10 * 32 + 18 * GREE_TEMP_MIN + 9 * half_steps) / 10;
target = fahrenheit_to_celsius(static_cast<float>(fahrenheit_degree));
}
this->target_temperature = target;
this->fan_mode = decoded->fan_mode;
this->swing_mode = decoded->swing_mode;
this->preset = decoded->preset;
this->feature_bits_ = decoded->feature_bits;
#ifdef USE_GREE_FEATURE_SWITCH
this->publish_feature_states_();
#endif
this->publish_state();
return true;
return GREE_PRESET_NONE;
}
#endif // USE_GREE_RECEIVER
} // namespace esphome::gree
+42 -110
View File
@@ -1,10 +1,8 @@
#pragma once
#include <array>
#include "esphome/components/climate_ir/climate_ir.h"
#include "esphome/core/defines.h"
#ifdef USE_GREE_FEATURE_SWITCH
#ifdef USE_GREE_SWITCH
#include "esphome/components/switch/switch.h"
#endif
@@ -45,15 +43,18 @@ static constexpr uint32_t GREE_YAC_HEADER_MARK = 6000;
static constexpr uint32_t GREE_YAC_HEADER_SPACE = 3000;
static constexpr uint32_t GREE_YAC_BIT_MARK = 650;
// YAW1F shares the longer YAC1FB9 header and inter-block gap.
// Timing specific to YAC1FB9
// Also used by YAW1F
static constexpr uint32_t GREE_YAC1FB9_HEADER_SPACE = 4500;
static constexpr uint32_t GREE_YAC1FB9_MESSAGE_SPACE = 19980;
// Gap between the YAW1F primary command and its fixed second message.
// YAW1F: every command is followed by a second 8-byte message after this gap
static constexpr uint32_t GREE_YAW1F_FOLLOWUP_SPACE = 40000;
// State Frame size
static constexpr uint8_t GREE_STATE_FRAME_SIZE = 8;
// Only available on YAN
// Vertical air directions. Note that these cannot be set on all heat pumps
static constexpr uint8_t GREE_VDIR_AUTO = 0x00;
static constexpr uint8_t GREE_VDIR_MANUAL = 0x00;
@@ -65,9 +66,6 @@ static constexpr uint8_t GREE_VDIR_MDOWN = 0x05;
static constexpr uint8_t GREE_VDIR_DOWN = 0x06;
// YX1FF vane-position byte while swinging
static constexpr uint8_t GREE_YX1FF_VANE_SWING = 0x11;
static constexpr uint8_t GREE_VDIR_SWING_DOWN = 0x07;
static constexpr uint8_t GREE_VDIR_SWING_MIDDLE = 0x09;
static constexpr uint8_t GREE_VDIR_SWING_UP = 0x0B;
// Only available on YAC/YAG
// Horizontal air directions. Note that these cannot be set on all heat pumps
@@ -80,7 +78,17 @@ static constexpr uint8_t GREE_HDIR_MIDDLE = 0x04;
static constexpr uint8_t GREE_HDIR_MRIGHT = 0x05;
static constexpr uint8_t GREE_HDIR_RIGHT = 0x06;
// Feature bits in byte 2
// Only available on YX1FF
// Turbo (high) fan mode
static constexpr uint8_t GREE_FAN_TURBO_BIT = 0x10;
// Available on YX1FF and YAC1FB9
// Sleep preset mode
static constexpr uint8_t GREE_PRESET_NONE = 0x00;
static constexpr uint8_t GREE_PRESET_SLEEP = 0x01;
static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80;
// Mode bits stored in remote_state[2] bits 4..7 for YAN/YAA/YAC/YAC1FB9.
static constexpr uint8_t GREE_MODE_BIT_TURBO = 0x10;
static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20;
static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40;
@@ -90,94 +98,24 @@ static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80;
static constexpr uint8_t GREE_FAHRENHEIT_BIT = 0x08;
static constexpr uint8_t GREE_TEMP_HALF_DEGREE_BIT = 0x04; // Half a degree Celsius added to the temperature
/// Checksum of a state frame: the low nibbles of bytes 0-3 plus the high nibbles of bytes 4-6 plus 0x0A, in the high
/// nibble.
inline uint8_t frame_checksum(const uint8_t *remote_state) {
return (
(((remote_state[0] & 0x0F) + (remote_state[1] & 0x0F) + (remote_state[2] & 0x0F) + (remote_state[3] & 0x0F) +
((remote_state[4] & 0xF0) >> 4) + ((remote_state[5] & 0xF0) >> 4) + ((remote_state[6] & 0xF0) >> 4) + 0x0A) &
0x0F)
<< 4);
}
#ifdef USE_GREE_FAHRENHEIT
/// Return k = 2 * temp_field + half_degree_bit (0..28) for a Celsius target. The unit displays
/// trunc(celsius_to_fahrenheit(GREE_TEMP_MIN + k / 2)), so k is the smallest value that shows the target in F.
uint8_t fahrenheit_half_steps(float target_celsius);
#endif
// Model-specific meanings for the same bit positions on YB1FA/YX1FF.
static constexpr uint8_t GREE_FAN_TURBO_BIT = 0x10;
static constexpr uint8_t GREE_MODEL_A_BIT = 0x40;
// Only available on YX1FF
// Sleep preset mode
static constexpr uint8_t GREE_PRESET_NONE = 0x00;
static constexpr uint8_t GREE_PRESET_SLEEP = 0x01;
static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80;
// Model codes
enum Model : uint8_t {
GREE_GENERIC,
GREE_YAN,
GREE_YAA,
GREE_YAC,
GREE_YAC1FB9,
GREE_YB1FA,
GREE_YX1FF,
GREE_YAG,
GREE_YAW1F
};
enum GreeFeature : uint8_t {
GREE_FEATURE_TURBO = 0,
GREE_FEATURE_LIGHT = 1,
GREE_FEATURE_HEALTH = 2,
GREE_FEATURE_XFAN = 3,
};
static constexpr uint8_t gree_feature_bit(GreeFeature feature) { return GREE_MODE_BIT_TURBO << feature; }
using GreeState = std::array<uint8_t, GREE_STATE_FRAME_SIZE>;
struct GreeClimateData {
climate::ClimateMode mode;
uint8_t target_temperature;
climate::ClimateFanMode fan_mode;
climate::ClimateSwingMode swing_mode;
climate::ClimatePreset preset;
uint8_t feature_bits{0};
// The unit's panel shows Fahrenheit; half_degree adds half a degree Celsius to target_temperature
bool fahrenheit{false};
bool half_degree{false};
};
class GreeProtocol {
public:
explicit GreeProtocol(Model model) : model_(model) {}
void encode(remote_base::RemoteTransmitData *data, const GreeState &state) const;
#ifdef USE_GREE_RECEIVER
optional<GreeState> decode(remote_base::RemoteReceiveData data) const;
#endif
static uint8_t calculate_checksum(const GreeState &state);
#ifdef USE_GREE_RECEIVER
static bool valid_checksum(const GreeState &state);
#endif
protected:
Model model_;
};
class GreeClimateCodec {
public:
static GreeState encode(Model model, const GreeClimateData &data);
#ifdef USE_GREE_RECEIVER
/// Decode a frame whose checksum the protocol layer already checked.
static optional<GreeClimateData> decode(Model model, const GreeState &state);
#endif
protected:
static uint8_t encode_operation_mode(climate::ClimateMode mode);
static uint8_t encode_fan_mode(Model model, climate::ClimateFanMode fan_mode);
static uint8_t encode_horizontal_swing(climate::ClimateSwingMode swing_mode);
static uint8_t encode_vertical_swing(climate::ClimateSwingMode swing_mode);
#ifdef USE_GREE_RECEIVER
static optional<GreeClimateData> decode_legacy(Model model, const GreeState &state);
static optional<GreeClimateData> decode_model_a(Model model, const GreeState &state);
#endif
};
enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG, GREE_YAW1F };
class GreeClimate final : public climate_ir::ClimateIR {
public:
@@ -188,38 +126,32 @@ class GreeClimate final : public climate_ir::ClimateIR {
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {}
#ifdef USE_GREE_FEATURE_SWITCH
void setup() override;
#endif
void set_model(Model model);
#ifdef USE_GREE_FAHRENHEIT
void set_use_fahrenheit(bool use_fahrenheit) { this->use_fahrenheit_ = use_fahrenheit; }
#endif
void set_feature_state(GreeFeature feature, bool enabled);
bool get_feature_state(GreeFeature feature) const;
#ifdef USE_GREE_FEATURE_SWITCH
void register_feature_switch(GreeFeature feature, switch_::Switch *feature_switch);
void set_mode_bit(uint8_t bit_mask, bool enabled);
#ifdef USE_GREE_SWITCH
void setup() override;
SUB_SWITCH(turbo)
SUB_SWITCH(light)
SUB_SWITCH(health)
SUB_SWITCH(xfan)
#endif
protected:
void control(const climate::ClimateCall &call) override;
// Transmit via IR the state of this climate controller.
void transmit_state() override;
#ifdef USE_GREE_RECEIVER
bool on_receive(remote_base::RemoteReceiveData data) override;
#endif
#ifdef USE_GREE_FEATURE_SWITCH
void publish_feature_state_(GreeFeature feature);
#ifdef USE_GREE_RECEIVER
void publish_feature_states_();
#endif
std::array<switch_::Switch *, 4> feature_switches_{};
#endif
uint8_t operation_mode_();
uint8_t fan_speed_();
uint8_t horizontal_swing_();
uint8_t vertical_swing_();
uint8_t temperature_();
uint8_t preset_();
Model model_{};
uint8_t feature_bits_{0};
uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise
#ifdef USE_GREE_FAHRENHEIT
bool use_fahrenheit_{false};
#endif
+53 -155
View File
@@ -1,193 +1,91 @@
from collections.abc import Callable
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import (
CONF_INITIAL_STATE,
CONF_LIGHT,
CONF_RESTORE_MODE,
CONF_RESTORE_STATE,
DEVICE_CLASS_SWITCH,
ENTITY_CATEGORY_CONFIG,
)
from esphome.core import CORE
from esphome.const import CONF_LIGHT, DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG
import esphome.final_validate as fv
from esphome.types import ConfigType
from .. import gree_ns
from ..climate import CONF_MODEL, GreeClimate
CODEOWNERS = ["@nagyrobi"]
GreeFeature = gree_ns.enum("GreeFeature")
CONF_TURBO = "turbo"
CONF_HEALTH = "health"
CONF_XFAN = "xfan"
CONF_GREE_ID = "gree_id"
# Switch configurations: (config_key, feature, icon, default_restore_mode)
SWITCH_CONFIGS = (
(
CONF_TURBO,
GreeFeature.GREE_FEATURE_TURBO,
"mdi:car-turbocharger",
"RESTORE_DEFAULT_OFF",
),
# The display light was on before the switches existed, so it stays on by default
(
CONF_LIGHT,
GreeFeature.GREE_FEATURE_LIGHT,
"mdi:led-outline",
"RESTORE_DEFAULT_ON",
),
(
CONF_HEALTH,
GreeFeature.GREE_FEATURE_HEALTH,
"mdi:pine-tree",
"RESTORE_DEFAULT_OFF",
),
(
CONF_XFAN,
GreeFeature.GREE_FEATURE_XFAN,
"mdi:wall-sconce-flat",
"RESTORE_DEFAULT_OFF",
),
)
ALL_FEATURES = frozenset(key for key, *_ in SWITCH_CONFIGS)
MODEL_FEATURES = {
"yan": ALL_FEATURES,
"yaa": ALL_FEATURES,
"yac": ALL_FEATURES,
"yac1fb9": ALL_FEATURES,
"yb1fa": ALL_FEATURES - {CONF_HEALTH},
"yx1ff": frozenset({CONF_LIGHT}),
SWITCH_ICONS = {
CONF_TURBO: "mdi:car-turbocharger",
CONF_LIGHT: "mdi:led-outline",
CONF_HEALTH: "mdi:pine-tree",
CONF_XFAN: "mdi:wall-sconce-flat",
}
# These models receive feature state over IR, so the switches follow it instead of restoring
RX_FEATURE_MODELS = frozenset({"yb1fa", "yx1ff"})
_RESTORE_KEYS = (CONF_RESTORE_MODE, CONF_RESTORE_STATE, CONF_INITIAL_STATE)
def _feature_switch_schema(
icon: str, default_restore_mode: str
) -> Callable[[ConfigType], ConfigType]:
schema = switch.switch_schema(
switch.ApplySwitch,
icon=icon,
default_restore_mode=default_restore_mode,
device_class=DEVICE_CLASS_SWITCH,
entity_category=ENTITY_CATEGORY_CONFIG,
)
def validator(value: ConfigType) -> ConfigType:
explicit = isinstance(value, dict) and any(
key in value for key in _RESTORE_KEYS
)
config = schema(value)
# Leave an omitted restore mode unset, so final validation can tell it from an explicit one
# and to_code can fill in the default for the model.
if not explicit:
config.pop(CONF_RESTORE_MODE, None)
return config
return validator
SUPPORTED_MODELS = {
"yan",
"yaa",
"yac",
"yac1fb9",
}
CONFIG_SCHEMA = cv.Schema(
{
cv.Required(CONF_GREE_ID): cv.use_id(GreeClimate),
**{
cv.Optional(key): _feature_switch_schema(icon, default_restore_mode)
for key, _, icon, default_restore_mode in SWITCH_CONFIGS
cv.Optional(key): switch.switch_schema(
switch.ApplySwitch,
icon=icon,
# The display light was on before the switches existed, so it stays on by default
default_restore_mode="RESTORE_DEFAULT_ON"
if key == CONF_LIGHT
else "RESTORE_DEFAULT_OFF",
device_class=DEVICE_CLASS_SWITCH,
entity_category=ENTITY_CATEGORY_CONFIG,
)
for key, icon in SWITCH_ICONS.items()
},
}
)
def _validate_features_for_model(model: str, config: ConfigType) -> None:
supported_features = MODEL_FEATURES.get(model)
if supported_features is None:
def _validate_model(config: ConfigType) -> None:
climate_conf = fv.get_declaring_config(config[CONF_GREE_ID])
if climate_conf[CONF_MODEL] not in SUPPORTED_MODELS:
raise cv.Invalid(
"Gree switches are only supported for the "
+ ", ".join(sorted(MODEL_FEATURES))
+ ", ".join(SUPPORTED_MODELS)
+ " models"
)
configured_features = {key for key, *_ in SWITCH_CONFIGS if key in config}
if unsupported := configured_features - supported_features:
suffix = "switch" if len(unsupported) == 1 else "switches"
raise cv.Invalid(
f"Gree model {model} does not support the "
+ ", ".join(sorted(unsupported))
+ f" {suffix}"
)
def _validate_model(config: ConfigType) -> None:
model = fv.get_declaring_config(config[CONF_GREE_ID])[CONF_MODEL]
_validate_features_for_model(model, config)
if model not in RX_FEATURE_MODELS:
return
for feature, *_ in SWITCH_CONFIGS:
if (feature_conf := config.get(feature)) is None:
continue
if (
CONF_RESTORE_STATE in feature_conf
or str(feature_conf.get(CONF_RESTORE_MODE, "DISABLED")) != "DISABLED"
):
raise cv.Invalid(
f"Gree model {model} receives {feature} state from IR; "
"restore_mode must be DISABLED or omitted",
path=[feature],
)
# The climate keeps one switch per feature, so a second entry would take over the first
_validate_unique = fv.unique_platform_key(
domain=switch.DOMAIN,
platform="gree",
keys=(CONF_GREE_ID,),
message="Only one gree switch entry is allowed per climate ('{gree_id}')",
)
def _final_validate(config: ConfigType) -> None:
_validate_unique(config)
_validate_model(config)
FINAL_VALIDATE_SCHEMA = _final_validate
def _with_default_restore_mode(
feature_conf: ConfigType, default_restore_mode: str, receives_state: bool
) -> ConfigType:
"""Fill in the model's restore mode when none was configured."""
if CONF_RESTORE_MODE in feature_conf or CONF_RESTORE_STATE in feature_conf:
return feature_conf
mode = "DISABLED" if receives_state else default_restore_mode
return {**feature_conf, CONF_RESTORE_MODE: switch.RESTORE_MODES[mode]}
FINAL_VALIDATE_SCHEMA = _validate_model
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_GREE_ID])
receives_state = (
fv.get_declaring_config(config[CONF_GREE_ID], CORE.config)[CONF_MODEL]
in RX_FEATURE_MODELS
)
cg.add_define("USE_GREE_FEATURE_SWITCH")
cg.add_define("USE_GREE_SWITCH")
switches = switch.sub_switches(config, parent=hub)
for conf_key, feature, _, default_restore_mode in SWITCH_CONFIGS:
if (feature_conf := config.get(conf_key)) is None:
continue
await switches.create(
_with_default_restore_mode(
feature_conf, default_restore_mode, receives_state
),
lambda sw, feature=feature: hub.register_feature_switch(feature, sw),
apply=f"set_feature_state({feature}, state)",
)
await switches(
CONF_TURBO,
hub.set_turbo_switch,
apply="set_mode_bit(gree::GREE_MODE_BIT_TURBO, state)",
publish="after",
)
await switches(
CONF_LIGHT,
hub.set_light_switch,
apply="set_mode_bit(gree::GREE_MODE_BIT_LIGHT, state)",
publish="after",
)
await switches(
CONF_HEALTH,
hub.set_health_switch,
apply="set_mode_bit(gree::GREE_MODE_BIT_HEALTH, state)",
publish="after",
)
await switches(
CONF_XFAN,
hub.set_xfan_switch,
apply="set_mode_bit(gree::GREE_MODE_BIT_XFAN, state)",
publish="after",
)
+6 -1
View File
@@ -102,6 +102,8 @@ static constexpr uint8_t ENGINEERING_FRAME_MIN = OUT_PIN_SENSOR + 3 + HEADER_FOO
// Data Header & Footer
static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0xF2, 0xF1};
static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5};
// MAC address the module uses when Bluetooth is disabled
static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
void LD2410Component::dump_config() {
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
@@ -423,7 +425,10 @@ void LD2410Component::handle_ack_data_() {
break; // too short to hold a MAC address
}
this->bluetooth_on_ = ld24xx::store_mac(&this->buffer_data_[10], this->mac_address_);
this->bluetooth_on_ = std::memcmp(&this->buffer_data_[10], NO_MAC, sizeof(NO_MAC)) != 0;
if (this->bluetooth_on_) {
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
}
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
+6 -1
View File
@@ -120,6 +120,8 @@ static constexpr uint8_t ENGINEERING_FRAME_MIN = LIGHT_SENSOR + 3 + HEADER_FOOTE
// Data Header & Footer
static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0xF2, 0xF1};
static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5};
// MAC address the module uses when Bluetooth is disabled
static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
static constexpr uint8_t GATE_SENS_ACK_SIZE = ACK_PAYLOAD + TOTAL_GATES + HEADER_FOOTER_SIZE;
void LD2412Component::dump_config() {
@@ -475,7 +477,10 @@ void LD2412Component::handle_ack_data_() {
break; // too short to hold a MAC address
}
this->bluetooth_on_ = ld24xx::store_mac(&this->buffer_data_[10], this->mac_address_);
this->bluetooth_on_ = std::memcmp(&this->buffer_data_[10], NO_MAC, sizeof(NO_MAC)) != 0;
if (this->bluetooth_on_) {
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
}
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
+20 -42
View File
@@ -79,6 +79,8 @@ static constexpr uint8_t DATA_FRAME_FOOTER[2] = {0x55, 0xCC};
// 3 zones * 8 + footer (4).
static constexpr uint8_t ZONE_ACK_FRAME_SIZE = 40;
static constexpr uint8_t PERIODIC_FRAME_SIZE = 30; // header, three targets of 8 bytes, footer
// MAC address the module uses when Bluetooth is disabled
static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
static inline uint32_t convert_seconds_to_ms(uint16_t value) { return (uint32_t) value * 1000; };
@@ -190,11 +192,6 @@ void LD2450Component::dump_config() {
for (text_sensor::TextSensor *s : this->direction_text_sensors_) {
LOG_TEXT_SENSOR(" ", "Direction", s);
}
#ifdef USE_LD2450_TARGET_XY
for (text_sensor::TextSensor *s : this->target_xy_text_sensors_) {
LOG_TEXT_SENSOR(" ", "TargetXY", s);
}
#endif
#endif
#ifdef USE_NUMBER
ESP_LOGCONFIG(TAG, "Numbers:");
@@ -298,6 +295,9 @@ bool LD2450Component::get_timeout_status_(uint32_t check_millis) {
if (check_millis == 0) {
return true;
}
if (this->timeout_ == 0) {
this->timeout_ = ld2450::convert_seconds_to_ms(DEFAULT_PRESENCE_TIMEOUT);
}
return App.get_loop_component_start_time() - check_millis >= this->timeout_;
}
@@ -468,16 +468,9 @@ void LD2450Component::handle_periodic_data_() {
// Store target info for zone target count. Zero out untracked targets (td==0)
// so stale coordinates don't produce ghost counts in count_targets_in_zone_().
#ifdef USE_LD2450_TARGET_XY
const Target previous = this->target_info_[index];
#endif
this->target_info_[index].x = (td > 0) ? tx : 0;
this->target_info_[index].y = (td > 0) ? ty : 0;
this->target_info_[index].is_moving = (td > 0) && is_moving;
#ifdef USE_LD2450_TARGET_XY
this->update_target_xy_(index,
previous.x != this->target_info_[index].x || previous.y != this->target_info_[index].y);
#endif
} // End loop thru targets
@@ -628,7 +621,10 @@ void LD2450Component::handle_ack_data_() {
break; // too short to hold a MAC address
}
this->bluetooth_on_ = ld24xx::store_mac(&this->buffer_data_[10], this->mac_address_);
this->bluetooth_on_ = std::memcmp(&this->buffer_data_[10], NO_MAC, sizeof(NO_MAC)) != 0;
if (this->bluetooth_on_) {
std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_));
}
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
@@ -866,27 +862,6 @@ void LD2450Component::set_direction_text_sensor(uint8_t target, text_sensor::Tex
this->direction_text_sensors_[target] = s;
}
#endif
#ifdef USE_LD2450_TARGET_XY
void LD2450Component::set_target_xy_text_sensor(uint8_t target, text_sensor::TextSensor *s) {
this->target_xy_text_sensors_[target] = s;
}
// Publish the position of a target in one radar frame; the sensor's filters throttle it
void LD2450Component::update_target_xy_(uint8_t target, bool changed) {
text_sensor::TextSensor *s = this->target_xy_text_sensors_[target];
if (s == nullptr || (!changed && s->has_state()))
return;
const Target &pos = this->target_info_[target];
if (pos.x == 0 && pos.y == 0) {
s->publish_state(DirectionStrings::get_progmem_str(DIRECTION_NA, DirectionStrings::LAST_INDEX));
return;
}
// Longest is "-32767,-32767"
char text[16];
const size_t len = buf_append_printf(text, sizeof(text), 0, "%d,%d", pos.x, pos.y);
s->publish_state(text, len);
}
#endif
// Send Zone coordinates data to LD2450
#ifdef USE_NUMBER
@@ -925,14 +900,17 @@ void LD2450Component::set_zone_numbers(uint8_t zone, number::Number *x1, number:
// Set Presence Timeout load and save from flash
#ifdef USE_NUMBER
void LD2450Component::set_presence_timeout() {
if (this->presence_timeout_number_ == nullptr)
return;
// Restore only while the number has no state yet: 0 is a valid presence_timeout (no hold)
if (!this->presence_timeout_number_->has_state())
this->presence_timeout_number_->publish_state(this->restore_from_flash_());
const float timeout = this->presence_timeout_number_->state;
this->save_to_flash_(timeout);
this->timeout_ = ld2450::convert_seconds_to_ms(timeout);
if (this->presence_timeout_number_ != nullptr) {
if (this->presence_timeout_number_->state == 0) {
float timeout = this->restore_from_flash_();
this->presence_timeout_number_->publish_state(timeout);
this->timeout_ = ld2450::convert_seconds_to_ms(timeout);
}
if (this->presence_timeout_number_->has_state()) {
this->save_to_flash_(this->presence_timeout_number_->state);
this->timeout_ = ld2450::convert_seconds_to_ms(this->presence_timeout_number_->state);
}
}
}
// Save Presence Timeout to flash
-10
View File
@@ -146,9 +146,6 @@ class LD2450Component : public Component, public uart::UARTDevice {
#ifdef USE_TEXT_SENSOR
void set_direction_text_sensor(uint8_t target, text_sensor::TextSensor *s);
#endif
#ifdef USE_LD2450_TARGET_XY
void set_target_xy_text_sensor(uint8_t target, text_sensor::TextSensor *s);
#endif
#ifdef USE_NUMBER
void set_zone_coordinate(number::Number *n);
void set_zone_numbers(uint8_t zone, number::Number *x1, number::Number *y1, number::Number *x2, number::Number *y2);
@@ -192,9 +189,6 @@ class LD2450Component : public Component, public uart::UARTDevice {
void save_to_flash_(float value);
float restore_from_flash_();
bool get_timeout_status_(uint32_t check_millis);
#ifdef USE_LD2450_TARGET_XY
void update_target_xy_(uint8_t target, bool changed);
#endif
void count_targets_in_zone_(const Zone &zone, uint8_t &still, uint8_t &moving);
uint32_t presence_millis_ = 0;
@@ -232,10 +226,6 @@ class LD2450Component : public Component, public uart::UARTDevice {
#ifdef USE_TEXT_SENSOR
std::array<text_sensor::TextSensor *, MAX_TARGETS> direction_text_sensors_{};
#endif
#ifdef USE_LD2450_TARGET_XY
// Publishes a target's position as "x,y" text, so both values always come from the same radar frame
std::array<text_sensor::TextSensor *, MAX_TARGETS> target_xy_text_sensors_{};
#endif
#ifdef LD2450_POLYGON_ZONE_COUNT
StaticVector<PolygonZone *, LD2450_POLYGON_ZONE_COUNT> polygon_zones_;
@@ -40,8 +40,6 @@ void PolygonZone::control(const std::string &value) {
ESP_LOGW(TAG, "'%s': could not save the polygon; keeping the previous one", LOG_STR_ARG(this->get_log_name()));
} else {
this->polygon_ = polygon;
// A user-made edit is written to flash now, as wall-powered sensors lose power without warning
global_preferences->sync();
// A target seen inside the previous polygon must not keep the new one on
this->last_inside_ms_ = 0;
if (this->polygon_.empty())
+3 -16
View File
@@ -18,10 +18,6 @@ from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401
DEPENDENCIES = ["ld2450"]
CONF_XY = "xy"
ICON_CROSSHAIRS_GPS = "mdi:crosshairs-gps"
MAX_TARGETS = 3
CONFIG_SCHEMA = cv.Schema(
@@ -47,11 +43,6 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend(
entity_category=ENTITY_CATEGORY_NONE,
icon=ICON_SIGN_DIRECTION,
),
cv.Optional(CONF_XY): text_sensor.text_sensor_schema(
entity_category=ENTITY_CATEGORY_NONE,
icon=ICON_CROSSHAIRS_GPS,
filters=text_sensor.timeout_throttle_filters("NA"),
),
}
)
for n in range(MAX_TARGETS)
@@ -65,12 +56,8 @@ async def to_code(config: ConfigType) -> None:
await text_sensors(CONF_VERSION, hub.set_version_text_sensor)
await text_sensors(CONF_MAC_ADDRESS, hub.set_mac_text_sensor)
for n in range(MAX_TARGETS):
if not (target_conf := config.get(f"target_{n + 1}")):
continue
if direction_config := target_conf.get(CONF_DIRECTION):
if (direction_conf := config.get(f"target_{n + 1}")) and (
direction_config := direction_conf.get(CONF_DIRECTION)
):
sens = await text_sensor.new_text_sensor(direction_config)
cg.add(hub.set_direction_text_sensor(n, sens))
if xy_config := target_conf.get(CONF_XY):
cg.add_define("USE_LD2450_TARGET_XY")
sens = await text_sensor.new_text_sensor(xy_config)
cg.add(hub.set_target_xy_text_sensor(n, sens))
+1 -24
View File
@@ -41,15 +41,7 @@ template<typename T, size_t N> optional<size_t> find_index(const T (&arr)[N], T
// Formats the MAC address into the buffer, or "unknown" when it is not valid
inline const char *format_mac_str(const uint8_t *mac_address, std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buffer) {
// mac_address_is_valid() rejects the multicast bit, which radar Bluetooth MACs may have set; only all-00 and all-FF
// mean no address
uint8_t any_set = 0x00;
uint8_t all_set = 0xFF;
for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++) {
any_set |= mac_address[i];
all_set &= mac_address[i];
}
if (any_set != 0x00 && all_set != 0xFF) {
if (mac_address_is_valid(mac_address)) {
format_mac_addr_upper(mac_address, buffer.data());
} else {
ESPHOME_strncpy_P(buffer.data(), ESPHOME_PSTR("unknown"), buffer.size());
@@ -57,21 +49,6 @@ inline const char *format_mac_str(const uint8_t *mac_address, std::span<char, MA
return buffer.data();
}
// MAC address the module uses when Bluetooth is disabled
static constexpr uint8_t NO_MAC[MAC_ADDRESS_SIZE] PROGMEM = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
// Stores the radar's reported MAC, or clears it when the radar reports Bluetooth off; returns whether Bluetooth is on
inline bool store_mac(const uint8_t *reported, uint8_t *mac_address) {
uint8_t no_mac[MAC_ADDRESS_SIZE];
progmem_memcpy(no_mac, NO_MAC, MAC_ADDRESS_SIZE);
if (std::memcmp(reported, no_mac, MAC_ADDRESS_SIZE) == 0) {
std::memset(mac_address, 0, MAC_ADDRESS_SIZE);
return false;
}
std::memcpy(mac_address, reported, MAC_ADDRESS_SIZE);
return true;
}
// Helper function to format firmware version with stack allocation
// Buffer must be exactly 20 bytes (format: "x.xxXXXXXX" fits in 11 + null terminator, 20 for safety)
inline void format_version_str(const uint8_t *version, std::span<char, 20> buffer) {
@@ -1,5 +1,4 @@
#include "prometheus_handler.h"
#include <cinttypes>
#if defined(USE_NETWORK) && !defined(USE_HOST)
#include "esphome/core/application.h"
@@ -101,24 +100,6 @@ void PrometheusHandler::handleRequest(AsyncWebServerRequest *req) {
this->valve_row_(stream, obj, area, node, friendly_name);
#endif
#ifdef USE_DATETIME_DATE
this->date_type_(stream);
for (auto *obj : App.get_dates())
this->date_row_(stream, obj, area, node, friendly_name);
#endif
#ifdef USE_DATETIME_TIME
this->time_type_(stream);
for (auto *obj : App.get_times())
this->time_row_(stream, obj, area, node, friendly_name);
#endif
#ifdef USE_DATETIME_DATETIME
this->datetime_type_(stream);
for (auto *obj : App.get_datetimes())
this->datetime_row_(stream, obj, area, node, friendly_name);
#endif
#ifdef USE_CLIMATE
this->climate_type_(stream);
for (auto *obj : App.get_climates())
@@ -1115,78 +1096,6 @@ void PrometheusHandler::climate_row_(AsyncResponseStream *stream, climate::Clima
}
#endif
#if defined(USE_DATETIME_DATE) || defined(USE_DATETIME_DATETIME) || defined(USE_DATETIME_TIME)
void PrometheusHandler::date_time_rows_(AsyncResponseStream *stream, ProgmemStr failed_metric, ProgmemStr value_metric,
EntityBase *obj, int64_t value_ms, std::string &area, std::string &node,
std::string &friendly_name) {
if (obj->is_internal() && !this->include_internal_)
return;
this->print_metric_labels_(stream, failed_metric, obj, area, node, friendly_name);
if (!obj->has_state()) {
stream->print(ESPHOME_F("\"} 1\n"));
return;
}
stream->print(ESPHOME_F("\"} 0\n"));
this->print_metric_labels_(stream, value_metric, obj, area, node, friendly_name);
// Date and datetime are ms since the epoch (what Grafana expects), time is ms since midnight
char buf[32];
snprintf(buf, sizeof(buf), "\"} %" PRId64 "\n", value_ms);
stream->print(buf);
}
#endif
#ifdef USE_DATETIME_DATE
void PrometheusHandler::date_type_(AsyncResponseStream *stream) {
stream->print(ESPHOME_F("#TYPE esphome_date_value gauge\n"));
stream->print(ESPHOME_F("#TYPE esphome_date_failed gauge\n"));
}
void PrometheusHandler::date_row_(AsyncResponseStream *stream, datetime::DateEntity *obj, std::string &area,
std::string &node, std::string &friendly_name) {
int64_t value_ms = 0;
if (obj->has_state()) {
// DateEntity::state_as_esptime() leaves the time fields unset, so build midnight here
ESPTime t{};
t.year = obj->year;
t.month = obj->month;
t.day_of_month = obj->day;
t.recalc_timestamp_local();
value_ms = static_cast<int64_t>(t.timestamp) * 1000;
}
this->date_time_rows_(stream, ESPHOME_F("esphome_date_failed"), ESPHOME_F("esphome_date_value"), obj, value_ms, area,
node, friendly_name);
}
#endif
#ifdef USE_DATETIME_TIME
void PrometheusHandler::time_type_(AsyncResponseStream *stream) {
stream->print(ESPHOME_F("#TYPE esphome_time_value gauge\n"));
stream->print(ESPHOME_F("#TYPE esphome_time_failed gauge\n"));
}
void PrometheusHandler::time_row_(AsyncResponseStream *stream, datetime::TimeEntity *obj, std::string &area,
std::string &node, std::string &friendly_name) {
int64_t value_ms = 0;
if (obj->has_state())
value_ms = (static_cast<int64_t>(obj->hour) * 3600 + obj->minute * 60 + obj->second) * 1000;
this->date_time_rows_(stream, ESPHOME_F("esphome_time_failed"), ESPHOME_F("esphome_time_value"), obj, value_ms, area,
node, friendly_name);
}
#endif
#ifdef USE_DATETIME_DATETIME
void PrometheusHandler::datetime_type_(AsyncResponseStream *stream) {
stream->print(ESPHOME_F("#TYPE esphome_datetime_value gauge\n"));
stream->print(ESPHOME_F("#TYPE esphome_datetime_failed gauge\n"));
}
void PrometheusHandler::datetime_row_(AsyncResponseStream *stream, datetime::DateTimeEntity *obj, std::string &area,
std::string &node, std::string &friendly_name) {
int64_t value_ms = 0;
if (obj->has_state())
value_ms = static_cast<int64_t>(obj->state_as_esptime().timestamp) * 1000;
this->date_time_rows_(stream, ESPHOME_F("esphome_datetime_failed"), ESPHOME_F("esphome_datetime_value"), obj,
value_ms, area, node, friendly_name);
}
#endif
} // namespace esphome::prometheus
#endif
@@ -193,36 +193,6 @@ class PrometheusHandler final : public AsyncWebHandler, public Component {
std::string &friendly_name);
#endif
#if defined(USE_DATETIME_DATE) || defined(USE_DATETIME_DATETIME) || defined(USE_DATETIME_TIME)
/// Print the failed row and, when the entity has a state, the value row in milliseconds
void date_time_rows_(AsyncResponseStream *stream, ProgmemStr failed_metric, ProgmemStr value_metric, EntityBase *obj,
int64_t value_ms, std::string &area, std::string &node, std::string &friendly_name);
#endif
#ifdef USE_DATETIME_DATE
/// Return the type for prometheus
void date_type_(AsyncResponseStream *stream);
/// Return the date state as prometheus data point
void date_row_(AsyncResponseStream *stream, datetime::DateEntity *obj, std::string &area, std::string &node,
std::string &friendly_name);
#endif
#ifdef USE_DATETIME_TIME
/// Return the type for prometheus
void time_type_(AsyncResponseStream *stream);
/// Return the time state as prometheus data point
void time_row_(AsyncResponseStream *stream, datetime::TimeEntity *obj, std::string &area, std::string &node,
std::string &friendly_name);
#endif
#ifdef USE_DATETIME_DATETIME
/// Return the type for prometheus
void datetime_type_(AsyncResponseStream *stream);
/// Return the datetime state as prometheus data point
void datetime_row_(AsyncResponseStream *stream, datetime::DateTimeEntity *obj, std::string &area, std::string &node,
std::string &friendly_name);
#endif
#ifdef USE_CLIMATE
/// Return the type for prometheus
void climate_type_(AsyncResponseStream *stream);
@@ -8,6 +8,8 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <new>
#ifdef USE_ESP_IDF
#include "esp_task_wdt.h"
#endif
@@ -94,10 +96,21 @@ static int draw_callback(JPEGDRAW *jpeg) {
return 1;
}
JpegDecoder::JpegDecoder(RuntimeImage *image)
: ImageDecoder(image, JPEG), jpeg_(RAMAllocator<JPEGDEC>().make_unique()) {
if (this->jpeg_ == nullptr) {
JpegDecoder::JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {
RAMAllocator<JPEGDEC> allocator;
JPEGDEC *jpeg = allocator.allocate(1);
if (jpeg == nullptr) {
ESP_LOGE(TAG, "Failed to allocate memory for the JPEG decoder");
return;
}
this->jpeg_ = new (jpeg) JPEGDEC();
}
JpegDecoder::~JpegDecoder() {
if (this->jpeg_ != nullptr) {
this->jpeg_->~JPEGDEC();
RAMAllocator<JPEGDEC> allocator;
allocator.deallocate(this->jpeg_, 1);
}
}
@@ -19,6 +19,7 @@ class JpegDecoder : public ImageDecoder {
* @param image The RuntimeImage to decode the stream into.
*/
JpegDecoder(RuntimeImage *image);
~JpegDecoder() override;
int prepare(size_t expected_size) override;
int HOT decode(uint8_t *buffer, size_t size) override;
@@ -46,8 +47,8 @@ class JpegDecoder : public ImageDecoder {
static int32_t source_seek(JPEGFILE *file, int32_t position);
static void source_close(void *handle) {}
/// About 18 KB of decoder state: PSRAM first, nullptr if out of memory
RAMUniquePtr<JPEGDEC> jpeg_;
/// About 18 KB of decoder state, allocated like pngle in PngDecoder: PSRAM first, nullptr if out of memory
JPEGDEC *jpeg_{nullptr};
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
bool source_failed_{false};
@@ -74,16 +74,9 @@ class TinyUSB final : public Component {
.bLength = sizeof(tusb_desc_device_t),
.bDescriptorType = TUSB_DESC_DEVICE,
.bcdUSB = 0x0200,
#if CFG_TUD_CDC || CFG_TUD_NCM || CFG_TUD_ECM_RNDIS
// Interface association (IAD) only for classes that group interfaces, as esp_tinyusb's default descriptor does
.bDeviceClass = TUSB_CLASS_MISC,
.bDeviceSubClass = MISC_SUBCLASS_COMMON,
.bDeviceProtocol = MISC_PROTOCOL_IAD,
#else
.bDeviceClass = TUSB_CLASS_UNSPECIFIED,
.bDeviceSubClass = 0,
.bDeviceProtocol = 0,
#endif
.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
.idVendor = 0x303A,
.idProduct = 0x4001,
+1 -3
View File
@@ -102,8 +102,7 @@
#define USE_GRAPH
#define USE_GRAPHICAL_DISPLAY_MENU
#define USE_GREE_FAHRENHEIT
#define USE_GREE_FEATURE_SWITCH
#define USE_GREE_RECEIVER
#define USE_GREE_SWITCH
#define USE_GREE_YAW1F
#define USE_HOERMANN_HCP_IDENTITY
#define USE_HOMEASSISTANT_TIME
@@ -185,7 +184,6 @@
#define MDNS_DYNAMIC_TXT_COUNT 2
#define LD2420_LISTENER_COUNT 1
#define LD2450_POLYGON_ZONE_COUNT 2
#define USE_LD2450_TARGET_XY
#define SEN6X_STATUS_SENSOR_COUNT 1
#define UART_BINARY_SENSOR_COUNT 2
#define MICRONOVA_LISTENER_COUNT 1
+6 -10
View File
@@ -137,7 +137,6 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
delay = 1;
}
uint32_t offset = 0;
// Take lock early to protect scheduler_item_pool_head_ access
{
LockGuard guard{this->lock_};
@@ -182,8 +181,13 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
if (type == SchedulerItem::INTERVAL) {
item->interval = delay;
// first execution happens immediately after a random smallish offset
offset = this->calculate_interval_offset_(delay);
uint32_t offset = this->calculate_interval_offset_(delay);
item->set_next_execution(now_64 + offset);
#ifdef ESPHOME_LOG_HAS_VERBOSE
SchedulerNameLog name_log;
ESP_LOGV(TAG, "Scheduler interval for %s is %" PRIu32 "ms, offset %" PRIu32 "ms",
name_log.format(name_type, static_name, hash_or_id), delay, offset);
#endif
} else {
item->interval = 0;
item->set_next_execution(now_64 + delay);
@@ -209,14 +213,6 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
}
#endif
}
#ifdef ESPHOME_LOG_HAS_VERBOSE
// Logged after releasing the lock: a logger on_message automation can call back into the scheduler.
if (type == SchedulerItem::INTERVAL) {
SchedulerNameLog name_log;
ESP_LOGV(TAG, "Scheduler interval for %s is %" PRIu32 "ms, offset %" PRIu32 "ms",
name_log.format(name_type, static_name, hash_or_id), delay, offset);
}
#endif
// A background insertion may shorten a sleep whose deadline was already computed.
if (!is_main_loop_thread()) [[unlikely]]
wake_scheduler_threadsafe();
@@ -16,6 +16,7 @@ from esphome.core import EsphomeError
("test_bk7231t.yaml", "BK7231T.*BLE 4.2"),
("test_bk7252.yaml", "BK7251.*BLE 4.2"),
("test_bk7231q.yaml", "BK7231Q.*no BLE"),
("test_bk7238.yaml", "BK7238.*bootloader"),
],
)
def test_unsupported_family_rejected(
@@ -32,11 +33,9 @@ def test_unsupported_family_rejected(
assert "cannot compile" in caplog.text
@pytest.mark.parametrize("config_file", ["test_bk7231n.yaml", "test_bk7238.yaml"])
def test_ble5_family_generates(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
) -> None:
main_cpp = generate_main(component_config_path(config_file))
main_cpp = generate_main(component_config_path("test_bk7231n.yaml"))
assert "bk72xx_ble::BK72xxBLE" in main_cpp
@@ -1,31 +0,0 @@
esphome:
name: gree-test
esp32:
board: esp32dev
remote_transmitter:
id: xmitr
pin: GPIO4
carrier_duty_percent: 50%
climate:
- platform: gree
id: gree_yan
name: GREE YAN
model: yan
transmitter_id: xmitr
switch:
- platform: gree
gree_id: gree_yan
light:
id: yan_light
name: YAN Light
turbo:
id: yan_turbo
name: YAN Turbo
health:
id: yan_health
name: YAN Health
restore_mode: ALWAYS_ON
@@ -1,29 +0,0 @@
esphome:
name: gree-test
esp32:
board: esp32dev
remote_transmitter:
id: xmitr
pin: GPIO4
carrier_duty_percent: 50%
remote_receiver:
id: rcvr
pin: GPIO5
climate:
- platform: gree
id: gree_yx1ff
name: GREE YX1FF
model: yx1ff
transmitter_id: xmitr
receiver_id: rcvr
switch:
- platform: gree
gree_id: gree_yx1ff
light:
id: yx1ff_light
name: YX1FF Light
@@ -1,4 +1,4 @@
"""GREE code paths are compiled in only for configs that use them."""
"""The YAW1F follow-up message is compiled in only for configs that use that model."""
from collections.abc import Callable
from pathlib import Path
@@ -20,26 +20,3 @@ def test_other_models_leave_the_define_out(
) -> None:
generate_main(component_config_path("yan.yaml"))
assert "USE_GREE_YAW1F" not in {define.name for define in CORE.defines}
def test_receiver_and_switch_defines_follow_the_config(
generate_main: Callable[[Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("yx1ff_switch.yaml"))
defines = {define.name for define in CORE.defines}
assert {"USE_GREE_RECEIVER", "USE_GREE_FEATURE_SWITCH"} <= defines
# YX1FF receives the light state over IR, so the switch never restores its own
assert (
"yx1ff_light->set_restore_mode(switch_::SWITCH_RESTORE_DISABLED);" in main_cpp
)
def test_transmit_only_config_leaves_the_receiver_out(
generate_main: Callable[[Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path("yan.yaml"))
defines = {define.name for define in CORE.defines}
assert "USE_GREE_RECEIVER" not in defines
assert "USE_GREE_FEATURE_SWITCH" not in defines
-199
View File
@@ -1,199 +0,0 @@
"""Tests for GREE feature switch validation."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome import config_validation as cv, final_validate as fv
from esphome.components import switch
from esphome.components.gree.switch import (
CONF_GREE_ID,
CONF_HEALTH,
CONF_TURBO,
CONF_XFAN,
_feature_switch_schema,
_validate_model,
_validate_unique,
)
from esphome.const import (
CONF_ID,
CONF_INITIAL_STATE,
CONF_LIGHT,
CONF_MODEL,
CONF_NAME,
CONF_RESTORE_MODE,
CONF_RESTORE_STATE,
CONF_STATE,
)
from esphome.core import ID
from esphome.types import ConfigType
CONF_CLIMATE = "climate"
RESTORE_MODE_VALIDATOR = cv.enum(switch.RESTORE_MODES, upper=True, space="_")
def _switch_config(model: str, features: ConfigType) -> ConfigType:
climate_id = ID("gree_climate", is_declaration=True)
full_config = fv.full_config.get()
full_config[CONF_CLIMATE] = [{CONF_ID: climate_id, CONF_MODEL: model}]
full_config.declare_ids.append((climate_id, [CONF_CLIMATE, 0, CONF_ID]))
return {CONF_GREE_ID: ID("gree_climate", is_declaration=False), **features}
def _validate_switch_config(
model: str, features: set[str], restore_modes: dict[str, str] | None = None
) -> ConfigType:
config = _switch_config(
model,
{
feature: (
{CONF_RESTORE_MODE: RESTORE_MODE_VALIDATOR(restore_modes[feature])}
if restore_modes and feature in restore_modes
else {}
)
for feature in features
},
)
_validate_model(config)
return config
@pytest.mark.parametrize(
("model", "feature"),
[
pytest.param("yx1ff", CONF_TURBO, id="yx1ff-turbo"),
pytest.param("yx1ff", CONF_HEALTH, id="yx1ff-health"),
pytest.param("yb1fa", CONF_HEALTH, id="yb1fa-health"),
pytest.param("yx1ff", CONF_XFAN, id="yx1ff-xfan"),
],
)
def test_unsupported_feature_is_rejected(model: str, feature: str) -> None:
with pytest.raises(
cv.Invalid,
match=rf"Gree model {model} does not support the {feature} switch",
):
_validate_switch_config(model, {feature})
@pytest.mark.parametrize(
("model", "features"),
[
pytest.param("yx1ff", {CONF_LIGHT}, id="yx1ff-light"),
pytest.param(
"yb1fa",
{CONF_TURBO, CONF_LIGHT, CONF_XFAN},
id="yb1fa-all-supported",
),
pytest.param(
"yan",
{CONF_TURBO, CONF_LIGHT, CONF_HEALTH, CONF_XFAN},
id="legacy-all-supported",
),
],
)
def test_supported_features_are_accepted(model: str, features: set[str]) -> None:
_validate_switch_config(model, features)
@pytest.mark.parametrize(
("raw", "expected"),
[
pytest.param({}, None, id="omitted"),
pytest.param(
{CONF_RESTORE_MODE: "RESTORE_DEFAULT_ON"},
"RESTORE_DEFAULT_ON",
id="explicit-schema-default",
),
pytest.param({CONF_RESTORE_MODE: "DISABLED"}, "DISABLED", id="explicit"),
],
)
def test_schema_keeps_only_explicit_restore_modes(
raw: dict[str, str], expected: str | None
) -> None:
validator = _feature_switch_schema("mdi:led-outline", "RESTORE_DEFAULT_ON")
config = validator({CONF_NAME: "Light", **raw})
if expected is None:
assert CONF_RESTORE_MODE not in config
else:
assert str(config[CONF_RESTORE_MODE]) == expected
def test_schema_initial_state_keeps_the_feature_default() -> None:
validator = _feature_switch_schema("mdi:led-outline", "RESTORE_DEFAULT_ON")
config = validator({CONF_NAME: "Light", CONF_INITIAL_STATE: False})
assert CONF_RESTORE_MODE not in config
assert config[CONF_RESTORE_STATE][CONF_STATE] == switch.RESTORE_STATE_KEEP
@pytest.mark.parametrize("model", ["yb1fa", "yx1ff"])
def test_rx_capable_switches_accept_omitted_or_disabled(model: str) -> None:
_validate_switch_config(model, {CONF_LIGHT})
_validate_switch_config(model, {CONF_LIGHT}, {CONF_LIGHT: "DISABLED"})
@pytest.mark.parametrize(
("model", "feature", "restore_mode"),
[
("yx1ff", CONF_LIGHT, "RESTORE_DEFAULT_ON"),
("yx1ff", CONF_LIGHT, "ALWAYS_ON"),
("yx1ff", CONF_LIGHT, "RESTORE_DEFAULT_OFF"),
("yb1fa", CONF_TURBO, "RESTORE_DEFAULT_OFF"),
("yb1fa", CONF_TURBO, "ALWAYS_OFF"),
("yb1fa", CONF_LIGHT, "ALWAYS_ON"),
],
)
def test_rx_capable_switches_reject_explicit_restore_modes(
model: str, feature: str, restore_mode: str
) -> None:
with pytest.raises(cv.Invalid, match="restore_mode must be DISABLED or omitted"):
_validate_switch_config(model, {feature}, {feature: restore_mode})
def test_rx_capable_switches_reject_restore_state() -> None:
with pytest.raises(cv.Invalid, match="restore_mode must be DISABLED or omitted"):
_validate_model(_switch_config("yx1ff", {CONF_LIGHT: {CONF_RESTORE_STATE: {}}}))
def test_legacy_switches_keep_custom_restore_modes() -> None:
config = _validate_switch_config("yan", {CONF_LIGHT}, {CONF_LIGHT: "ALWAYS_OFF"})
assert str(config[CONF_LIGHT][CONF_RESTORE_MODE]) == "ALWAYS_OFF"
def test_omitted_restore_mode_follows_the_model(
generate_main: Callable[[Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("yan_switch.yaml"))
assert (
"yan_light->set_restore_mode(switch_::SWITCH_RESTORE_DEFAULT_ON);" in main_cpp
)
assert (
"yan_turbo->set_restore_mode(switch_::SWITCH_RESTORE_DEFAULT_OFF);" in main_cpp
)
assert "yan_health->set_restore_mode(switch_::SWITCH_ALWAYS_ON);" in main_cpp
assert (
"gree_yan->register_feature_switch(gree::GREE_FEATURE_LIGHT, yan_light);"
in (main_cpp)
)
def test_second_switch_entry_for_one_climate_rejected() -> None:
"""The climate keeps one switch per feature, so a second entry for it would take one over."""
entry = {"platform": "gree", CONF_GREE_ID: "gree_climate"}
fv.full_config.get()["switch"] = [entry, dict(entry)]
with pytest.raises(cv.Invalid, match="Only one gree switch entry"):
_validate_unique(entry)
def test_each_climate_may_have_its_own_switch_entry() -> None:
"""Switch entries of different climates do not compete."""
entries = [
{"platform": "gree", CONF_GREE_ID: "gree_a"},
{"platform": "gree", CONF_GREE_ID: "gree_b"},
]
fv.full_config.get()["switch"] = entries
for entry in entries:
_validate_unique(entry)
@@ -1,2 +0,0 @@
packages:
bk72xx_ble: !include common.yaml
+4 -3
View File
@@ -8,10 +8,11 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.dependencies = manifest.dependencies + ["climate_ir", "climate", "switch"]
async def to_code_testing(config: ConfigType) -> None:
# These paths are define-gated; emit the defines so the tests cover them.
# Fahrenheit support is define-gated; emit it so the tests cover that path.
cg.add_define("USE_GREE_FAHRENHEIT")
cg.add_define("USE_GREE_FEATURE_SWITCH")
cg.add_define("USE_GREE_RECEIVER")
# The YAW1F follow-up message is compiled in only for configs that use that model
cg.add_define("USE_GREE_YAW1F")
# The mode bit switches are held by the climate, which restores them in setup()
cg.add_define("USE_GREE_SWITCH")
manifest.to_code = to_code_testing
-65
View File
@@ -1,65 +0,0 @@
#pragma once
#include <gtest/gtest.h>
#include "esphome/components/gree/gree.h"
#include "esphome/components/remote_base/remote_base.h"
namespace esphome::gree::testing {
using remote_base::RawTimings;
inline remote_base::RemoteReceiveData receive_data(const RawTimings &raw) {
return remote_base::RemoteReceiveData(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE);
}
inline RawTimings encode_signal(Model model, const GreeState &state) {
remote_base::RemoteTransmitData data;
GreeProtocol(model).encode(&data, state);
return data.get_data();
}
inline optional<GreeState> decode_signal(Model model, const RawTimings &raw) {
return GreeProtocol(model).decode(receive_data(raw));
}
inline GreeState with_checksum(GreeState state) {
state[GREE_STATE_FRAME_SIZE - 1] = GreeProtocol::calculate_checksum(state);
return state;
}
/// Hand raw timings to the climate as the remote receiver would.
inline bool receive_raw(GreeClimate &device, const RawTimings &raw) {
remote_base::RemoteReceiverListener *listener = &device;
return listener->on_receive(receive_data(raw));
}
inline bool receive(GreeClimate &device, Model model, const GreeState &state) {
return receive_raw(device, encode_signal(model, state));
}
/// Counts transmissions and keeps the last one's timings instead of driving IR hardware.
class CapturingTransmitter final : public remote_base::RemoteTransmitterBase {
public:
CapturingTransmitter() : RemoteTransmitterBase(nullptr) {}
/// The last transmitted frame, decoded with GreeProtocol.
GreeState frame(Model model) const {
const auto state = decode_signal(model, this->last_raw);
EXPECT_TRUE(state.has_value());
return state.value_or(GreeState{});
}
uint32_t send_count{0};
RawTimings last_raw;
uint32_t last_carrier_frequency{0};
protected:
void send_internal(uint32_t send_times, uint32_t /*send_wait*/) override {
this->send_count += send_times;
this->last_raw = this->temp_.get_data();
this->last_carrier_frequency = this->temp_.get_carrier_frequency();
}
};
} // namespace esphome::gree::testing
+1 -23
View File
@@ -4,21 +4,13 @@ climate:
id: my_gree_ac
model: YAN
transmitter_id: xmitr
# YX1FF, the model use_fahrenheit was verified on
# YX1FF, the model use_fahrenheit was verified on; it has no mode switches, so they stay on YAN
- platform: gree
name: GREE Fahrenheit
id: my_gree_ac_fahrenheit
model: YX1FF
use_fahrenheit: true
transmitter_id: xmitr
receiver_id: rcvr
- platform: gree
name: GREE YB1FA
id: my_gree_yb1fa_ac
model: YB1FA
transmitter_id: xmitr
receiver_id: rcvr
switch:
- platform: gree
@@ -31,17 +23,3 @@ switch:
name: "AC Health"
xfan:
name: "AC X-Fan"
- platform: gree
gree_id: my_gree_yb1fa_ac
light:
name: "YB1FA AC Lights"
turbo:
name: "YB1FA AC Turbo"
xfan:
name: "YB1FA AC X-Fan"
- platform: gree
gree_id: my_gree_ac_fahrenheit
light:
name: "YX1FF AC Lights"
File diff suppressed because it is too large. Load diff
@@ -1,185 +0,0 @@
#include <gtest/gtest.h>
#include "../common.h"
#include "esphome/components/switch/switch.h"
#include "esphome/core/application.h"
#include "esphome/core/preferences.h"
namespace esphome::gree::testing {
namespace {
// Stands in for the hub pointer code generation gives each switch's apply function.
GreeClimate *hub = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
template<GreeFeature F> void apply_feature(switch_::Switch * /*entity*/, bool state) {
hub->set_feature_state(F, state);
}
// The switch class code generation emits for `apply="set_feature_state(<feature>, state)"`.
template<GreeFeature F> using FeatureSwitch = switch_::ApplySwitch<apply_feature<F>>;
void initialize_climate(GreeClimate &device, Model model, CapturingTransmitter &transmitter) {
hub = &device;
device.set_model(model);
device.set_transmitter(&transmitter);
device.mode = climate::CLIMATE_MODE_COOL;
device.target_temperature = 20;
device.fan_mode = climate::CLIMATE_FAN_LOW;
device.swing_mode = climate::CLIMATE_SWING_OFF;
device.preset = climate::CLIMATE_PRESET_NONE;
}
/// Set up the climate as the application would at boot, with nothing saved.
void setup_climate(GreeClimate &device, Model model, CapturingTransmitter &transmitter) {
if (global_preferences != nullptr)
global_preferences->reset();
static char name[] = "test_gree";
static char friendly_name[] = "";
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
hub = &device;
device.set_model(model);
device.set_transmitter(&transmitter);
static_cast<Component &>(device).setup();
}
GreeClimateData decode_transmission(const CapturingTransmitter &transmitter) {
auto decoded = GreeClimateCodec::decode(GREE_YB1FA, transmitter.frame(GREE_YB1FA));
EXPECT_TRUE(decoded.has_value());
return decoded.value_or(GreeClimateData{climate::CLIMATE_MODE_OFF, GREE_TEMP_MIN, climate::CLIMATE_FAN_AUTO,
climate::CLIMATE_SWING_OFF, climate::CLIMATE_PRESET_NONE});
}
void receive_yb1fa_turbo(GreeClimate &device) {
ASSERT_TRUE(receive(device, GREE_YB1FA, {0x49, 0x04, 0x70, 0x50, 0x01, 0x21, 0x00, 0x90}));
}
} // namespace
// Code generation disables restoring for the models that receive feature state over IR.
TEST(GreeFeatureSwitch, DisabledRestoreSetupUsesParentStateWithoutTransmit) {
for (const Model model : {GREE_YB1FA, GREE_YX1FF}) {
CapturingTransmitter transmitter;
GreeClimate device;
FeatureSwitch<GREE_FEATURE_LIGHT> light;
light.set_restore_mode(switch_::SWITCH_RESTORE_DISABLED);
device.register_feature_switch(GREE_FEATURE_LIGHT, &light);
setup_climate(device, model, transmitter);
EXPECT_EQ(transmitter.send_count, 0U);
EXPECT_TRUE(light.has_state());
EXPECT_TRUE(light.state);
}
}
TEST(GreeFeatureSwitch, LegacySetupRestoresAllSwitchesInOneTransmission) {
CapturingTransmitter transmitter;
GreeClimate device;
FeatureSwitch<GREE_FEATURE_TURBO> turbo;
turbo.set_restore_mode(switch_::SWITCH_ALWAYS_ON);
device.register_feature_switch(GREE_FEATURE_TURBO, &turbo);
FeatureSwitch<GREE_FEATURE_LIGHT> light;
light.set_restore_mode(switch_::SWITCH_RESTORE_DEFAULT_OFF);
device.register_feature_switch(GREE_FEATURE_LIGHT, &light);
setup_climate(device, GREE_YAN, transmitter);
EXPECT_EQ(transmitter.send_count, 1U);
EXPECT_TRUE(turbo.state);
EXPECT_FALSE(light.state);
EXPECT_TRUE(device.get_feature_state(GREE_FEATURE_TURBO));
EXPECT_FALSE(device.get_feature_state(GREE_FEATURE_LIGHT));
const GreeState transmitted = transmitter.frame(GREE_YAN);
EXPECT_EQ(transmitted[2] & (GREE_MODE_BIT_TURBO | GREE_MODE_BIT_LIGHT), GREE_MODE_BIT_TURBO);
}
TEST(GreeFeatureSwitch, UnsupportedModelFeatureDoesNotTransmitAndRestoresState) {
CapturingTransmitter transmitter;
GreeClimate device;
initialize_climate(device, GREE_YX1FF, transmitter);
FeatureSwitch<GREE_FEATURE_TURBO> turbo;
device.register_feature_switch(GREE_FEATURE_TURBO, &turbo);
turbo.turn_on();
EXPECT_EQ(transmitter.send_count, 0U);
EXPECT_FALSE(turbo.state);
EXPECT_FALSE(device.get_feature_state(GREE_FEATURE_TURBO));
}
TEST(GreeFeatureSwitch, EnablingYB1FATurboSetsEffectiveFanHigh) {
CapturingTransmitter transmitter;
GreeClimate device;
initialize_climate(device, GREE_YB1FA, transmitter);
FeatureSwitch<GREE_FEATURE_TURBO> turbo;
device.register_feature_switch(GREE_FEATURE_TURBO, &turbo);
turbo.turn_on();
EXPECT_EQ(transmitter.send_count, 1U);
EXPECT_TRUE(turbo.state);
EXPECT_TRUE(device.get_feature_state(GREE_FEATURE_TURBO));
ASSERT_TRUE(device.fan_mode.has_value());
EXPECT_EQ(device.fan_mode.value_or(climate::CLIMATE_FAN_ON), climate::CLIMATE_FAN_HIGH);
const auto transmitted = decode_transmission(transmitter);
EXPECT_EQ(transmitted.fan_mode, climate::CLIMATE_FAN_HIGH);
EXPECT_TRUE(transmitted.feature_bits & GREE_MODE_BIT_TURBO);
}
TEST(GreeFeatureSwitch, ReceivingYB1FATurboSetsSwitchAndEffectiveFanHigh) {
CapturingTransmitter transmitter;
GreeClimate device;
initialize_climate(device, GREE_YB1FA, transmitter);
FeatureSwitch<GREE_FEATURE_TURBO> turbo;
device.register_feature_switch(GREE_FEATURE_TURBO, &turbo);
receive_yb1fa_turbo(device);
EXPECT_EQ(transmitter.send_count, 0U);
EXPECT_TRUE(turbo.state);
EXPECT_TRUE(device.get_feature_state(GREE_FEATURE_TURBO));
ASSERT_TRUE(device.fan_mode.has_value());
EXPECT_EQ(device.fan_mode.value_or(climate::CLIMATE_FAN_ON), climate::CLIMATE_FAN_HIGH);
}
TEST(GreeFeatureSwitch, FanChangeAfterEnablingYB1FATurboClearsTurbo) {
CapturingTransmitter transmitter;
GreeClimate device;
initialize_climate(device, GREE_YB1FA, transmitter);
FeatureSwitch<GREE_FEATURE_TURBO> turbo;
device.register_feature_switch(GREE_FEATURE_TURBO, &turbo);
turbo.turn_on();
auto call = device.make_call();
call.set_fan_mode(climate::CLIMATE_FAN_LOW);
call.perform();
EXPECT_EQ(transmitter.send_count, 2U);
EXPECT_FALSE(turbo.state);
EXPECT_FALSE(device.get_feature_state(GREE_FEATURE_TURBO));
const auto transmitted = decode_transmission(transmitter);
EXPECT_EQ(transmitted.fan_mode, climate::CLIMATE_FAN_LOW);
EXPECT_FALSE(transmitted.feature_bits & GREE_MODE_BIT_TURBO);
}
TEST(GreeFeatureSwitch, FanChangeAfterReceivingYB1FATurboClearsTurbo) {
CapturingTransmitter transmitter;
GreeClimate device;
initialize_climate(device, GREE_YB1FA, transmitter);
FeatureSwitch<GREE_FEATURE_TURBO> turbo;
device.register_feature_switch(GREE_FEATURE_TURBO, &turbo);
receive_yb1fa_turbo(device);
auto call = device.make_call();
call.set_fan_mode(climate::CLIMATE_FAN_MEDIUM);
call.perform();
EXPECT_EQ(transmitter.send_count, 1U);
EXPECT_FALSE(turbo.state);
EXPECT_FALSE(device.get_feature_state(GREE_FEATURE_TURBO));
const auto transmitted = decode_transmission(transmitter);
EXPECT_EQ(transmitted.fan_mode, climate::CLIMATE_FAN_MEDIUM);
EXPECT_FALSE(transmitted.feature_bits & GREE_MODE_BIT_TURBO);
}
} // namespace esphome::gree::testing
+2 -2
View File
@@ -1,4 +1,4 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml
gree: !include common.yaml
<<: !include common.yaml
+2 -2
View File
@@ -1,4 +1,4 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml
gree: !include common.yaml
<<: !include common.yaml
-7
View File
@@ -30,18 +30,11 @@ class MockUARTComponent : public uart::UARTComponent {
class TestableLD2450 : public LD2450Component {
public:
using LD2450Component::buffer_data_;
using LD2450Component::bluetooth_on_;
using LD2450Component::buffer_pos_;
using LD2450Component::get_timeout_status_;
using LD2450Component::mac_address_;
using LD2450Component::readline_;
using LD2450Component::timeout_;
using LD2450Component::version_;
using LD2450Component::zone_config_;
using LD2450Component::zone_type_;
#ifdef USE_NUMBER
using LD2450Component::pref_;
#endif
void feed(const std::vector<uint8_t> &data) {
for (uint8_t byte : data) {
-11
View File
@@ -131,23 +131,12 @@ text_sensor:
target_1:
direction:
name: Target-1 Direction
xy:
name: Target-1 XY
target_2:
direction:
name: Target-2 Direction
xy:
name: Target-2 XY
filters:
- throttle_with_priority:
timeout: 200ms
value: NA
target_3:
direction:
name: Target-3 Direction
xy:
name: Target-3 XY
filters: []
number:
- platform: ld2450
-43
View File
@@ -1,43 +0,0 @@
#include "common.h"
#include "esphome/components/ld24xx/ld24xx.h"
namespace esphome::ld2450::testing {
namespace {
// MAC query ACK (command 0xA5): header, length 10, command, result ok, 6 MAC bytes, footer.
std::vector<uint8_t> make_mac_ack(const uint8_t (&mac)[MAC_ADDRESS_SIZE]) {
std::vector<uint8_t> frame(std::begin(ld24xx::CMD_FRAME_HEADER), std::end(ld24xx::CMD_FRAME_HEADER));
frame.insert(frame.end(), {0x0A, 0x00, 0xA5, 0x01, 0x00, 0x00});
frame.insert(frame.end(), mac, mac + MAC_ADDRESS_SIZE);
frame.insert(frame.end(), std::begin(ld24xx::CMD_FRAME_FOOTER), std::end(ld24xx::CMD_FRAME_FOOTER));
return frame;
}
} // namespace
class LD2450MacAckTest : public ::testing::Test {
protected:
void SetUp() override { this->ld2450_.set_uart_parent(&this->mock_uart_); }
std::string mac_str_() const {
char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
return ld24xx::format_mac_str(this->ld2450_.mac_address_, buffer);
}
MockUARTComponent mock_uart_;
TestableLD2450 ld2450_;
};
TEST_F(LD2450MacAckTest, BluetoothOffAckClearsStoredMac) {
// Captured from radar firmware 2.04.23101915; the first byte has the multicast bit set
static constexpr uint8_t BEDROOM_MAC[MAC_ADDRESS_SIZE] = {0x81, 0x2C, 0x62, 0xBE, 0xC9, 0x96};
this->ld2450_.feed(make_mac_ack(BEDROOM_MAC));
EXPECT_TRUE(this->ld2450_.bluetooth_on_);
EXPECT_THAT(this->ld2450_.mac_address_, ::testing::ElementsAreArray(BEDROOM_MAC));
ASSERT_EQ(this->mac_str_(), "81:2C:62:BE:C9:96");
this->ld2450_.feed(make_mac_ack(ld24xx::NO_MAC));
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
EXPECT_FALSE(this->ld2450_.bluetooth_on_);
EXPECT_THAT(this->ld2450_.mac_address_, ::testing::Each(0));
}
} // namespace esphome::ld2450::testing
@@ -1,157 +0,0 @@
#ifdef USE_HOST
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include "common.h"
#include "esphome/components/host/preferences.h"
#include "esphome/components/number/number.h"
#include "esphome/core/application.h"
namespace esphome::ld2450::testing {
namespace fs = std::filesystem;
namespace {
TestableLD2450 *hub = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// What the generated apply="set_presence_timeout()", publish="before" number does
void apply_presence_timeout(number::Number *entity, float value) {
entity->publish_state(value);
hub->set_presence_timeout();
}
using PresenceTimeoutNumber = number::ApplyNumber<apply_presence_timeout>;
// The presence_timeout part of LD2450Component::setup(), without its radar restart and scheduled info read
void boot(TestableLD2450 &radar, number::Number *entity) {
radar.set_presence_timeout_number(entity);
if (entity != nullptr) {
radar.pref_ = entity->make_entity_preference<float>();
radar.set_presence_timeout();
}
}
} // namespace
class LD2450PresenceTimeoutTest : public ::testing::Test {
protected:
void SetUp() override {
this->temp_dir_ = fs::temp_directory_path() / "esphome_ld2450_presence_timeout_test";
fs::remove_all(this->temp_dir_);
fs::create_directories(this->temp_dir_);
setenv("ESPHOME_PREFDIR", this->temp_dir_.c_str(), 1);
App.pre_setup("test_ld2450_timeout", strlen("test_ld2450_timeout"), "", 0);
// A fresh store per test: the shared one keeps the file path of whichever test set it up first, whose directory
// may already be gone, and its in-memory values
host::host_preferences = &this->prefs_;
global_preferences = &this->prefs_;
this->ld2450_.set_uart_parent(&this->uart_);
hub = &this->ld2450_;
}
void TearDown() override {
hub = nullptr;
host::setup_preferences();
unsetenv("ESPHOME_PREFDIR");
fs::remove_all(this->temp_dir_);
}
/// Save a presence_timeout the way a previous boot did, and flush it
void save_timeout(float seconds) {
PresenceTimeoutNumber saved;
ESPPreferenceObject pref = saved.make_entity_preference<float>();
ASSERT_TRUE(pref.save(&seconds));
ASSERT_TRUE(global_preferences->sync());
}
/// Whether a frame without a target, now ms after the last one, clears presence
bool cleared_after(uint32_t last_target_ms, uint32_t now_ms) {
LoopBlockingGuard clock{nullptr, nullptr, now_ms};
return this->ld2450_.get_timeout_status_(last_target_ms);
}
host::HostPreferences prefs_;
MockUARTComponent uart_;
TestableLD2450 ld2450_;
PresenceTimeoutNumber timeout_number_;
fs::path temp_dir_;
};
TEST_F(LD2450PresenceTimeoutTest, NoNumberKeepsDefaultHold) {
boot(this->ld2450_, nullptr);
EXPECT_EQ(this->ld2450_.timeout_, 5000u);
EXPECT_FALSE(this->cleared_after(1000, 5999));
EXPECT_TRUE(this->cleared_after(1000, 6000));
}
TEST_F(LD2450PresenceTimeoutTest, SetupWithNothingSavedPublishesDefault) {
boot(this->ld2450_, &this->timeout_number_);
ASSERT_TRUE(this->timeout_number_.has_state());
EXPECT_EQ(this->timeout_number_.state, 5.0f);
EXPECT_EQ(this->ld2450_.timeout_, 5000u);
}
TEST_F(LD2450PresenceTimeoutTest, SetupWithSavedZeroPublishesZero) {
this->save_timeout(0);
boot(this->ld2450_, &this->timeout_number_);
ASSERT_TRUE(this->timeout_number_.has_state());
EXPECT_EQ(this->timeout_number_.state, 0.0f);
EXPECT_EQ(this->ld2450_.timeout_, 0u);
}
TEST_F(LD2450PresenceTimeoutTest, SetupWithSavedValuePublishesIt) {
this->save_timeout(12);
boot(this->ld2450_, &this->timeout_number_);
EXPECT_EQ(this->timeout_number_.state, 12.0f);
EXPECT_EQ(this->ld2450_.timeout_, 12000u);
}
TEST_F(LD2450PresenceTimeoutTest, ControlZeroKeepsZeroAndHoldsNothing) {
boot(this->ld2450_, &this->timeout_number_);
ASSERT_EQ(this->timeout_number_.state, 5.0f);
auto call = this->timeout_number_.make_call();
call.set_value(0);
call.perform();
// The old code restored the saved 5 s as soon as the state read 0
EXPECT_EQ(this->timeout_number_.state, 0.0f);
EXPECT_EQ(this->ld2450_.timeout_, 0u);
EXPECT_TRUE(this->cleared_after(1000, 1000));
EXPECT_TRUE(this->cleared_after(1000, 1001));
}
TEST_F(LD2450PresenceTimeoutTest, ControlZeroIsSavedForTheNextBoot) {
boot(this->ld2450_, &this->timeout_number_);
auto call = this->timeout_number_.make_call();
call.set_value(0);
call.perform();
ASSERT_TRUE(global_preferences->sync());
TestableLD2450 rebooted;
PresenceTimeoutNumber rebooted_number;
rebooted.set_uart_parent(&this->uart_);
hub = &rebooted;
boot(rebooted, &rebooted_number);
ASSERT_TRUE(rebooted_number.has_state());
EXPECT_EQ(rebooted_number.state, 0.0f);
EXPECT_EQ(rebooted.timeout_, 0u);
}
TEST_F(LD2450PresenceTimeoutTest, ControlNonZeroSetsHold) {
boot(this->ld2450_, &this->timeout_number_);
auto call = this->timeout_number_.make_call();
call.set_value(3);
call.perform();
EXPECT_EQ(this->ld2450_.timeout_, 3000u);
EXPECT_FALSE(this->cleared_after(1000, 3999));
EXPECT_TRUE(this->cleared_after(1000, 4000));
}
} // namespace esphome::ld2450::testing
#endif // USE_HOST
-55
View File
@@ -1,55 +0,0 @@
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include "esphome/components/ld24xx/ld24xx.h"
namespace esphome::ld24xx::testing {
static std::string format_mac(const uint8_t (&mac)[MAC_ADDRESS_SIZE]) {
char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
return format_mac_str(mac, buffer);
}
TEST(LD24xxFormatMac, MulticastBitMacIsShownAsIs) {
// First byte 0x81 has the multicast bit set, and the radar reports it as its Bluetooth MAC.
const uint8_t mac[MAC_ADDRESS_SIZE] = {0x81, 0x2C, 0x62, 0xBE, 0xC9, 0x96};
EXPECT_EQ(format_mac(mac), "81:2C:62:BE:C9:96");
}
TEST(LD24xxFormatMac, UnicastMacIsShownAsIs) {
const uint8_t mac[MAC_ADDRESS_SIZE] = {0x80, 0x2C, 0x62, 0xBE, 0xC9, 0x96};
EXPECT_EQ(format_mac(mac), "80:2C:62:BE:C9:96");
}
TEST(LD24xxFormatMac, AllZeroMacIsUnknown) {
const uint8_t mac[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0};
EXPECT_EQ(format_mac(mac), "unknown");
}
TEST(LD24xxFormatMac, AllFfMacIsUnknown) {
const uint8_t mac[MAC_ADDRESS_SIZE] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
EXPECT_EQ(format_mac(mac), "unknown");
}
TEST(LD24xxFormatMac, MacWithSomeZeroOrFfBytesIsShown) {
const uint8_t mac[MAC_ADDRESS_SIZE] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x01};
EXPECT_EQ(format_mac(mac), "00:00:00:00:00:01");
const uint8_t ff_mac[MAC_ADDRESS_SIZE] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE};
EXPECT_EQ(format_mac(ff_mac), "FF:FF:FF:FF:FF:FE");
}
TEST(LD24xxStoreMac, ReportedMacIsStored) {
const uint8_t reported[MAC_ADDRESS_SIZE] = {0x81, 0x2C, 0x62, 0xBE, 0xC9, 0x96};
uint8_t mac[MAC_ADDRESS_SIZE] = {};
EXPECT_TRUE(store_mac(reported, mac));
EXPECT_THAT(mac, ::testing::ElementsAreArray(reported));
}
TEST(LD24xxStoreMac, NoMacClearsStoredMac) {
uint8_t mac[MAC_ADDRESS_SIZE] = {0x81, 0x2C, 0x62, 0xBE, 0xC9, 0x96};
EXPECT_FALSE(store_mac(NO_MAC, mac));
EXPECT_THAT(mac, ::testing::Each(0));
}
} // namespace esphome::ld24xx::testing
-32
View File
@@ -182,29 +182,6 @@ climate:
name: LG Climate
transmitter_id: xmitr
datetime:
- platform: template
name: Date
id: test_date
type: date
initial_value: "2000-1-2"
set_action:
- logger.log: "set_value"
- platform: template
name: Time
id: test_time
type: time
initial_value: "12:34:56am"
set_action:
- logger.log: "set_value"
- platform: template
name: DateTime
id: test_datetime
type: datetime
initial_value: "2000-1-2 12:34:56"
set_action:
- logger.log: "set_value"
prometheus:
include_internal: true
relabel:
@@ -214,12 +191,3 @@ prometheus:
template_text_sensor1:
id: hello_text
name: Text Substitution
test_date:
id: prometheus_date
name: Prometheus Date
test_time:
id: prometheus_time
name: Prometheus Time
test_datetime:
id: prometheus_datetime
name: Prometheus DateTime
@@ -3,19 +3,21 @@
#include "esphome/components/runtime_image/jpeg_decoder.h"
#include "esphome/components/runtime_image/runtime_image.h"
#include <utility>
#ifdef USE_RUNTIME_IMAGE_JPEG
namespace esphome::runtime_image::testing {
// A decoder whose JPEGDEC state is missing, as when its allocation fails. The state moves to state_, which frees it.
// A decoder whose JPEGDEC state is missing, as when its allocation fails. The state is handed back before the base
// destructor runs, so the teardown stays with JpegDecoder.
class StatelessJpegDecoder : public JpegDecoder {
public:
explicit StatelessJpegDecoder(RuntimeImage *image) : JpegDecoder(image), state_(std::move(this->jpeg_)) {}
explicit StatelessJpegDecoder(RuntimeImage *image) : JpegDecoder(image), state_(this->jpeg_) {
this->jpeg_ = nullptr;
}
~StatelessJpegDecoder() override { this->jpeg_ = this->state_; }
protected:
RAMUniquePtr<JPEGDEC> state_;
JPEGDEC *state_;
};
// The ~18 KB of JPEGDEC state lives in its own allocation, so it can come from PSRAM
@@ -1,27 +0,0 @@
esphome:
name: sched-verbose-log
host:
api:
logger:
level: VERBOSE
on_message:
# The scheduler logs each new interval at VERBOSE. This automation then
# calls back into the scheduler (the delay is a scheduler timeout).
level: VERBOSE
then:
- delay: 1ms
globals:
- id: fire_count
type: int
initial_value: "0"
interval:
- interval: 50ms
then:
- lambda: |-
id(fire_count) += 1;
if (id(fire_count) == 5) {
ESP_LOGI("test", "INTERVAL_5_FIRES_REACHED");
}
@@ -1,198 +0,0 @@
esphome:
name: uart-mock-ld2450-target-xy
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2450's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
id: mock_uart
baud_rate: 256000
auto_start: false
responses:
# Generic ACK for any command, to unblock setup commands
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# One frame every 100 ms, as the radar sends them. Coordinates are sign-magnitude, with the high bit set for
# positive values.
injections:
# Targets 1 and 2 move, one step per frame. Target 3 moves for 3 frames, then leaves while its
# later positions are still held back by the throttle
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x58, 0x02, 0xE8, 0x83, 0x00, 0x00, 0x64, 0x00,
0xC8, 0x80, 0xD0, 0x87, 0x00, 0x00, 0x64, 0x00,
0x2C, 0x01, 0xB8, 0x8B, 0x00, 0x00, 0x64, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0xF4, 0x01, 0x1A, 0x84, 0x00, 0x00, 0x64, 0x00,
0x18, 0x81, 0xEE, 0x87, 0x00, 0x00, 0x64, 0x00,
0xFA, 0x00, 0xEA, 0x8B, 0x00, 0x00, 0x64, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x90, 0x01, 0x4C, 0x84, 0x00, 0x00, 0x64, 0x00,
0x68, 0x81, 0x0C, 0x88, 0x00, 0x00, 0x64, 0x00,
0xC8, 0x00, 0x1C, 0x8C, 0x00, 0x00, 0x64, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x01, 0x7E, 0x84, 0x00, 0x00, 0x64, 0x00,
0xB8, 0x81, 0x2A, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0xC8, 0x00, 0xB0, 0x84, 0x00, 0x00, 0x64, 0x00,
0x08, 0x82, 0x48, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x64, 0x00, 0xE2, 0x84, 0x00, 0x00, 0x64, 0x00,
0x58, 0x82, 0x66, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x14, 0x85, 0x00, 0x00, 0x64, 0x00,
0xA8, 0x82, 0x84, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x64, 0x80, 0x46, 0x85, 0x00, 0x00, 0x64, 0x00,
0xF8, 0x82, 0xA2, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0xC8, 0x80, 0x78, 0x85, 0x00, 0x00, 0x64, 0x00,
0x48, 0x83, 0xC0, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x2C, 0x81, 0xAA, 0x85, 0x00, 0x00, 0x64, 0x00,
0x98, 0x83, 0xDE, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x90, 0x81, 0xDC, 0x85, 0x00, 0x00, 0x64, 0x00,
0xE8, 0x83, 0xFC, 0x88, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
- delay: 100ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0xF4, 0x81, 0x0E, 0x86, 0x00, 0x00, 0x64, 0x00,
0x38, 0x84, 0x1A, 0x89, 0x00, 0x00, 0x64, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
# Targets 1 and 2 stand still for 1.2 s (repeated frames are ignored), then leave
- delay: 1300ms
inject_rx:
[
0xAA, 0xFF, 0x03, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x55, 0xCC,
]
ld2450:
id: ld2450_dev
uart_id: mock_uart
text_sensor:
- platform: ld2450
ld2450_id: ld2450_dev
target_1:
xy:
name: "Target 1 XY"
filters:
- timeout:
timeout: 500ms
value: last
- throttle_with_priority:
timeout: 500ms
value: NA
target_2:
xy:
name: "Target 2 XY"
filters: []
target_3:
xy:
name: "Target 3 XY"
filters:
- timeout:
timeout: 500ms
value: last
- throttle_with_priority:
timeout: 500ms
value: NA
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(mock_uart).start_scenario();'
@@ -1,34 +0,0 @@
"""Test that a logger on_message automation can use the scheduler from a scheduler log.
Regression test for a self-deadlock: Scheduler::set_timer_common_ logged a new
interval at VERBOSE while holding the scheduler lock. The logger calls on_message
listeners on the same thread, and an automation with a delay adds a scheduler
timeout, which took the same non-recursive lock again. The first set_interval()
in setup() hung the main loop.
"""
from __future__ import annotations
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_scheduler_verbose_log_on_message(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""The main loop keeps running when on_message reacts to a scheduler log."""
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await assert_device_name(client, "sched-verbose-log")
await waiter.wait_for("Scheduler interval for", timeout=5.0)
await waiter.wait_for("INTERVAL_5_FIRES_REACHED", timeout=5.0)
@@ -1,87 +0,0 @@
"""Integration test for the LD2450 target xy text sensor, with and without a throttle."""
from __future__ import annotations
import asyncio
from itertools import groupby
from aioesphomeapi import ButtonInfo, EntityState, TextSensorInfo, TextSensorState
import pytest
from .state_utils import require_entity, subscribe_after_initial_states
from .types import APIClientConnectedFactory, RunCompiledFunction
# The positions of each target in the fixture, one per radar frame
TARGET_1 = [f"{-600 + 100 * i},{1000 + 50 * i}" for i in range(12)]
TARGET_2 = [f"{200 + 80 * i},{2000 + 30 * i}" for i in range(12)]
# Target 3 moves for 3 frames (300 ms) and leaves, with a 500 ms throttle
TARGET_3 = [f"{-300 + 50 * i},{3000 + 50 * i}" for i in range(3)]
# Frames are 100 ms apart: "NA" must come well before the 500 ms throttle ends
TARGET_3_LEAVES_WITHIN_S = 0.45
def _distinct(states: list[str]) -> list[str]:
"""Drop the repeat that the timeout filter sends after a pause."""
return [state for state, _ in groupby(states)]
@pytest.mark.asyncio
async def test_uart_mock_ld2450_target_xy(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test the target xy text sensor with and without a throttle."""
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
target_1 = require_entity(entities, "target_1_xy", TextSensorInfo)
target_2 = require_entity(entities, "target_2_xy", TextSensorInfo)
target_3 = require_entity(entities, "target_3_xy", TextSensorInfo)
history: dict[int, list[str]] = {
target.key: [] for target in (target_1, target_2, target_3)
}
target_3_times: list[float] = []
loop = asyncio.get_running_loop()
both_left = loop.create_future()
def on_state(state: EntityState) -> None:
if not isinstance(state, TextSensorState) or state.key not in history:
return
history[state.key].append(state.state)
if state.key == target_3.key:
target_3_times.append(loop.time())
# Target 3 left long before, so only targets 1 and 2 end the scenario
left = all(
history[key] and history[key][-1] == "NA"
for key in (target_1.key, target_2.key)
)
if left and not both_left.done():
both_left.set_result(None)
await subscribe_after_initial_states(client, entities, on_state=on_state)
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
await asyncio.wait_for(both_left, timeout=10.0)
# Throttled: the first position at once, then a few, in order, and the final one before the target leaves
throttled = _distinct(history[target_1.key])
assert throttled[-1] == "NA"
positions = throttled[:-1]
assert positions[0] == TARGET_1[0]
assert positions[-1] == TARGET_1[-1], (
"the final position was held back for good"
)
indexes = [TARGET_1.index(position) for position in positions]
assert indexes == sorted(set(indexes)), f"out of order or repeated: {positions}"
assert 3 <= len(positions) <= 6, f"expected a few updates, got {positions}"
# Not throttled: every change is published
assert history[target_2.key] == [*TARGET_2, "NA"]
# "NA" bypasses the throttle and drops the held position: nothing follows it
assert _distinct(history[target_3.key]) == [TARGET_3[0], "NA"]
assert target_3_times[1] - target_3_times[0] < TARGET_3_LEAVES_WITHIN_S