From 99b61760c3e30cc1fa07b106437895d36ff0f71c Mon Sep 17 00:00:00 2001 From: Michael Smith <2015782+mikesmitty@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:37:31 -0400 Subject: [PATCH] [remote_base] Add the Pioneer WYT transmit protocol (#17175) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 18 ++++++ .../remote_base/pioneer_wyt_protocol.cpp | 30 ++++++++++ .../remote_base/pioneer_wyt_protocol.h | 55 +++++++++++++++++ esphome/core/defines.h | 1 + .../config/byte_payloads.yaml | 5 ++ .../remote_transmitter/test_byte_payloads.py | 8 ++- tests/components/remote_base/__init__.py | 1 + .../remote_base/pioneer_wyt_test.cpp | 60 +++++++++++++++++++ .../remote_transmitter/common-buttons.yaml | 12 ++++ .../remote_base/test_pioneer_wyt.py | 56 +++++++++++++++++ 10 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 esphome/components/remote_base/pioneer_wyt_protocol.cpp create mode 100644 esphome/components/remote_base/pioneer_wyt_protocol.h create mode 100644 tests/components/remote_base/pioneer_wyt_test.cpp create mode 100644 tests/unit_tests/components/remote_base/test_pioneer_wyt.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 45873f2385..e347ee96ff 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1228,6 +1228,24 @@ register_protocol_action( ) +# PioneerWyt +PioneerWytData = ns.class_("PioneerWytData") +PIONEER_WYT_SCHEMA = cv.Schema( + { + # 13 bytes get the checksum appended; a 14th byte is replaced by the recomputed checksum + cv.Required(CONF_CODE): cv.All([cv.hex_uint8_t], cv.Length(min=13, max=14)), + } +) + +register_protocol_action( + "pioneer_wyt", + "PioneerWyt", + PIONEER_WYT_SCHEMA, + _bytes_field(CONF_CODE, f"data = {PioneerWytData}({{}})"), + automation.ApplyCall("data.finalize()"), +) + + # Pronto ( ProntoData, diff --git a/esphome/components/remote_base/pioneer_wyt_protocol.cpp b/esphome/components/remote_base/pioneer_wyt_protocol.cpp new file mode 100644 index 0000000000..0c7d6c7ec9 --- /dev/null +++ b/esphome/components/remote_base/pioneer_wyt_protocol.cpp @@ -0,0 +1,30 @@ +#include "pioneer_wyt_protocol.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.pioneer_wyt"); + +// The timings and frame header are shared with tcl112 +static constexpr uint32_t HEADER_MARK_US = 3100; +static constexpr uint32_t HEADER_SPACE_US = 1650; +static constexpr uint32_t BIT_MARK_US = 500; +static constexpr uint32_t BIT_ONE_SPACE_US = 1100; +static constexpr uint32_t BIT_ZERO_SPACE_US = 350; + +void PioneerWytProtocol::encode(RemoteTransmitData *dst, const PioneerWytData &data) { + char hex_buf[format_hex_pretty_size(PioneerWytData::SIZE)]; + ESP_LOGD(TAG, "Transmit PioneerWyt: %s", format_hex_pretty_to(hex_buf, data.data(), data.size())); + dst->set_carrier_frequency(38000); + // header mark and space, a mark and space per bit, then the trailing mark + dst->reserve(dst->get_data().size() + 3 + PioneerWytData::SIZE * 8 * 2); + dst->item(HEADER_MARK_US, HEADER_SPACE_US); + for (uint8_t i = 0; i < data.size(); i++) { + for (uint8_t b = 0; b < 8; b++) + dst->item(BIT_MARK_US, (data[i] & (1 << b)) ? BIT_ONE_SPACE_US : BIT_ZERO_SPACE_US); + } + dst->mark(BIT_MARK_US); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pioneer_wyt_protocol.h b/esphome/components/remote_base/pioneer_wyt_protocol.h new file mode 100644 index 0000000000..9946d4230b --- /dev/null +++ b/esphome/components/remote_base/pioneer_wyt_protocol.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +#include "esphome/core/helpers.h" +#include "remote_base.h" + +namespace esphome::remote_base { + +class PioneerWytData { + public: + static constexpr uint8_t SIZE = 14; + enum PioneerWytDataType : uint8_t { + PIONEER_WYT_TYPE_GENERAL = 0x01, + PIONEER_WYT_TYPE_FAN = 0x02, + }; + + // Make default + PioneerWytData() {} + // Make from initializer_list + PioneerWytData(std::initializer_list data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + // Make from vector + PioneerWytData(const std::vector &data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + PioneerWytData(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(); } + uint8_t size() const { return this->data_.size(); } + // FAN frames add 0x0F to the byte sum + void finalize() { + this->data_[OFFSET_CS] = + sum_bytes(this->data_.data(), OFFSET_CS, this->type() == PIONEER_WYT_TYPE_FAN ? 0x0F : 0x00); + } + PioneerWytDataType type() const { return static_cast(this->data_[3]); } + uint8_t &operator[](size_t idx) { return this->data_[idx]; } + const uint8_t &operator[](size_t idx) const { return this->data_[idx]; } + + protected: + static constexpr uint8_t OFFSET_CS = SIZE - 1; + std::array data_{}; +}; + +class PioneerWytProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const PioneerWytData &data); +}; + +} // namespace esphome::remote_base diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7c3c7d2861..4658438934 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -182,6 +182,7 @@ #define USE_REMOTE_PROTOCOL_ONKYORI #define USE_REMOTE_PROTOCOL_PANASONIC #define USE_REMOTE_PROTOCOL_PIONEER +#define USE_REMOTE_PROTOCOL_PIONEER_WYT #define USE_REMOTE_PROTOCOL_PRONTO #define USE_REMOTE_PROTOCOL_RAW #define USE_REMOTE_PROTOCOL_RC5 diff --git a/tests/component_tests/remote_transmitter/config/byte_payloads.yaml b/tests/component_tests/remote_transmitter/config/byte_payloads.yaml index 41eefdad7f..106ac129e2 100644 --- a/tests/component_tests/remote_transmitter/config/byte_payloads.yaml +++ b/tests/component_tests/remote_transmitter/config/byte_payloads.yaml @@ -31,3 +31,8 @@ button: on_press: - remote_transmitter.transmit_midea: code: !lambda return {0xA2, 0x08, 0xFF, 0xFF, 0xFF}; + - platform: template + name: Pioneer WYT lambda + on_press: + - remote_transmitter.transmit_pioneer_wyt: + code: !lambda return {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80}; diff --git a/tests/component_tests/remote_transmitter/test_byte_payloads.py b/tests/component_tests/remote_transmitter/test_byte_payloads.py index 5bdd60cae0..0cdde16d4f 100644 --- a/tests/component_tests/remote_transmitter/test_byte_payloads.py +++ b/tests/component_tests/remote_transmitter/test_byte_payloads.py @@ -11,7 +11,7 @@ def test_byte_payloads( component_config_path: Callable[[str], Path], ) -> None: main_cpp = generate_main(component_config_path("byte_payloads.yaml")) - abb, aeha, midea = protocol_call_bodies(main_cpp) + abb, aeha, midea, pioneer_wyt = protocol_call_bodies(main_cpp) # The address length goes first, a configured id turns the automatic one off assert abb.index("set_three_byte_address(true);") < abb.index("set_source_address(") assert abb.index("auto_message_id = true;") < abb.index( @@ -31,3 +31,9 @@ def test_byte_payloads( in midea ) assert midea.rstrip().endswith("apply_call.data.finalize();") + # A 13-byte code is copied into the frame and the checksum is appended by finalize() + assert ( + "apply_call.data = remote_base::PioneerWytData([]() -> std::vector {" + in pioneer_wyt + ) + assert pioneer_wyt.rstrip().endswith("apply_call.data.finalize();") diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 36060d079c..2fe959bb16 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -17,6 +17,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "nec", "magiquest", "rc5x", + "pioneer_wyt", "drayton", "panasonic", ): diff --git a/tests/components/remote_base/pioneer_wyt_test.cpp b/tests/components/remote_base/pioneer_wyt_test.cpp new file mode 100644 index 0000000000..3854356546 --- /dev/null +++ b/tests/components/remote_base/pioneer_wyt_test.cpp @@ -0,0 +1,60 @@ +#include + +#include +#include +#include "esphome/components/remote_base/pioneer_wyt_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +using Frame = std::array; + +// Factory remote capture: cool mode, 26°C, beeper on, display on; the last byte is the checksum +constexpr Frame COOL_26_CAPTURE = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80, 0xC1}; + +Frame bytes(const PioneerWytData &data) { + Frame out{}; + std::copy_n(data.data(), out.size(), out.begin()); + return out; +} + +} // namespace + +TEST(PioneerWytProtocolTest, RawCodeGetsTheCaptureChecksum) { + PioneerWytData data(std::vector(COOL_26_CAPTURE.begin(), COOL_26_CAPTURE.end() - 1)); + data.finalize(); + EXPECT_EQ(bytes(data), COOL_26_CAPTURE); +} + +TEST(PioneerWytProtocolTest, GivenChecksumIsRecomputed) { + PioneerWytData data(COOL_26_CAPTURE.data(), COOL_26_CAPTURE.size()); + data[PioneerWytData::SIZE - 1] = 0x00; + data.finalize(); + EXPECT_EQ(bytes(data), COOL_26_CAPTURE); +} + +// FAN frames add 0x0F to the byte sum +TEST(PioneerWytProtocolTest, FanFrameChecksumHasOffset) { + PioneerWytData data{0x23, 0xCB, 0x26, 0x02, 0x00, 0x40, 0xC0, 0x10, 0xC0, 0x00, 0x00, 0x00, 0x00}; + EXPECT_EQ(data.type(), PioneerWytData::PIONEER_WYT_TYPE_FAN); + data.finalize(); + EXPECT_EQ(data[PioneerWytData::SIZE - 1], 0xF5); +} + +TEST(PioneerWytProtocolTest, EncodesHeaderAndAllBits) { + RemoteTransmitData dst; + PioneerWytProtocol().encode(&dst, PioneerWytData(COOL_26_CAPTURE.data(), COOL_26_CAPTURE.size())); + // header mark and space, a mark and space per bit, then the trailing mark + const auto &raw = dst.get_data(); + ASSERT_EQ(raw.size(), 3u + PioneerWytData::SIZE * 8 * 2); + EXPECT_EQ(dst.get_carrier_frequency(), 38000u); + EXPECT_EQ(raw[0], 3100); + EXPECT_EQ(raw[1], -1650); + // 0x23 LSB first: bit 0 is one, bit 2 is zero + EXPECT_EQ(raw[3], -1100); + EXPECT_EQ(raw[7], -350); + EXPECT_EQ(raw.back(), 500); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 7fa8cd1132..dff4e8f9c5 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -127,6 +127,18 @@ button: rc_code_2: 0xA506 repeat: times: 2 + - platform: template + name: Pioneer WYT + on_press: + remote_transmitter.transmit_pioneer_wyt: + # Captured from the factory remote: cool mode, 26°C. The encoder appends the checksum. + code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80] + - platform: template + name: Pioneer WYT With Checksum + on_press: + remote_transmitter.transmit_pioneer_wyt: + # Same capture with its checksum byte included + code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80, 0xC1] - platform: template name: RC Switch Raw on_press: diff --git a/tests/unit_tests/components/remote_base/test_pioneer_wyt.py b/tests/unit_tests/components/remote_base/test_pioneer_wyt.py new file mode 100644 index 0000000000..fd9e5afe4a --- /dev/null +++ b/tests/unit_tests/components/remote_base/test_pioneer_wyt.py @@ -0,0 +1,56 @@ +"""Schema checks for the pioneer_wyt transmit action.""" + +import pytest + +from esphome.automation import ACTION_REGISTRY +import esphome.config_validation as cv +from esphome.core import Lambda + +# Factory remote capture: cool mode, 26°C, without the trailing checksum byte +RAW_CODE = [ + 0x23, + 0xCB, + 0x26, + 0x01, + 0x00, + 0x24, + 0x03, + 0x05, + 0x00, + 0x00, + 0x00, + 0x00, + 0x80, +] +RAW_CODE_CHECKSUM = 0xC1 + + +@pytest.fixture +def schema() -> cv.Schema: + return ACTION_REGISTRY["remote_transmitter.transmit_pioneer_wyt"].raw_schema + + +def test_code_checksum_matches_capture() -> None: + assert sum(RAW_CODE) & 0xFF == RAW_CODE_CHECKSUM + + +def test_code_accepts_with_and_without_checksum_byte(schema: cv.Schema) -> None: + with_checksum = [*RAW_CODE, RAW_CODE_CHECKSUM] + assert schema({"code": RAW_CODE})["code"] == RAW_CODE + assert schema({"code": with_checksum})["code"] == with_checksum + + +@pytest.mark.parametrize("code", [RAW_CODE[:-1], [*RAW_CODE, RAW_CODE_CHECKSUM, 0x00]]) +def test_code_rejects_other_lengths(schema: cv.Schema, code: list[int]) -> None: + with pytest.raises(cv.Invalid): + schema({"code": code}) + + +def test_code_accepts_lambda(schema: cv.Schema) -> None: + code = Lambda("return {0x23, 0xCB};") + assert isinstance(schema({"code": code})["code"], Lambda) + + +def test_code_is_required(schema: cv.Schema) -> None: + with pytest.raises(cv.Invalid): + schema({})