diff --git a/CODEOWNERS b/CODEOWNERS index f068a3a2215..fc14cb33d09 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -214,6 +214,7 @@ esphome/components/feedback/* @ianchi esphome/components/file/* @esphome/core esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund esphome/components/font/* @clydebarrow @esphome/core +esphome/components/friedrich/* @rwrozelle esphome/components/fs3000/* @kahrendt esphome/components/ft5x06/* @clydebarrow esphome/components/ft63x6/* @gpambrozio diff --git a/esphome/components/friedrich/__init__.py b/esphome/components/friedrich/__init__.py new file mode 100644 index 00000000000..09f1d1df4fe --- /dev/null +++ b/esphome/components/friedrich/__init__.py @@ -0,0 +1 @@ +DOMAIN = "friedrich" diff --git a/esphome/components/friedrich/climate.py b/esphome/components/friedrich/climate.py new file mode 100644 index 00000000000..52f1f35708d --- /dev/null +++ b/esphome/components/friedrich/climate.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +import esphome.config_validation as cv +from esphome.const import CONF_MODEL, CONF_SENSOR +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@rwrozelle"] + +friedrich_ns = cg.esphome_ns.namespace("friedrich") +FriedrichClimate = friedrich_ns.class_("FriedrichClimate", climate_ir.ClimateIR) + +Model = friedrich_ns.enum("Model", is_class=True) +MODELS = { + "MW12Y3H": Model.MODEL_MW12Y3H, +} + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(FriedrichClimate).extend( + { + cv.Optional(CONF_MODEL, default="MW12Y3H"): cv.enum(MODELS, upper=True), + # The unit works in Fahrenheit, so a Celsius temperature sensor cannot feed it + cv.Optional(CONF_SENSOR): cv.invalid( + "'sensor' is not supported by the Friedrich climate component" + ), + } +) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("aeha") # used from C++ + var = await climate_ir.new_climate_ir(config) + cg.add(var.set_model(config[CONF_MODEL])) diff --git a/esphome/components/friedrich/friedrich.cpp b/esphome/components/friedrich/friedrich.cpp new file mode 100644 index 00000000000..b1fcfe06150 --- /dev/null +++ b/esphome/components/friedrich/friedrich.cpp @@ -0,0 +1,234 @@ +#include "friedrich.h" + +#include + +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::friedrich { +// clang-format off +/* +* MW12Y3H - Currently the only supported model. Model field is retained to allow future per-model code differentiation. +* AEHA +* Byte0 Fixed = 0x00 +* Byte1 Fixed = 0x08 +* Byte2 Fixed = 0x08 +* Byte3 Command: Move Louver = 0x36 Off = 0x40, else Fixed = 0x7F +* Byte4 Checksum when command, else Fixed = 0x90 +* Byte5 N/A when command, else Fixed = 0x0C +* Byte6 N/A when command, else Temp(F), add 0x80 for On +* Byte7 N/A when command, else Mode (Min Heat = 0xD0 and Coil Dry = 0x10 are not exposed) +* Byte8 N/A when command, else Fan, add 0x08 for Vertical Swing +* Byte9 N/A when command, else Fixed = 0x00 // not interested +* Byte10 N/A when command, else Fixed = 0x00 // not interested +* Byte11 N/A when command, else Fixed = 0x00 // not interested +* Byte12 N/A when command, else Economy (On = 0x00, Off = 0x04) +* Byte13 N/A when command, else CheckSum +*/ +// clang-format on +ESPHOME_LOG_TAG(TAG, "friedrich"); + +static constexpr uint8_t TEMP_MIN_NOT_HEAT = 64; +static constexpr uint8_t TEMP_DEFAULT = 72; +static constexpr uint8_t STATE_MESSAGE_LENGTH = FriedrichClimate::STATE_MESSAGE_LENGTH; +static constexpr uint8_t STATE_MESSAGE_LENGTH_UTIL = 5; +static constexpr uint16_t CARRIER_ADDRESS = 0x28C6; +static constexpr uint32_t CARRIER_FREQUENCY = 38000; + +static constexpr uint8_t BYTE3_POWER_OFF = 0x40; +static constexpr uint8_t BYTE6_POWER_ON = 0x80; +static constexpr uint8_t BYTE7_MODE_AUTO = 0x00; +static constexpr uint8_t BYTE8_FAN_AUTO = 0x00; +static constexpr uint8_t BYTE8_FAN_SWING = 0x08; + +// Every state frame: power on, economy off; bytes 6, 7, 8 and 13 are patched per state +static constexpr uint8_t STATE_TEMPLATE[STATE_MESSAGE_LENGTH] PROGMEM = { + 0x00, 0x08, 0x08, 0x7F, 0x90, 0x0C, BYTE6_POWER_ON, BYTE7_MODE_AUTO, BYTE8_FAN_AUTO, 0x00, 0x00, 0x00, 0x04, 0x00}; +// The off command, including its checksum (255 - byte 3) +static constexpr uint8_t OFF_FRAME[STATE_MESSAGE_LENGTH_UTIL] PROGMEM = {0x00, 0x08, 0x08, BYTE3_POWER_OFF, + 255 - BYTE3_POWER_OFF}; + +struct Code { + uint8_t value; // a climate enum, all of which are uint8_t + uint8_t code; +}; +static constexpr Code MODES[] PROGMEM = { + {climate::CLIMATE_MODE_HEAT_COOL, BYTE7_MODE_AUTO}, + {climate::CLIMATE_MODE_COOL, 0x80}, + {climate::CLIMATE_MODE_DRY, 0x40}, + {climate::CLIMATE_MODE_FAN_ONLY, 0xC0}, + {climate::CLIMATE_MODE_HEAT, 0x20}, +}; +static constexpr Code FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, BYTE8_FAN_AUTO}, {climate::CLIMATE_FAN_HIGH, 0x80}, + {climate::CLIMATE_FAN_MEDIUM, 0x40}, {climate::CLIMATE_FAN_LOW, 0xC0}, + {climate::CLIMATE_FAN_QUIET, 0x20}, +}; + +// The entry whose value (or code) matches key. Entries are copied out of PROGMEM. +template static optional find_entry(const Code (&table)[N], uint8_t key, bool match_code) { + for (const Code &slot : table) { + Code entry; + progmem_memcpy(&entry, &slot, sizeof(entry)); + if ((match_code ? entry.code : entry.value) == key) + return entry; + } + return {}; +} + +// The temperature code is the 4-bit reversal of (F - TEMP_MIN) / TEMP_STEP; the mapping is its own inverse. +static uint8_t reverse_nibble(uint8_t value) { return reverse_bits(value) >> 4; } + +static bool matches_template(const uint8_t *data, uint8_t from, uint8_t to) { + for (uint8_t i = from; i <= to; i++) { + if (data[i] != progmem_read_byte(&STATE_TEMPLATE[i])) + return false; + } + return true; +} + +/* + * https://gist.github.com/GeorgeDewar/11171561 + * 1. Reverse (flip) bytes 6 - 13) + * 2. Sum those bytes + * 3. (208 - sum) % 256 + * 4. Reverse (flip) bytes of result + */ +static uint8_t checksum_state(const uint8_t *data) { + uint8_t sum = 0; + for (uint8_t i = 6; i < STATE_MESSAGE_LENGTH - 1; ++i) { + sum += reverse_bits(data[i]); + } + return reverse_bits(static_cast(208 - sum)); +} + +static const LogString *model_to_string(Model model) { + switch (model) { + case Model::MODEL_MW12Y3H: + default: + return LOG_STR("MW12Y3H"); + } +} + +climate::ClimateTraits FriedrichClimate::traits() { + auto traits = ClimateIR::traits(); + traits.set_temperature_unit(TemperatureUnit::FAHRENHEIT); + return traits; +} + +void FriedrichClimate::setup() { + ClimateIR::setup(); + // The base class defaults assume Celsius. This also discards a value saved in Celsius by an earlier version. + if (std::isnan(this->target_temperature) || this->target_temperature < TEMP_MIN) { + this->target_temperature = TEMP_DEFAULT; + } + this->target_temperature = std::min(this->target_temperature, TEMP_MAX); +} + +void FriedrichClimate::dump_config() { + // ClimateIR::dump_config() is not called: it prints the temperature range in Celsius + LOG_CLIMATE("", "Friedrich Climate", this); + ESP_LOGCONFIG(TAG, + " Temperature range: %u to %u °F\n" + " Model: %s", + TEMP_MIN, TEMP_MAX, LOG_STR_ARG(model_to_string(this->model_))); +} + +void FriedrichClimate::transmit_state() { + if (this->mode == climate::CLIMATE_MODE_OFF) { + this->transmit_off_(); + return; + } + + ESP_LOGV(TAG, "Transmit state"); + + // Snap to what the unit accepts (even values, 64°F minimum unless heating) and store it, + // so the state published after this call matches the frame sent. + const uint8_t effective_min = this->mode == climate::CLIMATE_MODE_HEAT ? TEMP_MIN : TEMP_MIN_NOT_HEAT; + uint8_t temperature = (uint8_t) roundf(clamp(this->target_temperature, effective_min, TEMP_MAX)); + temperature += temperature % 2; + this->target_temperature = temperature; + + const auto mode = find_entry(MODES, this->mode, false); + const auto fan = find_entry(FANS, this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO), false); + + uint8_t frame[STATE_MESSAGE_LENGTH]; + progmem_memcpy(frame, STATE_TEMPLATE, sizeof(frame)); + frame[6] |= reverse_nibble((temperature - TEMP_MIN) / TEMP_STEP); + frame[7] = mode.has_value() ? mode->code : BYTE7_MODE_AUTO; + frame[8] = fan.has_value() ? fan->code : BYTE8_FAN_AUTO; + if (this->swing_mode != climate::CLIMATE_SWING_OFF) + frame[8] |= BYTE8_FAN_SWING; + frame[STATE_MESSAGE_LENGTH - 1] = checksum_state(frame); + + this->transmit_(frame, sizeof(frame)); +} + +void FriedrichClimate::transmit_off_() { + ESP_LOGV(TAG, "Transmit off"); + + uint8_t frame[STATE_MESSAGE_LENGTH_UTIL]; + progmem_memcpy(frame, OFF_FRAME, sizeof(frame)); + this->transmit_(frame, sizeof(frame)); +} + +void FriedrichClimate::transmit_(const uint8_t *data, uint8_t len) { + ESP_LOGV(TAG, "Transmit message"); + + auto transmit = this->transmitter_->transmit(); + auto *dst = transmit.get_data(); + dst->set_carrier_frequency(CARRIER_FREQUENCY); + remote_base::AEHAProtocol().encode(dst, CARRIER_ADDRESS, data, len); + transmit.perform(); +} + +bool FriedrichClimate::on_receive(remote_base::RemoteReceiveData src) { + ESP_LOGV(TAG, "Received message"); + const auto odata = remote_base::AEHAProtocol().decode(src); + if (!odata.has_value()) + return false; + const auto &d = odata->data; + // Other vendors also use AEHA; only accept frames with our address and fixed header bytes. + if (odata->address != CARRIER_ADDRESS || d.size() < STATE_MESSAGE_LENGTH_UTIL || !matches_template(d.data(), 0, 2)) + return false; + + if (d.size() == STATE_MESSAGE_LENGTH_UTIL) { + // Not looking for other types of messages + if (d[3] != BYTE3_POWER_OFF || d[4] != 255 - d[3]) + return false; + ESP_LOGV(TAG, "Received off message"); + this->mode = climate::CLIMATE_MODE_OFF; + this->publish_state(); + return true; + } + + if (d.size() != STATE_MESSAGE_LENGTH || d[STATE_MESSAGE_LENGTH - 1] != checksum_state(d.data()) || + !matches_template(d.data(), 3, 5)) + return false; + + // Ignore frames with a mode this component does not expose (e.g. MIN_HEAT, COIL_DRY) + const auto mode = find_entry(MODES, d[7], true); + if (!mode.has_value()) { + ESP_LOGV(TAG, "Ignoring unknown mode 0x%02X", d[7]); + return false; + } + this->mode = static_cast(mode->value); + + // State frames always carry the power-on flag in bit 7 (power off is the separate 5-byte frame), so it is + // stripped rather than acted on + const uint8_t temp_code = d[6] & ~BYTE6_POWER_ON; + const uint8_t temp_index = reverse_nibble(temp_code); + if (temp_code <= 0x0F && temp_index <= (TEMP_MAX - TEMP_MIN) / TEMP_STEP) + this->target_temperature = TEMP_MIN + temp_index * TEMP_STEP; + + this->swing_mode = (d[8] & BYTE8_FAN_SWING) ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; + const auto fan = find_entry(FANS, d[8] & ~BYTE8_FAN_SWING, true); + this->fan_mode = fan.has_value() ? static_cast(fan->value) : climate::CLIMATE_FAN_AUTO; + + this->publish_state(); + return true; +} + +} // namespace esphome::friedrich diff --git a/esphome/components/friedrich/friedrich.h b/esphome/components/friedrich/friedrich.h new file mode 100644 index 00000000000..3a0be00f00a --- /dev/null +++ b/esphome/components/friedrich/friedrich.h @@ -0,0 +1,47 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::friedrich { + +// Extension point for other units: add new models here and branch on model_ where they differ. +// The default must stay MW12Y3H so existing configurations do not change. +enum class Model : uint8_t { + MODEL_MW12Y3H, // MW12Y3H built from a remote that only provided Fahrenheit +}; + +class FriedrichClimate : public climate_ir::ClimateIR { + public: + // The unit reports temperatures in Fahrenheit (see traits()); the unit only accepts even values. + static constexpr uint8_t TEMP_MIN = 60; // actually 64 for anything but heating + static constexpr uint8_t TEMP_MAX = 88; + static constexpr uint8_t TEMP_STEP = 2; + static constexpr uint8_t STATE_MESSAGE_LENGTH = 14; + + FriedrichClimate() + : ClimateIR(TEMP_MIN, TEMP_MAX, TEMP_STEP, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_QUIET}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) {} + + void set_model(Model model) { this->model_ = model; } + + protected: + void setup() override; + void dump_config() override; + climate::ClimateTraits traits() override; + /// Transmit via IR the state of this climate controller. + void transmit_state() override; + /// Transmit via IR power off command. + void transmit_off_(); + + /// Parse incoming message + bool on_receive(remote_base::RemoteReceiveData src) override; + + /// Transmit message as IR pulses + void transmit_(const uint8_t *data, uint8_t len); + + Model model_{Model::MODEL_MW12Y3H}; +}; + +} // namespace esphome::friedrich diff --git a/tests/components/friedrich/__init__.py b/tests/components/friedrich/__init__.py new file mode 100644 index 00000000000..3028881357b --- /dev/null +++ b/tests/components/friedrich/__init__.py @@ -0,0 +1,15 @@ +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + from esphome.components.remote_base import request_protocol + + # The harness walks the component manifest, not the climate platform, so declare its auto-load here + manifest.auto_load = ["climate_ir", "climate"] + + async def to_code_testing(config: ConfigType) -> None: + # The AEHA source is compiled only behind its define; climate.py requests it in the real build. + request_protocol("aeha") + + manifest.to_code = to_code_testing diff --git a/tests/components/friedrich/common.yaml b/tests/components/friedrich/common.yaml new file mode 100644 index 00000000000..e56be394c59 --- /dev/null +++ b/tests/components/friedrich/common.yaml @@ -0,0 +1,5 @@ +climate: + - platform: friedrich + name: Friedrich Climate + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/friedrich/friedrich_test.cpp b/tests/components/friedrich/friedrich_test.cpp new file mode 100644 index 00000000000..610eca9c091 --- /dev/null +++ b/tests/components/friedrich/friedrich_test.cpp @@ -0,0 +1,215 @@ +#include +#include + +#include "esphome/components/friedrich/friedrich.h" +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/core/helpers.h" + +namespace esphome::friedrich::testing { + +using remote_base::AEHAData; +using remote_base::AEHAProtocol; + +// Records the last transmission instead of sending it +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + int sends{0}; + remote_base::RawTimings last; + + protected: + void send_internal(uint32_t, uint32_t) override { + this->sends++; + this->last = this->temp_.get_data(); + } +}; + +class TestFriedrich : public FriedrichClimate { + public: + using FriedrichClimate::on_receive; + using FriedrichClimate::setup; + using FriedrichClimate::traits; + using FriedrichClimate::transmit_state; +}; + +constexpr uint16_t ADDRESS = 0x28C6; +constexpr uint8_t STATE_MESSAGE_LENGTH = FriedrichClimate::STATE_MESSAGE_LENGTH; +// Byte 6 holds the temperature code (bit 7 is the power flag), byte 7 the mode, byte 8 the fan +constexpr uint8_t TEMP_72_CODE = 0x06; + +struct Fixture { + TestFriedrich sut; + CapturingTransmitter transmitter; + + Fixture() { this->sut.set_transmitter(&this->transmitter); } + + // The last transmission decoded, or an empty frame if it could not be decoded + AEHAData sent() { return AEHAProtocol().decode(rx_of(this->transmitter.last)).value_or(AEHAData{}); } + + bool receive(const AEHAData &frame) { + remote_base::RemoteTransmitData tx; + AEHAProtocol().encode(&tx, frame); + return this->sut.on_receive(rx_of(tx.get_data())); + } + + static remote_base::RemoteReceiveData rx_of(const remote_base::RawTimings &timings) { + return remote_base::RemoteReceiveData(timings, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + } +}; + +// Independent implementation of the 14 byte frame checksum +static AEHAData state_frame(uint8_t temp_code, uint8_t mode, uint8_t fan = 0x00) { + AEHAData f{ADDRESS, {0x00, 0x08, 0x08, 0x7F, 0x90, 0x0C, (uint8_t) (temp_code | 0x80), mode, fan, 0, 0, 0, 0x04, 0}}; + uint8_t sum = 0; + for (int i = 6; i < 13; i++) + sum += reverse_bits(f.data[i]); + f.data[13] = reverse_bits(static_cast(208 - sum)); + return f; +} + +static AEHAData off_frame() { return AEHAData{ADDRESS, {0x00, 0x08, 0x08, 0x40, 0xBF}}; } + +TEST(FriedrichTests, TraitsReportFahrenheit) { + Fixture f; + auto traits = f.sut.traits(); + EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT); + EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 60.0f); + EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f); + EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 2.0f); +} + +TEST(FriedrichTests, OffIsAlwaysSentAsFiveByteFrame) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_OFF; + f.sut.transmit_state(); + ASSERT_EQ(f.transmitter.sends, 1); + EXPECT_EQ(f.sent(), off_frame()); +} + +TEST(FriedrichTests, StateFrameEncodesFahrenheitTarget) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 72; + f.sut.fan_mode = climate::CLIMATE_FAN_AUTO; + f.sut.swing_mode = climate::CLIMATE_SWING_OFF; + f.sut.transmit_state(); + EXPECT_EQ(f.sent(), state_frame(TEMP_72_CODE, 0x80)); +} + +TEST(FriedrichTests, OddTargetStepsUpToEven) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 71; + f.sut.transmit_state(); + auto data = f.sent(); + ASSERT_EQ(data.data.size(), STATE_MESSAGE_LENGTH); + EXPECT_EQ(data.data[6], TEMP_72_CODE | 0x80); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); // stored value matches what was sent +} + +// Codes captured from the remote +TEST(FriedrichTests, EveryTemperatureEncodesToRemoteCode) { + static constexpr uint8_t CODES[] = {0x00, 0x08, 0x04, 0x0C, 0x02, 0x0A, 0x06, 0x0E, + 0x01, 0x09, 0x05, 0x0D, 0x03, 0x0B, 0x07}; + for (size_t i = 0; i < sizeof(CODES); i++) { + const auto fahrenheit = static_cast(60 + i * 2); + SCOPED_TRACE(fahrenheit); + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_HEAT; + f.sut.target_temperature = fahrenheit; + f.sut.transmit_state(); + EXPECT_EQ(f.sent(), state_frame(CODES[i], 0x20)); + + Fixture g; + EXPECT_TRUE(g.receive(state_frame(CODES[i], 0x20))); + EXPECT_FLOAT_EQ(g.sut.target_temperature, fahrenheit); + } +} + +TEST(FriedrichTests, NonHeatModesAreRaisedToSixtyFour) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 60; + f.sut.transmit_state(); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 64.0f); + auto data = f.sent(); + ASSERT_EQ(data.data.size(), STATE_MESSAGE_LENGTH); + EXPECT_EQ(data.data[6], 0x04 | 0x80); // 64 F + + f.sut.mode = climate::CLIMATE_MODE_HEAT; + f.sut.target_temperature = 60; + f.sut.transmit_state(); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 60.0f); +} + +TEST(FriedrichTests, ReceivesStateFrame) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(TEMP_72_CODE, 0x20, 0x80))); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); + EXPECT_EQ(f.sut.fan_mode, climate::CLIMATE_FAN_HIGH); +} + +TEST(FriedrichTests, ReceivesOffFrame) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + EXPECT_TRUE(f.receive(off_frame())); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_OFF); +} + +TEST(FriedrichTests, IgnoresUnknownModeWithoutChangingState) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 80; + EXPECT_FALSE(f.receive(state_frame(TEMP_72_CODE, 0xD0))); // MIN_HEAT is not exposed + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 80.0f); +} + +TEST(FriedrichTests, IgnoresCorruptedFrames) { + struct Case { + const char *name; + AEHAData frame; + std::function corrupt; + }; + const Case cases[] = { + {"other address", off_frame(), [](AEHAData &f) { f.address = 0x1234; }}, + {"bad header byte", off_frame(), [](AEHAData &f) { f.data[1] = 0x09; }}, + {"bad off checksum", off_frame(), [](AEHAData &f) { f.data[4] ^= 0xFF; }}, + {"bad state checksum", state_frame(TEMP_72_CODE, 0x20), [](AEHAData &f) { f.data[13] ^= 0xFF; }}, + }; + for (const auto &c : cases) { + SCOPED_TRACE(c.name); + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + AEHAData frame = c.frame; + c.corrupt(frame); + EXPECT_FALSE(f.receive(frame)); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_COOL); + } +} + +TEST(FriedrichTests, OwnTransmissionIsAccepted) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_FAN_ONLY; + f.sut.target_temperature = 76; + f.sut.fan_mode = climate::CLIMATE_FAN_LOW; + f.sut.swing_mode = climate::CLIMATE_SWING_VERTICAL; + f.sut.transmit_state(); + + Fixture g; + EXPECT_TRUE(g.sut.on_receive(Fixture::rx_of(f.transmitter.last))); + EXPECT_EQ(g.sut.mode, climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_FLOAT_EQ(g.sut.target_temperature, 76.0f); + EXPECT_EQ(g.sut.fan_mode, climate::CLIMATE_FAN_LOW); + EXPECT_EQ(g.sut.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(FriedrichTests, SetupDefaultsTargetToSeventyTwoFahrenheit) { + Fixture f; + f.sut.setup(); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_OFF); +} + +} // namespace esphome::friedrich::testing diff --git a/tests/components/friedrich/test.esp32-idf.yaml b/tests/components/friedrich/test.esp32-idf.yaml new file mode 100644 index 00000000000..bee19625978 --- /dev/null +++ b/tests/components/friedrich/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + friedrich: !include common.yaml diff --git a/tests/components/friedrich/test.esp8266-ard.yaml b/tests/components/friedrich/test.esp8266-ard.yaml new file mode 100644 index 00000000000..d58d8014944 --- /dev/null +++ b/tests/components/friedrich/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + friedrich: !include common.yaml