Compare commits

...
Author SHA1 Message Date
J. Nick Koston 206f350dd0 [binary_sensor] Clarify why the autorepeat reset does not use Filter::reset 2026-10-27 14:25:29 -10:00
J. Nick Koston 7c9a826abf [binary_sensor] Give the timeout filter's real reason for skipping de-dup 2026-10-27 14:17:19 -10:00
J. Nick Koston 85ce32d85f [binary_sensor] Reuse the autorepeat release to reset it 2026-10-27 13:58:16 -10:00
J. Nick Koston ab65ae4abb [binary_sensor] Clean up the invalidate tests through the filter reset and shorten the autorepeat wait 2026-10-27 13:47:40 -10:00
J. Nick Koston 2149f15b63 [binary_sensor] Reset filters when the state becomes unknown 2026-10-27 13:43:50 -10:00
Jordan Zucker 205ef9407f [prometheus] Add prometheus datetime metrics (#9941) 2026-10-27 13:09:01 -10:00
Adam Vigneaux d552396650 [tinyusb] Only declare the IAD device class when a class needs it (#21719) 2026-10-27 22:20:50 +00:00
J. Nick Koston a101d57476 [runtime_image] Hold the JPEG decoder state in a RAMUniquePtr (#21717) 2026-10-27 11:37:24 -10:00
Bogdan Dumitru 195cb3b2b4 [ld2450] Allow a presence_timeout of 0 (#21733) 2026-10-27 11:23:59 -10:00
Bohdan 8529b7a59c [bk72xx_ble] Add BK7238 support (#21735) 2026-10-27 21:22:24 +00:00
Bogdan Dumitru 4e63342350 [ld2450] Write an edited polygon zone to flash right away (#21732) 2026-10-27 10:51:57 -10:00
Fabio Pugliese Ornellas 3784a3d56a [ld2450] Add a text sensor with the position of each target (#21416) 2026-10-27 10:50:35 -10:00
Bogdan Dumitru 848d1ac4e8 [ld24xx] Show radar MAC addresses that have the multicast bit set (#21731) 2026-10-27 10:45:00 -10:00
c3348789eb [gree] Add IR receiver support and YB1FA model (#18435)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Leonardo Rivera <leodrivera@gmail.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-27 09:28:11 -10:00
6ea4d976c2 [tas58xx] Add mixer mode select (#21637)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-27 09:20:00 -10:00
ebd5a8bb18 [debug] Log per-thread CPU usage on nRF52 (#13938)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-27 08:46:22 -10:00
61 changed files with 3657 additions and 497 deletions

No files matched your search

@@ -31,6 +31,13 @@ void BinarySensor::publish_initial_state(bool new_state) {
this->publish_state(new_state);
}
bool BinarySensor::set_new_state(const optional<bool> &new_state) {
#ifdef USE_BINARY_SENSOR_FILTER
// An unknown state drops each filter's pending output and last value, so the next value is not dropped as a repeat
if (!new_state.has_value()) {
for (Filter *filter = this->filter_list_; filter != nullptr; filter = filter->next_)
filter->reset();
}
#endif
if (StatefulEntityBase::set_new_state(new_state)) {
// weirdly, this file could be compiled even without USE_BINARY_SENSOR defined
#if defined(USE_BINARY_SENSOR) && defined(USE_CONTROLLER_REGISTRY)
+19 -1
View File
@@ -17,6 +17,10 @@ void Filter::output(bool value) {
this->next_->input(value);
}
}
void Filter::reset() {
this->dedup_.next_unknown();
App.scheduler.cancel_timeout(this);
}
void Filter::input(bool value) {
if (!this->dedup_.next(value))
return;
@@ -28,7 +32,7 @@ void Filter::input(bool value) {
void TimeoutFilter::input(bool value) {
App.scheduler.set_timeout(this, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); });
// we do not de-dup here otherwise changes from invalid to valid state will not be output
// Every input re-arms the timeout, including repeats, so this filter must not de-dup
this->output(value);
}
@@ -82,6 +86,12 @@ optional<bool> AutorepeatFilterBase::new_value(bool value) {
}
}
void AutorepeatFilterBase::reset() {
this->dedup_.next_unknown();
// The release cancels both timers; Filter::reset() would leave the on/off timer running
AutorepeatFilterBase::new_value(false);
}
void AutorepeatFilterBase::next_timing_() {
if (this->active_timing_ < this->timings_count_) {
App.scheduler.set_timeout(this, this->timings_[this->active_timing_].delay, [this]() { this->next_timing_(); });
@@ -96,6 +106,9 @@ void AutorepeatFilterBase::next_timing_() {
void AutorepeatFilterBase::next_value_(bool val) {
const AutorepeatFilterTiming &timing = this->timings_[this->active_timing_ - 2];
this->output(val);
// A callback of that output may have stopped the filter
if (this->active_timing_ < 2)
return;
App.scheduler.set_timeout(&this->active_timing_, val ? timing.time_on : timing.time_off,
[this, val]() { this->next_value_(!val); });
}
@@ -104,6 +117,11 @@ LambdaFilter::LambdaFilter(std::function<optional<bool>(bool)> f) : f_(std::move
optional<bool> LambdaFilter::new_value(bool value) { return this->f_(value); }
void SettleFilter::reset() {
Filter::reset();
this->steady_ = true;
}
optional<bool> SettleFilter::new_value(bool value) {
if (!this->steady_) {
App.scheduler.set_timeout(this, this->delay_.value(), [this, value]() {
@@ -24,6 +24,9 @@ class Filter {
protected:
friend BinarySensor;
/// Forget the last value and drop any pending scheduled output; called when the sensor's state is invalidated.
virtual void reset();
Filter *next_{nullptr};
BinarySensor *parent_{nullptr};
Deduplicator<bool> dedup_;
@@ -100,6 +103,7 @@ class AutorepeatFilterBase : public Filter {
protected:
AutorepeatFilterBase() = default;
void reset() override;
void next_timing_();
void next_value_(bool val);
@@ -156,6 +160,8 @@ class SettleFilter : public Filter {
template<typename T> void set_delay(T delay) { this->delay_ = delay; }
protected:
void reset() override;
TemplatableFn<uint32_t> delay_{};
bool steady_{true};
};
+11 -18
View File
@@ -4,12 +4,11 @@ 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), 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
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
`__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.
@@ -29,7 +28,6 @@ from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231Q,
FAMILY_BK7231T,
FAMILY_BK7238,
FAMILY_BK7251,
)
import esphome.config_validation as cv
@@ -54,7 +52,9 @@ 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)
# 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)
MIN_LIBRETINY_VERSION = (1, 14, 0)
_LOGGER = logging.getLogger(__name__)
@@ -86,14 +86,6 @@ 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
@@ -117,14 +109,15 @@ 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 is safe with BLE."""
"""Reject a LibreTiny release older than the one whose WiFi sleep and bond storage are 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"
"station stops while BLE runs, and on BK7238 the bond store erases flash "
"sector 0 and bricks the chip"
)
+10
View File
@@ -1,4 +1,5 @@
import esphome.codegen as cg
from esphome.components import logger
from esphome.components.nrf52.framework import include_west_project
from esphome.components.zephyr import zephyr_add_prj_conf
from esphome.config_helpers import filter_source_files_from_platform
@@ -9,6 +10,8 @@ from esphome.const import (
CONF_FRAGMENTATION,
CONF_FREE,
CONF_ID,
CONF_LEVEL,
CONF_LOGGER,
CONF_LOOP_TIME,
PlatformFramework,
)
@@ -62,6 +65,13 @@ async def to_code(config: ConfigType) -> None:
zephyr_add_prj_conf("LOG_BUFFER_SIZE", 4096)
zephyr_add_prj_conf("SEGGER_RTT_MODE_BLOCK_IF_FIFO_FULL", True)
zephyr_add_prj_conf("PRINTK", True)
# per-thread CPU usage, logged by update_platform_() at verbose level
level = CORE.config[CONF_LOGGER][CONF_LEVEL]
if logger.LOG_LEVEL_SEVERITY.index(level) >= logger.LOG_LEVEL_SEVERITY.index(
"VERBOSE"
):
zephyr_add_prj_conf("THREAD_RUNTIME_STATS", True)
zephyr_add_prj_conf("SCHED_THREAD_USAGE_ANALYSIS", True)
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add_define("USE_DEBUG")
+29 -1
View File
@@ -6,6 +6,7 @@
#include "esphome/core/log.h"
#include <esphome/components/zephyr/reset_reason.h>
#include <zephyr/drivers/hwinfo.h>
#include <zephyr/kernel.h>
#include <hal/nrf_power.h>
#include <cstdint>
#include <zephyr/storage/flash_map.h>
@@ -422,7 +423,34 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
return pos;
}
void DebugComponent::update_platform_() {}
// The Python side enables the statistics only for a verbose logger
#ifdef CONFIG_SCHED_THREAD_USAGE_ANALYSIS
static void log_thread_usage(const struct k_thread *thread, void *user_data) {
const auto cpu_cycles = *static_cast<const uint64_t *>(user_data);
auto *tid = const_cast<k_tid_t>(thread); // NOLINT(cppcoreguidelines-pro-type-const-cast)
k_thread_runtime_stats_t stats;
if (k_thread_runtime_stats_get(tid, &stats) != 0)
return;
char state[32];
const char *name = k_thread_name_get(tid);
ESP_LOGV(TAG, " %-16s prio %3d %-10s %3u%% peak %" PRIu32 " avg %" PRIu32, name != nullptr ? name : "?",
thread->base.prio, k_thread_state_str(tid, state, sizeof(state)),
static_cast<unsigned>(stats.execution_cycles * 100 / cpu_cycles),
static_cast<uint32_t>(k_cyc_to_us_floor64(stats.peak_cycles)),
static_cast<uint32_t>(k_cyc_to_us_floor64(stats.average_cycles)));
}
#endif
void DebugComponent::update_platform_() {
#ifdef CONFIG_SCHED_THREAD_USAGE_ANALYSIS
k_thread_runtime_stats_t cpu;
if (k_thread_runtime_stats_all_get(&cpu) != 0 || cpu.execution_cycles == 0)
return;
ESP_LOGV(TAG, "Thread CPU usage since boot (peak and average us per time slice):");
// The unlocked walk lets the log output inside the callback block
k_thread_foreach_unlocked(log_thread_usage, &cpu.execution_cycles);
#endif
}
} // namespace esphome::debug
#endif
+13 -2
View File
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import climate_ir
from esphome.components import climate_ir, remote_base
import esphome.config_validation as cv
from esphome.const import CONF_MODEL, CONF_USE_FAHRENHEIT
from esphome.types import ConfigType
@@ -13,7 +13,16 @@ GreeClimate = gree_ns.class_("GreeClimate", climate_ir.ClimateIR)
Model = gree_ns.enum("Model")
MODELS = Model.options(
"generic", "yan", "yaa", "yac", "yac1fb9", "yx1ff", "yag", "yaw1f", prefix="GREE_"
"generic",
"yan",
"yaa",
"yac",
"yac1fb9",
"yb1fa",
"yx1ff",
"yag",
"yaw1f",
prefix="GREE_",
)
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend(
@@ -32,3 +41,5 @@ 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")
+537 -190
View File
@@ -4,8 +4,6 @@
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/helpers.h"
#include <algorithm>
namespace esphome::gree {
ESPHOME_LOG_TAG(TAG, "gree.climate");
@@ -13,27 +11,25 @@ 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};
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);
}
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;
#endif
this->model_ = model;
// 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;
}
void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) {
assign_bits(this->mode_bits_, bit_mask, enabled);
this->transmit_state();
static constexpr uint8_t default_feature_bits(Model model) {
return supported_feature_mask(model) & GREE_MODE_BIT_LIGHT;
}
#ifdef USE_GREE_FAHRENHEIT
@@ -51,190 +47,292 @@ uint8_t fahrenheit_half_steps(float target_celsius) {
}
#endif
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;
struct GreeFrameSpaces {
uint32_t header;
uint32_t message;
};
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_();
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;
}
#endif
if (model == GREE_YAN) {
remote_state[3] = 0x50; // bits 4..7 always 0101
remote_state[4] = this->vertical_swing_();
// 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;
}
return position == GREE_VDIR_MANUAL || (position >= GREE_VDIR_UP && position <= GREE_VDIR_DOWN);
}
if (model == GREE_YX1FF || yag_layout) {
remote_state[2] = 0x60;
remote_state[3] = 0x50;
remote_state[4] = this->vertical_swing_();
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) {
// 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;
}
}
}
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 (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_;
}
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;
}
// 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 (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_QUIET;
if (fan == GREE_FAN_2)
return climate::CLIMATE_FAN_LOW;
return climate::CLIMATE_FAN_MEDIUM;
}
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
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();
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);
data->set_carrier_frequency(GREE_IR_FREQUENCY);
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);
write_frame(data, state, spaces);
#ifdef USE_GREE_YAW1F
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;
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;
data->space(GREE_YAW1F_FOLLOWUP_SPACE);
encode(remote_state);
write_frame(data, followup, spaces);
}
#endif
data->space(0);
transmit.perform();
}
uint8_t GreeClimate::operation_mode_() {
uint8_t operating_mode = GREE_MODE_ON;
#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 {};
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;
#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 {};
}
return operating_mode;
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;
}
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;
#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;
}
}
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) {
case climate::CLIMATE_FAN_LOW:
return GREE_FAN_1;
case climate::CLIMATE_FAN_MEDIUM:
return GREE_FAN_2;
case climate::CLIMATE_FAN_HIGH:
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;
}
uint8_t GreeClimate::horizontal_swing_() {
switch (this->swing_mode) {
#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)
return GREE_FAN_1;
if (fan_mode == climate::CLIMATE_FAN_LOW)
return GREE_FAN_2;
if (fan_mode == climate::CLIMATE_FAN_MEDIUM || fan_mode == climate::CLIMATE_FAN_HIGH)
return GREE_FAN_3;
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) {
case climate::CLIMATE_SWING_HORIZONTAL:
case climate::CLIMATE_SWING_BOTH:
return GREE_HDIR_SWING;
@@ -243,8 +341,8 @@ uint8_t GreeClimate::horizontal_swing_() {
}
}
uint8_t GreeClimate::vertical_swing_() {
switch (this->swing_mode) {
uint8_t GreeClimateCodec::encode_vertical_swing(climate::ClimateSwingMode swing_mode) {
switch (swing_mode) {
case climate::CLIMATE_SWING_VERTICAL:
case climate::CLIMATE_SWING_BOTH:
return GREE_VDIR_SWING;
@@ -253,24 +351,273 @@ uint8_t GreeClimate::vertical_swing_() {
}
}
uint8_t GreeClimate::temperature_() {
return (uint8_t) roundf(clamp<float>(this->target_temperature, GREE_TEMP_MIN, GREE_TEMP_MAX)) - GREE_TEMP_MIN;
}
#ifdef USE_GREE_RECEIVER
optional<GreeClimateData> GreeClimateCodec::decode_legacy(Model model, const GreeState &state) {
if (is_auxiliary_frame(model, state))
return {};
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;
}
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;
}
return GREE_PRESET_NONE;
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;
}
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;
}
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;
}
#endif // USE_GREE_RECEIVER
} // namespace esphome::gree
+113 -35
View File
@@ -1,7 +1,12 @@
#pragma once
#include <array>
#include "esphome/components/climate_ir/climate_ir.h"
#include "esphome/core/defines.h"
#ifdef USE_GREE_FEATURE_SWITCH
#include "esphome/components/switch/switch.h"
#endif
namespace esphome::gree {
@@ -40,18 +45,15 @@ 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;
// Timing specific to YAC1FB9
// Also used by YAW1F
// YAW1F shares the longer YAC1FB9 header and inter-block gap.
static constexpr uint32_t GREE_YAC1FB9_HEADER_SPACE = 4500;
static constexpr uint32_t GREE_YAC1FB9_MESSAGE_SPACE = 19980;
// YAW1F: every command is followed by a second 8-byte message after this gap
// Gap between the YAW1F primary command and its fixed second message.
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;
@@ -63,6 +65,9 @@ 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
@@ -75,17 +80,7 @@ static constexpr uint8_t GREE_HDIR_MIDDLE = 0x04;
static constexpr uint8_t GREE_HDIR_MRIGHT = 0x05;
static constexpr uint8_t GREE_HDIR_RIGHT = 0x06;
// 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.
// Feature bits in byte 2
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;
@@ -95,24 +90,94 @@ 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_YX1FF, GREE_YAG, GREE_YAW1F };
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
};
class GreeClimate final : public climate_ir::ClimateIR {
public:
@@ -123,25 +188,38 @@ 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_mode_bit(uint8_t bit_mask, bool enabled);
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);
#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
uint8_t operation_mode_();
uint8_t fan_speed_();
uint8_t horizontal_swing_();
uint8_t vertical_swing_();
uint8_t temperature_();
uint8_t preset_();
#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
Model model_{};
uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise
uint8_t feature_bits_{0};
#ifdef USE_GREE_FAHRENHEIT
bool use_fahrenheit_{false};
#endif
+144 -37
View File
@@ -1,7 +1,17 @@
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_LIGHT, DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG
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
import esphome.final_validate as fv
from esphome.types import ConfigType
@@ -10,77 +20,174 @@ from ..climate import CONF_MODEL, GreeClimate
CODEOWNERS = ["@nagyrobi"]
GreeModeBitSwitch = gree_ns.class_("GreeModeBitSwitch", switch.Switch, cg.Component)
GreeFeature = gree_ns.enum("GreeFeature")
CONF_TURBO = "turbo"
CONF_HEALTH = "health"
CONF_XFAN = "xfan"
CONF_GREE_ID = "gree_id"
# Switch configurations: (config_key, display_name, mode_bit, icon)
# Switch configurations: (config_key, feature, icon, default_restore_mode)
SWITCH_CONFIGS = (
(
CONF_TURBO,
"Gree Turbo Switch",
gree_ns.GREE_MODE_BIT_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_LIGHT, "Gree Light Switch", gree_ns.GREE_MODE_BIT_LIGHT, "mdi:led-outline"),
(CONF_HEALTH, "Gree Health Switch", gree_ns.GREE_MODE_BIT_HEALTH, "mdi:pine-tree"),
(
CONF_XFAN,
"Gree X-FAN Switch",
gree_ns.GREE_MODE_BIT_XFAN,
GreeFeature.GREE_FEATURE_XFAN,
"mdi:wall-sconce-flat",
"RESTORE_DEFAULT_OFF",
),
)
SUPPORTED_MODELS = {
"yan",
"yaa",
"yac",
"yac1fb9",
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}),
}
# 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
CONFIG_SCHEMA = cv.Schema(
{
cv.Required(CONF_GREE_ID): cv.use_id(GreeClimate),
**{
cv.Optional(key): switch.switch_schema(
GreeModeBitSwitch,
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_CONFIGS
cv.Optional(key): _feature_switch_schema(icon, default_restore_mode)
for key, _, icon, default_restore_mode in SWITCH_CONFIGS
},
}
)
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:
def _validate_features_for_model(model: str, config: ConfigType) -> None:
supported_features = MODEL_FEATURES.get(model)
if supported_features is None:
raise cv.Invalid(
"Gree switches are only supported for the "
+ ", ".join(SUPPORTED_MODELS)
+ ", ".join(sorted(MODEL_FEATURES))
+ " 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}"
)
FINAL_VALIDATE_SCHEMA = _validate_model
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]}
async def to_code(config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_GREE_ID])
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")
for conf_key, name, bit_mask, _ in SWITCH_CONFIGS:
if switch_conf := config.get(conf_key):
sw = cg.new_Pvariable(switch_conf[cv.CONF_ID], name, bit_mask)
await switch.register_switch(sw, switch_conf)
await cg.register_component(sw, switch_conf)
await cg.register_parented(sw, parent)
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)",
)
@@ -1,17 +0,0 @@
#include "gree_switch.h"
#include "esphome/core/log.h"
namespace esphome::gree {
ESPHOME_LOG_TAG(TAG, "gree.switch");
void GreeModeBitSwitch::setup() { this->restore(); }
void GreeModeBitSwitch::dump_config() { log_switch(TAG, " ", this->name_, this); }
void GreeModeBitSwitch::write_state(bool state) {
this->parent_->set_mode_bit(this->bit_mask_, state);
this->publish_state(state);
}
} // namespace esphome::gree
@@ -1,22 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/switch/switch.h"
#include "esphome/components/gree/gree.h"
namespace esphome::gree {
class GreeModeBitSwitch final : public switch_::Switch, public Component, public Parented<GreeClimate> {
public:
GreeModeBitSwitch(const char *name, uint8_t bit_mask) : name_(name), bit_mask_(bit_mask) {}
void setup() override;
void dump_config() override;
void write_state(bool state) override;
protected:
const char *name_;
uint8_t bit_mask_;
};
} // namespace esphome::gree
+1 -6
View File
@@ -102,8 +102,6 @@ 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];
@@ -425,10 +423,7 @@ void LD2410Component::handle_ack_data_() {
break; // too short to hold a 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_));
}
this->bluetooth_on_ = ld24xx::store_mac(&this->buffer_data_[10], this->mac_address_);
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
+1 -6
View File
@@ -120,8 +120,6 @@ 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() {
@@ -477,10 +475,7 @@ void LD2412Component::handle_ack_data_() {
break; // too short to hold a 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_));
}
this->bluetooth_on_ = ld24xx::store_mac(&this->buffer_data_[10], this->mac_address_);
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
+42 -20
View File
@@ -79,8 +79,6 @@ 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; };
@@ -192,6 +190,11 @@ 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:");
@@ -295,9 +298,6 @@ 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,9 +468,16 @@ 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
@@ -621,10 +628,7 @@ void LD2450Component::handle_ack_data_() {
break; // too short to hold a 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_));
}
this->bluetooth_on_ = ld24xx::store_mac(&this->buffer_data_[10], this->mac_address_);
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s);
@@ -862,6 +866,27 @@ 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
@@ -900,17 +925,14 @@ 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) {
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);
}
}
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);
}
// Save Presence Timeout to flash
+10
View File
@@ -146,6 +146,9 @@ 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);
@@ -189,6 +192,9 @@ 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;
@@ -226,6 +232,10 @@ 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,6 +40,8 @@ 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())
+16 -3
View File
@@ -18,6 +18,10 @@ 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(
@@ -43,6 +47,11 @@ 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)
@@ -56,8 +65,12 @@ 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 (direction_conf := config.get(f"target_{n + 1}")) and (
direction_config := direction_conf.get(CONF_DIRECTION)
):
if not (target_conf := config.get(f"target_{n + 1}")):
continue
if direction_config := target_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))
+24 -1
View File
@@ -41,7 +41,15 @@ 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) {
if (mac_address_is_valid(mac_address)) {
// 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) {
format_mac_addr_upper(mac_address, buffer.data());
} else {
ESPHOME_strncpy_P(buffer.data(), ESPHOME_PSTR("unknown"), buffer.size());
@@ -49,6 +57,21 @@ 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,4 +1,5 @@
#include "prometheus_handler.h"
#include <cinttypes>
#if defined(USE_NETWORK) && !defined(USE_HOST)
#include "esphome/core/application.h"
@@ -100,6 +101,24 @@ 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())
@@ -1096,6 +1115,78 @@ 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,6 +193,36 @@ 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,8 +8,6 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <new>
#ifdef USE_ESP_IDF
#include "esp_task_wdt.h"
#endif
@@ -96,21 +94,10 @@ static int draw_callback(JPEGDRAW *jpeg) {
return 1;
}
JpegDecoder::JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {
RAMAllocator<JPEGDEC> allocator;
JPEGDEC *jpeg = allocator.allocate(1);
if (jpeg == nullptr) {
JpegDecoder::JpegDecoder(RuntimeImage *image)
: ImageDecoder(image, JPEG), jpeg_(RAMAllocator<JPEGDEC>().make_unique()) {
if (this->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,7 +19,6 @@ 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;
@@ -47,8 +46,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, allocated like pngle in PngDecoder: PSRAM first, nullptr if out of memory
JPEGDEC *jpeg_{nullptr};
/// About 18 KB of decoder state: PSRAM first, nullptr if out of memory
RAMUniquePtr<JPEGDEC> jpeg_;
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};
@@ -0,0 +1,67 @@
import esphome.codegen as cg
from esphome.components import select
import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_CONFIG
from esphome.core import CORE
import esphome.final_validate as fv
from esphome.types import ConfigType
from .. import DOMAIN
from ..audio_dac import (
CONF_DAC_MODE,
CONF_MIXER_MODE,
CONF_TAS58XX_ID,
MIXER_MODES,
TAS58xx,
)
ICON_SPEAKER_MULTIPLE = "mdi:speaker-multiple"
# Option text of each mixer mode, in MixerMode order
MIXER_MODE_OPTIONS = {
"stereo": "Stereo",
"stereo_inverse": "Stereo inverse",
"mono": "Mono",
"left": "Left",
"right": "Right",
}
# PBTL drives single speaker, so stereo modes are not offered
PBTL_MIXER_MODES = ("mono", "left", "right")
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_TAS58XX_ID): cv.use_id(TAS58xx),
cv.Optional(CONF_MIXER_MODE): select.select_schema(
select.ApplySelect,
entity_category=ENTITY_CATEGORY_CONFIG,
icon=ICON_SPEAKER_MULTIPLE,
),
}
)
# audio_dac keeps one pointer to its select, so a second select would replace the first
FINAL_VALIDATE_SCHEMA = fv.unique_platform_key(
domain=select.DOMAIN,
platform=DOMAIN,
keys=(CONF_TAS58XX_ID,),
message="Only one tas58xx select entry is allowed per audio_dac ('{tas58xx_id}')",
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_TAS58XX_ID])
hub_conf = fv.get_declaring_config(config[CONF_TAS58XX_ID], CORE.config)
modes = (
PBTL_MIXER_MODES
if hub_conf[CONF_DAC_MODE] == "pbtl"
else tuple(MIXER_MODE_OPTIONS)
)
selects = select.sub_selects(config, parent=hub)
# Options run from first mode of dac_mode to MIXER_MODE_RIGHT, so index is an offset from it
await selects(
CONF_MIXER_MODE,
hub.set_mixer_mode_select,
options=[MIXER_MODE_OPTIONS[mode] for mode in modes],
apply=f"apply_mixer_mode(static_cast<tas58xx::MixerMode>({MIXER_MODES[modes[0]]} + index))",
)
+73 -24
View File
@@ -64,6 +64,21 @@ static const LogString *power_state_name(uint8_t state) {
return LOG_STR("Play");
}
// DSP coefficients can only be written while I2S clock runs, i.e. in Play (datasheet 7.5.3.1)
static bool dsp_writable(uint8_t power_state) { return power_state == TAS58XX_CTRL_STATE_PLAY; }
static const LogString *mixer_mode_name(MixerMode mixer_mode) {
if (mixer_mode == MIXER_MODE_STEREO_INVERSE)
return LOG_STR("Stereo inverse");
if (mixer_mode == MIXER_MODE_MONO)
return LOG_STR("Mono");
if (mixer_mode == MIXER_MODE_LEFT)
return LOG_STR("Left");
if (mixer_mode == MIXER_MODE_RIGHT)
return LOG_STR("Right");
return LOG_STR("Stereo");
}
void TAS58xx::setup() {
if (this->enable_pin_ != nullptr) {
this->enable_pin_->setup();
@@ -77,6 +92,10 @@ void TAS58xx::setup() {
return;
}
this->publish_active_(true); // init_() leaves the device in play
#ifdef USE_SELECT
// After init, so an on_value handler acts on a ready amplifier and init does not undo it
this->publish_mixer_mode_();
#endif
}
bool TAS58xx::select_book_page_(uint8_t book, uint8_t page) {
@@ -242,45 +261,41 @@ bool TAS58xx::read_faults_() {
}
void TAS58xx::on_audio_started() {
// DSP coefficients can only be written with a running I2S clock (datasheet 7.5.3.1) and are kept when it stops
if (this->is_failed() || this->mixer_written_)
return;
if (!this->write_mixer_()) {
// Speaker calls this once I2S clock runs, so DSP is writable
if (!this->is_failed())
this->write_dsp_();
}
bool TAS58xx::read_power_state_(uint8_t &power_state) {
if (!this->read_byte(TAS58XX_POWER_STATE, &power_state))
return false;
power_state &= TAS58XX_CTRL_STATE_MASK;
return true;
}
// DSP keeps its coefficients when clock stops, so each setting is written once after reset
void TAS58xx::write_dsp_() {
if (!this->mixer_written_ && !this->write_mixer_()) {
ESP_LOGW(TAG, "Failed to write mixer");
}
}
void TAS58xx::update() {
uint8_t power_state{0};
if (!this->status_comm_result(this->read_faults_() && this->read_byte(TAS58XX_POWER_STATE, &power_state),
if (!this->status_comm_result(this->read_faults_() && this->read_power_state_(power_state),
LOG_STR("Failed to read status")))
return;
power_state &= TAS58XX_CTRL_STATE_MASK;
if (power_state != this->power_state_) {
ESP_LOGD(TAG, "[0x%02X] Power state: %s", this->address_, LOG_STR_ARG(power_state_name(power_state)));
this->power_state_ = power_state;
}
// The device only plays with a running I2S clock. Fallback for speakers that do not call on_audio_started();
// a failed write is retried on the next update.
if (power_state == TAS58XX_CTRL_STATE_PLAY && !this->mixer_written_ && !this->write_mixer_()) {
ESP_LOGW(TAG, "Failed to write mixer");
}
// Fallback for speakers that do not call on_audio_started()
if (dsp_writable(power_state))
this->write_dsp_();
}
void TAS58xx::dump_config() {
const LogString *mixer_mode;
if (this->mixer_mode_ == MIXER_MODE_STEREO_INVERSE) {
mixer_mode = LOG_STR("Stereo inverse");
} else if (this->mixer_mode_ == MIXER_MODE_MONO) {
mixer_mode = LOG_STR("Mono");
} else if (this->mixer_mode_ == MIXER_MODE_LEFT) {
mixer_mode = LOG_STR("Left");
} else if (this->mixer_mode_ == MIXER_MODE_RIGHT) {
mixer_mode = LOG_STR("Right");
} else {
mixer_mode = LOG_STR("Stereo");
}
const LogString *fault_output;
if (this->fault_output_pin_ == FAULT_OUTPUT_NONE) {
fault_output = LOG_STR("None");
@@ -306,7 +321,8 @@ void TAS58xx::dump_config() {
this->analog_gain_db_,
this->dac_mode_ == DAC_MODE_PBTL ? LOG_STR_LITERAL("PBTL") : LOG_STR_LITERAL("BTL"),
this->modulation_ == MODULATION_1SPW_MODE ? LOG_STR_LITERAL("1SPW") : LOG_STR_LITERAL("BD"),
LOG_STR_ARG(mixer_mode), this->volume_min_db_, this->volume_max_db_, LOG_STR_ARG(fault_output));
LOG_STR_ARG(mixer_mode_name(this->mixer_mode_)), this->volume_min_db_, this->volume_max_db_,
LOG_STR_ARG(fault_output));
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Any Fault", this->have_fault_binary_sensor_);
for (auto *sensor : this->fault_binary_sensors_) {
@@ -316,6 +332,9 @@ void TAS58xx::dump_config() {
#ifdef USE_SWITCH
LOG_SWITCH(" ", "Activate", this->activate_switch_);
#endif
#ifdef USE_SELECT
LOG_SELECT(" ", "Mixer Mode", this->mixer_mode_select_);
#endif
}
bool TAS58xx::set_mute_(bool muted) {
@@ -344,6 +363,33 @@ bool TAS58xx::write_volume_() {
return this->write_byte(TAS58XX_DIG_VOL, dig_vol);
}
#ifdef USE_SELECT
void TAS58xx::publish_mixer_mode_() {
if (this->mixer_mode_select_ != nullptr) {
// Select options start at mono for PBTL
const uint8_t first_mode = this->dac_mode_ == DAC_MODE_PBTL ? MIXER_MODE_MONO : MIXER_MODE_STEREO;
this->mixer_mode_select_->publish_state(this->mixer_mode_ - first_mode);
}
}
void TAS58xx::apply_mixer_mode(MixerMode mixer_mode) {
if (this->is_failed())
return;
// Stereo modes are invalid for PBTL
if (this->dac_mode_ == DAC_MODE_PBTL && mixer_mode < MIXER_MODE_MONO) {
ESP_LOGW(TAG, "[0x%02X] Mixer mode %s is not valid for PBTL", this->address_,
LOG_STR_ARG(mixer_mode_name(mixer_mode)));
return;
}
this->mixer_mode_ = mixer_mode;
this->mixer_written_ = false;
this->publish_mixer_mode_();
uint8_t power_state;
if (this->read_power_state_(power_state) && dsp_writable(power_state))
this->write_dsp_();
}
#endif
bool TAS58xx::write_mixer_() {
uint8_t left_to_left = TAS58XX_MIXER_0DB;
uint8_t right_to_left = TAS58XX_MIXER_MUTE;
@@ -373,6 +419,9 @@ bool TAS58xx::write_mixer_() {
// Always return to the control port, even after a failed write
ok = this->select_book_page_(TAS58XX_BOOK_CONTROL, TAS58XX_PAGE_0) && ok;
this->mixer_written_ = ok;
if (ok) {
ESP_LOGD(TAG, "[0x%02X] Mixer mode written: %s", this->address_, LOG_STR_ARG(mixer_mode_name(this->mixer_mode_)));
}
return ok;
}
+18 -1
View File
@@ -11,6 +11,9 @@
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
#ifdef USE_SELECT
#include "esphome/components/select/select.h"
#endif
#ifdef USE_SWITCH
#include "esphome/components/switch/switch.h"
#endif
@@ -146,6 +149,12 @@ class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c:
void set_volume_min_db(float volume_min_db) { this->volume_min_db_ = volume_min_db; }
void set_volume_max_db(float volume_max_db) { this->volume_max_db_ = volume_max_db; }
#ifdef USE_SELECT
SUB_SELECT(mixer_mode)
/// Change input mixer; except if invalid for dac_mode. Written immediately if playing, otherwise when audio starts
void apply_mixer_mode(MixerMode mixer_mode);
#endif
#ifdef USE_BINARY_SENSOR
SUB_BINARY_SENSOR(have_fault)
void set_fault_binary_sensor(FaultSensor fault, binary_sensor::BinarySensor *sensor) {
@@ -174,6 +183,14 @@ class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c:
bool write_volume_();
bool write_mixer_();
bool read_faults_();
/// Read POWER_STATE, masked to device state; false if read failed
bool read_power_state_(uint8_t &power_state);
/// Write DSP settings not written since last reset; call only while DSP is writable
void write_dsp_();
#ifdef USE_SELECT
/// Show current mixer mode on mixer mode select
void publish_mixer_mode_();
#endif
const ModelInfo *model_;
GPIOPin *enable_pin_{nullptr};
@@ -191,7 +208,7 @@ class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c:
Modulation modulation_{MODULATION_BD_MODE};
uint8_t ctrl_state_{0};
uint8_t power_state_{0xFF}; // Last POWER_STATE seen by update(), 0xFF until the first read
bool mixer_written_{false}; // Mixer written since the last reset
bool mixer_written_{false}; // False while mixer write is pending
};
} // namespace esphome::tas58xx
@@ -74,9 +74,16 @@ 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,
+3
View File
@@ -102,6 +102,8 @@
#define USE_GRAPH
#define USE_GRAPHICAL_DISPLAY_MENU
#define USE_GREE_FAHRENHEIT
#define USE_GREE_FEATURE_SWITCH
#define USE_GREE_RECEIVER
#define USE_GREE_YAW1F
#define USE_HOERMANN_HCP_IDENTITY
#define USE_HOMEASSISTANT_TIME
@@ -183,6 +185,7 @@
#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
@@ -16,7 +16,6 @@ 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(
@@ -33,9 +32,11 @@ 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("test_bk7231n.yaml"))
main_cpp = generate_main(component_config_path(config_file))
assert "bk72xx_ble::BK72xxBLE" in main_cpp
Whitespace-only changes.
@@ -0,0 +1,11 @@
esphome:
name: debugtest
nrf52:
board: adafruit_itsybitsy_nrf52840
bootloader: adafruit_nrf52_sd140_v6
logger:
level: DEBUG
debug:
@@ -0,0 +1,11 @@
esphome:
name: debugtest
nrf52:
board: adafruit_itsybitsy_nrf52840
bootloader: adafruit_nrf52_sd140_v6
logger:
level: VERBOSE
debug:
@@ -0,0 +1,11 @@
esphome:
name: debugtest
nrf52:
board: adafruit_itsybitsy_nrf52840
bootloader: adafruit_nrf52_sd140_v6
logger:
level: VERY_VERBOSE
debug:
@@ -0,0 +1,32 @@
"""Tests for the per-thread CPU usage statistics debug enables on nRF52."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.zephyr import zephyr_data
from esphome.components.zephyr.const import KEY_PRJ_CONF
STATS_KEYS = ("CONFIG_THREAD_RUNTIME_STATS", "CONFIG_SCHED_THREAD_USAGE_ANALYSIS")
@pytest.mark.parametrize(
("fixture", "enabled"),
[
("logger_debug.yaml", False),
("logger_verbose.yaml", True),
("logger_very_verbose.yaml", True),
],
)
def test_thread_usage_follows_logger_level(
fixture: str,
enabled: bool,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The statistics are only built in when the verbose table can be logged."""
generate_main(component_config_path(fixture))
prj_conf = zephyr_data()[KEY_PRJ_CONF][""]
for key in STATS_KEYS:
assert (prj_conf.get(key, (False,))[0] is True) is enabled, key
@@ -0,0 +1,31 @@
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
@@ -0,0 +1,29 @@
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 @@
"""The YAW1F follow-up message is compiled in only for configs that use that model."""
"""GREE code paths are compiled in only for configs that use them."""
from collections.abc import Callable
from pathlib import Path
@@ -20,3 +20,26 @@ 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
@@ -0,0 +1,199 @@
"""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)
@@ -0,0 +1,80 @@
"""Tests for the TAS58xx mixer mode select."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.tas58xx.select import FINAL_VALIDATE_SCHEMA
import esphome.config_validation as cv
from esphome.const import PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
def test_mixer_select_options_follow_dac_mode(
generate_main: Callable[[str | Path], str],
) -> None:
"""BTL offers every mixer mode, PBTL only mono, left and right, starting at its first mode."""
main_cpp = generate_main(
"tests/component_tests/tas58xx/test_tas58xx_mixer_select.yaml"
)
assert (
'select_options[] PROGMEM = {"Stereo", "Stereo inverse", "Mono", "Left", "Right"};'
in main_cpp
)
assert (
"apply_mixer_mode(static_cast<tas58xx::MixerMode>(tas58xx::MIXER_MODE_STEREO + index))"
in main_cpp
)
assert "mixer_btl->traits.set_options_static(select_options, 5);" in main_cpp
assert 'select_options_2[] PROGMEM = {"Mono", "Left", "Right"};' in main_cpp
assert (
"apply_mixer_mode(static_cast<tas58xx::MixerMode>(tas58xx::MIXER_MODE_MONO + index))"
in main_cpp
)
assert "mixer_pbtl->traits.set_options_static(select_options_2, 3);" in main_cpp
def test_mixer_select_is_given_its_amplifier(
generate_main: Callable[[str | Path], str],
) -> None:
"""Each select is handed to its own amplifier, which applies the mode."""
main_cpp = generate_main(
"tests/component_tests/tas58xx/test_tas58xx_mixer_select.yaml"
)
# The amplifier publishes the select itself, after it accepts a mode
assert "publish_state(index)" not in main_cpp
assert "amp_btl->set_mixer_mode_select(mixer_btl);" in main_cpp
assert "amp_pbtl->set_mixer_mode_select(mixer_pbtl);" in main_cpp
assert "App.register_component_(mixer_btl" not in main_cpp
def test_second_select_entry_for_one_amplifier_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The amplifier keeps one select, so a second entry for it would take over the first."""
entry = {"platform": "tas58xx", "tas58xx_id": "amp"}
set_core_config(
PlatformFramework.ESP32_IDF, full_config={"select": [entry, dict(entry)]}
)
with pytest.raises(cv.Invalid, match="Only one tas58xx select entry"):
FINAL_VALIDATE_SCHEMA(entry)
def test_each_amplifier_may_have_its_own_select(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Selects of different amplifiers do not compete."""
entries = [
{"platform": "tas58xx", "tas58xx_id": "amp_a"},
{"platform": "tas58xx", "tas58xx_id": "amp_b"},
]
set_core_config(PlatformFramework.ESP32_IDF, full_config={"select": entries})
for entry in entries:
assert FINAL_VALIDATE_SCHEMA(entry) is entry
@@ -0,0 +1,35 @@
esphome:
name: test
esp32:
board: esp32dev
i2c:
id: i2c_bus
sda: 21
scl: 22
audio_dac:
- platform: tas58xx
model: tas5805m
id: amp_btl
ignore_enable_pin_warning: true
- platform: tas58xx
model: tas5825m
id: amp_pbtl
address: 0x2C
dac_mode: pbtl
mixer_mode: left
ignore_enable_pin_warning: true
select:
- platform: tas58xx
tas58xx_id: amp_btl
mixer_mode:
id: mixer_btl
name: Mixer BTL
- platform: tas58xx
tas58xx_id: amp_pbtl
mixer_mode:
id: mixer_pbtl
name: Mixer PBTL
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config: ConfigType) -> None:
# filter.cpp only compiles with the define the real to_code emits for configured filters
cg.add_define("USE_BINARY_SENSOR_FILTER")
manifest.to_code = to_code_testing
@@ -0,0 +1,111 @@
#include <gtest/gtest.h>
#include "esphome/components/binary_sensor/binary_sensor.h"
#include "esphome/components/binary_sensor/filter.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
namespace esphome::binary_sensor::testing {
static void run_scheduler_after(uint32_t ms) {
delay(ms);
App.scheduler.call(millis());
}
TEST(BinarySensorInvalidate, RepeatedValuePassesTheFiltersAfterInvalidate) {
BinarySensor sensor;
InvertFilter invert;
sensor.add_filter(&invert);
sensor.publish_state(false);
EXPECT_TRUE(sensor.state);
sensor.invalidate_state();
EXPECT_FALSE(sensor.has_state());
sensor.publish_state(false);
ASSERT_TRUE(sensor.has_state());
EXPECT_TRUE(sensor.state);
// publish_initial_state() invalidates first, so a repeated value passes there too
sensor.publish_state(true);
sensor.publish_initial_state(true);
ASSERT_TRUE(sensor.has_state());
EXPECT_FALSE(sensor.state);
}
TEST(BinarySensorInvalidate, PendingDelayedOutputIsDropped) {
BinarySensor sensor;
DelayedOnFilter delayed_on;
delayed_on.set_delay([]() -> uint32_t { return 10; });
sensor.add_filter(&delayed_on);
sensor.publish_state(true);
sensor.invalidate_state();
run_scheduler_after(30);
EXPECT_FALSE(sensor.has_state());
}
TEST(BinarySensorInvalidate, SettleFilterStartsSteadyAfterInvalidate) {
BinarySensor sensor;
SettleFilter settle;
settle.set_delay([]() -> uint32_t { return 10; });
sensor.add_filter(&settle);
sensor.publish_state(true);
EXPECT_TRUE(sensor.state);
sensor.invalidate_state();
sensor.publish_state(true);
ASSERT_TRUE(sensor.has_state());
EXPECT_TRUE(sensor.state);
// A change inside the settle time is pending; invalidating drops it
sensor.publish_state(false);
EXPECT_TRUE(sensor.state);
sensor.invalidate_state();
run_scheduler_after(30);
EXPECT_FALSE(sensor.has_state());
}
// TimeoutFilter invalidates the sensor from its own scheduler callback, which resets that same filter
TEST(BinarySensorInvalidate, TimeoutFilterInvalidatesAndTheSameValuePassesAgain) {
BinarySensor sensor;
TimeoutFilter timeout;
timeout.set_timeout_value([]() -> uint32_t { return 10; });
InvertFilter invert;
sensor.add_filters({&timeout, &invert});
sensor.publish_state(false);
ASSERT_TRUE(sensor.has_state());
EXPECT_TRUE(sensor.state);
run_scheduler_after(30);
EXPECT_FALSE(sensor.has_state());
sensor.publish_state(false);
ASSERT_TRUE(sensor.has_state());
EXPECT_TRUE(sensor.state);
// Cancel the timeout that publish armed before the sensor on this stack frame goes away
sensor.invalidate_state();
}
// An on_release automation that invalidates the sensor resets the autorepeat filter while it is still outputting
TEST(BinarySensorInvalidate, AutorepeatResetFromItsOwnOutputStopsTheRepeat) {
BinarySensor sensor;
AutorepeatFilter<1> autorepeat({{10, 10, 10}});
sensor.add_filter(&autorepeat);
bool invalidate_on_release = true;
sensor.add_on_state_callback([&sensor, &invalidate_on_release](bool state) {
if (!state && invalidate_on_release) {
invalidate_on_release = false;
sensor.invalidate_state();
}
});
sensor.publish_state(true);
ASSERT_TRUE(sensor.state);
// The repeat starts after 10 ms with a release, and that release invalidates the sensor
for (int i = 0; i < 3; i++) {
run_scheduler_after(10);
}
EXPECT_FALSE(sensor.has_state());
}
} // namespace esphome::binary_sensor::testing
@@ -0,0 +1,2 @@
packages:
bk72xx_ble: !include common.yaml
+4 -3
View File
@@ -5,12 +5,13 @@ from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# AUTO_LOAD sits on the climate platform, which the unit test build does not load.
manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"]
manifest.dependencies = manifest.dependencies + ["climate_ir", "climate", "switch"]
async def to_code_testing(config: ConfigType) -> None:
# Fahrenheit support is define-gated; emit it so the tests cover that path.
# These paths are define-gated; emit the defines so the tests cover them.
cg.add_define("USE_GREE_FAHRENHEIT")
# The YAW1F follow-up message is compiled in only for configs that use that model
cg.add_define("USE_GREE_FEATURE_SWITCH")
cg.add_define("USE_GREE_RECEIVER")
cg.add_define("USE_GREE_YAW1F")
manifest.to_code = to_code_testing
+65
View File
@@ -0,0 +1,65 @@
#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
+23 -1
View File
@@ -4,13 +4,21 @@ climate:
id: my_gree_ac
model: YAN
transmitter_id: xmitr
# YX1FF, the model use_fahrenheit was verified on; it has no mode switches, so they stay on YAN
# YX1FF, the model use_fahrenheit was verified on
- 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
@@ -23,3 +31,17 @@ 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
@@ -0,0 +1,185 @@
#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
<<: !include common.yaml
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml
gree: !include common.yaml
+2 -2
View File
@@ -1,4 +1,4 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml
<<: !include common.yaml
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml
gree: !include common.yaml
+7
View File
@@ -30,11 +30,18 @@ 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,12 +131,23 @@ 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
@@ -0,0 +1,43 @@
#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
@@ -0,0 +1,157 @@
#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
@@ -0,0 +1,55 @@
#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,6 +182,29 @@ 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:
@@ -191,3 +214,12 @@ 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,21 +3,19 @@
#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 is handed back before the base
// destructor runs, so the teardown stays with JpegDecoder.
// A decoder whose JPEGDEC state is missing, as when its allocation fails. The state moves to state_, which frees it.
class StatelessJpegDecoder : public JpegDecoder {
public:
explicit StatelessJpegDecoder(RuntimeImage *image) : JpegDecoder(image), state_(this->jpeg_) {
this->jpeg_ = nullptr;
}
~StatelessJpegDecoder() override { this->jpeg_ = this->state_; }
explicit StatelessJpegDecoder(RuntimeImage *image) : JpegDecoder(image), state_(std::move(this->jpeg_)) {}
protected:
JPEGDEC *state_;
RAMUniquePtr<JPEGDEC> state_;
};
// The ~18 KB of JPEGDEC state lives in its own allocation, so it can come from PSRAM
+6
View File
@@ -26,6 +26,12 @@ esphome:
id: tas58xx_amp
- tas58xx.activate: tas58xx_amp_2
select:
- platform: tas58xx
tas58xx_id: tas58xx_amp
mixer_mode:
name: Mixer Mode
binary_sensor:
- platform: tas58xx
tas58xx_id: tas58xx_amp
@@ -10,3 +10,9 @@ audio_dac:
mixer_mode: mono
analog_gain: -3.5dB
fault_output: none
select:
- platform: tas58xx
tas58xx_id: tas58xx_pbtl
mixer_mode:
name: PBTL Mixer Mode
@@ -0,0 +1,198 @@
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();'
@@ -0,0 +1,87 @@
"""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