From 5bccdab01d8c16dcc139abc23e33d01d8336f0a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 17:29:11 -1000 Subject: [PATCH] [remote_base] Add helpers to send and read IR frame bytes bit by bit (#21167) --- esphome/components/remote_base/remote_base.h | 38 ++++++ .../remote_base/byte_items_test.cpp | 126 ++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 tests/components/remote_base/byte_items_test.cpp diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 783343c5a20..6417a0c806a 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -19,6 +19,19 @@ enum ToleranceMode : uint8_t { using RawTimings = std::vector; +enum class BitOrder : uint8_t { + BIT_ORDER_LSB_FIRST, + BIT_ORDER_MSB_FIRST, +}; + +/// A bit is bit_mark followed by one_space or zero_space. +struct BitTimings { + uint32_t bit_mark; + uint32_t one_space; + uint32_t zero_space; + BitOrder bit_order{BitOrder::BIT_ORDER_LSB_FIRST}; +}; + class RemoteTransmitData { public: void mark(uint32_t length) { this->data_.push_back(length); } @@ -28,6 +41,15 @@ class RemoteTransmitData { this->space(space); } void reserve(uint32_t len) { this->data_.reserve(len); } + /// One item per bit of each byte. + template void item_bytes(const uint8_t *bytes, size_t len) { + for (size_t i = 0; i < len; i++) { + for (uint8_t bit = 0; bit < 8; bit++) { + const uint8_t mask = T.bit_order == BitOrder::BIT_ORDER_MSB_FIRST ? (0x80 >> bit) : (1 << bit); + this->item(T.bit_mark, (bytes[i] & mask) ? T.one_space : T.zero_space); + } + } + } void set_carrier_frequency(uint32_t carrier_frequency) { this->carrier_frequency_ = carrier_frequency; } uint32_t get_carrier_frequency() const { return this->carrier_frequency_; } const RawTimings &get_data() const { return this->data_; } @@ -77,6 +99,22 @@ class RemoteReceiveData { bool expect_space(uint32_t length); bool expect_item(uint32_t mark, uint32_t space); bool expect_pulse_with_gap(uint32_t mark, uint32_t space); + /// False at the first item matching neither space, leaving it unread. + template bool expect_bytes(uint8_t *out, size_t len) { + for (size_t i = 0; i < len; i++) { + uint8_t byte = 0; + for (uint8_t bit = 0; bit < 8; bit++) { + const uint8_t mask = T.bit_order == BitOrder::BIT_ORDER_MSB_FIRST ? (0x80 >> bit) : (1 << bit); + if (this->expect_item(T.bit_mark, T.one_space)) { + byte |= mask; + } else if (!this->expect_item(T.bit_mark, T.zero_space)) { + return false; + } + } + out[i] = byte; + } + return true; + } void advance(uint32_t amount = 1) { this->index_ += amount; } void reset() { this->index_ = 0; } diff --git a/tests/components/remote_base/byte_items_test.cpp b/tests/components/remote_base/byte_items_test.cpp new file mode 100644 index 00000000000..b4f40638478 --- /dev/null +++ b/tests/components/remote_base/byte_items_test.cpp @@ -0,0 +1,126 @@ +#include +#include +#include "esphome/components/remote_base/remote_base.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr uint32_t BIT_MARK = 500; +constexpr uint32_t ONE_SPACE = 1500; +constexpr uint32_t ZERO_SPACE = 400; +constexpr BitTimings TIMINGS{.bit_mark = BIT_MARK, .one_space = ONE_SPACE, .zero_space = ZERO_SPACE}; +constexpr BitTimings MSB_TIMINGS{ + .bit_mark = BIT_MARK, .one_space = ONE_SPACE, .zero_space = ZERO_SPACE, .bit_order = BitOrder::BIT_ORDER_MSB_FIRST}; +constexpr std::array FRAME = {0xA5, 0x01, 0x80, 0xFF}; + +RemoteReceiveData receive(const RawTimings &timings) { + return RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE); +} + +RawTimings expected_items(uint8_t byte, bool msb_first) { + RawTimings out; + for (uint8_t bit = 0; bit < 8; bit++) { + const uint8_t mask = msb_first ? (0x80 >> bit) : (1 << bit); + out.push_back(BIT_MARK); + out.push_back(-static_cast((byte & mask) ? ONE_SPACE : ZERO_SPACE)); + } + return out; +} + +} // namespace + +TEST(RemoteBaseByteItemsTest, LsbItemSequence) { + RemoteTransmitData tx; + const uint8_t byte = 0x4B; + tx.item_bytes(&byte, 1); + EXPECT_EQ(tx.get_data(), expected_items(byte, false)); +} + +TEST(RemoteBaseByteItemsTest, MsbItemSequence) { + RemoteTransmitData tx; + const uint8_t byte = 0x4B; + tx.item_bytes(&byte, 1); + EXPECT_EQ(tx.get_data(), expected_items(byte, true)); +} + +TEST(RemoteBaseByteItemsTest, AppendsAfterExistingItems) { + RemoteTransmitData tx; + tx.item(9000, 4500); + tx.item_bytes(FRAME.data(), FRAME.size()); + tx.mark(BIT_MARK); + ASSERT_EQ(tx.get_data().size(), 2 + FRAME.size() * 16 + 1); + EXPECT_EQ(tx.get_data()[0], 9000); + EXPECT_EQ(tx.get_data()[1], -4500); + EXPECT_EQ(tx.get_data().back(), static_cast(BIT_MARK)); +} + +TEST(RemoteBaseByteItemsTest, LsbRoundTrip) { + RemoteTransmitData tx; + tx.item_bytes(FRAME.data(), FRAME.size()); + auto rx = receive(tx.get_data()); + std::array out{}; + ASSERT_TRUE(rx.expect_bytes(out.data(), out.size())); + EXPECT_EQ(out, FRAME); + EXPECT_FALSE(rx.is_valid()); +} + +TEST(RemoteBaseByteItemsTest, MsbRoundTrip) { + RemoteTransmitData tx; + tx.item_bytes(FRAME.data(), FRAME.size()); + auto rx = receive(tx.get_data()); + std::array out{}; + ASSERT_TRUE(rx.expect_bytes(out.data(), out.size())); + EXPECT_EQ(out, FRAME); +} + +TEST(RemoteBaseByteItemsTest, BitOrderMismatchReversesBits) { + RemoteTransmitData tx; + const uint8_t byte = 0x01; + tx.item_bytes(&byte, 1); + auto rx = receive(tx.get_data()); + uint8_t out = 0; + ASSERT_TRUE(rx.expect_bytes(&out, 1)); + EXPECT_EQ(out, 0x80); +} + +TEST(RemoteBaseByteItemsTest, FailsMidByteAndStopsThere) { + RemoteTransmitData tx; + tx.item_bytes(FRAME.data(), FRAME.size()); + RawTimings timings = tx.get_data(); + // Bit 3 of byte 1 gets a space that matches neither length + const size_t bad = (8 + 3) * 2 + 1; + timings[bad] = -3000; + auto rx = receive(timings); + std::array out = {0x11, 0x22, 0x33, 0x44}; + EXPECT_FALSE(rx.expect_bytes(out.data(), out.size())); + EXPECT_EQ(rx.get_index(), bad - 1); + // Byte 0 was read in full, the partial byte and the rest are untouched + EXPECT_EQ(out[0], FRAME[0]); + EXPECT_EQ(out[1], 0x22); + EXPECT_EQ(out[2], 0x33); +} + +TEST(RemoteBaseByteItemsTest, FailsWhenDataRunsOut) { + RemoteTransmitData tx; + tx.item_bytes(FRAME.data(), 1); + auto rx = receive(tx.get_data()); + std::array out{}; + EXPECT_FALSE(rx.expect_bytes(out.data(), out.size())); + EXPECT_EQ(out[0], FRAME[0]); +} + +TEST(RemoteBaseByteItemsTest, ZeroLength) { + RemoteTransmitData tx; + tx.item_bytes(nullptr, 0); + tx.item_bytes(nullptr, 0); + EXPECT_TRUE(tx.get_data().empty()); + + const RawTimings empty; + auto rx = receive(empty); + EXPECT_TRUE(rx.expect_bytes(nullptr, 0)); + EXPECT_TRUE(rx.expect_bytes(nullptr, 0)); + EXPECT_EQ(rx.get_index(), 0u); +} + +} // namespace esphome::remote_base::testing