[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
+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