mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 10:12:52 +00:00
[pd_pioneer_ir] Add Pioneer mini-split IR climate component (#17680)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
co-authored by
J. Nick Koston
parent
0b6856c052
commit
4e3845ba68
@@ -417,6 +417,7 @@ esphome/components/pca9554/* @bdraco @clydebarrow @hwstar
|
||||
esphome/components/pcf85063/* @brogon
|
||||
esphome/components/pcf8563/* @KoenBreeman
|
||||
esphome/components/pcm5122/* @remcom
|
||||
esphome/components/pd_pioneer_ir/* @martinsah
|
||||
esphome/components/pi4ioe5v6408/* @jesserockz
|
||||
esphome/components/pid/* @OttoWinter
|
||||
esphome/components/pipsolar/* @andreashergert1984
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DOMAIN = "pd_pioneer_ir"
|
||||
@@ -0,0 +1,26 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate_ir, remote_base
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_USE_FAHRENHEIT
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["climate_ir"]
|
||||
CODEOWNERS = ["@martinsah"]
|
||||
|
||||
pd_pioneer_ir_ns = cg.esphome_ns.namespace("pd_pioneer_ir")
|
||||
PDPioneerIR = pd_pioneer_ir_ns.class_("PDPioneerIR", climate_ir.ClimateIR)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(PDPioneerIR).extend(
|
||||
{
|
||||
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
|
||||
# The unit's auto mode is encoded but untested, so it is opt-in
|
||||
cv.Optional(climate_ir.CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
remote_base.request_protocol("pd_pioneer") # used from C++
|
||||
var = await climate_ir.new_climate_ir(config)
|
||||
cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT]))
|
||||
@@ -0,0 +1,111 @@
|
||||
#include "pd_pioneer_data.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::pd_pioneer_ir {
|
||||
|
||||
// Default frames stay in flash; the constructor copies them with progmem_memcpy. Even byte 5 is PWR_ON, byte 6
|
||||
// MODE_COOL
|
||||
static const uint8_t DEFAULT_ODD[] PROGMEM = {0x23, 0xCB, 0x26, 0x02, 0x00, 0x40, 0xC0,
|
||||
0x00, 0xC3, 0x00, 0x00, 0x00, 0x00};
|
||||
static const uint8_t DEFAULT_EVEN[] PROGMEM = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03,
|
||||
0x0B, 0x05, 0x00, 0x00, 0x00, 0x80};
|
||||
|
||||
// Odd positions 5 and 6 and the even position 8 fan code, indexed from CLIMATE_FAN_AUTO to CLIMATE_FAN_HIGH
|
||||
static const uint8_t FAN_CODES[][3] PROGMEM = {
|
||||
{0x40, 0x20, 0x00}, {0x60, 0x40, 0x02}, {0x40, 0x60, 0x03}, {0x40, 0xC0, 0x05}};
|
||||
static constexpr size_t FAN_CODE_COUNT = sizeof(FAN_CODES) / sizeof(FAN_CODES[0]);
|
||||
|
||||
ControlData::ControlData() : odd_(DEFAULT_ODD, sizeof(DEFAULT_ODD)), even_(DEFAULT_EVEN, sizeof(DEFAULT_EVEN)) {}
|
||||
|
||||
void ControlData::set_temp(float temp_c) {
|
||||
// The temperature travels as 31 - C in whole degrees at position 7, with bit 2 of position 12 adding a half
|
||||
temp_c = clamp(temp_c, static_cast<float>(PDPIONEER_TEMPC_MIN), static_cast<float>(PDPIONEER_TEMPC_MAX));
|
||||
// Round to a quarter, then down to a half, so Fahrenheit set points encode as before
|
||||
const int halves = static_cast<int>(lroundf(temp_c * 4)) / 2;
|
||||
this->even_[7] = static_cast<uint8_t>(31 - halves / 2);
|
||||
this->even_[12] = static_cast<uint8_t>(0x80 | ((halves & 1) ? 0x04 : 0x00));
|
||||
}
|
||||
|
||||
void ControlData::set_mode(ClimateMode mode) {
|
||||
switch (mode) {
|
||||
case ClimateMode::CLIMATE_MODE_OFF:
|
||||
this->even_[5] = PWR_OFF;
|
||||
this->even_[6] = MODE_HEAT;
|
||||
this->even_[7] = 0x0C;
|
||||
this->even_[12] = 0x80;
|
||||
return;
|
||||
case ClimateMode::CLIMATE_MODE_COOL:
|
||||
this->even_[6] = MODE_COOL;
|
||||
break;
|
||||
case ClimateMode::CLIMATE_MODE_DRY:
|
||||
this->even_[6] = MODE_DRY;
|
||||
break;
|
||||
case ClimateMode::CLIMATE_MODE_FAN_ONLY:
|
||||
this->even_[6] = MODE_FAN_ONLY;
|
||||
break;
|
||||
case ClimateMode::CLIMATE_MODE_HEAT:
|
||||
this->even_[6] = MODE_HEAT;
|
||||
break;
|
||||
default:
|
||||
this->even_[6] = MODE_AUTO;
|
||||
break;
|
||||
}
|
||||
this->even_[5] = PWR_ON;
|
||||
}
|
||||
|
||||
optional<ClimateMode> ControlData::get_mode(const PDPioneerData &even) {
|
||||
if (even[5] == PWR_OFF)
|
||||
return ClimateMode::CLIMATE_MODE_OFF;
|
||||
switch (even[6]) {
|
||||
case MODE_COOL:
|
||||
return ClimateMode::CLIMATE_MODE_COOL;
|
||||
case MODE_DRY:
|
||||
return ClimateMode::CLIMATE_MODE_DRY;
|
||||
case MODE_FAN_ONLY:
|
||||
return ClimateMode::CLIMATE_MODE_FAN_ONLY;
|
||||
case MODE_HEAT:
|
||||
return ClimateMode::CLIMATE_MODE_HEAT;
|
||||
case MODE_AUTO:
|
||||
return ClimateMode::CLIMATE_MODE_HEAT_COOL;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
void ControlData::finalize() {
|
||||
const size_t idx = this->fan_ >= climate::CLIMATE_FAN_AUTO && this->fan_ <= climate::CLIMATE_FAN_HIGH
|
||||
? this->fan_ - climate::CLIMATE_FAN_AUTO
|
||||
: FAN_CODE_COUNT - 1;
|
||||
this->odd_[5] = progmem_read_byte(&FAN_CODES[idx][0]);
|
||||
this->odd_[6] = progmem_read_byte(&FAN_CODES[idx][1]);
|
||||
this->odd_[7] = this->swing_;
|
||||
this->even_[8] = static_cast<uint8_t>(progmem_read_byte(&FAN_CODES[idx][2]) |
|
||||
((this->swing_ & SWING_VERTICAL) ? SWING_VERTICAL_EVEN_MASK : 0));
|
||||
this->odd_.finalize();
|
||||
this->even_.finalize();
|
||||
}
|
||||
|
||||
ClimateFanMode ControlData::get_fan_mode(const PDPioneerData &even) {
|
||||
const uint8_t code = even[8] & ~SWING_VERTICAL_EVEN_MASK;
|
||||
for (size_t idx = 0; idx < FAN_CODE_COUNT; idx++) {
|
||||
if (progmem_read_byte(&FAN_CODES[idx][2]) == code)
|
||||
return static_cast<ClimateFanMode>(climate::CLIMATE_FAN_AUTO + idx);
|
||||
}
|
||||
return ClimateFanMode::CLIMATE_FAN_HIGH;
|
||||
}
|
||||
|
||||
void ControlData::set_swing_mode(climate::ClimateSwingMode mode) {
|
||||
const bool vertical = mode == climate::CLIMATE_SWING_VERTICAL || mode == climate::CLIMATE_SWING_BOTH;
|
||||
const bool horizontal = mode == climate::CLIMATE_SWING_HORIZONTAL || mode == climate::CLIMATE_SWING_BOTH;
|
||||
this->swing_ = static_cast<uint8_t>((vertical ? SWING_VERTICAL : 0) | (horizontal ? SWING_HORIZONTAL : 0));
|
||||
}
|
||||
|
||||
climate::ClimateSwingMode ControlData::get_swing_mode(const PDPioneerData &even, bool horizontal) {
|
||||
const bool vertical = (even[8] & SWING_VERTICAL_EVEN_MASK) == SWING_VERTICAL_EVEN_MASK;
|
||||
if (vertical)
|
||||
return horizontal ? climate::CLIMATE_SWING_BOTH : climate::CLIMATE_SWING_VERTICAL;
|
||||
return horizontal ? climate::CLIMATE_SWING_HORIZONTAL : climate::CLIMATE_SWING_OFF;
|
||||
}
|
||||
|
||||
} // namespace esphome::pd_pioneer_ir
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/remote_base/pd_pioneer_protocol.h"
|
||||
#include "esphome/components/climate/climate_mode.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::pd_pioneer_ir {
|
||||
|
||||
constexpr uint8_t PDPIONEER_TEMPC_MIN = 16;
|
||||
constexpr uint8_t PDPIONEER_TEMPC_MAX = 31;
|
||||
|
||||
using climate::ClimateMode;
|
||||
using climate::ClimateFanMode;
|
||||
using remote_base::PDPioneerData;
|
||||
|
||||
class ControlData {
|
||||
public:
|
||||
ControlData();
|
||||
void set_temp(float temp_c);
|
||||
void set_mode(ClimateMode mode);
|
||||
void set_fan_mode(ClimateFanMode mode) { this->fan_ = mode; }
|
||||
void set_swing_mode(climate::ClimateSwingMode mode);
|
||||
void set_eco(bool enabled) {
|
||||
if (enabled)
|
||||
this->even_[5] = PWR_ECO;
|
||||
}
|
||||
|
||||
/// Write the fan and swing positions, which share bytes, then the checksums
|
||||
void finalize();
|
||||
const PDPioneerData &odd() const { return this->odd_; }
|
||||
const PDPioneerData &even() const { return this->even_; }
|
||||
|
||||
/// Clamped to the set point range, so a frame from a sibling remote cannot publish an impossible value
|
||||
static float get_temp(const PDPioneerData &even) {
|
||||
const float temp = ((even[12] & 0x04) ? 31.5f : 31.0f) - static_cast<float>(even[7]);
|
||||
return clamp(temp, static_cast<float>(PDPIONEER_TEMPC_MIN), static_cast<float>(PDPIONEER_TEMPC_MAX));
|
||||
}
|
||||
/// Empty for a mode byte this remote does not use
|
||||
static optional<ClimateMode> get_mode(const PDPioneerData &even);
|
||||
static bool get_eco(const PDPioneerData &even) { return even[5] == PWR_ECO; }
|
||||
static ClimateFanMode get_fan_mode(const PDPioneerData &even);
|
||||
/// Only the odd burst carries horizontal swing; the even burst carries vertical swing
|
||||
static bool get_horizontal_swing(const PDPioneerData &odd) { return (odd[7] & SWING_HORIZONTAL) == SWING_HORIZONTAL; }
|
||||
static climate::ClimateSwingMode get_swing_mode(const PDPioneerData &even, bool horizontal);
|
||||
|
||||
protected:
|
||||
static constexpr uint8_t MODE_HEAT = 0x01;
|
||||
static constexpr uint8_t MODE_DRY = 0x02;
|
||||
static constexpr uint8_t MODE_COOL = 0x03;
|
||||
static constexpr uint8_t MODE_FAN_ONLY = 0x07;
|
||||
static constexpr uint8_t MODE_AUTO = 0x08;
|
||||
|
||||
static constexpr uint8_t PWR_ON = 0x24;
|
||||
static constexpr uint8_t PWR_ECO = 0x25;
|
||||
static constexpr uint8_t PWR_OFF = 0xA0;
|
||||
|
||||
static constexpr uint8_t SWING_VERTICAL = 0x08;
|
||||
static constexpr uint8_t SWING_HORIZONTAL = 0x90;
|
||||
/// Vertical swing also sets this mask on even frame position 8 (fan sub-code).
|
||||
static constexpr uint8_t SWING_VERTICAL_EVEN_MASK = 0x38;
|
||||
|
||||
PDPioneerData odd_;
|
||||
PDPioneerData even_;
|
||||
ClimateFanMode fan_{climate::CLIMATE_FAN_HIGH};
|
||||
/// Odd-burst position 7: SWING_VERTICAL and SWING_HORIZONTAL flags
|
||||
uint8_t swing_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::pd_pioneer_ir
|
||||
@@ -0,0 +1,59 @@
|
||||
#include "pd_pioneer_ir.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::pd_pioneer_ir {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "pd_pioneer_ir");
|
||||
|
||||
void PDPioneerIR::setup() {
|
||||
climate_ir::ClimateIR::setup();
|
||||
this->publish_state();
|
||||
}
|
||||
|
||||
void PDPioneerIR::transmit_state() {
|
||||
ControlData data;
|
||||
data.set_mode(this->mode);
|
||||
if (this->mode != climate::CLIMATE_MODE_OFF && this->mode != climate::CLIMATE_MODE_FAN_ONLY)
|
||||
data.set_temp(this->target_temperature);
|
||||
data.set_fan_mode(this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO));
|
||||
data.set_swing_mode(this->swing_mode);
|
||||
// ECO shares the power byte, so it must not undo an off command
|
||||
data.set_eco(this->mode != climate::CLIMATE_MODE_OFF && this->preset == climate::CLIMATE_PRESET_ECO);
|
||||
data.finalize();
|
||||
// The remote sends the two bursts as separate transmissions
|
||||
this->transmit_<remote_base::PDPioneerProtocol>(data.odd());
|
||||
this->transmit_<remote_base::PDPioneerProtocol>(data.even());
|
||||
}
|
||||
|
||||
bool PDPioneerIR::on_receive(remote_base::RemoteReceiveData data) {
|
||||
auto frame = remote_base::PDPioneerProtocol().decode(data);
|
||||
if (!frame.has_value())
|
||||
return false;
|
||||
if (frame->is_odd_burst()) {
|
||||
this->rx_horizontal_swing_ = ControlData::get_horizontal_swing(*frame);
|
||||
return false;
|
||||
}
|
||||
// decode() only returns odd or even bursts; a mode byte this remote never sends is another device's frame
|
||||
const optional<climate::ClimateMode> decoded = ControlData::get_mode(*frame);
|
||||
if (!decoded.has_value())
|
||||
return false;
|
||||
// An auto-mode frame from the remote must not publish HEAT_COOL when the traits leave it out
|
||||
if (this->get_traits().supports_mode(*decoded)) {
|
||||
this->mode = *decoded;
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Received mode %s is not enabled, keeping the current mode",
|
||||
LOG_STR_ARG(climate::climate_mode_to_string(*decoded)));
|
||||
}
|
||||
// Off and fan-only bursts carry no target temperature
|
||||
if (*decoded != climate::CLIMATE_MODE_OFF && *decoded != climate::CLIMATE_MODE_FAN_ONLY)
|
||||
this->target_temperature = ControlData::get_temp(*frame);
|
||||
this->fan_mode = ControlData::get_fan_mode(*frame);
|
||||
this->swing_mode = ControlData::get_swing_mode(*frame, this->rx_horizontal_swing_);
|
||||
// A missed odd burst must not carry this command's horizontal swing into the next one
|
||||
this->rx_horizontal_swing_ = false;
|
||||
this->preset = ControlData::get_eco(*frame) ? climate::CLIMATE_PRESET_ECO : climate::CLIMATE_PRESET_NONE;
|
||||
this->publish_state();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::pd_pioneer_ir
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/climate_ir/climate_ir.h"
|
||||
#include "pd_pioneer_data.h"
|
||||
|
||||
namespace esphome::pd_pioneer_ir {
|
||||
|
||||
class PDPioneerIR : public climate_ir::ClimateIR {
|
||||
public:
|
||||
PDPioneerIR()
|
||||
: climate_ir::ClimateIR(PDPIONEER_TEMPC_MIN, PDPIONEER_TEMPC_MAX, 1.0f, true, true,
|
||||
{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_NONE, climate::CLIMATE_PRESET_ECO}) {}
|
||||
|
||||
void setup() override;
|
||||
|
||||
void set_fahrenheit(bool value) { this->temperature_step_ = value ? 0.5f : 1.0f; }
|
||||
|
||||
protected:
|
||||
void transmit_state() override;
|
||||
bool on_receive(remote_base::RemoteReceiveData data) override;
|
||||
|
||||
// Horizontal swing from the odd burst, applied to the even burst that follows it
|
||||
bool rx_horizontal_swing_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::pd_pioneer_ir
|
||||
@@ -2409,6 +2409,62 @@ register_protocol_action(
|
||||
)
|
||||
|
||||
|
||||
# PD Pioneer
|
||||
(
|
||||
PDPioneerData,
|
||||
PDPioneerBinarySensor,
|
||||
PDPioneerTrigger,
|
||||
_,
|
||||
PDPioneerDumper,
|
||||
) = declare_protocol("PDPioneer")
|
||||
|
||||
|
||||
def _validate_pd_pioneer_burst(code: list[int]) -> list[int]:
|
||||
"""Byte 3 tells the odd (0x02) and even (0x01) bursts apart; decode() drops anything else."""
|
||||
if code[3] not in (0x01, 0x02):
|
||||
raise cv.Invalid("Byte 3 of a pd_pioneer code must be 0x01 or 0x02")
|
||||
return code
|
||||
|
||||
|
||||
PDPIONEER_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_CODE): cv.All(
|
||||
[cv.hex_uint8_t], cv.Length(min=13, max=14), _validate_pd_pioneer_burst
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _pd_pioneer_finalized(code: list[int]) -> list[int]:
|
||||
"""Fill in the checksum: the sum of the first 13 bytes, plus 15 for an odd burst."""
|
||||
checksum = sum(code[:13], 15 if code[3] == 0x02 else 0) & 0xFF
|
||||
return [*code[:13], checksum]
|
||||
|
||||
|
||||
@register_binary_sensor("pd_pioneer", PDPioneerBinarySensor, PDPIONEER_SCHEMA)
|
||||
def pd_pioneer_binary_sensor(var: MockObj, config: ConfigType) -> None:
|
||||
cg.add(var.set_data(_pd_pioneer_finalized(config[CONF_CODE])))
|
||||
|
||||
|
||||
@register_trigger("pd_pioneer", PDPioneerTrigger, PDPioneerData)
|
||||
def pd_pioneer_trigger(var: MockObj, config: ConfigType) -> None:
|
||||
"""The trigger takes no options beyond the automation."""
|
||||
|
||||
|
||||
@register_dumper("pd_pioneer", PDPioneerDumper)
|
||||
def pd_pioneer_dumper(var: MockObj, config: ConfigType) -> None:
|
||||
"""The dumper takes no options."""
|
||||
|
||||
|
||||
register_protocol_action(
|
||||
"pd_pioneer",
|
||||
"PDPioneer",
|
||||
PDPIONEER_SCHEMA,
|
||||
_bytes_field(CONF_CODE, f"data = {PDPioneerData}({{}})"),
|
||||
automation.ApplyCall("data.finalize()"),
|
||||
)
|
||||
|
||||
|
||||
# Midea
|
||||
MideaData, MideaBinarySensor, MideaTrigger, _, MideaDumper = declare_protocol("Midea")
|
||||
MIDEA_SCHEMA = cv.Schema(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#include "pd_pioneer_protocol.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::remote_base {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "remote.pd_pioneer");
|
||||
|
||||
// Timings from captured Pronto codes (006D timebase, 26 us per word)
|
||||
static constexpr uint32_t HEADER_MARK_US = 3120;
|
||||
static constexpr uint32_t HEADER_SPACE_US = 1560;
|
||||
static constexpr uint32_t BIT_MARK_US = 520;
|
||||
static constexpr uint32_t BIT_ONE_SPACE_US = 1066;
|
||||
static constexpr uint32_t BIT_ZERO_SPACE_US = 312;
|
||||
static constexpr uint32_t FOOTER_MARK_US = 520;
|
||||
static constexpr uint32_t FOOTER_SPACE_US = 10010;
|
||||
|
||||
void PDPioneerProtocol::encode(RemoteTransmitData *dst, const PDPioneerData &src) {
|
||||
dst->set_carrier_frequency(38000);
|
||||
dst->reserve(2 + PDPioneerData::FRAME_SIZE * 8 * 2 + 2);
|
||||
dst->item(HEADER_MARK_US, HEADER_SPACE_US);
|
||||
for (uint8_t idx = 0; idx < PDPioneerData::FRAME_SIZE; idx++) {
|
||||
for (uint8_t mask = 1; mask; mask <<= 1)
|
||||
dst->item(BIT_MARK_US, (src[idx] & mask) ? BIT_ONE_SPACE_US : BIT_ZERO_SPACE_US);
|
||||
}
|
||||
dst->item(FOOTER_MARK_US, FOOTER_SPACE_US);
|
||||
}
|
||||
|
||||
optional<PDPioneerData> PDPioneerProtocol::decode(RemoteReceiveData src) {
|
||||
PDPioneerData out;
|
||||
if (!src.expect_item(HEADER_MARK_US, HEADER_SPACE_US))
|
||||
return {};
|
||||
for (uint8_t idx = 0; idx < PDPioneerData::FRAME_SIZE; idx++) {
|
||||
for (uint8_t mask = 1; mask; mask <<= 1) {
|
||||
if (src.expect_item(BIT_MARK_US, BIT_ONE_SPACE_US)) {
|
||||
out[idx] |= mask;
|
||||
} else if (!src.expect_item(BIT_MARK_US, BIT_ZERO_SPACE_US)) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
// The receiver's idle timeout usually ends the capture inside the footer space, so only the mark is required
|
||||
if (!src.expect_mark(FOOTER_MARK_US))
|
||||
return {};
|
||||
if (!out.is_valid()) {
|
||||
char buf[PDPioneerData::TO_STR_BUFFER_SIZE];
|
||||
ESP_LOGV(TAG, "Checksum mismatch: %s", out.to_str(buf));
|
||||
return {};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void PDPioneerProtocol::dump(const PDPioneerData &data) {
|
||||
char buf[PDPioneerData::TO_STR_BUFFER_SIZE];
|
||||
ESP_LOGI(TAG, "Received PD-Pioneer: %s", data.to_str(buf));
|
||||
}
|
||||
|
||||
} // namespace esphome::remote_base
|
||||
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "remote_base.h"
|
||||
|
||||
namespace esphome::remote_base {
|
||||
|
||||
class PDPioneerData {
|
||||
public:
|
||||
static constexpr uint8_t FRAME_SIZE = 14;
|
||||
static constexpr uint8_t OFFSET_CS = 13;
|
||||
|
||||
PDPioneerData() = default;
|
||||
PDPioneerData(std::initializer_list<uint8_t> data) {
|
||||
std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin());
|
||||
}
|
||||
PDPioneerData(const std::vector<uint8_t> &data) {
|
||||
std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin());
|
||||
}
|
||||
PDPioneerData(const uint8_t *data, size_t len) {
|
||||
progmem_memcpy(this->data_.data(), data, std::min(len, this->data_.size()));
|
||||
}
|
||||
|
||||
uint8_t *data() { return this->data_.data(); }
|
||||
const uint8_t *data() const { return this->data_.data(); }
|
||||
|
||||
// Position 3 tells the two bursts of a command apart; the odd burst's checksum carries an extra 15
|
||||
bool is_odd_burst() const { return this->data_[3] == 0x02; }
|
||||
bool is_even_burst() const { return this->data_[3] == 0x01; }
|
||||
|
||||
bool is_valid() const {
|
||||
return (this->is_odd_burst() || this->is_even_burst()) && this->data_[OFFSET_CS] == this->checksum_();
|
||||
}
|
||||
|
||||
void finalize() { this->data_[OFFSET_CS] = this->checksum_(); }
|
||||
|
||||
static constexpr size_t TO_STR_BUFFER_SIZE = format_hex_pretty_size(FRAME_SIZE);
|
||||
const char *to_str(char *buffer) const {
|
||||
format_hex_pretty_to(buffer, TO_STR_BUFFER_SIZE, this->data_.data(), this->data_.size(), '.');
|
||||
return buffer;
|
||||
}
|
||||
|
||||
bool operator==(const PDPioneerData &rhs) const { return this->data_ == rhs.data_; }
|
||||
|
||||
uint8_t &operator[](size_t idx) { return this->data_[idx]; }
|
||||
const uint8_t &operator[](size_t idx) const { return this->data_[idx]; }
|
||||
|
||||
protected:
|
||||
uint8_t checksum_() const {
|
||||
uint8_t cs = this->is_odd_burst() ? 15 : 0;
|
||||
for (uint8_t idx = 0; idx < OFFSET_CS; idx++)
|
||||
cs += this->data_[idx];
|
||||
return cs;
|
||||
}
|
||||
|
||||
std::array<uint8_t, FRAME_SIZE> data_{};
|
||||
};
|
||||
|
||||
class PDPioneerProtocol : public RemoteProtocol<PDPioneerData> {
|
||||
public:
|
||||
void encode(RemoteTransmitData *dst, const PDPioneerData &src);
|
||||
optional<PDPioneerData> decode(RemoteReceiveData src);
|
||||
void dump(const PDPioneerData &data);
|
||||
};
|
||||
|
||||
DECLARE_REMOTE_PROTOCOL(PDPioneer)
|
||||
|
||||
} // namespace esphome::remote_base
|
||||
@@ -182,6 +182,7 @@
|
||||
#define USE_REMOTE_PROTOCOL_NEXUS
|
||||
#define USE_REMOTE_PROTOCOL_ONKYORI
|
||||
#define USE_REMOTE_PROTOCOL_PANASONIC
|
||||
#define USE_REMOTE_PROTOCOL_PD_PIONEER
|
||||
#define USE_REMOTE_PROTOCOL_PIONEER
|
||||
#define USE_REMOTE_PROTOCOL_PIONEER_WYT
|
||||
#define USE_REMOTE_PROTOCOL_PRONTO
|
||||
|
||||
@@ -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 the protocol source
|
||||
remote_base.request_protocol("pd_pioneer")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
@@ -0,0 +1,2 @@
|
||||
# Registers the climate platform so USE_CLIMATE is defined for the host unit test build
|
||||
climate: []
|
||||
@@ -0,0 +1,6 @@
|
||||
climate:
|
||||
- platform: pd_pioneer_ir
|
||||
name: Pioneer AC
|
||||
transmitter_id: xmitr
|
||||
receiver_id: rcvr
|
||||
use_fahrenheit: true
|
||||
@@ -0,0 +1,372 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstdint>
|
||||
#include <new>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/pd_pioneer_ir/pd_pioneer_ir.h"
|
||||
#include "esphome/components/remote_base/pd_pioneer_protocol.h"
|
||||
#include "esphome/components/remote_base/remote_base.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
|
||||
namespace esphome::pd_pioneer_ir::testing {
|
||||
|
||||
using remote_base::PDPioneerData;
|
||||
using Frame = std::vector<uint8_t>;
|
||||
|
||||
static constexpr uint32_t TOLERANCE_PERCENT = 25;
|
||||
static constexpr uint8_t FRAME_SIZE = PDPioneerData::FRAME_SIZE;
|
||||
|
||||
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());
|
||||
}
|
||||
};
|
||||
|
||||
/// Fill in the checksum: the sum of the first 13 values, plus 15 for an odd burst
|
||||
static Frame finalized(Frame frame) {
|
||||
uint8_t sum = frame[3] == 0x02 ? 15 : 0;
|
||||
for (size_t idx = 0; idx < FRAME_SIZE - 1; idx++)
|
||||
sum += frame[idx];
|
||||
frame[FRAME_SIZE - 1] = sum;
|
||||
return frame;
|
||||
}
|
||||
|
||||
static Frame odd_frame(uint8_t fan5, uint8_t fan6, uint8_t swing) {
|
||||
return finalized({0x23, 0xCB, 0x26, 0x02, 0x00, fan5, fan6, swing, 0xC3, 0x00, 0x00, 0x00, 0x00, 0x00});
|
||||
}
|
||||
|
||||
static Frame even_frame(uint8_t power, uint8_t mode, uint8_t temp, uint8_t fan8, uint8_t byte12) {
|
||||
return finalized({0x23, 0xCB, 0x26, 0x01, 0x00, power, mode, temp, fan8, 0x00, 0x00, 0x00, byte12, 0x00});
|
||||
}
|
||||
|
||||
static remote_base::RawTimings encode(const Frame &frame) {
|
||||
remote_base::RemoteTransmitData data;
|
||||
remote_base::PDPioneerProtocol().encode(&data, PDPioneerData(frame));
|
||||
return data.get_data();
|
||||
}
|
||||
|
||||
/// RemoteReceiveData keeps a reference to the timings, so they must outlive it
|
||||
static remote_base::RemoteReceiveData rx(const remote_base::RawTimings &raw) {
|
||||
return remote_base::RemoteReceiveData(raw, TOLERANCE_PERCENT, remote_base::TOLERANCE_MODE_PERCENTAGE);
|
||||
}
|
||||
|
||||
static Frame decode(const remote_base::RawTimings &raw) {
|
||||
const auto decoded = remote_base::PDPioneerProtocol().decode(rx(raw));
|
||||
EXPECT_TRUE(decoded.has_value());
|
||||
if (!decoded.has_value())
|
||||
return {};
|
||||
return Frame(decoded->data(), decoded->data() + FRAME_SIZE);
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
CapturingTransmitter transmitter;
|
||||
PDPioneerIR 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_pd_pioneer_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,
|
||||
climate::ClimateSwingMode swing = climate::CLIMATE_SWING_OFF,
|
||||
climate::ClimatePreset preset = climate::CLIMATE_PRESET_NONE) {
|
||||
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.set_preset(preset);
|
||||
call.perform();
|
||||
}
|
||||
|
||||
/// The odd and even bursts of the last command, decoded by the protocol
|
||||
std::pair<Frame, Frame> last_pair() const {
|
||||
EXPECT_GE(this->transmitter.frames.size(), 2u);
|
||||
if (this->transmitter.frames.size() < 2)
|
||||
return {};
|
||||
const size_t n = this->transmitter.frames.size();
|
||||
return {decode(this->transmitter.frames[n - 2]), decode(this->transmitter.frames[n - 1])};
|
||||
}
|
||||
|
||||
bool receive(const remote_base::RawTimings &raw) {
|
||||
// The receiver calls through the listener interface, where on_receive is public
|
||||
return static_cast<remote_base::RemoteReceiverListener &>(this->climate).on_receive(rx(raw));
|
||||
}
|
||||
|
||||
bool receive(const Frame &frame) { return this->receive(encode(frame)); }
|
||||
};
|
||||
|
||||
TEST(PDPioneerIR, ProtocolRoundTripsAFrame) {
|
||||
const Frame frame = even_frame(0x24, 0x03, 0x07, 0x05, 0x80);
|
||||
EXPECT_EQ(decode(encode(frame)), frame);
|
||||
const Frame odd = odd_frame(0x40, 0xC0, 0x00);
|
||||
EXPECT_EQ(decode(encode(odd)), odd);
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, ProtocolRejectsBadChecksumAndUnknownBurst) {
|
||||
Frame frame = even_frame(0x24, 0x03, 0x07, 0x05, 0x80);
|
||||
frame[13] ^= 0x01;
|
||||
const remote_base::RawTimings bad = encode(frame);
|
||||
EXPECT_FALSE(remote_base::PDPioneerProtocol().decode(rx(bad)).has_value());
|
||||
Frame unknown = even_frame(0x24, 0x03, 0x07, 0x05, 0x80);
|
||||
unknown[3] = 0x03;
|
||||
unknown = finalized(unknown);
|
||||
const remote_base::RawTimings other = encode(unknown);
|
||||
EXPECT_FALSE(remote_base::PDPioneerProtocol().decode(rx(other)).has_value());
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, EncodesEachMode) {
|
||||
struct Case {
|
||||
climate::ClimateMode mode;
|
||||
uint8_t code;
|
||||
};
|
||||
// 24 C: 31.75 - 24 = 7.75, position 7 = 7, bit 2 of position 12 clear
|
||||
static constexpr Case CASES[] = {
|
||||
{climate::CLIMATE_MODE_COOL, 0x03},
|
||||
{climate::CLIMATE_MODE_HEAT, 0x01},
|
||||
{climate::CLIMATE_MODE_DRY, 0x02},
|
||||
{climate::CLIMATE_MODE_HEAT_COOL, 0x08},
|
||||
};
|
||||
for (const auto &c : CASES) {
|
||||
SCOPED_TRACE(static_cast<int>(c.mode));
|
||||
Fixture f;
|
||||
f.command(c.mode, 24.0f, climate::CLIMATE_FAN_HIGH);
|
||||
const auto [odd, even] = f.last_pair();
|
||||
EXPECT_EQ(odd, odd_frame(0x40, 0xC0, 0x00));
|
||||
EXPECT_EQ(even, even_frame(0x24, c.code, 0x07, 0x05, 0x80));
|
||||
}
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, FanOnlyCarriesNoTemperature) {
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_FAN_ONLY, 24.0f, climate::CLIMATE_FAN_LOW);
|
||||
const auto [odd, even] = f.last_pair();
|
||||
EXPECT_EQ(odd, odd_frame(0x60, 0x40, 0x00));
|
||||
// Position 7 keeps the template value 0x0B and position 8 follows the fan
|
||||
EXPECT_EQ(even, even_frame(0x24, 0x07, 0x0B, 0x02, 0x80));
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, EncodesOff) {
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_OFF, 24.0f, climate::CLIMATE_FAN_HIGH);
|
||||
const auto [odd, even] = f.last_pair();
|
||||
EXPECT_EQ(odd, odd_frame(0x40, 0xC0, 0x00));
|
||||
EXPECT_EQ(even, even_frame(0xA0, 0x01, 0x0C, 0x05, 0x80));
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, EncodesFanModes) {
|
||||
struct Case {
|
||||
climate::ClimateFanMode fan;
|
||||
uint8_t odd5;
|
||||
uint8_t odd6;
|
||||
uint8_t even8;
|
||||
};
|
||||
static constexpr Case CASES[] = {
|
||||
{climate::CLIMATE_FAN_AUTO, 0x40, 0x20, 0x00},
|
||||
{climate::CLIMATE_FAN_LOW, 0x60, 0x40, 0x02},
|
||||
{climate::CLIMATE_FAN_MEDIUM, 0x40, 0x60, 0x03},
|
||||
{climate::CLIMATE_FAN_HIGH, 0x40, 0xC0, 0x05},
|
||||
};
|
||||
for (const auto &c : CASES) {
|
||||
SCOPED_TRACE(static_cast<int>(c.fan));
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_COOL, 24.0f, c.fan);
|
||||
const auto [odd, even] = f.last_pair();
|
||||
EXPECT_EQ(odd, odd_frame(c.odd5, c.odd6, 0x00));
|
||||
EXPECT_EQ(even, even_frame(0x24, 0x03, 0x07, c.even8, 0x80));
|
||||
}
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, EncodesSwing) {
|
||||
struct Case {
|
||||
climate::ClimateSwingMode swing;
|
||||
uint8_t odd7;
|
||||
uint8_t even8;
|
||||
};
|
||||
// Vertical swing also ORs 0x38 into the fan field of the even burst (0x05 for high)
|
||||
static constexpr Case CASES[] = {
|
||||
{climate::CLIMATE_SWING_VERTICAL, 0x08, 0x3D},
|
||||
{climate::CLIMATE_SWING_HORIZONTAL, 0x90, 0x05},
|
||||
{climate::CLIMATE_SWING_BOTH, 0x98, 0x3D},
|
||||
};
|
||||
for (const auto &c : CASES) {
|
||||
SCOPED_TRACE(static_cast<int>(c.swing));
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_COOL, 24.0f, climate::CLIMATE_FAN_HIGH, c.swing);
|
||||
const auto [odd, even] = f.last_pair();
|
||||
EXPECT_EQ(odd, odd_frame(0x40, 0xC0, c.odd7));
|
||||
EXPECT_EQ(even, even_frame(0x24, 0x03, 0x07, c.even8, 0x80));
|
||||
}
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, SwingAndFanEncodeInAnyOrder) {
|
||||
ControlData fan_first;
|
||||
fan_first.set_fan_mode(climate::CLIMATE_FAN_LOW);
|
||||
fan_first.set_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
fan_first.finalize();
|
||||
ControlData swing_first;
|
||||
swing_first.set_swing_mode(climate::CLIMATE_SWING_BOTH);
|
||||
swing_first.set_fan_mode(climate::CLIMATE_FAN_LOW);
|
||||
swing_first.finalize();
|
||||
EXPECT_EQ(swing_first.odd(), fan_first.odd());
|
||||
EXPECT_EQ(swing_first.even(), fan_first.even());
|
||||
EXPECT_EQ(swing_first.odd()[7], 0x98);
|
||||
EXPECT_EQ(swing_first.even()[8], 0x3A);
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, EncodesTemperatureInHalfDegrees) {
|
||||
struct Case {
|
||||
float temperature;
|
||||
uint8_t byte7;
|
||||
uint8_t byte12;
|
||||
};
|
||||
// 31 - C in whole degrees, plus a bit for the half: 16 -> 15, 24.5 -> 7 with the bit set, 31 -> 0
|
||||
static constexpr Case CASES[] = {
|
||||
{16.0f, 0x0F, 0x80}, {24.0f, 0x07, 0x80}, {24.5f, 0x07, 0x84},
|
||||
{31.0f, 0x00, 0x80}, {10.0f, 0x0F, 0x80}, {35.0f, 0x00, 0x80},
|
||||
};
|
||||
for (const auto &c : CASES) {
|
||||
SCOPED_TRACE(c.temperature);
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_COOL, c.temperature, climate::CLIMATE_FAN_HIGH);
|
||||
const auto [odd, even] = f.last_pair();
|
||||
EXPECT_EQ(even, even_frame(0x24, 0x03, c.byte7, 0x05, c.byte12));
|
||||
}
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, EncodesEcoPreset) {
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_COOL, 24.0f, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF,
|
||||
climate::CLIMATE_PRESET_ECO);
|
||||
const auto [odd, even] = f.last_pair();
|
||||
EXPECT_EQ(even, even_frame(0x25, 0x03, 0x07, 0x05, 0x80));
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, OffWithEcoPresetStaysOff) {
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_OFF, 24.0f, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF,
|
||||
climate::CLIMATE_PRESET_ECO);
|
||||
const auto [odd, even] = f.last_pair();
|
||||
EXPECT_EQ(even, even_frame(0xA0, 0x01, 0x0C, 0x05, 0x80));
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, ReceivesAnOddAndEvenPair) {
|
||||
Fixture f;
|
||||
// The odd burst alone changes nothing; the even burst completes the command
|
||||
EXPECT_FALSE(f.receive(odd_frame(0x60, 0x40, 0x98)));
|
||||
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
|
||||
EXPECT_TRUE(f.receive(even_frame(0x25, 0x01, 0x07, 0x3A, 0x84)));
|
||||
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT);
|
||||
EXPECT_FLOAT_EQ(f.climate.target_temperature, 24.5f);
|
||||
EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_LOW);
|
||||
EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_BOTH);
|
||||
EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO);
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, ReceivesAnEvenBurstWithoutItsOddBurst) {
|
||||
Fixture f;
|
||||
// Position 8 is the medium fan code 0x03 with the vertical swing mask 0x38
|
||||
EXPECT_TRUE(f.receive(even_frame(0x24, 0x03, 0x07, 0x3B, 0x80)));
|
||||
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
|
||||
EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_MEDIUM);
|
||||
EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL);
|
||||
// Horizontal swing from an earlier pair does not carry over to an even burst whose odd burst was missed
|
||||
EXPECT_FALSE(f.receive(odd_frame(0x60, 0x40, 0x98)));
|
||||
EXPECT_TRUE(f.receive(even_frame(0x24, 0x03, 0x07, 0x3A, 0x80)));
|
||||
EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_BOTH);
|
||||
EXPECT_TRUE(f.receive(even_frame(0x24, 0x03, 0x07, 0x00, 0x80)));
|
||||
EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_AUTO);
|
||||
EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_OFF);
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, ReceivedUnknownModeIsIgnored) {
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH);
|
||||
// Mode byte 0x05 belongs to no mode of this remote
|
||||
EXPECT_FALSE(f.receive(even_frame(0x24, 0x05, 0x07, 0x05, 0x80)));
|
||||
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
|
||||
EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.0f);
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, ReceivedTemperatureIsClamped) {
|
||||
Fixture f;
|
||||
// Position 7 above 0x0F would decode below the 16 C minimum
|
||||
EXPECT_TRUE(f.receive(even_frame(0x24, 0x03, 0x1F, 0x05, 0x80)));
|
||||
EXPECT_FLOAT_EQ(f.climate.target_temperature, 16.0f);
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, ReceivedAutoModeIsIgnoredWithoutHeatCool) {
|
||||
Fixture f;
|
||||
f.climate.set_supports_heat_cool(false);
|
||||
f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH);
|
||||
EXPECT_FALSE(f.receive(odd_frame(0x60, 0x40, 0x08)));
|
||||
EXPECT_TRUE(f.receive(even_frame(0x24, 0x08, 0x07, 0x3A, 0x80)));
|
||||
// The mode stays as it was; the rest of the command still applies
|
||||
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
|
||||
EXPECT_FLOAT_EQ(f.climate.target_temperature, 24.0f);
|
||||
EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_LOW);
|
||||
EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL);
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, ReceivedOffAndFanOnlyKeepTheTemperature) {
|
||||
Fixture f;
|
||||
f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH);
|
||||
EXPECT_FALSE(f.receive(odd_frame(0x40, 0xC0, 0x00)));
|
||||
EXPECT_TRUE(f.receive(even_frame(0xA0, 0x01, 0x0C, 0x03, 0x80)));
|
||||
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
|
||||
EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.0f);
|
||||
EXPECT_FALSE(f.receive(odd_frame(0x40, 0xC0, 0x00)));
|
||||
EXPECT_TRUE(f.receive(even_frame(0x24, 0x07, 0x0B, 0x05, 0x80)));
|
||||
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_FAN_ONLY);
|
||||
EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.0f);
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, TransmittedCommandIsReceivedBack) {
|
||||
Fixture sender;
|
||||
sender.command(climate::CLIMATE_MODE_DRY, 19.0f, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_VERTICAL);
|
||||
Fixture receiver;
|
||||
const size_t n = sender.transmitter.frames.size();
|
||||
EXPECT_FALSE(receiver.receive(sender.transmitter.frames[n - 2]));
|
||||
EXPECT_TRUE(receiver.receive(sender.transmitter.frames[n - 1]));
|
||||
EXPECT_EQ(receiver.climate.mode, climate::CLIMATE_MODE_DRY);
|
||||
EXPECT_FLOAT_EQ(receiver.climate.target_temperature, 19.0f);
|
||||
EXPECT_EQ(receiver.climate.fan_mode, climate::CLIMATE_FAN_MEDIUM);
|
||||
EXPECT_EQ(receiver.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL);
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, AutoFanIsReceivedBack) {
|
||||
Fixture sender;
|
||||
sender.command(climate::CLIMATE_MODE_COOL, 24.0f, climate::CLIMATE_FAN_AUTO);
|
||||
Fixture receiver;
|
||||
const size_t n = sender.transmitter.frames.size();
|
||||
EXPECT_FALSE(receiver.receive(sender.transmitter.frames[n - 2]));
|
||||
EXPECT_TRUE(receiver.receive(sender.transmitter.frames[n - 1]));
|
||||
EXPECT_EQ(receiver.climate.fan_mode, climate::CLIMATE_FAN_AUTO);
|
||||
}
|
||||
|
||||
TEST(PDPioneerIR, StaticCodeIsSentWithItsChecksum) {
|
||||
// A 13-byte code, filled and finalized as the transmit action does
|
||||
static const uint8_t CODE[] = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x07, 0x05, 0x00, 0x00, 0x00, 0x80};
|
||||
remote_base::PDPioneerData data(CODE, sizeof(CODE));
|
||||
data.finalize();
|
||||
remote_base::RemoteTransmitData dst;
|
||||
remote_base::PDPioneerProtocol().encode(&dst, data);
|
||||
EXPECT_EQ(decode(dst.get_data()), even_frame(0x24, 0x03, 0x07, 0x05, 0x80));
|
||||
}
|
||||
|
||||
} // namespace esphome::pd_pioneer_ir::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
|
||||
pd_pioneer_ir: !include common.yaml
|
||||
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml
|
||||
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml
|
||||
pd_pioneer_ir: !include common.yaml
|
||||
@@ -192,6 +192,11 @@ on_hob2hood:
|
||||
- logger.log:
|
||||
format: "on_hob2hood: %u"
|
||||
args: ["x.command"]
|
||||
on_pd_pioneer:
|
||||
then:
|
||||
- logger.log:
|
||||
format: "on_pd_pioneer: burst %u power %u"
|
||||
args: ["x[3]", "x[5]"]
|
||||
on_ira211:
|
||||
then:
|
||||
- logger.log:
|
||||
|
||||
@@ -62,3 +62,7 @@ binary_sensor:
|
||||
nexus:
|
||||
channel: 1
|
||||
address: 0x42
|
||||
- platform: remote_receiver
|
||||
name: PD Pioneer Cool
|
||||
pd_pioneer:
|
||||
code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x0B, 0x05, 0x00, 0x00, 0x00, 0x80]
|
||||
|
||||
@@ -531,3 +531,8 @@ button:
|
||||
repeat:
|
||||
times: 3
|
||||
wait_time: 30ms
|
||||
- platform: template
|
||||
name: PD Pioneer
|
||||
on_press:
|
||||
- remote_transmitter.transmit_pd_pioneer:
|
||||
code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x0B, 0x05, 0x00, 0x00, 0x00, 0x80]
|
||||
|
||||
Reference in New Issue
Block a user