mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 02:02:51 +00:00
[friedrich] New Climate IR component (#9178)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: J. Nick Koston <nick@koston.org> Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
J. Nick Koston
J. Nick Koston
parent
5017165800
commit
e289a94451
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DOMAIN = "friedrich"
|
||||
@@ -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]))
|
||||
@@ -0,0 +1,234 @@
|
||||
#include "friedrich.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#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<size_t N> static optional<Code> 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<uint8_t>(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<float>(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<float>(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<climate::ClimateMode>(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<climate::ClimateFanMode>(fan->value) : climate::CLIMATE_FAN_AUTO;
|
||||
|
||||
this->publish_state();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::friedrich
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
climate:
|
||||
- platform: friedrich
|
||||
name: Friedrich Climate
|
||||
transmitter_id: xmitr
|
||||
receiver_id: rcvr
|
||||
@@ -0,0 +1,215 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <functional>
|
||||
|
||||
#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<uint8_t>(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<uint8_t>(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<void(AEHAData &)> 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user