[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
@@ -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]