[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:
Martin Klingensmith
2026-10-12 11:20:13 -10:00
committed by GitHub
co-authored by J. Nick Koston
parent 0b6856c052
commit 4e3845ba68
20 changed files with 899 additions and 0 deletions
+1
View File
@@ -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
+1
View File
@@ -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]