mirror of
https://github.com/esphome/esphome.git
synced 2026-10-30 18:02:11 +00:00
[gree] Add use_fahrenheit for Fahrenheit display on the unit's panel (#20751)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
7 files changed
+143
-10
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate_ir
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_MODEL
|
||||
from esphome.const import CONF_MODEL, CONF_USE_FAHRENHEIT
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import gree_ns
|
||||
@@ -19,6 +19,7 @@ MODELS = Model.options(
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend(
|
||||
{
|
||||
cv.Required(CONF_MODEL): cv.enum(MODELS, lower=True),
|
||||
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -26,5 +27,8 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend(
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await climate_ir.new_climate_ir(config)
|
||||
cg.add(var.set_model(config[CONF_MODEL]))
|
||||
if config[CONF_USE_FAHRENHEIT]:
|
||||
cg.add_define("USE_GREE_FAHRENHEIT")
|
||||
cg.add(var.set_use_fahrenheit(True))
|
||||
if config[CONF_MODEL] == "yaw1f":
|
||||
cg.add_define("USE_GREE_YAW1F")
|
||||
@@ -1,4 +1,6 @@
|
||||
#include "gree.h"
|
||||
|
||||
#include <cmath>
|
||||
#include "esphome/components/remote_base/remote_base.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
@@ -34,6 +36,21 @@ void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) {
|
||||
this->transmit_state();
|
||||
}
|
||||
|
||||
#ifdef USE_GREE_FAHRENHEIT
|
||||
uint8_t fahrenheit_half_steps(float target_celsius) {
|
||||
if (!std::isfinite(target_celsius)) {
|
||||
return 0;
|
||||
}
|
||||
// Whole degrees shown at k = 0 and at the top of the range
|
||||
static constexpr int MIN_F = static_cast<int>(celsius_to_fahrenheit(GREE_TEMP_MIN));
|
||||
static constexpr int MAX_F = static_cast<int>(celsius_to_fahrenheit(GREE_TEMP_MAX));
|
||||
const int desired_f = static_cast<int>(roundf(clamp<float>(celsius_to_fahrenheit(target_celsius), MIN_F, MAX_F)));
|
||||
// The display at k is (10 * 32 + 18 * GREE_TEMP_MIN + 9 * k) / 10; pick the smallest k that reaches desired_f
|
||||
const int tenths_above_min = 10 * desired_f - (10 * 32 + 18 * GREE_TEMP_MIN);
|
||||
return static_cast<uint8_t>((tenths_above_min + 8) / 9);
|
||||
}
|
||||
#endif
|
||||
|
||||
void GreeClimate::transmit_state() {
|
||||
uint8_t remote_state[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00};
|
||||
const Model model = this->model_;
|
||||
@@ -41,7 +58,12 @@ void GreeClimate::transmit_state() {
|
||||
const bool yag_layout = model == GREE_YAG || model == GREE_YAW1F;
|
||||
|
||||
remote_state[0] = this->fan_speed_() | this->operation_mode_();
|
||||
#ifdef USE_GREE_FAHRENHEIT
|
||||
const uint8_t half_steps = this->use_fahrenheit_ ? fahrenheit_half_steps(this->target_temperature) : 0;
|
||||
remote_state[1] = this->use_fahrenheit_ ? half_steps >> 1 : this->temperature_();
|
||||
#else
|
||||
remote_state[1] = this->temperature_();
|
||||
#endif
|
||||
|
||||
if (model == GREE_YAN) {
|
||||
remote_state[3] = 0x50; // bits 4..7 always 0101
|
||||
@@ -107,16 +129,15 @@ void GreeClimate::transmit_state() {
|
||||
remote_state[0] |= GREE_PRESET_SLEEP_BIT;
|
||||
}
|
||||
|
||||
// Calculate the checksum
|
||||
if (model == GREE_YAN) {
|
||||
remote_state[7] = ((remote_state[0] << 4) + (remote_state[1] << 4) + 0xC0);
|
||||
} else {
|
||||
remote_state[7] =
|
||||
((((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
|
||||
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();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/climate_ir/climate_ir.h"
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
namespace esphome::gree {
|
||||
|
||||
@@ -90,6 +91,26 @@ static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20;
|
||||
static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40;
|
||||
static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80;
|
||||
|
||||
// Byte 3 Fahrenheit bits
|
||||
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 codes
|
||||
enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG, GREE_YAW1F };
|
||||
|
||||
@@ -103,6 +124,9 @@ class GreeClimate final : public climate_ir::ClimateIR {
|
||||
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {}
|
||||
|
||||
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);
|
||||
|
||||
protected:
|
||||
@@ -118,6 +142,9 @@ class GreeClimate final : public climate_ir::ClimateIR {
|
||||
|
||||
Model model_{};
|
||||
uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise
|
||||
#ifdef USE_GREE_FAHRENHEIT
|
||||
bool use_fahrenheit_{false};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::gree
|
||||
@@ -96,6 +96,7 @@
|
||||
#define USE_GPIO_SWITCH_INTERLOCK
|
||||
#define USE_GRAPH
|
||||
#define USE_GRAPHICAL_DISPLAY_MENU
|
||||
#define USE_GREE_FAHRENHEIT
|
||||
#define USE_GREE_YAW1F
|
||||
#define USE_HOERMANN_HCP_IDENTITY
|
||||
#define USE_HOMEASSISTANT_TIME
|
||||
|
||||
@@ -8,6 +8,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"]
|
||||
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
# Fahrenheit support is define-gated; emit it so the tests cover that path.
|
||||
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_YAW1F")
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ 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
|
||||
- platform: gree
|
||||
name: GREE Fahrenheit
|
||||
id: my_gree_ac_fahrenheit
|
||||
model: YX1FF
|
||||
use_fahrenheit: true
|
||||
transmitter_id: xmitr
|
||||
|
||||
switch:
|
||||
- platform: gree
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include "esphome/components/gree/gree.h"
|
||||
#include "esphome/components/remote_base/remote_base.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::gree::testing {
|
||||
|
||||
@@ -24,6 +26,75 @@ TEST(GreeClimateTest, HeatCoolOverrideAdvertisedWithoutHeat) {
|
||||
EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT));
|
||||
}
|
||||
|
||||
#ifdef USE_GREE_FAHRENHEIT
|
||||
// Expectations from frames captured off a real YX1FF remote and the panel of a Blueridge mini-split.
|
||||
TEST(GreeClimateTest, FahrenheitHalfStepsMatchCapturedRemoteFrames) {
|
||||
struct Expectation {
|
||||
float celsius;
|
||||
uint8_t temp_field;
|
||||
bool extra_degree;
|
||||
};
|
||||
const Expectation expectations[] = {
|
||||
{18.33f, 2, true}, // 65 F -- captured remote frame b1=02 b3=5C
|
||||
{18.89f, 3, false}, // 66 F -- captured remote frame b1=03 b3=58
|
||||
{21.67f, 6, false}, // 71 F
|
||||
{22.22f, 6, true}, // 72 F
|
||||
{25.0f, 9, false}, // 77 F
|
||||
{25.56f, 10, false}, // 78 F
|
||||
};
|
||||
for (const auto &expectation : expectations) {
|
||||
const uint8_t half_steps = fahrenheit_half_steps(expectation.celsius);
|
||||
EXPECT_EQ(expectation.temp_field, half_steps >> 1) << "celsius " << expectation.celsius;
|
||||
EXPECT_EQ(expectation.extra_degree, (half_steps & 1) != 0) << "celsius " << expectation.celsius;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(GreeClimateTest, FahrenheitHalfStepsCoverEveryWholeDegreeInRange) {
|
||||
// 61..86 F is the range a YX1FF remote offers.
|
||||
for (int fahrenheit = 61; fahrenheit <= 86; fahrenheit++) {
|
||||
const uint8_t half_steps = fahrenheit_half_steps(fahrenheit_to_celsius(static_cast<float>(fahrenheit)));
|
||||
const float encoded = static_cast<float>(GREE_TEMP_MIN) + 0.5f * static_cast<float>(half_steps);
|
||||
EXPECT_EQ(fahrenheit, static_cast<int>(celsius_to_fahrenheit(encoded))) << "fahrenheit " << fahrenheit;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(GreeClimateTest, FahrenheitHalfStepsHandleNonFiniteTarget) {
|
||||
// transmit_state() can run from setup() via restore_state_(), when the target may still be NaN.
|
||||
EXPECT_EQ(0, fahrenheit_half_steps(NAN));
|
||||
}
|
||||
#endif // USE_GREE_FAHRENHEIT
|
||||
|
||||
// Byte 7 is zeroed in these captured YX1FF frames because frame_checksum() computes it.
|
||||
TEST(GreeClimateTest, FrameChecksumMatchesCapturedFrames) {
|
||||
const uint8_t frame_65f[GREE_STATE_FRAME_SIZE] = {0x09, 0x02, 0x60, 0x5C, 0x00, 0x20, 0x00, 0x00};
|
||||
EXPECT_EQ(0x30, frame_checksum(frame_65f));
|
||||
|
||||
const uint8_t frame_78f[GREE_STATE_FRAME_SIZE] = {0x09, 0x0A, 0x60, 0x58, 0x00, 0x20, 0x00, 0x00};
|
||||
EXPECT_EQ(0x70, frame_checksum(frame_78f));
|
||||
}
|
||||
|
||||
// The removed yan/yx1ff checksum `(b0 << 4) + (b1 << 4) + 0xC0` equals frame_checksum() for every frame they emit.
|
||||
TEST(GreeClimateTest, FrameChecksumMatchesTheRemovedFoldedFormula) {
|
||||
int mismatches = 0;
|
||||
for (int byte0 = 0; byte0 < 256; byte0++) {
|
||||
for (int byte1 = 0; byte1 < 256; byte1++) {
|
||||
// byte 2 low nibble is always 0
|
||||
static constexpr uint8_t BYTE2_VALUES[] = {0x00, 0x20, 0x60, 0x70};
|
||||
for (const uint8_t byte2 : BYTE2_VALUES) {
|
||||
for (uint8_t byte4 = 0; byte4 <= GREE_VDIR_DOWN; byte4++) {
|
||||
const uint8_t frame[GREE_STATE_FRAME_SIZE] = {
|
||||
static_cast<uint8_t>(byte0), static_cast<uint8_t>(byte1), byte2, 0x50, byte4, 0x20, 0x00, 0x00};
|
||||
const uint8_t folded = static_cast<uint8_t>((byte0 << 4) + (byte1 << 4) + 0xC0);
|
||||
if (frame_checksum(frame) != folded) {
|
||||
mismatches++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPECT_EQ(0, mismatches);
|
||||
}
|
||||
|
||||
class CapturingGreeTransmitter final : public remote_base::RemoteTransmitterBase {
|
||||
public:
|
||||
CapturingGreeTransmitter() : RemoteTransmitterBase(nullptr) {}
|
||||
|
||||
Reference in new issue
Block a user