[daikin] Add presets and half-degree temperature steps (#17891)

Co-authored-by: James Nimmo <james@nimmo.net.nz>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
opcod3
2026-10-10 23:13:21 -10:00
committed by GitHub
co-authored by James Nimmo J. Nick Koston
parent 254690100c
commit cb54f6d6cc
9 changed files with 1084 additions and 34 deletions
+112 -18
View File
@@ -1,25 +1,47 @@
#include "daikin.h"
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/application.h"
namespace esphome::daikin {
ESPHOME_LOG_TAG(TAG, "daikin.climate");
static constexpr uint32_t BOOST_TIMEOUT_ID = 0;
static uint8_t sum_bytes(const uint8_t *data, size_t len) {
uint8_t sum = 0;
for (size_t i = 0; i < len; i++) {
sum += data[i];
}
return sum;
}
void DaikinClimate::setup() {
climate_ir::ClimateIR::setup();
// A boost preset restored from flash gets its timer too
this->update_boost_timer_(false);
}
void DaikinClimate::control(const climate::ClimateCall &call) {
const bool was_boost = this->preset == climate::CLIMATE_PRESET_BOOST;
climate_ir::ClimateIR::control(call);
this->update_boost_timer_(was_boost);
}
void DaikinClimate::transmit_state() {
uint8_t remote_state[35] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0xD7, 0x11, 0xDA, 0x27, 0x00,
uint8_t remote_state[35] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0x00, 0x11, 0xDA, 0x27, 0x00,
0x42, 0x49, 0x05, 0xA2, 0x11, 0xDA, 0x27, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00};
this->set_presets_(remote_state);
remote_state[21] = this->operation_mode_();
remote_state[22] = this->temperature_();
uint16_t fan_speed = this->fan_speed_();
remote_state[24] = fan_speed >> 8;
remote_state[25] = fan_speed & 0xff;
// Calculate checksum
for (int i = 16; i < 34; i++) {
remote_state[34] += remote_state[i];
}
remote_state[7] = sum_bytes(remote_state, 7);
remote_state[34] = sum_bytes(remote_state + 16, 18);
auto transmit = this->transmitter_->transmit();
auto *data = transmit.get_data();
@@ -136,18 +158,52 @@ uint8_t DaikinClimate::temperature_() const {
case climate::CLIMATE_MODE_DRY:
return 0xc0;
default:
uint8_t temperature = (uint8_t) roundf(clamp<float>(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX));
return temperature << 1;
return static_cast<uint8_t>(
roundf(clamp<float>(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX) * 2.0f));
}
}
bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) {
uint8_t checksum = 0;
for (int i = 0; i < (DAIKIN_STATE_FRAME_SIZE - 1); i++) {
checksum += frame[i];
void DaikinClimate::set_presets_(uint8_t *remote_state) const {
switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) {
case climate::CLIMATE_PRESET_BOOST:
remote_state[29] = DAIKIN_PRESET_POWERFUL_ON;
break;
case climate::CLIMATE_PRESET_SLEEP:
remote_state[29] = DAIKIN_PRESET_QUIET_ON;
break;
case climate::CLIMATE_PRESET_ECO:
remote_state[32] = DAIKIN_PRESET_ECONO_ON;
break;
case climate::CLIMATE_PRESET_ACTIVITY:
remote_state[32] = DAIKIN_PRESET_SENSOR_ON;
break;
case climate::CLIMATE_PRESET_COMFORT:
remote_state[6] = DAIKIN_PRESET_COMFORT_ON; // the only preset carried by the first frame
break;
default:
break;
}
if (frame[DAIKIN_STATE_FRAME_SIZE - 1] != checksum)
return false;
}
void DaikinClimate::update_boost_timer_(bool was_boost) {
const bool is_boost = this->preset == climate::CLIMATE_PRESET_BOOST;
if (is_boost && !was_boost) {
this->set_timeout(BOOST_TIMEOUT_ID, DAIKIN_BOOST_DURATION_MS, [this]() { this->cancel_boost_mode_(); });
} else if (was_boost && !is_boost) {
this->cancel_timeout(BOOST_TIMEOUT_ID);
}
}
void DaikinClimate::cancel_boost_mode_() {
if (this->preset != climate::CLIMATE_PRESET_BOOST)
return;
ESP_LOGD(TAG, "Boost ended");
this->preset = climate::CLIMATE_PRESET_NONE;
this->publish_state();
}
bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) {
const bool was_boost = this->preset == climate::CLIMATE_PRESET_BOOST;
uint8_t mode = frame[5];
// Temperature is given in degrees celcius * 2
// only update for states that use the temperature
@@ -206,16 +262,41 @@ bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) {
this->fan_mode = climate::CLIMATE_FAN_QUIET;
break;
}
// Comfort lives in the first frame, recorded as pending until this frame proves the transmission valid
// A comfort frame whose own state frame never came must not attach to a later transmission
const uint32_t now = App.get_loop_component_start_time();
const PendingComfort comfort =
now - this->pending_comfort_at_ <= DAIKIN_TRANSMISSION_MS ? this->pending_comfort_ : PendingComfort::UNKNOWN;
this->pending_comfort_ = PendingComfort::UNKNOWN;
if (frame[13] & DAIKIN_PRESET_POWERFUL_ON) {
this->preset = climate::CLIMATE_PRESET_BOOST;
} else if (frame[13] & DAIKIN_PRESET_QUIET_ON) {
this->preset = climate::CLIMATE_PRESET_SLEEP;
} else if (frame[16] & DAIKIN_PRESET_ECONO_ON) {
this->preset = climate::CLIMATE_PRESET_ECO;
} else if (frame[16] & DAIKIN_PRESET_SENSOR_ON) {
this->preset = climate::CLIMATE_PRESET_ACTIVITY;
} else if (comfort == PendingComfort::ON) {
this->preset = climate::CLIMATE_PRESET_COMFORT;
} else if (comfort == PendingComfort::OFF || this->preset != climate::CLIMATE_PRESET_COMFORT) {
// Without a comfort frame in this transmission a current comfort preset is left alone
this->preset = climate::CLIMATE_PRESET_NONE;
}
this->update_boost_timer_(was_boost);
this->publish_state();
return true;
}
bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) {
uint8_t state_frame[DAIKIN_STATE_FRAME_SIZE] = {};
uint8_t frame[DAIKIN_STATE_FRAME_SIZE] = {};
if (!data.expect_item(DAIKIN_HEADER_MARK, DAIKIN_HEADER_SPACE)) {
return false;
}
for (uint8_t pos = 0; pos < DAIKIN_STATE_FRAME_SIZE; pos++) {
uint8_t frame_size = DAIKIN_STATE_FRAME_SIZE;
for (uint8_t pos = 0; pos < frame_size; pos++) {
uint8_t byte = 0;
for (int8_t bit = 0; bit < 8; bit++) {
if (data.expect_item(DAIKIN_BIT_MARK, DAIKIN_ONE_SPACE)) {
@@ -224,7 +305,7 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) {
return false;
}
}
state_frame[pos] = byte;
frame[pos] = byte;
if (pos == 0) {
// frame header
if (byte != 0x11)
@@ -243,11 +324,24 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) {
return false;
} else if (pos == 4) {
// frame type
if (byte != 0x00)
if (byte == DAIKIN_FRAME_TYPE_COMFORT) {
frame_size = DAIKIN_COMFORT_FRAME_SIZE;
} else if (byte != DAIKIN_FRAME_TYPE_STATE) {
return false;
}
}
}
return this->parse_state_frame_(state_frame);
if (frame[frame_size - 1] != sum_bytes(frame, frame_size - 1)) {
return false;
}
if (frame_size == DAIKIN_COMFORT_FRAME_SIZE) {
// Nothing changes yet: the state frame that follows applies it once it has passed its own checksum
this->pending_comfort_ = (frame[6] & DAIKIN_PRESET_COMFORT_ON) ? PendingComfort::ON : PendingComfort::OFF;
this->pending_comfort_at_ = App.get_loop_component_start_time();
return true;
}
return this->parse_state_frame_(frame);
}
} // namespace esphome::daikin
+34 -6
View File
@@ -27,6 +27,17 @@ const uint8_t DAIKIN_FAN_3 = 0x50;
const uint8_t DAIKIN_FAN_4 = 0x60;
const uint8_t DAIKIN_FAN_5 = 0x70;
// Presets: bit flags in byte 6 (comfort), byte 29 (powerful, quiet) and byte 32 (sensor, econo)
const uint8_t DAIKIN_PRESET_COMFORT_ON = 0x10;
const uint8_t DAIKIN_PRESET_POWERFUL_ON = 0x01;
const uint8_t DAIKIN_PRESET_QUIET_ON = 0x20;
const uint8_t DAIKIN_PRESET_ECONO_ON = 0x04;
const uint8_t DAIKIN_PRESET_SENSOR_ON = 0x02;
// The unit leaves powerful mode by itself after 20 minutes; mirror that in the entity
const uint32_t DAIKIN_BOOST_DURATION_MS = 20 * 60 * 1000;
// The three frames of one transmission arrive well inside this window
const uint32_t DAIKIN_TRANSMISSION_MS = 1000;
// IR Transmission
const uint32_t DAIKIN_IR_FREQUENCY = 38000;
const uint32_t DAIKIN_HEADER_MARK = 3360;
@@ -36,27 +47,44 @@ const uint32_t DAIKIN_ONE_SPACE = 1370;
const uint32_t DAIKIN_ZERO_SPACE = 360;
const uint32_t DAIKIN_MESSAGE_SPACE = 32300;
// State Frame size
// Frame types (byte 4) and sizes
const uint8_t DAIKIN_FRAME_TYPE_STATE = 0x00;
const uint8_t DAIKIN_FRAME_TYPE_COMFORT = 0xC5;
const uint8_t DAIKIN_STATE_FRAME_SIZE = 19;
const uint8_t DAIKIN_COMFORT_FRAME_SIZE = 8;
class DaikinClimate final : public climate_ir::ClimateIR {
public:
DaikinClimate()
: climate_ir::ClimateIR(DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX, 1.0f, true, true,
{climate::CLIMATE_FAN_QUIET, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW,
climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH},
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {}
: climate_ir::ClimateIR(
DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX, 0.5f, true, true,
{climate::CLIMATE_FAN_QUIET, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW,
climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH},
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL,
climate::CLIMATE_SWING_BOTH},
{climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST, climate::CLIMATE_PRESET_COMFORT,
climate::CLIMATE_PRESET_SLEEP, climate::CLIMATE_PRESET_ACTIVITY, climate::CLIMATE_PRESET_NONE}) {}
protected:
void setup() override;
void control(const climate::ClimateCall &call) override;
// Transmit via IR the state of this climate controller.
void transmit_state() override;
uint8_t operation_mode_() const;
uint16_t fan_speed_() const;
uint8_t temperature_() const;
void set_presets_(uint8_t *remote_state) const;
/// Arms or cancels the boost timer when the preset moves into or out of boost.
void update_boost_timer_(bool was_boost);
void cancel_boost_mode_();
// Handle received IR Buffer
bool on_receive(remote_base::RemoteReceiveData data) override;
bool parse_state_frame_(const uint8_t frame[]);
/// What the 0xC5 frame of the current transmission said about comfort, applied by the state frame
enum class PendingComfort : uint8_t { UNKNOWN, OFF, ON };
PendingComfort pending_comfort_{PendingComfort::UNKNOWN};
uint32_t pending_comfort_at_{0};
};
} // namespace esphome::daikin
+8
View File
@@ -0,0 +1,8 @@
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"]
+4
View File
@@ -0,0 +1,4 @@
# Registers the climate platform so USE_CLIMATE is defined for the host unit test
# build. The daikin, climate_ir and remote_transmitter sources come in through the
# dependency walk in __init__.py; their code generation is suppressed.
climate: []
+3 -8
View File
@@ -1,9 +1,4 @@
climate:
- platform: heatpumpir
protocol: daikin
horizontal_default: middle
vertical_default: middle
name: HeatpumpIR Climate
min_temperature: 18
max_temperature: 30
transmitter_id: xmitr
- platform: daikin
name: Daikin AC
receiver_id: rcvr
@@ -0,0 +1,477 @@
// Regression tests for the behaviour the daikin climate had before presets were added.
// Every expected byte is worked out by hand from the original daikin.cpp, and this file
// only uses the API that both the original and the current component provide.
#include <gtest/gtest.h>
#include <array>
#include <cstdint>
#include <new>
#include <vector>
#include "esphome/components/daikin/daikin.h"
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/application.h"
#include "esphome/core/preferences.h"
namespace esphome::daikin::regression {
static constexpr size_t MESSAGE_SIZE = 35;
using Message = std::array<uint8_t, MESSAGE_SIZE>;
static constexpr int32_t HEADER_MARK = 3360;
static constexpr int32_t HEADER_SPACE = 1760;
static constexpr int32_t BIT_MARK = 520;
static constexpr int32_t ONE_SPACE = 1370;
static constexpr int32_t ZERO_SPACE = 360;
static constexpr int32_t MESSAGE_SPACE = 32300;
static constexpr uint32_t CARRIER = 38000;
// Sum of the constant bytes 16..33 of the third frame (11 DA 27 00 00 .. 06 60 00 00 C0 00 00),
// 0x238 truncated to 8 bits. Byte 34 is this plus bytes 21, 22, 24 and 25.
static constexpr uint8_t FRAME3_CONSTANT_SUM = 0x38;
struct Capture {
remote_base::RawTimings raw;
uint32_t carrier;
};
class RecordingTransmitter : public remote_base::RemoteTransmitterBase {
public:
RecordingTransmitter() : RemoteTransmitterBase(nullptr) {}
std::vector<Capture> sent;
protected:
void send_internal(uint32_t send_times, uint32_t send_wait) override {
this->sent.push_back({this->temp_.get_data(), this->temp_.get_carrier_frequency()});
}
};
static void read_bytes(const remote_base::RawTimings &raw, size_t &pos, uint8_t *out, size_t count) {
for (size_t byte = 0; byte < count; byte++) {
uint8_t value = 0;
for (int bit = 0; bit < 8; bit++) {
EXPECT_EQ(raw[pos], BIT_MARK) << "bit mark at " << pos;
const int32_t space = -raw[pos + 1];
if (space == ONE_SPACE) {
value |= 1 << bit;
} else {
EXPECT_EQ(space, ZERO_SPACE) << "bit space at " << pos + 1;
}
pos += 2;
}
out[byte] = value;
}
}
static void expect_pair(const remote_base::RawTimings &raw, size_t &pos, int32_t mark, int32_t space) {
EXPECT_EQ(raw[pos], mark) << "mark at " << pos;
EXPECT_EQ(raw[pos + 1], -space) << "space at " << pos + 1;
pos += 2;
}
/// Decodes the three frames (8, 8 and 19 bytes) and checks every header, bit and gap timing
static Message decode(const remote_base::RawTimings &raw) {
Message msg{};
// 3 headers + 3 trailers of two timings each, plus two timings per bit
constexpr size_t expected_size = 3 * 4 + 2 * 8 * MESSAGE_SIZE;
EXPECT_EQ(raw.size(), expected_size);
if (raw.size() != expected_size)
return msg;
size_t pos = 0;
expect_pair(raw, pos, HEADER_MARK, HEADER_SPACE);
read_bytes(raw, pos, msg.data(), 8);
expect_pair(raw, pos, BIT_MARK, MESSAGE_SPACE);
expect_pair(raw, pos, HEADER_MARK, HEADER_SPACE);
read_bytes(raw, pos, msg.data() + 8, 8);
expect_pair(raw, pos, BIT_MARK, MESSAGE_SPACE);
expect_pair(raw, pos, HEADER_MARK, HEADER_SPACE);
read_bytes(raw, pos, msg.data() + 16, 19);
expect_pair(raw, pos, BIT_MARK, 0);
return msg;
}
/// Receiver timings for a 19-byte frame
static remote_base::RawTimings timings_for(const std::vector<uint8_t> &bytes) {
remote_base::RawTimings raw;
raw.push_back(HEADER_MARK);
raw.push_back(-HEADER_SPACE);
for (uint8_t byte : bytes) {
for (int bit = 0; bit < 8; bit++) {
raw.push_back(BIT_MARK);
raw.push_back(-((byte >> bit) & 1 ? ONE_SPACE : ZERO_SPACE));
}
}
raw.push_back(BIT_MARK);
raw.push_back(-MESSAGE_SPACE);
return raw;
}
/// A 19-byte state frame (type 0x00) with its checksum in byte 18
static std::vector<uint8_t> state_frame(uint8_t mode, uint8_t temperature, uint8_t fan, uint8_t swing) {
std::vector<uint8_t> f{0x11, 0xDA, 0x27, 0x00, 0x00, mode, temperature, 0x00, fan, swing,
0x00, 0x06, 0x60, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00};
uint8_t total = 0;
for (size_t i = 0; i < 18; i++)
total += f[i];
f[18] = total;
return f;
}
struct Rig {
RecordingTransmitter transmitter;
DaikinClimate climate;
Rig() {
// All instances share one preference key; clear it so setup starts from defaults
if (global_preferences != nullptr)
global_preferences->reset();
static const bool APP_CONSTRUCTED = (new (&App) Application(), true);
(void) APP_CONSTRUCTED;
static char name[] = "test_daikin_regression";
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();
}
Message send(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan,
climate::ClimateSwingMode swing) {
auto call = this->climate.make_call();
call.set_mode(mode);
call.set_target_temperature(temperature);
call.set_fan_mode(fan);
call.set_swing_mode(swing);
call.perform();
EXPECT_FALSE(this->transmitter.sent.empty());
if (this->transmitter.sent.empty())
return Message{};
return decode(this->transmitter.sent.back().raw);
}
bool receive(const std::vector<uint8_t> &bytes) {
const remote_base::RawTimings raw = timings_for(bytes);
remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE);
return static_cast<remote_base::RemoteReceiverListener &>(this->climate).on_receive(data);
}
};
/// Checks every byte that the original encoder never changes, and both checksums
static void expect_fixed_bytes(const Message &m) {
const uint8_t frame1[8] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0xD7};
const uint8_t frame2[8] = {0x11, 0xDA, 0x27, 0x00, 0x42, 0x49, 0x05, 0xA2};
for (size_t i = 0; i < 8; i++) {
EXPECT_EQ(m[i], frame1[i]) << "byte " << i;
EXPECT_EQ(m[8 + i], frame2[i]) << "byte " << 8 + i;
}
EXPECT_EQ(m[16], 0x11);
EXPECT_EQ(m[17], 0xDA);
EXPECT_EQ(m[18], 0x27);
EXPECT_EQ(m[19], 0x00);
EXPECT_EQ(m[20], 0x00);
EXPECT_EQ(m[23], 0x00);
EXPECT_EQ(m[26], 0x00);
EXPECT_EQ(m[27], 0x06);
EXPECT_EQ(m[28], 0x60);
EXPECT_EQ(m[29], 0x00);
EXPECT_EQ(m[30], 0x00);
EXPECT_EQ(m[31], 0xC0);
EXPECT_EQ(m[32], 0x00);
EXPECT_EQ(m[33], 0x00);
// 0x11 + 0xDA + 0x27 + 0xC5 = 0x1D7
EXPECT_EQ(m[7], 0xD7);
const uint8_t expected34 = FRAME3_CONSTANT_SUM + m[21] + m[22] + m[24] + m[25];
EXPECT_EQ(m[34], expected34);
}
struct ModeCase {
climate::ClimateMode mode;
uint8_t byte21;
uint8_t byte22;
uint8_t byte34;
};
// At 24 degrees, fan auto (0xA0, 0x00), swing off:
// OFF 0x00, 24 << 1 = 0x30, 0x38 + 0x00 + 0x30 + 0xA0 = 0x108 -> 0x08
// COOL 0x31, 0x30, 0x38 + 0x31 + 0x30 + 0xA0 = 0x139 -> 0x39
// HEAT 0x41, 0x30, 0x38 + 0x41 + 0x30 + 0xA0 = 0x149 -> 0x49
// DRY 0x21, 0xC0, 0x38 + 0x21 + 0xC0 + 0xA0 = 0x1B9 -> 0xB9
// FAN_ONLY 0x61, 0x32, 0x38 + 0x61 + 0x32 + 0xA0 = 0x16B -> 0x6B
// HEAT_COOL 0x01, 0x30, 0x38 + 0x01 + 0x30 + 0xA0 = 0x109 -> 0x09
static const ModeCase MODE_CASES[] = {
{climate::CLIMATE_MODE_OFF, 0x00, 0x30, 0x08}, {climate::CLIMATE_MODE_COOL, 0x31, 0x30, 0x39},
{climate::CLIMATE_MODE_HEAT, 0x41, 0x30, 0x49}, {climate::CLIMATE_MODE_DRY, 0x21, 0xC0, 0xB9},
{climate::CLIMATE_MODE_FAN_ONLY, 0x61, 0x32, 0x6B}, {climate::CLIMATE_MODE_HEAT_COOL, 0x01, 0x30, 0x09},
};
TEST(DaikinRegression, EncodesEveryMode) {
for (const auto &c : MODE_CASES) {
Rig r;
const Message m = r.send(c.mode, 24.0f, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF);
SCOPED_TRACE(static_cast<int>(c.mode));
expect_fixed_bytes(m);
EXPECT_EQ(m[21], c.byte21);
EXPECT_EQ(m[22], c.byte22);
EXPECT_EQ(m[24], 0xA0);
EXPECT_EQ(m[25], 0x00);
EXPECT_EQ(m[34], c.byte34);
}
}
struct FanCase {
climate::ClimateFanMode fan;
uint8_t b24;
uint8_t byte34;
};
// Cool at 24 (0x31, 0x30), swing off: b34 = 0x38 + 0x31 + 0x30 + b24 = 0x99 + b24
static const FanCase FAN_CASES[] = {
{climate::CLIMATE_FAN_QUIET, 0xB0, 0x49}, // 0x99 + 0xB0 = 0x149
{climate::CLIMATE_FAN_AUTO, 0xA0, 0x39}, // 0x99 + 0xA0 = 0x139
{climate::CLIMATE_FAN_LOW, 0x30, 0xC9}, // 0x99 + 0x30
{climate::CLIMATE_FAN_MEDIUM, 0x50, 0xE9}, // 0x99 + 0x50
{climate::CLIMATE_FAN_HIGH, 0x70, 0x09}, // 0x99 + 0x70 = 0x109
};
TEST(DaikinRegression, EncodesEveryFanMode) {
for (const auto &c : FAN_CASES) {
Rig r;
const Message m = r.send(climate::CLIMATE_MODE_COOL, 24.0f, c.fan, climate::CLIMATE_SWING_OFF);
SCOPED_TRACE(static_cast<int>(c.fan));
expect_fixed_bytes(m);
EXPECT_EQ(m[21], 0x31);
EXPECT_EQ(m[22], 0x30);
EXPECT_EQ(m[24], c.b24);
EXPECT_EQ(m[25], 0x00);
EXPECT_EQ(m[34], c.byte34);
}
}
struct SwingCase {
climate::ClimateSwingMode swing;
uint8_t b24;
uint8_t b25;
uint8_t byte34;
};
// Heat at 22 (0x41, 0x2C), fan medium (0x50): b34 = 0x38 + 0x41 + 0x2C + b24 + b25 = 0xA5 + ...
static const SwingCase SWING_CASES[] = {
{climate::CLIMATE_SWING_OFF, 0x50, 0x00, 0xF5}, // 0xA5 + 0x50
{climate::CLIMATE_SWING_VERTICAL, 0x5F, 0x00, 0x04}, // 0xA5 + 0x5F = 0x104
{climate::CLIMATE_SWING_HORIZONTAL, 0x50, 0x0F, 0x04}, // 0xA5 + 0x50 + 0x0F = 0x104
{climate::CLIMATE_SWING_BOTH, 0x5F, 0x0F, 0x13}, // 0xA5 + 0x5F + 0x0F = 0x113
};
TEST(DaikinRegression, EncodesEverySwingMode) {
for (const auto &c : SWING_CASES) {
Rig r;
const Message m = r.send(climate::CLIMATE_MODE_HEAT, 22.0f, climate::CLIMATE_FAN_MEDIUM, c.swing);
SCOPED_TRACE(static_cast<int>(c.swing));
expect_fixed_bytes(m);
EXPECT_EQ(m[21], 0x41);
EXPECT_EQ(m[22], 0x2C);
EXPECT_EQ(m[24], c.b24);
EXPECT_EQ(m[25], c.b25);
EXPECT_EQ(m[34], c.byte34);
}
}
TEST(DaikinRegression, EncodesSwingWithQuietAndHighFan) {
{
// Dry, quiet, vertical: 0x21, 0xC0, 0xB0 | 0x0F = 0xBF; 0x38 + 0x21 + 0xC0 + 0xBF = 0x1D8 -> 0xD8
Rig r;
const Message m =
r.send(climate::CLIMATE_MODE_DRY, 24.0f, climate::CLIMATE_FAN_QUIET, climate::CLIMATE_SWING_VERTICAL);
expect_fixed_bytes(m);
EXPECT_EQ(m[21], 0x21);
EXPECT_EQ(m[22], 0xC0);
EXPECT_EQ(m[24], 0xBF);
EXPECT_EQ(m[25], 0x00);
EXPECT_EQ(m[34], 0xD8);
}
{
// Fan only, low, horizontal: 0x61, 0x32, 0x30, 0x0F; 0x38 + 0x61 + 0x32 + 0x30 + 0x0F = 0x10A -> 0x0A
Rig r;
const Message m =
r.send(climate::CLIMATE_MODE_FAN_ONLY, 24.0f, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_HORIZONTAL);
expect_fixed_bytes(m);
EXPECT_EQ(m[21], 0x61);
EXPECT_EQ(m[22], 0x32);
EXPECT_EQ(m[24], 0x30);
EXPECT_EQ(m[25], 0x0F);
EXPECT_EQ(m[34], 0x0A);
}
{
// Heat at 30, high, both: 0x41, 0x3C, 0x7F, 0x0F; 0x38 + 0x41 + 0x3C + 0x7F + 0x0F = 0x143 -> 0x43
Rig r;
const Message m = r.send(climate::CLIMATE_MODE_HEAT, 30.0f, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_BOTH);
expect_fixed_bytes(m);
EXPECT_EQ(m[21], 0x41);
EXPECT_EQ(m[22], 0x3C);
EXPECT_EQ(m[24], 0x7F);
EXPECT_EQ(m[25], 0x0F);
EXPECT_EQ(m[34], 0x43);
}
}
struct TemperatureCase {
float target;
uint8_t byte22;
};
// Clamped to 10..30, rounded, then doubled. The call layer does not clamp, so 5 and 35 reach the encoder.
static const TemperatureCase TEMPERATURE_CASES[] = {
{5.0f, 0x14}, {10.0f, 0x14}, {11.0f, 0x16}, {18.0f, 0x24}, {21.0f, 0x2A},
{24.0f, 0x30}, {29.0f, 0x3A}, {30.0f, 0x3C}, {35.0f, 0x3C},
};
TEST(DaikinRegression, EncodesWholeDegreesWithClamp) {
for (const auto &c : TEMPERATURE_CASES) {
for (auto mode : {climate::CLIMATE_MODE_COOL, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT_COOL}) {
Rig r;
const Message m = r.send(mode, c.target, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF);
SCOPED_TRACE(c.target);
SCOPED_TRACE(static_cast<int>(mode));
expect_fixed_bytes(m);
EXPECT_EQ(m[22], c.byte22);
}
}
}
TEST(DaikinRegression, HeatCoolAt18Checksum) {
// 0x01, 18 << 1 = 0x24, medium 0x50: 0x38 + 0x01 + 0x24 + 0x50 = 0xAD
Rig r;
const Message m =
r.send(climate::CLIMATE_MODE_HEAT_COOL, 18.0f, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF);
expect_fixed_bytes(m);
EXPECT_EQ(m[21], 0x01);
EXPECT_EQ(m[22], 0x24);
EXPECT_EQ(m[24], 0x50);
EXPECT_EQ(m[34], 0xAD);
}
TEST(DaikinRegression, FrameTimingsAndCarrier) {
Rig r;
r.send(climate::CLIMATE_MODE_COOL, 24.0f, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF);
ASSERT_EQ(r.transmitter.sent.size(), 1u);
const Capture &c = r.transmitter.sent.back();
EXPECT_EQ(c.carrier, CARRIER);
ASSERT_EQ(c.raw.size(), 572u);
// Header, then 64 bits of frame 1, its trailer and the 32300 gap, then frame 2's header
EXPECT_EQ(c.raw[0], 3360);
EXPECT_EQ(c.raw[1], -1760);
EXPECT_EQ(c.raw[2 + 128], 520);
EXPECT_EQ(c.raw[2 + 128 + 1], -32300);
EXPECT_EQ(c.raw[2 + 128 + 2], 3360);
EXPECT_EQ(c.raw[2 + 128 + 3], -1760);
// Frame 2's trailer and gap, then frame 3's header
EXPECT_EQ(c.raw[134 + 128], 520);
EXPECT_EQ(c.raw[134 + 128 + 1], -32300);
EXPECT_EQ(c.raw[134 + 128 + 2], 3360);
EXPECT_EQ(c.raw[134 + 128 + 3], -1760);
// Frame 3 ends with a bit mark and a zero space
EXPECT_EQ(c.raw[570], 520);
EXPECT_EQ(c.raw[571], 0);
// Byte 0 is 0x11, LSB first: bit 0 is a one (1370), bit 1 a zero (360)
EXPECT_EQ(c.raw[2], 520);
EXPECT_EQ(c.raw[3], -1370);
EXPECT_EQ(c.raw[4], 520);
EXPECT_EQ(c.raw[5], -360);
}
struct ReceiveCase {
float target;
uint8_t mode_byte;
uint8_t temperature;
uint8_t fan_byte;
uint8_t swing_byte;
climate::ClimateMode mode;
climate::ClimateFanMode fan;
climate::ClimateSwingMode swing;
};
// Byte 5 mode (low bit power), byte 6 temperature doubled, byte 8 fan high nibble and vertical swing
// low nibble, byte 9 horizontal swing low nibble. Fan nibbles: 3 and 4 low, 5 medium, 6 and 7 high,
// A auto, B quiet.
static const ReceiveCase RECEIVE_CASES[] = {
{24.0f, 0x31, 0x30, 0xA0, 0x00, climate::CLIMATE_MODE_COOL, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF},
{22.0f, 0x41, 0x2C, 0x3F, 0x00, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_FAN_LOW,
climate::CLIMATE_SWING_VERTICAL},
{20.0f, 0x01, 0x28, 0x50, 0x0F, climate::CLIMATE_MODE_HEAT_COOL, climate::CLIMATE_FAN_MEDIUM,
climate::CLIMATE_SWING_HORIZONTAL},
{10.0f, 0x31, 0x14, 0x7F, 0x0F, climate::CLIMATE_MODE_COOL, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_BOTH},
{30.0f, 0x41, 0x3C, 0xB0, 0x00, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_FAN_QUIET, climate::CLIMATE_SWING_OFF},
{18.0f, 0x31, 0x24, 0x40, 0x00, climate::CLIMATE_MODE_COOL, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF},
{18.0f, 0x31, 0x24, 0x60, 0x00, climate::CLIMATE_MODE_COOL, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF},
};
TEST(DaikinRegression, DecodesStateFrames) {
for (const auto &c : RECEIVE_CASES) {
Rig r;
SCOPED_TRACE(static_cast<int>(c.mode_byte));
SCOPED_TRACE(static_cast<int>(c.fan_byte));
EXPECT_TRUE(r.receive(state_frame(c.mode_byte, c.temperature, c.fan_byte, c.swing_byte)));
EXPECT_EQ(r.climate.mode, c.mode);
EXPECT_FLOAT_EQ(r.climate.target_temperature, c.target);
EXPECT_EQ(r.climate.fan_mode, c.fan);
EXPECT_EQ(r.climate.swing_mode, c.swing);
}
}
TEST(DaikinRegression, DecodesDryAndFanOnlyWithoutTouchingTemperature) {
Rig r;
ASSERT_TRUE(r.receive(state_frame(0x31, 0x30, 0xA0, 0x00)));
ASSERT_FLOAT_EQ(r.climate.target_temperature, 24.0f);
// Dry carries 0xC0 in the temperature byte; the target stays at 24
EXPECT_TRUE(r.receive(state_frame(0x21, 0xC0, 0x7F, 0x0F)));
EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_DRY);
EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f);
EXPECT_EQ(r.climate.fan_mode, climate::CLIMATE_FAN_HIGH);
EXPECT_EQ(r.climate.swing_mode, climate::CLIMATE_SWING_BOTH);
// Fan only carries 0x32; the target still stays at 24
EXPECT_TRUE(r.receive(state_frame(0x61, 0x32, 0xBF, 0x00)));
EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_FAN_ONLY);
EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f);
EXPECT_EQ(r.climate.fan_mode, climate::CLIMATE_FAN_QUIET);
EXPECT_EQ(r.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL);
}
TEST(DaikinRegression, DecodesPowerOffAsOff) {
Rig r;
ASSERT_TRUE(r.receive(state_frame(0x31, 0x30, 0xA0, 0x00)));
ASSERT_EQ(r.climate.mode, climate::CLIMATE_MODE_COOL);
// Cool with the power bit clear, at 26: mode becomes off and the temperature is not applied
EXPECT_TRUE(r.receive(state_frame(0x30, 0x34, 0x50, 0x00)));
EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_OFF);
EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f);
EXPECT_EQ(r.climate.fan_mode, climate::CLIMATE_FAN_MEDIUM);
}
TEST(DaikinRegression, RejectsBadChecksum) {
Rig r;
ASSERT_TRUE(r.receive(state_frame(0x31, 0x30, 0xA0, 0x00)));
auto frame = state_frame(0x41, 0x2C, 0x50, 0x00);
frame[18] ^= 0x01;
EXPECT_FALSE(r.receive(frame));
EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f);
EXPECT_EQ(r.climate.fan_mode, climate::CLIMATE_FAN_AUTO);
}
TEST(DaikinRegression, RejectsWrongHeaderByte) {
for (size_t index = 0; index < 5; index++) {
Rig r;
ASSERT_TRUE(r.receive(state_frame(0x31, 0x30, 0xA0, 0x00)));
auto frame = state_frame(0x41, 0x2C, 0x50, 0x00);
frame[index] ^= 0x01;
// Recompute the checksum so only the header byte is wrong
uint8_t total = 0;
for (size_t i = 0; i < 18; i++)
total += frame[i];
frame[18] = total;
SCOPED_TRACE(index);
EXPECT_FALSE(r.receive(frame));
EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f);
}
}
} // namespace esphome::daikin::regression
+440
View File
@@ -0,0 +1,440 @@
#include <gtest/gtest.h>
#include <array>
#include <cstdint>
#include <cstdio>
#include <new>
#include <string>
#include <vector>
#include "esphome/components/daikin/daikin.h"
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/application.h"
#include "esphome/core/preferences.h"
namespace esphome::daikin::testing {
static constexpr size_t MESSAGE_SIZE = 35;
using Message = std::array<uint8_t, MESSAGE_SIZE>;
static constexpr uint32_t BOOST_TIMEOUT_ID = 0;
// The host scheduler reads the real clock, so the 20 minute boost timer cannot elapse in a test.
// Explicit instantiation skips access checks, which lets the test run the timer's callback itself.
template<void (DaikinClimate::*M)()> struct BoostCallback {
friend void run_boost_callback(DaikinClimate &climate) { (climate.*M)(); }
};
void run_boost_callback(DaikinClimate &climate);
template struct BoostCallback<&DaikinClimate::cancel_boost_mode_>;
static uint8_t sum(const uint8_t *data, size_t len) {
uint8_t total = 0;
for (size_t i = 0; i < len; i++)
total += data[i];
return total;
}
static std::string hex(const uint8_t *data, size_t len) {
std::string out;
for (size_t i = 0; i < len; i++) {
char buf[4];
snprintf(buf, sizeof(buf), "%02X ", data[i]);
out += buf;
}
return out;
}
class CapturingTransmitter : public remote_base::RemoteTransmitterBase {
public:
CapturingTransmitter() : RemoteTransmitterBase(nullptr) {}
std::vector<remote_base::RawTimings> frames;
protected:
void send_internal(uint32_t send_times, uint32_t send_wait) override {
this->frames.push_back(this->temp_.get_data());
}
};
/// Reads `count` LSB-first bytes starting at raw[pos], advancing pos past them
static void read_bytes(const remote_base::RawTimings &raw, size_t &pos, uint8_t *out, size_t count) {
for (size_t byte = 0; byte < count; byte++) {
uint8_t value = 0;
for (int bit = 0; bit < 8; bit++) {
EXPECT_EQ(raw[pos], static_cast<int32_t>(DAIKIN_BIT_MARK)) << "bit mark at " << pos;
const int32_t space = -raw[pos + 1];
if (space == static_cast<int32_t>(DAIKIN_ONE_SPACE)) {
value |= 1 << bit;
} else {
EXPECT_EQ(space, static_cast<int32_t>(DAIKIN_ZERO_SPACE)) << "bit space at " << pos + 1;
}
pos += 2;
}
out[byte] = value;
}
}
static void expect_header(const remote_base::RawTimings &raw, size_t &pos) {
EXPECT_EQ(raw[pos], static_cast<int32_t>(DAIKIN_HEADER_MARK)) << "header mark at " << pos;
EXPECT_EQ(raw[pos + 1], -static_cast<int32_t>(DAIKIN_HEADER_SPACE)) << "header space at " << pos + 1;
pos += 2;
}
static void expect_trailer(const remote_base::RawTimings &raw, size_t &pos, uint32_t space) {
EXPECT_EQ(raw[pos], static_cast<int32_t>(DAIKIN_BIT_MARK)) << "trailer mark at " << pos;
EXPECT_EQ(raw[pos + 1], -static_cast<int32_t>(space)) << "trailer space at " << pos + 1;
pos += 2;
}
/// Decodes a captured transmission back into its 35 bytes, checking headers and frame gaps
static Message decode(const remote_base::RawTimings &raw) {
Message msg{};
// Three frames of 8, 8 and 19 bytes, each a header, two timings per bit and a trailer
const size_t expected_size = 3 * 4 + 2 * 8 * MESSAGE_SIZE;
EXPECT_EQ(raw.size(), expected_size);
if (raw.size() != expected_size)
return msg;
size_t pos = 0;
expect_header(raw, pos);
read_bytes(raw, pos, msg.data(), 8);
expect_trailer(raw, pos, DAIKIN_MESSAGE_SPACE);
expect_header(raw, pos);
read_bytes(raw, pos, msg.data() + 8, 8);
expect_trailer(raw, pos, DAIKIN_MESSAGE_SPACE);
expect_header(raw, pos);
read_bytes(raw, pos, msg.data() + 16, 19);
expect_trailer(raw, pos, 0);
return msg;
}
/// Timings for one received frame (8 or 19 bytes) as the receiver would deliver it
static remote_base::RawTimings frame_timings(const std::vector<uint8_t> &bytes) {
remote_base::RawTimings raw;
raw.push_back(DAIKIN_HEADER_MARK);
raw.push_back(-static_cast<int32_t>(DAIKIN_HEADER_SPACE));
for (uint8_t byte : bytes) {
for (int bit = 0; bit < 8; bit++) {
raw.push_back(DAIKIN_BIT_MARK);
raw.push_back(-static_cast<int32_t>((byte >> bit) & 1 ? DAIKIN_ONE_SPACE : DAIKIN_ZERO_SPACE));
}
}
raw.push_back(DAIKIN_BIT_MARK);
raw.push_back(-static_cast<int32_t>(DAIKIN_MESSAGE_SPACE));
return raw;
}
/// The 0xC5 frame, with a valid checksum unless one is given
static std::vector<uint8_t> comfort_frame(uint8_t comfort) {
std::vector<uint8_t> f{0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, comfort, 0x00};
f[7] = sum(f.data(), 7);
return f;
}
/// The 19-byte state frame: cool at `temperature` (half degrees), fan auto, no swing
static std::vector<uint8_t> state_frame(uint8_t byte13, uint8_t byte16, uint8_t temperature = 44) {
std::vector<uint8_t> f{0x11, 0xDA, 0x27, 0x00, 0x00, DAIKIN_MODE_ON | DAIKIN_MODE_COOL,
temperature, 0x00, 0xA0, 0x00, 0x00, 0x06,
0x60, byte13, 0x00, 0xC0, byte16, 0x00,
0x00};
f[18] = sum(f.data(), 18);
return f;
}
struct Fixture {
CapturingTransmitter transmitter;
DaikinClimate climate;
/// Pass keep_preferences to restore what an earlier fixture saved
explicit Fixture(bool keep_preferences = false) {
// Every fixture shares one preference key, so clear what an earlier test saved or setup restores it
if (!keep_preferences && global_preferences != nullptr)
global_preferences->reset();
// The test main does not construct App as generated code does; the boost timer needs its scheduler
static const bool APP_CONSTRUCTED = (new (&App) Application(), true);
(void) APP_CONSTRUCTED;
static char name[] = "test_daikin";
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();
}
~Fixture() {
// Drop a pending boost timer while the climate is alive; App outlives each test
App.scheduler.cancel_timeout(&this->climate, BOOST_TIMEOUT_ID);
App.scheduler.call(millis());
}
void command(climate::ClimateMode mode, float temperature, climate::ClimatePreset preset) {
auto call = this->climate.make_call();
call.set_mode(mode);
call.set_target_temperature(temperature);
call.set_preset(preset);
call.perform();
}
Message last_message() const { return decode(this->transmitter.frames.back()); }
bool receive(const std::vector<uint8_t> &bytes) {
const remote_base::RawTimings raw = frame_timings(bytes);
remote_base::RemoteReceiveData data(raw, 25, 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);
}
/// Fires the boost timer as the scheduler would if it is pending; returns whether it was
bool fire_boost_timer() {
if (!App.scheduler.cancel_timeout(&this->climate, BOOST_TIMEOUT_ID))
return false;
run_boost_callback(this->climate);
return true;
}
};
static void expect_checksums(const Message &m) {
EXPECT_EQ(m[7], sum(m.data(), 7)) << hex(m.data(), MESSAGE_SIZE);
EXPECT_EQ(m[34], sum(m.data() + 16, 18)) << hex(m.data(), MESSAGE_SIZE);
}
TEST(Daikin, EncodesHalfDegree) {
Fixture f;
f.command(climate::CLIMATE_MODE_COOL, 22.5f, climate::CLIMATE_PRESET_NONE);
ASSERT_EQ(f.transmitter.frames.size(), 1u);
const Message m = f.last_message();
EXPECT_EQ(m[16 + 5], DAIKIN_MODE_ON | DAIKIN_MODE_COOL);
EXPECT_EQ(m[22], 45);
EXPECT_EQ(m[6], 0x00);
EXPECT_EQ(m[29], 0x00);
EXPECT_EQ(m[32], 0x00);
expect_checksums(m);
}
TEST(Daikin, EncodesEachPresetInItsOwnByte) {
struct Case {
climate::ClimatePreset preset;
uint8_t byte6, byte29, byte32;
};
const Case cases[] = {
{climate::CLIMATE_PRESET_NONE, 0x00, 0x00, 0x00}, {climate::CLIMATE_PRESET_BOOST, 0x00, 0x01, 0x00},
{climate::CLIMATE_PRESET_SLEEP, 0x00, 0x20, 0x00}, {climate::CLIMATE_PRESET_ECO, 0x00, 0x00, 0x04},
{climate::CLIMATE_PRESET_ACTIVITY, 0x00, 0x00, 0x02}, {climate::CLIMATE_PRESET_COMFORT, 0x10, 0x00, 0x00},
};
Fixture f;
uint8_t plain_first_checksum = 0;
for (const Case &c : cases) {
SCOPED_TRACE(static_cast<int>(c.preset));
f.command(climate::CLIMATE_MODE_COOL, 22.5f, c.preset);
const Message m = f.last_message();
EXPECT_EQ(m[6], c.byte6);
EXPECT_EQ(m[29], c.byte29);
EXPECT_EQ(m[32], c.byte32);
EXPECT_EQ(m[22], 45);
expect_checksums(m);
if (c.preset == climate::CLIMATE_PRESET_NONE) {
plain_first_checksum = m[7];
} else if (c.preset == climate::CLIMATE_PRESET_COMFORT) {
EXPECT_NE(m[7], plain_first_checksum);
EXPECT_EQ(static_cast<uint8_t>(m[7] - plain_first_checksum), 0x10);
} else {
EXPECT_EQ(m[7], plain_first_checksum);
}
}
}
TEST(Daikin, ReceivedComfortFrameSetsComfort) {
Fixture f;
EXPECT_TRUE(f.receive(comfort_frame(0x10)));
EXPECT_TRUE(f.receive(state_frame(0x00, 0x00)));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT);
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
}
TEST(Daikin, ReceivedPowerfulGivesBoost) {
Fixture f;
EXPECT_TRUE(f.receive(comfort_frame(0x10)));
EXPECT_TRUE(f.receive(state_frame(0x00, 0x00)));
EXPECT_TRUE(f.receive(comfort_frame(0x00)));
EXPECT_TRUE(f.receive(state_frame(0x01, 0x00)));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_BOOST);
}
TEST(Daikin, ReceivedStateWithoutBitsClearsEco) {
Fixture f;
EXPECT_TRUE(f.receive(comfort_frame(0x00)));
EXPECT_TRUE(f.receive(state_frame(0x00, 0x04)));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO);
EXPECT_TRUE(f.receive(comfort_frame(0x00)));
EXPECT_TRUE(f.receive(state_frame(0x00, 0x00)));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE);
}
TEST(Daikin, ReceivedOtherPresets) {
Fixture f;
EXPECT_TRUE(f.receive(state_frame(0x20, 0x00)));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_SLEEP);
EXPECT_TRUE(f.receive(state_frame(0x00, 0x02)));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ACTIVITY);
}
TEST(Daikin, ReceivedHalfDegree) {
Fixture f;
EXPECT_TRUE(f.receive(state_frame(0x00, 0x00, 45)));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.5f);
}
TEST(Daikin, ReceivedFrameRoundTripsTransmittedState) {
Fixture f;
f.command(climate::CLIMATE_MODE_COOL, 22.5f, climate::CLIMATE_PRESET_COMFORT);
const Message m = f.last_message();
f.command(climate::CLIMATE_MODE_HEAT, 18.0f, climate::CLIMATE_PRESET_NONE);
EXPECT_TRUE(f.receive(std::vector<uint8_t>(m.begin(), m.begin() + 8)));
EXPECT_TRUE(f.receive(std::vector<uint8_t>(m.begin() + 16, m.end())));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.5f);
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT);
}
TEST(Daikin, BoostTimerCancelledWhenRemoteLeavesBoost) {
Fixture f;
f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST);
ASSERT_EQ(f.climate.preset, climate::CLIMATE_PRESET_BOOST);
EXPECT_TRUE(f.receive(comfort_frame(0x00)));
EXPECT_TRUE(f.receive(state_frame(0x00, 0x04)));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO);
// No boost timer may remain to end a boost that is already over
EXPECT_FALSE(f.fire_boost_timer());
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO);
}
TEST(Daikin, BoostTimerCancelledWhenEntityLeavesBoost) {
Fixture f;
f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST);
f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_SLEEP);
EXPECT_FALSE(f.fire_boost_timer());
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_SLEEP);
}
TEST(Daikin, ReceivedBoostEndsWhenTimerFires) {
Fixture f;
EXPECT_TRUE(f.receive(comfort_frame(0x00)));
EXPECT_TRUE(f.receive(state_frame(0x01, 0x00)));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_BOOST);
EXPECT_TRUE(f.fire_boost_timer());
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE);
}
TEST(Daikin, RepeatedBoostFrameKeepsOneTimer) {
Fixture f;
EXPECT_TRUE(f.receive(state_frame(0x01, 0x00)));
EXPECT_TRUE(f.receive(state_frame(0x01, 0x00)));
EXPECT_TRUE(f.fire_boost_timer());
EXPECT_FALSE(f.fire_boost_timer());
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE);
}
TEST(Daikin, BoostToComfortTimerDoesNotChangePreset) {
Fixture f;
f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST);
EXPECT_TRUE(f.receive(comfort_frame(0x10)));
EXPECT_TRUE(f.receive(state_frame(0x00, 0x00)));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT);
// The state frame saw boost as the previous preset and cancelled the timer
EXPECT_FALSE(f.fire_boost_timer());
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT);
}
TEST(Daikin, ComfortFrameAloneChangesNothing) {
Fixture f;
EXPECT_TRUE(f.receive(state_frame(0x00, 0x04)));
ASSERT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO);
// The comfort frame is only recorded until a valid state frame follows
EXPECT_TRUE(f.receive(comfort_frame(0x10)));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO);
std::vector<uint8_t> bad = state_frame(0x00, 0x00);
bad[18] ^= 0x01;
EXPECT_FALSE(f.receive(bad));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO);
EXPECT_TRUE(f.receive(state_frame(0x00, 0x00)));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT);
}
TEST(Daikin, LostStateFrameKeepsComfortUntilNextTransmission) {
Fixture f;
EXPECT_TRUE(f.receive(comfort_frame(0x10)));
EXPECT_TRUE(f.receive(state_frame(0x00, 0x00)));
ASSERT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT);
// Comfort off arrives but its state frame is lost: nothing changes
EXPECT_TRUE(f.receive(comfort_frame(0x00)));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT);
// The next transmission says comfort off again and its state frame arrives
EXPECT_TRUE(f.receive(comfort_frame(0x00)));
EXPECT_TRUE(f.receive(state_frame(0x00, 0x00)));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE);
}
TEST(Daikin, PendingComfortExpiresAfterOneSecond) {
Fixture f;
// LoopBlockingGuard publishes the dispatch time the component reads as the loop clock
{ LoopBlockingGuard clock(&f.climate, LOG_STR("test"), 1000); }
EXPECT_TRUE(f.receive(comfort_frame(0x10)));
// The state frame of that transmission is lost; the next one arrives after the window
{ LoopBlockingGuard clock(&f.climate, LOG_STR("test"), 1000 + DAIKIN_TRANSMISSION_MS + 1); }
EXPECT_TRUE(f.receive(state_frame(0x00, 0x00)));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE);
}
TEST(Daikin, StateFrameWithoutComfortFrameKeepsComfort) {
Fixture f;
EXPECT_TRUE(f.receive(comfort_frame(0x10)));
EXPECT_TRUE(f.receive(state_frame(0x00, 0x00)));
// A state frame whose 0xC5 frame was lost leaves a current comfort preset alone
EXPECT_TRUE(f.receive(state_frame(0x00, 0x00)));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT);
}
TEST(Daikin, RestoredBoostArmsTheTimer) {
{
Fixture first;
first.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST);
ASSERT_EQ(first.climate.preset, climate::CLIMATE_PRESET_BOOST);
}
Fixture restored(true);
EXPECT_EQ(restored.climate.preset, climate::CLIMATE_PRESET_BOOST);
EXPECT_EQ(restored.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_TRUE(App.scheduler.cancel_timeout(&restored.climate, BOOST_TIMEOUT_ID));
}
TEST(Daikin, FreshClimateHasNoBoostTimer) {
{
Fixture first;
first.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST);
}
// A fixture that clears the saved state restores nothing and arms no timer
Fixture fresh;
EXPECT_NE(fresh.climate.preset, climate::CLIMATE_PRESET_BOOST);
EXPECT_FALSE(App.scheduler.cancel_timeout(&fresh.climate, BOOST_TIMEOUT_ID));
}
TEST(Daikin, RejectsBadStateChecksum) {
Fixture f;
f.command(climate::CLIMATE_MODE_HEAT, 20.0f, climate::CLIMATE_PRESET_SLEEP);
std::vector<uint8_t> bad = state_frame(0x01, 0x00, 45);
bad[18] ^= 0x01;
EXPECT_FALSE(f.receive(bad));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 20.0f);
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_SLEEP);
EXPECT_FALSE(f.fire_boost_timer());
}
TEST(Daikin, RejectsBadComfortChecksum) {
Fixture f;
std::vector<uint8_t> bad = comfort_frame(0x10);
bad[7] ^= 0x01;
EXPECT_FALSE(f.receive(bad));
EXPECT_TRUE(f.receive(state_frame(0x00, 0x00)));
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE);
}
} // namespace esphome::daikin::testing
@@ -0,0 +1,4 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml
daikin: !include common.yaml
@@ -1,4 +1,4 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml
<<: !include common.yaml
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml
daikin: !include common.yaml