[whirlpool] Add the HISENSE_ARG model for Hisense and BGH units sold in Argentina (#17352)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
arpena
2026-10-12 07:57:23 -10:00
committed by GitHub
co-authored by J. Nick Koston J. Nick Koston
parent aa0bd9af72
commit ca69c33530
17 changed files with 603 additions and 26 deletions
+1 -1
View File
@@ -638,7 +638,7 @@ esphome/components/web_server_idf/* @dentra
esphome/components/weikai/* @DrCoolZic
esphome/components/weikai_i2c/* @DrCoolZic
esphome/components/weikai_spi/* @DrCoolZic
esphome/components/whirlpool/* @glmnet
esphome/components/whirlpool/* @arpena @glmnet
esphome/components/whynter/* @aeonsablaze
esphome/components/wiegand/* @ssieb
esphome/components/wireguard/* @droscy @lhoracek @thomas0bernard
+5 -1
View File
@@ -5,7 +5,7 @@ from esphome.const import CONF_MODEL
from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
CODEOWNERS = ["@glmnet"]
CODEOWNERS = ["@glmnet", "@arpena"]
whirlpool_ns = cg.esphome_ns.namespace("whirlpool")
WhirlpoolClimate = whirlpool_ns.class_("WhirlpoolClimate", climate_ir.ClimateIR)
@@ -14,6 +14,7 @@ Model = whirlpool_ns.enum("Model")
MODELS = {
"DG11J1-3A": Model.MODEL_DG11J1_3A,
"DG11J1-91": Model.MODEL_DG11J1_91,
"HISENSE_ARG": Model.MODEL_HISENSE_ARG,
}
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).extend(
@@ -26,3 +27,6 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).ext
async def to_code(config: ConfigType) -> None:
var = await climate_ir.new_climate_ir(config)
cg.add(var.set_model(config[CONF_MODEL]))
# cv.enum keeps the YAML key as a str subclass; the MockObj value cannot be compared
if config[CONF_MODEL] == "HISENSE_ARG":
cg.add_define("USE_WHIRLPOOL_HISENSE_ARG")
+91 -17
View File
@@ -27,10 +27,20 @@ const uint8_t WHIRLPOOL_FAN_HIGH = 1;
const uint8_t WHIRLPOOL_FAN_MED = 2;
const uint8_t WHIRLPOOL_FAN_LOW = 3;
const uint8_t WHIRLPOOL_SWING_MASK = 128;
const uint8_t WHIRLPOOL_FAN_MASK = 0x03;
const uint8_t WHIRLPOOL_SWING_MASK = 128; // byte 2
const uint8_t WHIRLPOOL_SWING_BYTE8 = 0x40; // byte 8
const uint8_t WHIRLPOOL_POWER = 0x04;
// Hisense ARG model; unused constexpr values cost nothing in DG11J1-only builds
static constexpr uint8_t HISENSE_ARG_DISPLAY_ON = 0x80; // byte 11
static constexpr uint8_t HISENSE_ARG_AMBIENT_TEMP = 0x18; // byte 12, 24 C
static constexpr uint8_t HISENSE_ARG_POWER_TOGGLE = 0x01; // byte 15
static constexpr uint8_t HISENSE_ARG_SETTINGS_ONLY = 0x02; // byte 15
static constexpr uint8_t HISENSE_ARG_FIXED_TEMP_STEP = 7; // auto and fan-only send 23 C
WhirlpoolClimate::WhirlpoolClimate()
: climate_ir::ClimateIR(
WHIRLPOOL_DG11J1_3A_TEMP_MIN, WHIRLPOOL_DG11J1_3A_TEMP_MAX, 1.0f, true, true,
@@ -44,11 +54,11 @@ void WhirlpoolClimate::transmit_state() {
remote_state[0] = 0x83;
remote_state[1] = 0x06;
remote_state[6] = 0x80;
// MODEL DG11J191
remote_state[18] = 0x08;
remote_state[18] = 0x08; // the same on every supported model
auto powered_on = this->mode != climate::CLIMATE_MODE_OFF;
if (powered_on != this->powered_on_assumed) {
const bool power_toggle = powered_on != this->powered_on_assumed;
if (power_toggle) {
// Set power toggle command
remote_state[2] = 4;
remote_state[15] = 1;
@@ -83,9 +93,16 @@ void WhirlpoolClimate::transmit_state() {
break;
}
const bool hisense_arg = this->is_hisense_arg_();
// Temperature
auto temp = (uint8_t) roundf(clamp(this->target_temperature, this->temperature_min_(), this->temperature_max_()));
remote_state[3] |= (uint8_t) (temp - this->temperature_min_()) << 4;
const auto temp_step = (uint8_t) (temp - this->temperature_min_());
if (hisense_arg && this->fixed_temp_mode_()) {
remote_state[3] |= HISENSE_ARG_FIXED_TEMP_STEP << 4;
} else {
remote_state[3] |= temp_step << 4;
}
// Fan speed
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) {
@@ -104,10 +121,17 @@ void WhirlpoolClimate::transmit_state() {
// Swing
ESP_LOGV(TAG, "send swing %s", this->send_swing_cmd_ ? LOG_STR_LITERAL("true") : LOG_STR_LITERAL("false"));
if (this->send_swing_cmd_) {
if (hisense_arg) {
// This model sends swing as a state on every frame; the DG11J1 remotes send a toggle
if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL) {
remote_state[2] |= WHIRLPOOL_SWING_MASK;
remote_state[8] = WHIRLPOOL_SWING_BYTE8;
}
this->encode_hisense_arg_(remote_state, power_toggle, temp_step);
} else if (this->send_swing_cmd_) {
if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL || this->swing_mode == climate::CLIMATE_SWING_OFF) {
remote_state[2] |= 128;
remote_state[8] |= 64;
remote_state[2] |= WHIRLPOOL_SWING_MASK;
remote_state[8] |= WHIRLPOOL_SWING_BYTE8;
}
}
@@ -155,6 +179,49 @@ void WhirlpoolClimate::transmit_state() {
transmit.perform();
}
bool WhirlpoolClimate::fixed_temp_mode_() const {
return this->mode == climate::CLIMATE_MODE_HEAT_COOL || this->mode == climate::CLIMATE_MODE_FAN_ONLY;
}
void WhirlpoolClimate::encode_hisense_arg_(uint8_t *remote_state, bool power_toggle, uint8_t temp_step) const {
#ifdef USE_WHIRLPOOL_HISENSE_ARG
remote_state[11] = HISENSE_ARG_DISPLAY_ON;
remote_state[12] = HISENSE_ARG_AMBIENT_TEMP;
remote_state[15] = power_toggle ? HISENSE_ARG_POWER_TOGGLE : HISENSE_ARG_SETTINGS_ONLY;
switch (this->mode) {
case climate::CLIMATE_MODE_OFF:
// The remote keeps the cool code in the power-off frame
remote_state[3] = (remote_state[3] & 0xF0) | WHIRLPOOL_COOL;
break;
case climate::CLIMATE_MODE_DRY:
// The unit runs these fan speeds itself; the user's choice is kept for the next mode change
remote_state[2] &= ~WHIRLPOOL_FAN_MASK;
break;
case climate::CLIMATE_MODE_FAN_ONLY:
if ((remote_state[2] & WHIRLPOOL_FAN_MASK) == WHIRLPOOL_FAN_AUTO) {
remote_state[2] |= WHIRLPOOL_FAN_MED;
}
break;
case climate::CLIMATE_MODE_HEAT_COOL:
// Auto sends the target as one of four range codes in byte 2 bits 6:4 instead of the temperature nibble.
// Taken from the HPA-4911 firmware: 16 to 21 C, 22 C, 24 C, and everything else (23 C and 25 C up) share a code.
if (temp_step < 6) {
remote_state[2] |= 0x30;
} else if (temp_step == 6) {
remote_state[2] |= 0x50;
} else if (temp_step == 8) {
remote_state[2] |= 0x40;
} else {
remote_state[2] |= 0x20;
}
break;
default:
break;
}
#endif
}
bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
// Check if the esp isn't currently transmitting.
if (millis() - this->last_transmit_time_ < 500) {
@@ -265,16 +332,19 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
}
}
// Set received temp
int temp = remote_state[3] & 0xF0;
ESP_LOGVV(TAG, "Temperature Raw: %02X", temp);
temp = (uint8_t) temp >> 4;
temp += static_cast<int>(this->temperature_min_());
ESP_LOGVV(TAG, "Temperature Climate: %u", temp);
this->target_temperature = temp;
// Set received temp; Hisense ARG sends fixed values in auto and fan-only
const bool hisense_arg = this->is_hisense_arg_();
if (!hisense_arg || !this->fixed_temp_mode_()) {
int temp = remote_state[3] & 0xF0;
ESP_LOGVV(TAG, "Temperature Raw: %02X", temp);
temp = (uint8_t) temp >> 4;
temp += static_cast<int>(this->temperature_min_());
ESP_LOGVV(TAG, "Temperature Climate: %u", temp);
this->target_temperature = temp;
}
// Set received fan speed
auto fan = remote_state[2] & 0x03;
auto fan = remote_state[2] & WHIRLPOOL_FAN_MASK;
ESP_LOGVV(TAG, "Fan: %02X", fan);
switch (fan) {
case WHIRLPOOL_FAN_HIGH:
@@ -293,7 +363,11 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
}
// Set received swing status
if ((remote_state[2] & WHIRLPOOL_SWING_MASK) == WHIRLPOOL_SWING_MASK && remote_state[8] == 0x40) {
if (hisense_arg) {
this->swing_mode =
(remote_state[8] & WHIRLPOOL_SWING_BYTE8) ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF;
} else if ((remote_state[2] & WHIRLPOOL_SWING_MASK) == WHIRLPOOL_SWING_MASK &&
remote_state[8] == WHIRLPOOL_SWING_BYTE8) {
ESP_LOGVV(TAG, "Swing toggle pressed ");
if (this->swing_mode == climate::CLIMATE_SWING_OFF) {
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
+16 -3
View File
@@ -6,8 +6,9 @@ namespace esphome::whirlpool {
/// Simple enum to represent models.
enum Model {
MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32
MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30
MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32
MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30
MODEL_HISENSE_ARG = 2, /// Hisense/BGH (Argentina), 16 to 30
};
// Temperature
@@ -46,10 +47,22 @@ class WhirlpoolClimate final : public climate_ir::ClimateIR {
void transmit_state() override;
/// Handle received IR Buffer
bool on_receive(remote_base::RemoteReceiveData data) override;
/// False when the model is compiled out, so the compiler drops the Hisense ARG branches.
bool is_hisense_arg_() const {
#ifdef USE_WHIRLPOOL_HISENSE_ARG
return this->model_ == MODEL_HISENSE_ARG;
#else
return false;
#endif
}
/// Modes in which the Hisense ARG model sends a fixed temperature.
bool fixed_temp_mode_() const;
/// Add the Hisense ARG bytes on top of the common whirlpool frame.
void encode_hisense_arg_(uint8_t *remote_state, bool power_toggle, uint8_t temp_step) const;
/// Set the time of the last transmission.
uint32_t last_transmit_time_{};
bool send_swing_cmd_{false};
bool send_swing_cmd_{false}; // the DG11J1 remotes send swing as a toggle, only on the call that changed it
Model model_;
float temperature_min_() {
+1
View File
@@ -265,6 +265,7 @@
#define USE_VALVE
#define USE_WATER_HEATER
#define USE_WATER_HEATER_VISUAL_OVERRIDES
#define USE_WHIRLPOOL_HISENSE_ARG
#define USE_ZWAVE_PROXY
// Feature flags which do not work for zephyr
@@ -0,0 +1,27 @@
"""The Hisense ARG code is compiled in only when that model is configured."""
from collections.abc import Callable
from pathlib import Path
from esphome.core import CORE
DEFINE = "USE_WHIRLPOOL_HISENSE_ARG"
HERE = Path(__file__).parent
def _define_names() -> set[str]:
return {define.name for define in CORE.defines}
def test_hisense_arg_model_emits_the_define(
generate_main: Callable[[str | Path], str],
) -> None:
generate_main(HERE / "test_model_define.yaml")
assert DEFINE in _define_names()
def test_dg11j1_model_does_not_emit_the_define(
generate_main: Callable[[str | Path], str],
) -> None:
generate_main(HERE / "test_model_define_dg11j1.yaml")
assert DEFINE not in _define_names()
@@ -0,0 +1,16 @@
esphome:
name: test
esp32:
board: esp32dev
remote_transmitter:
id: xmitr
pin: GPIO4
carrier_duty_percent: 50%
climate:
- platform: whirlpool
name: Hisense
model: HISENSE_ARG
transmitter_id: xmitr
@@ -0,0 +1,16 @@
esphome:
name: test
esp32:
board: esp32dev
remote_transmitter:
id: xmitr
pin: GPIO4
carrier_duty_percent: 50%
climate:
- platform: whirlpool
name: Whirlpool
model: DG11J1-91
transmitter_id: xmitr
+16
View File
@@ -0,0 +1,16 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The harness walks dependencies from the component manifest, not from the
# climate platform, so declare the platform's auto-load here. Otherwise the
# transmitter's code generation runs and the host build cannot link it.
manifest.auto_load = ["climate_ir", "climate"]
async def to_code_testing(config: ConfigType) -> None:
# Compiles in the Hisense ARG model, which climate.py adds only when it is configured
cg.add_define("USE_WHIRLPOOL_HISENSE_ARG")
manifest.to_code = to_code_testing
@@ -0,0 +1,4 @@
# Registers the climate platform so USE_CLIMATE is defined for the host unit test
# build. The whirlpool, climate_ir and remote_transmitter sources come in through the
# dependency walk in __init__.py; their code generation is suppressed.
climate: []
@@ -0,0 +1,5 @@
climate:
- platform: whirlpool
name: Whirlpool DG11J1-91 Climate
model: DG11J1-91
transmitter_id: xmitr
+6
View File
@@ -2,3 +2,9 @@ climate:
- platform: whirlpool
name: Whirlpool Climate
transmitter_id: xmitr
- platform: whirlpool
id: whirlpool_hisense_arg
name: Whirlpool Hisense ARG Climate
model: HISENSE_ARG
transmitter_id: xmitr
receiver_id: rcvr
@@ -0,0 +1,3 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml
whirlpool: !include common-dg11j1.yaml
@@ -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
whirlpool: !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
whirlpool: !include common.yaml
@@ -0,0 +1,392 @@
#include <gtest/gtest.h>
#include <array>
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/components/whirlpool/whirlpool.h"
#include "esphome/core/preferences.h"
namespace esphome::whirlpool::testing {
static constexpr size_t FRAME_LEN = 21;
using Frame = std::array<uint8_t, FRAME_LEN>;
static constexpr int32_t HEADER_MARK = 9000;
static constexpr int32_t HEADER_SPACE = 4494;
static constexpr int32_t BIT_MARK = 572;
static constexpr int32_t ONE_SPACE = 1659;
static constexpr int32_t ZERO_SPACE = 553;
static constexpr int32_t GAP_SPACE = 7960;
static bool divider_after(size_t bytes_sent) { return bytes_sent == 6 || bytes_sent == 14; }
/// The waveform for a frame: header, bytes LSB first, a divider after bytes 6 and 14, final mark
static remote_base::RawTimings waveform(const Frame &bytes) {
remote_base::RawTimings raw;
raw.push_back(HEADER_MARK);
raw.push_back(-HEADER_SPACE);
for (size_t i = 0; i < FRAME_LEN; i++) {
for (int bit = 0; bit < 8; bit++) {
raw.push_back(BIT_MARK);
raw.push_back((bytes[i] >> bit) & 1 ? -ONE_SPACE : -ZERO_SPACE);
}
if (divider_after(i + 1)) {
raw.push_back(BIT_MARK);
raw.push_back(-GAP_SPACE);
}
}
raw.push_back(BIT_MARK);
return raw;
}
/// Decodes a captured waveform back into bytes, checking header, dividers and footer positions
static ::testing::AssertionResult decode(const remote_base::RawTimings &raw, Frame &out) {
static constexpr size_t EXPECTED_LEN = 2 + FRAME_LEN * 16 + 2 * 2 + 1;
if (raw.size() != EXPECTED_LEN)
return ::testing::AssertionFailure() << "timing count " << raw.size() << ", expected " << EXPECTED_LEN;
if (raw[0] != HEADER_MARK || raw[1] != -HEADER_SPACE)
return ::testing::AssertionFailure() << "bad header " << raw[0] << " " << raw[1];
size_t pos = 2;
for (size_t i = 0; i < FRAME_LEN; i++) {
uint8_t value = 0;
for (int bit = 0; bit < 8; bit++) {
if (raw[pos] != BIT_MARK)
return ::testing::AssertionFailure() << "byte " << i << " bit " << bit << " mark " << raw[pos];
if (raw[pos + 1] == -ONE_SPACE) {
value |= 1 << bit;
} else if (raw[pos + 1] != -ZERO_SPACE) {
return ::testing::AssertionFailure() << "byte " << i << " bit " << bit << " space " << raw[pos + 1];
}
pos += 2;
}
out[i] = value;
if (divider_after(i + 1)) {
if (raw[pos] != BIT_MARK || raw[pos + 1] != -GAP_SPACE)
return ::testing::AssertionFailure() << "missing divider after byte " << i + 1;
pos += 2;
}
}
if (raw[pos] != BIT_MARK)
return ::testing::AssertionFailure() << "bad footer " << raw[pos];
return ::testing::AssertionSuccess();
}
static uint8_t xor_range(const Frame &f, size_t from, size_t to) {
uint8_t x = 0;
for (size_t i = from; i < to; i++)
x ^= f[i];
return x;
}
static ::testing::AssertionResult checksums_ok(const Frame &f) {
if (xor_range(f, 2, 13) != f[13])
return ::testing::AssertionFailure() << "byte 13 checksum";
if (xor_range(f, 14, 20) != f[20])
return ::testing::AssertionFailure() << "byte 20 checksum";
return ::testing::AssertionSuccess();
}
/// Fills in both checksums of a frame
static Frame with_checksums(Frame f) {
f[13] = xor_range(f, 2, 13);
f[20] = xor_range(f, 14, 20);
return f;
}
static std::string hex(const Frame &f) {
std::string out;
for (uint8_t b : f) {
char buf[4];
snprintf(buf, sizeof(buf), "%02X ", b);
out += buf;
}
return out;
}
class CapturingTransmitter : public remote_base::RemoteTransmitterBase {
public:
CapturingTransmitter() : RemoteTransmitterBase(nullptr) {}
std::vector<remote_base::RawTimings> frames;
protected:
void send_internal(uint32_t send_times, uint32_t send_wait) override {
this->frames.push_back(this->temp_.get_data());
}
};
struct Fixture {
CapturingTransmitter transmitter;
WhirlpoolClimate climate;
explicit Fixture(Model model) {
// Every fixture shares one preference key, so clear what an earlier test saved or setup restores it
if (global_preferences != nullptr)
global_preferences->reset();
this->climate.set_model(model);
this->climate.set_transmitter(&this->transmitter);
this->climate.setup();
}
void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan,
optional<climate::ClimateSwingMode> swing = {}) {
auto call = this->climate.make_call();
call.set_mode(mode);
call.set_target_temperature(temperature);
call.set_fan_mode(fan);
if (swing.has_value()) {
call.set_swing_mode(*swing);
}
call.perform();
}
/// Decodes the last transmitted frame, failing the test if it is malformed or has bad checksums
Frame last_frame() const {
Frame f{};
EXPECT_FALSE(this->transmitter.frames.empty());
if (this->transmitter.frames.empty())
return f;
EXPECT_TRUE(decode(this->transmitter.frames.back(), f));
EXPECT_TRUE(checksums_ok(f)) << hex(f);
return f;
}
};
static void expect_frame(const Frame &actual, const Frame &expected) {
EXPECT_EQ(actual, expected) << "actual " << hex(actual) << "\nexpected " << hex(expected);
}
// The receive path ignores frames for 500 ms after a transmit, so these run on a fixture that has not transmitted
static bool receive(WhirlpoolClimate &climate, const Frame &bytes) {
const remote_base::RawTimings raw = waveform(bytes);
remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE);
return static_cast<remote_base::RemoteReceiverListener &>(climate).on_receive(data);
}
TEST(WhirlpoolDG11J13A, CoolFromOffMatchesKnownFrame) {
Fixture f(MODEL_DG11J1_3A);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW);
// byte 2: power toggle 0x04 | fan low 3; byte 3: (24 - 18) << 4 | cool 2; byte 15: cool overwrites toggle with 6
expect_frame(f.last_frame(), {0x83, 0x06, 0x07, 0x62, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0xE5, 0x00, 0x06, 0x00, 0x00, 0x08, 0x00, 0x0E});
}
TEST(WhirlpoolDG11J191, CoolFromOffUsesSixteenDegreeBase) {
Fixture f(MODEL_DG11J1_91);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW);
// byte 3: (24 - 16) << 4 | cool 2
expect_frame(f.last_frame(), {0x83, 0x06, 0x07, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x05, 0x00, 0x06, 0x00, 0x00, 0x08, 0x00, 0x0E});
}
TEST(WhirlpoolHisenseArg, PowerToggleThenSettingsOnly) {
Fixture f(MODEL_HISENSE_ARG);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF);
// byte 11 display on, byte 12 ambient 24 C, byte 15 power toggle
expect_frame(f.last_frame(), {0x83, 0x06, 0x07, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00,
0x80, 0x18, 0x9D, 0x00, 0x01, 0x00, 0x00, 0x08, 0x00, 0x09});
// Only the fan changed: no power bit, byte 15 settings only
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_MEDIUM);
expect_frame(f.last_frame(), {0x83, 0x06, 0x02, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00,
0x80, 0x18, 0x98, 0x00, 0x02, 0x00, 0x00, 0x08, 0x00, 0x0A});
}
TEST(WhirlpoolHisenseArg, ModeSpecificFanAndTemperature) {
Fixture f(MODEL_HISENSE_ARG);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW);
f.command(climate::CLIMATE_MODE_DRY, 24, climate::CLIMATE_FAN_HIGH);
Frame frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x03, 0) << "dry forces fan auto";
EXPECT_EQ(frame[3], 0x83);
EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_HIGH) << "the user's choice survives the dry frame";
f.command(climate::CLIMATE_MODE_FAN_ONLY, 20, climate::CLIMATE_FAN_AUTO);
frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x03, 2) << "fan-only with fan auto sends medium";
EXPECT_EQ(frame[3], 0x74) << "fixed temperature nibble 7, fan mode 4";
f.command(climate::CLIMATE_MODE_FAN_ONLY, 20, climate::CLIMATE_FAN_HIGH);
EXPECT_EQ(f.last_frame()[2] & 0x03, 1);
struct RangeCase {
float target;
uint8_t range;
};
// 23 C (step 7) shares the catch-all code with the high end of the range
for (const RangeCase &c :
{RangeCase{16, 0x30}, RangeCase{22, 0x50}, RangeCase{23, 0x20}, RangeCase{24, 0x40}, RangeCase{30, 0x20}}) {
f.command(climate::CLIMATE_MODE_HEAT_COOL, c.target, climate::CLIMATE_FAN_LOW);
frame = f.last_frame();
EXPECT_EQ(frame[3], 0x71) << "target " << c.target;
EXPECT_EQ(frame[2] & 0x70, c.range) << "target " << c.target;
EXPECT_EQ(frame[2] & 0x03, 3) << "target " << c.target;
EXPECT_EQ(frame[15], 0x02) << "target " << c.target;
}
}
TEST(WhirlpoolHisenseArg, SwingIsSentOnEveryTransmit) {
Fixture f(MODEL_HISENSE_ARG);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL);
Frame frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x80, 0x80);
EXPECT_EQ(frame[8], 0x40);
// A later command that does not mention swing still carries it
f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW);
frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x80, 0x80);
EXPECT_EQ(frame[8], 0x40);
f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF);
frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x80, 0);
EXPECT_EQ(frame[8], 0x00);
f.command(climate::CLIMATE_MODE_COOL, 26, climate::CLIMATE_FAN_LOW);
frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x80, 0);
EXPECT_EQ(frame[8], 0x00);
}
TEST(WhirlpoolDG11J13A, SwingIsSentOnlyWhenItChanges) {
Fixture f(MODEL_DG11J1_3A);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL);
Frame frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x80, 0x80);
EXPECT_EQ(frame[8], 0x40);
f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW);
frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x80, 0);
EXPECT_EQ(frame[8], 0x00);
}
// Cool 24 C (step 8), fan low, powered on assumed
static Frame hisense_cool_frame() {
return with_checksums({0x83, 0x06, 0x03, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00,
0x80, 0x18, 0x00, 0x00, 0x02, 0x00, 0x00, 0x08, 0x00, 0x00});
}
static void assume_on(WhirlpoolClimate &climate, climate::ClimateMode mode, float target) {
climate.mode = mode;
climate.target_temperature = target;
climate.powered_on_assumed = true;
}
TEST(WhirlpoolHisenseArg, ReceiveSwingIsAbsolute) {
Fixture f(MODEL_HISENSE_ARG);
assume_on(f.climate, climate::CLIMATE_MODE_COOL, 20);
Frame frame = hisense_cool_frame();
frame[8] = 0x40;
EXPECT_TRUE(receive(f.climate, with_checksums(frame)));
EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL);
// The same frame again keeps vertical; the DG11J1 path would toggle back
EXPECT_TRUE(receive(f.climate, with_checksums(frame)));
EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL);
frame[8] = 0x00;
EXPECT_TRUE(receive(f.climate, with_checksums(frame)));
EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(WhirlpoolHisenseArg, ReceiveTemperature) {
Fixture f(MODEL_HISENSE_ARG);
assume_on(f.climate, climate::CLIMATE_MODE_COOL, 20);
EXPECT_TRUE(receive(f.climate, hisense_cool_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 24);
// Heat-cool carries a fixed temperature nibble, which must not overwrite the target
Frame frame = hisense_cool_frame();
frame[2] = 0x43;
frame[3] = 0x71;
EXPECT_TRUE(receive(f.climate, with_checksums(frame)));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT_COOL);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 24);
// Fan-only likewise
frame[2] = 0x02;
frame[3] = 0x74;
EXPECT_TRUE(receive(f.climate, with_checksums(frame)));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_FAN_ONLY);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 24);
}
TEST(WhirlpoolHisenseArg, ReceivePowerBitTogglesAssumption) {
Fixture f(MODEL_HISENSE_ARG);
ASSERT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
ASSERT_FALSE(f.climate.powered_on_assumed);
Frame frame = hisense_cool_frame();
frame[2] |= 0x04;
frame[15] = 0x01;
EXPECT_TRUE(receive(f.climate, with_checksums(frame)));
EXPECT_TRUE(f.climate.powered_on_assumed);
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_TRUE(receive(f.climate, with_checksums(frame)));
EXPECT_FALSE(f.climate.powered_on_assumed);
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
}
TEST(WhirlpoolHisenseArg, ReceiveRejectsBadChecksum) {
Fixture f(MODEL_HISENSE_ARG);
assume_on(f.climate, climate::CLIMATE_MODE_COOL, 20);
Frame frame = hisense_cool_frame();
frame[13] ^= 0x01;
EXPECT_FALSE(receive(f.climate, frame));
EXPECT_FLOAT_EQ(f.climate.target_temperature, 20);
frame = hisense_cool_frame();
frame[20] ^= 0x01;
EXPECT_FALSE(receive(f.climate, frame));
EXPECT_FLOAT_EQ(f.climate.target_temperature, 20);
}
TEST(WhirlpoolHisenseArg, ReceiveIgnoredRightAfterTransmit) {
Fixture f(MODEL_HISENSE_ARG);
f.command(climate::CLIMATE_MODE_COOL, 20, climate::CLIMATE_FAN_LOW);
EXPECT_FALSE(receive(f.climate, hisense_cool_frame()));
EXPECT_FLOAT_EQ(f.climate.target_temperature, 20);
}
TEST(WhirlpoolHisenseArg, PowerOffKeepsTheCoolModeCode) {
Fixture f(MODEL_HISENSE_ARG);
f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW);
f.command(climate::CLIMATE_MODE_OFF, 24, climate::CLIMATE_FAN_LOW);
const Frame frame = f.last_frame();
// The hardware-tested remote sends the cool code with the power toggle, not the heat code 0
EXPECT_EQ(frame[3] & 0x07, 2);
EXPECT_EQ(frame[2] & 0x04, 0x04);
EXPECT_EQ(frame[15], 0x01);
}
TEST(WhirlpoolHisenseArg, EveryTransmitHasValidChecksums) {
Fixture f(MODEL_HISENSE_ARG);
f.command(climate::CLIMATE_MODE_HEAT, 30, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_VERTICAL);
f.command(climate::CLIMATE_MODE_HEAT_COOL, 22, climate::CLIMATE_FAN_AUTO);
f.command(climate::CLIMATE_MODE_DRY, 18, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF);
f.command(climate::CLIMATE_MODE_FAN_ONLY, 25, climate::CLIMATE_FAN_LOW);
f.command(climate::CLIMATE_MODE_OFF, 25, climate::CLIMATE_FAN_LOW);
ASSERT_EQ(f.transmitter.frames.size(), 5u);
for (const auto &raw : f.transmitter.frames) {
Frame frame{};
ASSERT_TRUE(decode(raw, frame));
EXPECT_TRUE(checksums_ok(frame)) << hex(frame);
EXPECT_EQ(frame[0], 0x83);
EXPECT_EQ(frame[1], 0x06);
EXPECT_EQ(frame[11], 0x80);
EXPECT_EQ(frame[12], 0x18);
}
}
} // namespace esphome::whirlpool::testing