mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 02:02:51 +00:00
[mistral_ir] Add climate component for Mistral MHFE1126A IR AC units (#17812)
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
J. Nick Koston
J. Nick Koston
parent
fa519cea64
commit
b21354d6d9
@@ -379,6 +379,7 @@ esphome/components/midea_ir/* @dudanov
|
||||
esphome/components/mipi_dsi/* @clydebarrow
|
||||
esphome/components/mipi_rgb/* @clydebarrow
|
||||
esphome/components/mipi_spi/* @clydebarrow
|
||||
esphome/components/mistral_ir/* @lerhum
|
||||
esphome/components/mitsubishi/* @RubyBailey
|
||||
esphome/components/mitsubishi_cn105/* @crnjan
|
||||
esphome/components/mixer/speaker/* @kahrendt
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DOMAIN = "mistral_ir"
|
||||
@@ -0,0 +1,16 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate_ir, remote_base
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["climate_ir"]
|
||||
CODEOWNERS = ["@lerhum"]
|
||||
|
||||
mistral_ir_ns = cg.esphome_ns.namespace("mistral_ir")
|
||||
MistralIR = mistral_ir_ns.class_("MistralIR", climate_ir.ClimateIR)
|
||||
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MistralIR)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
remote_base.request_protocol("aeha") # used from C++
|
||||
await climate_ir.new_climate_ir(config)
|
||||
@@ -0,0 +1,135 @@
|
||||
#include "mistral_ir.h"
|
||||
#include "esphome/components/remote_base/aeha_protocol.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::mistral_ir {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "mistral_ir.climate");
|
||||
|
||||
// Fixed bytes of every frame; bytes 1, 3, 4, 5, 6 and 11 are filled in per state
|
||||
static const uint8_t FRAME_TEMPLATE[MISTRAL_FRAME_SIZE] PROGMEM = {0x56, 0x00, 0x08, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0xE8, 0x00, 0x60, 0x00};
|
||||
static const uint8_t FIXED_BYTES[] PROGMEM = {0, 2, 7, 8, 9, 10};
|
||||
static const uint8_t CHECKSUM_BYTES[] PROGMEM = {1, 3, 4, 5, 6, 8};
|
||||
|
||||
constexpr uint8_t MISTRAL_POWER_OFF = 0x00;
|
||||
constexpr uint8_t MISTRAL_POWER_ON = 0x20;
|
||||
constexpr uint8_t MISTRAL_TEMP_OFFSET = 0x9F;
|
||||
constexpr uint8_t MISTRAL_MODE_AUTO = 0x10;
|
||||
|
||||
// The tables live in flash, so entries are copied out before use
|
||||
struct ModeCode {
|
||||
climate::ClimateMode mode;
|
||||
uint8_t code;
|
||||
};
|
||||
static const ModeCode MODES[] PROGMEM = {
|
||||
{climate::CLIMATE_MODE_COOL, 0xC0},
|
||||
{climate::CLIMATE_MODE_DRY, 0x40},
|
||||
{climate::CLIMATE_MODE_FAN_ONLY, 0x80},
|
||||
{climate::CLIMATE_MODE_HEAT, 0x60},
|
||||
{climate::CLIMATE_MODE_HEAT_COOL, MISTRAL_MODE_AUTO},
|
||||
};
|
||||
|
||||
struct FanCode {
|
||||
climate::ClimateFanMode fan;
|
||||
uint8_t byte1;
|
||||
uint8_t byte6;
|
||||
};
|
||||
static const FanCode FANS[] PROGMEM = {
|
||||
{climate::CLIMATE_FAN_AUTO, 0xA4, 0x00},
|
||||
{climate::CLIMATE_FAN_LOW, 0x44, 0x40},
|
||||
{climate::CLIMATE_FAN_MEDIUM, 0x24, 0xC0},
|
||||
{climate::CLIMATE_FAN_HIGH, 0xA4, 0xA0},
|
||||
};
|
||||
|
||||
template<typename T> static T load_entry(const T &slot) {
|
||||
T entry;
|
||||
progmem_memcpy(&entry, &slot, sizeof(T));
|
||||
return entry;
|
||||
}
|
||||
|
||||
template<typename T, size_t N, typename Pred> static optional<T> find_entry(const T (&table)[N], Pred pred) {
|
||||
for (const T &slot : table) {
|
||||
T entry = load_entry(slot);
|
||||
if (pred(entry))
|
||||
return entry;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
static uint8_t compute_checksum(const uint8_t *frame) {
|
||||
uint8_t sum = 0;
|
||||
for (const uint8_t &idx : CHECKSUM_BYTES)
|
||||
sum += reverse_bits(frame[progmem_read_byte(&idx)]);
|
||||
return reverse_bits(sum);
|
||||
}
|
||||
|
||||
static bool is_valid_frame(const uint8_t *frame) {
|
||||
for (const uint8_t &slot : FIXED_BYTES) {
|
||||
const uint8_t idx = progmem_read_byte(&slot);
|
||||
if (frame[idx] != progmem_read_byte(&FRAME_TEMPLATE[idx]))
|
||||
return false;
|
||||
}
|
||||
return frame[11] == compute_checksum(frame);
|
||||
}
|
||||
|
||||
void MistralIR::transmit_state() {
|
||||
const auto mode = find_entry(MODES, [this](const ModeCode &m) { return m.mode == this->mode; });
|
||||
const climate::ClimateFanMode fan_mode = this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO);
|
||||
const FanCode fan =
|
||||
find_entry(FANS, [fan_mode](const FanCode &f) { return f.fan == fan_mode; }).value_or(load_entry(FANS[0]));
|
||||
const uint8_t temp = lroundf(clamp<float>(this->target_temperature, MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX));
|
||||
|
||||
uint8_t frame[MISTRAL_FRAME_SIZE];
|
||||
progmem_memcpy(frame, FRAME_TEMPLATE, sizeof(frame));
|
||||
frame[1] = fan.byte1;
|
||||
frame[3] = this->mode == climate::CLIMATE_MODE_OFF ? MISTRAL_POWER_OFF : MISTRAL_POWER_ON;
|
||||
frame[4] = mode.has_value() ? mode->code : MISTRAL_MODE_AUTO;
|
||||
frame[5] = reverse_bits(static_cast<uint8_t>(MISTRAL_TEMP_OFFSET - temp));
|
||||
frame[6] = fan.byte6;
|
||||
frame[11] = compute_checksum(frame);
|
||||
|
||||
auto transmit = this->transmitter_->transmit();
|
||||
remote_base::AEHAProtocol().encode(transmit.get_data(), MISTRAL_ADDRESS, frame, sizeof(frame));
|
||||
transmit.perform();
|
||||
}
|
||||
|
||||
bool MistralIR::on_receive(remote_base::RemoteReceiveData data) {
|
||||
auto aeha = remote_base::AEHAProtocol().decode(data);
|
||||
if (!aeha.has_value() || aeha->address != MISTRAL_ADDRESS || aeha->data.size() != MISTRAL_FRAME_SIZE)
|
||||
return false;
|
||||
|
||||
const uint8_t *frame = aeha->data.data();
|
||||
if (!is_valid_frame(frame))
|
||||
return false;
|
||||
|
||||
if (frame[3] == MISTRAL_POWER_OFF) {
|
||||
this->mode = climate::CLIMATE_MODE_OFF;
|
||||
this->publish_state();
|
||||
return true;
|
||||
}
|
||||
if (frame[3] != MISTRAL_POWER_ON)
|
||||
return false;
|
||||
|
||||
// Decode everything before touching the state so an unknown code leaves it untouched
|
||||
const auto mode = find_entry(MODES, [frame](const ModeCode &m) { return m.code == frame[4]; });
|
||||
const auto fan = find_entry(FANS, [frame](const FanCode &f) { return f.byte1 == frame[1] && f.byte6 == frame[6]; });
|
||||
if (!mode.has_value() || !fan.has_value())
|
||||
return false;
|
||||
|
||||
this->mode = mode->mode;
|
||||
this->target_temperature =
|
||||
clamp<float>(MISTRAL_TEMP_OFFSET - reverse_bits(frame[5]), MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX);
|
||||
this->fan_mode = fan->fan;
|
||||
|
||||
ESP_LOGV(TAG, "Received Mistral frame: mode=%d temp=%.0f fan=%d", static_cast<int>(this->mode),
|
||||
this->target_temperature, static_cast<int>(fan->fan));
|
||||
this->publish_state();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::mistral_ir
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/climate_ir/climate_ir.h"
|
||||
|
||||
namespace esphome::mistral_ir {
|
||||
|
||||
// Temperature range, Celsius
|
||||
constexpr uint8_t MISTRAL_TEMP_MIN = 16;
|
||||
constexpr uint8_t MISTRAL_TEMP_MAX = 30;
|
||||
|
||||
constexpr uint16_t MISTRAL_ADDRESS = 0x322C;
|
||||
constexpr size_t MISTRAL_FRAME_SIZE = 12;
|
||||
|
||||
/* Reverse-engineered protocol for a "Mistral" (Belgian brand, model MHFE1126A / MR112C) split AC unit
|
||||
from the 2000-2010 era, likely a rebadged OEM design. It is carried over AEHA IR timing (address
|
||||
0x322C) but does not match any protocol already implemented in ESPHome, so it is decoded/encoded
|
||||
directly here rather than through the generic AEHA helper.
|
||||
|
||||
Frame layout (12 bytes): 56 xx 08 xx xx xx xx 00 E8 00 60 xx
|
||||
byte[1] / byte[6] -- fan speed
|
||||
byte[3] -- power: 0x20 on, 0x00 off
|
||||
byte[4] -- mode
|
||||
byte[5] -- target temperature (bit-reversed offset from 0x9F)
|
||||
byte[11] -- checksum: bit-reverse the sum of bit-reversed bytes 1, 3, 4, 5, 6, 8
|
||||
*/
|
||||
class MistralIR : public climate_ir::ClimateIR {
|
||||
public:
|
||||
MistralIR()
|
||||
: climate_ir::ClimateIR(MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX, 1.0f, true, true,
|
||||
{climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM,
|
||||
climate::CLIMATE_FAN_HIGH}) {}
|
||||
|
||||
protected:
|
||||
void transmit_state() override;
|
||||
bool on_receive(remote_base::RemoteReceiveData data) override;
|
||||
};
|
||||
|
||||
} // namespace esphome::mistral_ir
|
||||
@@ -0,0 +1,14 @@
|
||||
from esphome.components import remote_base
|
||||
from esphome.types import ConfigType
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# 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 platform's to_code never runs here; without this request remote_base drops aeha_protocol.cpp
|
||||
remote_base.request_protocol("aeha")
|
||||
|
||||
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 mistral_ir, climate_ir and remote_transmitter sources come in through
|
||||
# the dependency walk in __init__.py; their code generation is suppressed.
|
||||
climate: []
|
||||
@@ -0,0 +1,4 @@
|
||||
climate:
|
||||
- platform: mistral_ir
|
||||
name: Mistral IR
|
||||
transmitter_id: xmitr
|
||||
@@ -0,0 +1,330 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstdint>
|
||||
#include <new>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/mistral_ir/mistral_ir.h"
|
||||
#include "esphome/components/remote_base/aeha_protocol.h"
|
||||
#include "esphome/components/remote_base/remote_base.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
|
||||
namespace esphome::mistral_ir::testing {
|
||||
|
||||
using Frame = std::vector<uint8_t>;
|
||||
|
||||
static constexpr uint8_t POWER_OFF = 0x00;
|
||||
static constexpr uint8_t POWER_ON = 0x20;
|
||||
static constexpr uint8_t MODE_COOL = 0xC0;
|
||||
static constexpr uint8_t MODE_DRY = 0x40;
|
||||
static constexpr uint8_t MODE_FAN_ONLY = 0x80;
|
||||
static constexpr uint8_t MODE_HEAT = 0x60;
|
||||
static constexpr uint8_t MODE_AUTO = 0x10;
|
||||
static constexpr uint8_t FAN_AUTO_BYTE1 = 0xA4;
|
||||
static constexpr uint8_t FAN_AUTO_BYTE6 = 0x00;
|
||||
static constexpr uint8_t FAN_LOW_BYTE1 = 0x44;
|
||||
static constexpr uint8_t FAN_LOW_BYTE6 = 0x40;
|
||||
static constexpr uint8_t FAN_MEDIUM_BYTE1 = 0x24;
|
||||
static constexpr uint8_t FAN_MEDIUM_BYTE6 = 0xC0;
|
||||
static constexpr uint8_t FAN_HIGH_BYTE1 = 0xA4;
|
||||
static constexpr uint8_t FAN_HIGH_BYTE6 = 0xA0;
|
||||
static constexpr uint16_t WRONG_ADDRESS = 0x322D;
|
||||
static constexpr uint32_t TOLERANCE_PERCENT = 25;
|
||||
|
||||
static constexpr climate::ClimateMode ALL_MODES[] = {
|
||||
climate::CLIMATE_MODE_COOL, climate::CLIMATE_MODE_DRY, climate::CLIMATE_MODE_FAN_ONLY,
|
||||
climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT_COOL,
|
||||
};
|
||||
static constexpr climate::ClimateFanMode ALL_FANS[] = {
|
||||
climate::CLIMATE_FAN_AUTO,
|
||||
climate::CLIMATE_FAN_LOW,
|
||||
climate::CLIMATE_FAN_MEDIUM,
|
||||
climate::CLIMATE_FAN_HIGH,
|
||||
};
|
||||
|
||||
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());
|
||||
}
|
||||
};
|
||||
|
||||
static uint8_t checksum(const Frame &frame) {
|
||||
uint8_t sum = 0;
|
||||
for (size_t idx : {1, 3, 4, 5, 6, 8})
|
||||
sum += reverse_bits(frame[idx]);
|
||||
return reverse_bits(sum);
|
||||
}
|
||||
|
||||
/// A received frame with a valid checksum, which callers may overwrite
|
||||
static Frame make_frame(uint8_t byte1, uint8_t power, uint8_t mode, uint8_t temperature, uint8_t byte6) {
|
||||
Frame frame{0x56, byte1, 0x08, power, mode, reverse_bits(static_cast<uint8_t>(0x9F - temperature)),
|
||||
byte6, 0x00, 0xE8, 0x00, 0x60, 0x00};
|
||||
frame[11] = checksum(frame);
|
||||
return frame;
|
||||
}
|
||||
|
||||
static remote_base::RawTimings encode(uint16_t address, const Frame &frame) {
|
||||
remote_base::RemoteTransmitData data;
|
||||
remote_base::AEHAProtocol().encode(&data, address, frame.data(), frame.size());
|
||||
return data.get_data();
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
CapturingTransmitter transmitter;
|
||||
MistralIR climate;
|
||||
|
||||
Fixture() {
|
||||
// Every fixture shares one preference key, so clear what an earlier test saved or setup restores it
|
||||
if (global_preferences != nullptr)
|
||||
global_preferences->reset();
|
||||
// The test main does not construct App as generated code does
|
||||
static const bool APP_CONSTRUCTED = (new (&App) Application(), true);
|
||||
(void) APP_CONSTRUCTED;
|
||||
static char name[] = "test_mistral_ir";
|
||||
static char friendly_name[] = "";
|
||||
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
|
||||
this->climate.set_transmitter(&this->transmitter);
|
||||
static_cast<Component &>(this->climate).setup();
|
||||
}
|
||||
|
||||
void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan) {
|
||||
auto call = this->climate.make_call();
|
||||
call.set_mode(mode);
|
||||
call.set_target_temperature(temperature);
|
||||
call.set_fan_mode(fan);
|
||||
call.perform();
|
||||
}
|
||||
|
||||
/// The 12 data bytes of the last transmission, decoded by the AEHA protocol
|
||||
Frame last_frame() const {
|
||||
EXPECT_FALSE(this->transmitter.frames.empty());
|
||||
if (this->transmitter.frames.empty())
|
||||
return {};
|
||||
remote_base::RemoteReceiveData data(this->transmitter.frames.back(), TOLERANCE_PERCENT,
|
||||
remote_base::TOLERANCE_MODE_PERCENTAGE);
|
||||
const auto aeha = remote_base::AEHAProtocol().decode(data);
|
||||
EXPECT_TRUE(aeha.has_value());
|
||||
if (!aeha.has_value())
|
||||
return {};
|
||||
EXPECT_EQ(aeha->address, MISTRAL_ADDRESS);
|
||||
EXPECT_EQ(aeha->data.size(), MISTRAL_FRAME_SIZE);
|
||||
return aeha->data;
|
||||
}
|
||||
|
||||
bool receive(const remote_base::RawTimings &raw) {
|
||||
remote_base::RemoteReceiveData data(raw, TOLERANCE_PERCENT, remote_base::TOLERANCE_MODE_PERCENTAGE);
|
||||
// The receiver calls through the listener interface, where on_receive is public
|
||||
return static_cast<remote_base::RemoteReceiverListener &>(this->climate).on_receive(data);
|
||||
}
|
||||
|
||||
bool receive(const Frame &frame, uint16_t address = MISTRAL_ADDRESS) { return this->receive(encode(address, frame)); }
|
||||
|
||||
void expect_state(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan) const {
|
||||
EXPECT_EQ(this->climate.mode, mode);
|
||||
EXPECT_FLOAT_EQ(this->climate.target_temperature, temperature);
|
||||
EXPECT_EQ(this->climate.fan_mode, fan);
|
||||
}
|
||||
};
|
||||
|
||||
TEST(MistralIR, EncodesEachMode) {
|
||||
struct Case {
|
||||
climate::ClimateMode mode;
|
||||
uint8_t code;
|
||||
uint8_t checksum;
|
||||
};
|
||||
// 22 C: 0x9F - 22 = 0x89, reversed 0x91. Checksum sums the reversed bytes 1, 3, 4, 5, 6 and 8:
|
||||
// fan AUTO 0xA4 -> 0x25, power on 0x20 -> 0x04, mode, temperature 0x89, byte 6 0x00, 0xE8 -> 0x17
|
||||
const Case cases[] = {
|
||||
// COOL 0xC0 -> 0x03: 0x25 + 0x04 + 0x03 + 0x89 + 0x00 + 0x17 = 0xCC, reversed 0x33
|
||||
{climate::CLIMATE_MODE_COOL, MODE_COOL, 0x33},
|
||||
// DRY 0x40 -> 0x02: sum 0xCB, reversed 0xD3
|
||||
{climate::CLIMATE_MODE_DRY, MODE_DRY, 0xD3},
|
||||
// FAN_ONLY 0x80 -> 0x01: sum 0xCA, reversed 0x53
|
||||
{climate::CLIMATE_MODE_FAN_ONLY, MODE_FAN_ONLY, 0x53},
|
||||
// HEAT 0x60 -> 0x06: sum 0xCF, reversed 0xF3
|
||||
{climate::CLIMATE_MODE_HEAT, MODE_HEAT, 0xF3},
|
||||
// HEAT_COOL 0x10 -> 0x08: sum 0xD1, reversed 0x8B
|
||||
{climate::CLIMATE_MODE_HEAT_COOL, MODE_AUTO, 0x8B},
|
||||
};
|
||||
Fixture f;
|
||||
for (const Case &c : cases) {
|
||||
SCOPED_TRACE(static_cast<int>(c.mode));
|
||||
f.command(c.mode, 22.0f, climate::CLIMATE_FAN_AUTO);
|
||||
const Frame expected{0x56, FAN_AUTO_BYTE1, 0x08, POWER_ON, c.code, 0x91,
|
||||
FAN_AUTO_BYTE6, 0x00, 0xE8, 0x00, 0x60, c.checksum};
|
||||
EXPECT_EQ(f.last_frame(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MistralIR, EncodesEachFanMode) {
|
||||
struct Case {
|
||||
climate::ClimateFanMode fan;
|
||||
uint8_t byte1;
|
||||
uint8_t byte6;
|
||||
uint8_t checksum;
|
||||
};
|
||||
// COOL at 24 C: 0x9F - 24 = 0x87, reversed 0xE1. Fixed part of the checksum sum:
|
||||
// power on 0x04 + COOL 0x03 + temperature 0x87 + 0xE8 -> 0x17 = 0xA5, plus reversed bytes 1 and 6
|
||||
const Case cases[] = {
|
||||
// AUTO 0xA4 -> 0x25, 0x00 -> 0x00: 0xA5 + 0x25 + 0x00 = 0xCA, reversed 0x53
|
||||
{climate::CLIMATE_FAN_AUTO, FAN_AUTO_BYTE1, FAN_AUTO_BYTE6, 0x53},
|
||||
// LOW 0x44 -> 0x22, 0x40 -> 0x02: 0xA5 + 0x22 + 0x02 = 0xC9, reversed 0x93
|
||||
{climate::CLIMATE_FAN_LOW, FAN_LOW_BYTE1, FAN_LOW_BYTE6, 0x93},
|
||||
// MEDIUM 0x24 -> 0x24, 0xC0 -> 0x03: 0xA5 + 0x24 + 0x03 = 0xCC, reversed 0x33
|
||||
{climate::CLIMATE_FAN_MEDIUM, FAN_MEDIUM_BYTE1, FAN_MEDIUM_BYTE6, 0x33},
|
||||
// HIGH 0xA4 -> 0x25, 0xA0 -> 0x05: 0xA5 + 0x25 + 0x05 = 0xCF, reversed 0xF3
|
||||
{climate::CLIMATE_FAN_HIGH, FAN_HIGH_BYTE1, FAN_HIGH_BYTE6, 0xF3},
|
||||
};
|
||||
Fixture f;
|
||||
for (const Case &c : cases) {
|
||||
SCOPED_TRACE(static_cast<int>(c.fan));
|
||||
f.command(climate::CLIMATE_MODE_COOL, 24.0f, c.fan);
|
||||
const Frame frame = f.last_frame();
|
||||
ASSERT_EQ(frame.size(), MISTRAL_FRAME_SIZE);
|
||||
EXPECT_EQ(frame[1], c.byte1);
|
||||
EXPECT_EQ(frame[4], MODE_COOL);
|
||||
EXPECT_EQ(frame[5], 0xE1);
|
||||
EXPECT_EQ(frame[6], c.byte6);
|
||||
EXPECT_EQ(frame[11], c.checksum);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MistralIR, EncodesOff) {
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_OFF, 22.0f, climate::CLIMATE_FAN_AUTO);
|
||||
// OFF has no mode code, so byte 4 falls back to auto 0x10 -> 0x08 with power 0x00:
|
||||
// 0x25 + 0x00 + 0x08 + 0x89 + 0x00 + 0x17 = 0xCD, reversed 0xB3
|
||||
const Frame expected{0x56, FAN_AUTO_BYTE1, 0x08, POWER_OFF, MODE_AUTO, 0x91,
|
||||
FAN_AUTO_BYTE6, 0x00, 0xE8, 0x00, 0x60, 0xB3};
|
||||
EXPECT_EQ(f.last_frame(), expected);
|
||||
}
|
||||
|
||||
TEST(MistralIR, EncodesTemperatureRoundedAndClamped) {
|
||||
struct Case {
|
||||
float requested;
|
||||
uint8_t byte5;
|
||||
};
|
||||
const Case cases[] = {
|
||||
{16.0f, 0xF1}, // 0x9F - 16 = 0x8F, reversed 0xF1
|
||||
{22.0f, 0x91}, // 0x9F - 22 = 0x89, reversed 0x91
|
||||
{30.0f, 0x81}, // 0x9F - 30 = 0x81, reversed 0x81
|
||||
{15.6f, 0xF1}, // rounds to 16
|
||||
{30.4f, 0x81}, // rounds to 30
|
||||
{10.0f, 0xF1}, // clamps to 16
|
||||
{35.0f, 0x81}, // clamps to 30
|
||||
};
|
||||
Fixture f;
|
||||
for (const Case &c : cases) {
|
||||
SCOPED_TRACE(c.requested);
|
||||
f.command(climate::CLIMATE_MODE_COOL, c.requested, climate::CLIMATE_FAN_AUTO);
|
||||
const Frame frame = f.last_frame();
|
||||
ASSERT_EQ(frame.size(), MISTRAL_FRAME_SIZE);
|
||||
EXPECT_EQ(frame[5], c.byte5);
|
||||
EXPECT_EQ(frame[11], checksum(frame));
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MistralIR, RoundTripsEveryModeAndFan) {
|
||||
for (climate::ClimateMode mode : ALL_MODES) {
|
||||
for (climate::ClimateFanMode fan : ALL_FANS) {
|
||||
for (float temperature : {16.0f, 23.0f, 30.0f}) {
|
||||
SCOPED_TRACE(static_cast<int>(mode) * 1000 + static_cast<int>(fan) * 100 + static_cast<int>(temperature));
|
||||
Fixture sender;
|
||||
sender.command(mode, temperature, fan);
|
||||
EXPECT_EQ(sender.last_frame().size(), MISTRAL_FRAME_SIZE);
|
||||
Fixture receiver;
|
||||
EXPECT_TRUE(receiver.receive(sender.transmitter.frames.back()));
|
||||
receiver.expect_state(mode, temperature, fan);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MistralIR, ReceivesHeatFrame) {
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH);
|
||||
EXPECT_TRUE(f.receive(make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6)));
|
||||
f.expect_state(climate::CLIMATE_MODE_HEAT, 25.0f, climate::CLIMATE_FAN_LOW);
|
||||
}
|
||||
|
||||
TEST(MistralIR, ReceivedPowerOffKeepsTemperatureAndFan) {
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH);
|
||||
EXPECT_TRUE(f.receive(make_frame(FAN_AUTO_BYTE1, POWER_OFF, MODE_AUTO, 28, FAN_AUTO_BYTE6)));
|
||||
f.expect_state(climate::CLIMATE_MODE_OFF, 22.0f, climate::CLIMATE_FAN_HIGH);
|
||||
}
|
||||
|
||||
TEST(MistralIR, RejectsBadChecksum) {
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH);
|
||||
Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6);
|
||||
frame[11] ^= 0x01;
|
||||
EXPECT_FALSE(f.receive(frame));
|
||||
f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH);
|
||||
}
|
||||
|
||||
TEST(MistralIR, RejectsChangedFixedByte) {
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH);
|
||||
for (size_t idx : {0, 2, 7, 8, 9, 10}) {
|
||||
SCOPED_TRACE(idx);
|
||||
Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6);
|
||||
frame[idx] ^= 0x01;
|
||||
// Byte 8 is part of the checksum, so keep it valid to test the fixed-byte check alone
|
||||
frame[11] = checksum(frame);
|
||||
EXPECT_FALSE(f.receive(frame));
|
||||
f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MistralIR, RejectsUnknownPowerModeAndFan) {
|
||||
struct Case {
|
||||
const char *name;
|
||||
uint8_t byte1;
|
||||
uint8_t power;
|
||||
uint8_t mode;
|
||||
uint8_t byte6;
|
||||
};
|
||||
const Case cases[] = {
|
||||
{"power 0x10", FAN_LOW_BYTE1, 0x10, MODE_HEAT, FAN_LOW_BYTE6},
|
||||
{"mode 0x20", FAN_LOW_BYTE1, POWER_ON, 0x20, FAN_LOW_BYTE6},
|
||||
{"fan 0x44/0x00", FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, FAN_AUTO_BYTE6},
|
||||
{"fan 0x00/0x40", 0x00, POWER_ON, MODE_HEAT, FAN_LOW_BYTE6},
|
||||
};
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH);
|
||||
for (const Case &c : cases) {
|
||||
SCOPED_TRACE(c.name);
|
||||
EXPECT_FALSE(f.receive(make_frame(c.byte1, c.power, c.mode, 25, c.byte6)));
|
||||
f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(MistralIR, RejectsWrongAddress) {
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH);
|
||||
const Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6);
|
||||
EXPECT_FALSE(f.receive(frame, WRONG_ADDRESS));
|
||||
f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH);
|
||||
}
|
||||
|
||||
TEST(MistralIR, RejectsWrongFrameLength) {
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH);
|
||||
const Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6);
|
||||
Frame shorter(frame.begin(), frame.begin() + 11);
|
||||
EXPECT_FALSE(f.receive(shorter));
|
||||
Frame longer = frame;
|
||||
longer.push_back(0x00);
|
||||
EXPECT_FALSE(f.receive(longer));
|
||||
f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH);
|
||||
}
|
||||
|
||||
} // namespace esphome::mistral_ir::testing
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml
|
||||
mistral_ir: !include common.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml
|
||||
mistral_ir: !include common.yaml
|
||||
Reference in New Issue
Block a user