diff --git a/CODEOWNERS b/CODEOWNERS index c75693d2d3..7e263a4514 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/esphome/components/whirlpool/climate.py b/esphome/components/whirlpool/climate.py index 3435a6c0bf..efc7e6ca57 100644 --- a/esphome/components/whirlpool/climate.py +++ b/esphome/components/whirlpool/climate.py @@ -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") diff --git a/esphome/components/whirlpool/whirlpool.cpp b/esphome/components/whirlpool/whirlpool.cpp index 73fab843fc..e341ce04bb 100644 --- a/esphome/components/whirlpool/whirlpool.cpp +++ b/esphome/components/whirlpool/whirlpool.cpp @@ -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(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(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; diff --git a/esphome/components/whirlpool/whirlpool.h b/esphome/components/whirlpool/whirlpool.h index b705ee95fa..8b5fc7519a 100644 --- a/esphome/components/whirlpool/whirlpool.h +++ b/esphome/components/whirlpool/whirlpool.h @@ -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_() { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5ae701c552..628435a4d8 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -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 diff --git a/tests/component_tests/whirlpool/__init__.py b/tests/component_tests/whirlpool/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/whirlpool/test_model_define.py b/tests/component_tests/whirlpool/test_model_define.py new file mode 100644 index 0000000000..1f6840f300 --- /dev/null +++ b/tests/component_tests/whirlpool/test_model_define.py @@ -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() diff --git a/tests/component_tests/whirlpool/test_model_define.yaml b/tests/component_tests/whirlpool/test_model_define.yaml new file mode 100644 index 0000000000..80c0bc142b --- /dev/null +++ b/tests/component_tests/whirlpool/test_model_define.yaml @@ -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 diff --git a/tests/component_tests/whirlpool/test_model_define_dg11j1.yaml b/tests/component_tests/whirlpool/test_model_define_dg11j1.yaml new file mode 100644 index 0000000000..37a8036265 --- /dev/null +++ b/tests/component_tests/whirlpool/test_model_define_dg11j1.yaml @@ -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 diff --git a/tests/components/whirlpool/__init__.py b/tests/components/whirlpool/__init__.py new file mode 100644 index 0000000000..badd604322 --- /dev/null +++ b/tests/components/whirlpool/__init__.py @@ -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 diff --git a/tests/components/whirlpool/benchmark.yaml b/tests/components/whirlpool/benchmark.yaml new file mode 100644 index 0000000000..7f0bd4e07c --- /dev/null +++ b/tests/components/whirlpool/benchmark.yaml @@ -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: [] diff --git a/tests/components/whirlpool/common-dg11j1.yaml b/tests/components/whirlpool/common-dg11j1.yaml new file mode 100644 index 0000000000..4c0280c9ff --- /dev/null +++ b/tests/components/whirlpool/common-dg11j1.yaml @@ -0,0 +1,5 @@ +climate: + - platform: whirlpool + name: Whirlpool DG11J1-91 Climate + model: DG11J1-91 + transmitter_id: xmitr diff --git a/tests/components/whirlpool/common.yaml b/tests/components/whirlpool/common.yaml index 6d55db1f08..8551f78c26 100644 --- a/tests/components/whirlpool/common.yaml +++ b/tests/components/whirlpool/common.yaml @@ -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 diff --git a/tests/components/whirlpool/test-dg11j1.esp32-idf.yaml b/tests/components/whirlpool/test-dg11j1.esp32-idf.yaml new file mode 100644 index 0000000000..7d6fa086ef --- /dev/null +++ b/tests/components/whirlpool/test-dg11j1.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + whirlpool: !include common-dg11j1.yaml diff --git a/tests/components/whirlpool/test.esp32-idf.yaml b/tests/components/whirlpool/test.esp32-idf.yaml index e891f9dc85..39e62c0664 100644 --- a/tests/components/whirlpool/test.esp32-idf.yaml +++ b/tests/components/whirlpool/test.esp32-idf.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 diff --git a/tests/components/whirlpool/test.esp8266-ard.yaml b/tests/components/whirlpool/test.esp8266-ard.yaml index 4bed2f03e5..6e2dfff6dd 100644 --- a/tests/components/whirlpool/test.esp8266-ard.yaml +++ b/tests/components/whirlpool/test.esp8266-ard.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 diff --git a/tests/components/whirlpool/whirlpool_tests.cpp b/tests/components/whirlpool/whirlpool_tests.cpp new file mode 100644 index 0000000000..ef628abe54 --- /dev/null +++ b/tests/components/whirlpool/whirlpool_tests.cpp @@ -0,0 +1,392 @@ +#include +#include +#include +#include +#include +#include + +#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; + +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 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 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(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