mirror of
https://github.com/esphome/esphome.git
synced 2026-10-28 00:42:11 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
206f350dd0 | ||
|
|
7c9a826abf | ||
|
|
85ce32d85f | ||
|
|
ab65ae4abb | ||
|
|
2149f15b63 | ||
|
|
205ef9407f | ||
|
|
d552396650 | ||
|
|
a101d57476 | ||
|
|
195cb3b2b4 | ||
|
|
8529b7a59c | ||
|
|
4e63342350 | ||
|
|
3784a3d56a | ||
|
|
848d1ac4e8 | ||
|
|
c3348789eb | ||
|
|
6ea4d976c2 | ||
|
|
ebd5a8bb18 |
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)
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
@@ -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))",
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user