From a576092f2577bc2ffb362e8754b70cd6fda03c0b Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:52:01 +0200 Subject: [PATCH] [remote_base] Send the 72-bit Jarolift TDEF group frame with transmit_keeloq (#20927) Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 19 +++- .../remote_base/keeloq_protocol.cpp | 17 +++- .../components/remote_base/keeloq_protocol.h | 12 ++- .../config/keeloq_suffix.yaml | 8 ++ .../remote_transmitter/test_keeloq_action.py | 10 +- tests/components/remote_base/keeloq_test.cpp | 91 ++++++++++++++++++- .../remote_receiver/common-actions.yaml | 4 +- .../remote_transmitter/common-buttons.yaml | 11 +++ .../components/remote_base/test_keeloq.py | 19 ++++ 9 files changed, 181 insertions(+), 10 deletions(-) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index abb216aad7d..990d2b87e4d 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1044,6 +1044,21 @@ KEELOQ_SCHEMA = cv.Schema( ) +def _keeloq_group_alone(config: ConfigType) -> ConfigType: + # The group bits take the places of level and the suffix. + if CONF_GROUP not in config: + return config + if CONF_SUFFIX in config or CONF_SUFFIX_BITS in config: + raise cv.Invalid( + "group cannot be combined with suffix or suffix_bits", [CONF_GROUP] + ) + if config[CONF_LEVEL] is not False: + raise cv.Invalid( + "group sets the bit that level sends; remove level", [CONF_LEVEL] + ) + return config + + def _keeloq_suffix_fits(config: ConfigType) -> ConfigType: # A lambda is only known on the device. A plain value has to fit the width. suffix = config.get(CONF_SUFFIX, 0) @@ -1060,6 +1075,7 @@ KEELOQ_ACTION_SCHEMA = KEELOQ_SCHEMA.extend( # No defaults: an absent key leaves the data at 0 and emits nothing. cv.Optional(CONF_SUFFIX): cv.All(cv.hex_int, cv.Range(min=0, max=0xFFFF)), cv.Optional(CONF_SUFFIX_BITS): cv.int_range(min=0, max=16), + cv.Optional(CONF_GROUP): cv.hex_uint8_t, } ) @@ -1097,7 +1113,8 @@ register_protocol_action( (CONF_LEVEL, "vlow", cg.bool_), (CONF_SUFFIX, "suffix", cg.uint16), (CONF_SUFFIX_BITS, "suffix_bits", cg.uint8), - validate=_keeloq_suffix_fits, + automation.ApplyField(CONF_GROUP, "data.set_group", cg.uint8), + validate=cv.All(_keeloq_group_alone, _keeloq_suffix_fits), ) diff --git a/esphome/components/remote_base/keeloq_protocol.cpp b/esphome/components/remote_base/keeloq_protocol.cpp index fff8eb897f0..b91f70227e9 100644 --- a/esphome/components/remote_base/keeloq_protocol.cpp +++ b/esphome/components/remote_base/keeloq_protocol.cpp @@ -93,9 +93,14 @@ void KeeloqProtocol::encode(RemoteTransmitData *dst, const KeeloqData &data) { dst->space(1 * BIT_TIME_US); } - // repeat flag - always sent as a '1' - dst->mark(1 * BIT_TIME_US); - dst->space(2 * BIT_TIME_US); + // repeat flag + if (data.repeat) { + dst->mark(1 * BIT_TIME_US); + dst->space(2 * BIT_TIME_US); + } else { + dst->mark(2 * BIT_TIME_US); + dst->space(1 * BIT_TIME_US); + } const uint8_t extra = data.suffix_bits > NBITS_SUFFIX_MAX ? NBITS_SUFFIX_MAX : data.suffix_bits; for (uint8_t i = 0; i < extra; i++) { @@ -234,6 +239,12 @@ optional KeeloqProtocol::decode(RemoteReceiveData src) { } void KeeloqProtocol::dump(const KeeloqData &data) { + if (data.suffix_bits == 6) { + // Jarolift TDEF frame: show the group as transmit_keeloq takes it. + ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x, encrypted=0x%08" PRIx32 ", group=0x%02x", + data.address, data.command, data.encrypted, data.group()); + return; + } ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x, encrypted=0x%08" PRIx32 ", suffix=0x%04x/%u", data.address, data.command, data.encrypted, data.suffix, data.suffix_bits); } diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index e391212e42d..43675eccaa6 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -9,11 +9,21 @@ struct KeeloqData { uint32_t encrypted; // 32 bit encrypted field uint32_t address; // 28 bit serial number uint8_t command; // Button Status S2-S1-S0-S3 - bool repeat; // Repeated command bit + bool repeat{true}; // Repeated command bit bool vlow; // Battery status bit uint16_t suffix{0}; // Optional bits after the 66-bit HCS301 word uint8_t suffix_bits{0}; + // Jarolift TDEF sends 8 group bits after the 64-bit word where the HCS301 has vlow and repeat. + void set_group(uint8_t group) { + this->vlow = group & 0x01; + this->repeat = group & 0x02; + this->suffix = group >> 2; + this->suffix_bits = 6; + } + // Only a Jarolift TDEF frame (suffix_bits == 6) carries a group; check suffix_bits before using it. + uint8_t group() const { return static_cast((this->suffix << 2) | (this->repeat << 1) | this->vlow); } + bool operator==(const KeeloqData &rhs) const { // 0x10 matches any command. The suffix is not compared: the binary sensor does not take it. if (address != rhs.address) { diff --git a/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml index f14bc75f305..a24a50cf8b8 100644 --- a/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml +++ b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml @@ -31,3 +31,11 @@ button: code: 0xd19ef0a9 suffix: !lambda return 0x2A; suffix_bits: !lambda return 6; + - platform: template + name: Group frame + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x1234500 + command: 0x8 + code: 0xF94A09F2 + group: 0x0F diff --git a/tests/component_tests/remote_transmitter/test_keeloq_action.py b/tests/component_tests/remote_transmitter/test_keeloq_action.py index cbc1efb0a69..c5137da1b4f 100644 --- a/tests/component_tests/remote_transmitter/test_keeloq_action.py +++ b/tests/component_tests/remote_transmitter/test_keeloq_action.py @@ -12,12 +12,18 @@ def test_keeloq_suffix_is_set_only_when_configured( ) -> None: main_cpp = generate_main(component_config_path("keeloq_suffix.yaml")) bodies = protocol_call_bodies(main_cpp) - assert len(bodies) == 3 - plain, constant, lambda_ = bodies + assert len(bodies) == 4 + plain, constant, lambda_, group = bodies # The plain word fills only the HCS301 fields. assert "data.suffix" not in plain + assert "set_group" not in plain assert "apply_call.data.suffix = 0x2A;" in constant assert "apply_call.data.suffix_bits = 6;" in constant # A lambda is called inline. assert "apply_call.data.suffix = static_cast(0x2A);" in lambda_ assert "apply_call.data.suffix_bits = static_cast(6);" in lambda_ + # group fills vlow, repeat and the suffix on the device. + assert "apply_call.data.set_group(0x0F);" in group + # It comes after the default level, so the group's bit 0 is what is sent. + assert group.index("data.vlow") < group.index("set_group") + assert "data.suffix" not in group diff --git a/tests/components/remote_base/keeloq_test.cpp b/tests/components/remote_base/keeloq_test.cpp index e96d8b9d01d..c2303b23d35 100644 --- a/tests/components/remote_base/keeloq_test.cpp +++ b/tests/components/remote_base/keeloq_test.cpp @@ -1,4 +1,5 @@ #include +#include #include "esphome/components/remote_base/keeloq_protocol.h" namespace esphome::remote_base::testing { @@ -42,6 +43,40 @@ RawTimings as_received(RawTimings timings, bool drop_tail = false) { return merged; } +// Jarolift TDEF group UP for the channel mask 0x0F0F, from a published test vector. The mask's low byte is +// inside the hopping code, the high byte is the group sent after the 64-bit word. +KeeloqData tdef_frame(uint8_t group) { + KeeloqData data{}; + data.encrypted = 0xF94A09F2; + data.address = 0x1234500; + data.command = 0x8; + data.set_group(group); + return data; +} + +// The data bits after the 10-bit-time header: a 1-bit-time mark is a 1, a 2-bit-time mark a 0. +std::vector sent_bits(const RawTimings &timings) { + std::vector bits; + size_t i = 0; + while (i < timings.size() && timings[i] != -static_cast(10 * BIT_US)) { + i++; + } + for (i++; i < timings.size(); i++) { + if (timings[i] > 0) { + bits.push_back(timings[i] == static_cast(BIT_US)); + } + } + return bits; +} + +uint32_t field(const std::vector &bits, size_t first, size_t count) { + uint32_t value = 0; + for (size_t i = 0; i < count; i++) { + value |= static_cast(bits[first + i]) << i; + } + return value; +} + optional decode(const RawTimings &timings) { return KeeloqProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); } @@ -146,7 +181,7 @@ TEST(KeeloqProtocolTest, ASpaceThatIsNeitherDataNorAGapDropsTheSuffix) { } TEST(KeeloqProtocolTest, ASuffixFollowsARepeatBitOfZero) { - // The encoder always sends the repeat bit as 1. Turn it into a 0 bit, with and without a suffix. + // frame() sends the repeat bit as 1. Turn it into a 0 bit, with and without a suffix. for (uint8_t suffix_bits : {0, 8}) { RawTimings timings = as_received(encode(frame(suffix_bits > 0 ? 0xA5 : 0, suffix_bits))); const size_t repeat = timings.size() - 2 * suffix_bits - 2; @@ -174,4 +209,58 @@ TEST(KeeloqProtocolTest, ASuffixEndsAtAGap) { } } +TEST(KeeloqProtocolTest, HcsWordStillEndsWithRepeatOne) { + // 32 code bits, 28 address bits, 4 command bits, vlow, repeat. + std::vector bits = sent_bits(encode(frame(0, 0))); + ASSERT_EQ(bits.size(), 66u); + EXPECT_FALSE(bits[64]); + EXPECT_TRUE(bits[65]); +} + +TEST(KeeloqProtocolTest, TdefGroupFrameSendsTheGroupAfterThe64BitWord) { + std::vector bits = sent_bits(encode(tdef_frame(0x0F))); + ASSERT_EQ(bits.size(), 72u); + EXPECT_EQ(field(bits, 0, 32), 0xF94A09F2u); + EXPECT_EQ(field(bits, 32, 28), 0x1234500u); + EXPECT_EQ(field(bits, 60, 4), 0x8u); + EXPECT_EQ(field(bits, 64, 8), 0x0Fu); +} + +TEST(KeeloqProtocolTest, TdefChannelOneSendsNoChannelTenBit) { + // Channels 1 to 8 have no bit in the group. Bit 65 is the HCS301 repeat bit, which is always 1 + // without a group; a Jarolift receiver reads it as channel 10. + std::vector bits = sent_bits(encode(tdef_frame(0x00))); + ASSERT_EQ(bits.size(), 72u); + EXPECT_EQ(field(bits, 64, 8), 0x00u); +} + +TEST(KeeloqProtocolTest, TdefGroupRoundTrips) { + // 0x0F is channels 9 to 12, 0x80 is channel 16, the last bit of the frame. + for (uint8_t group : {0x0F, 0x80}) { + auto decoded = decode(as_received(encode(tdef_frame(group)))); + ASSERT_TRUE(decoded.has_value()) << int(group); + if (decoded.has_value()) { + EXPECT_EQ(decoded->address, 0x1234500u); + EXPECT_EQ(decoded->command, 0x8); + EXPECT_EQ(decoded->encrypted, 0xF94A09F2u); + EXPECT_EQ(decoded->suffix_bits, 6); + EXPECT_EQ(decoded->group(), group); + } + } +} + +TEST(KeeloqProtocolTest, AReceivedRepeatBitOfZeroIsSentAgainAsZero) { + KeeloqData received = frame(0, 0); + received.repeat = false; + std::vector bits = sent_bits(encode(received)); + ASSERT_EQ(bits.size(), 66u); + EXPECT_FALSE(bits[65]); + + auto decoded = decode(as_received(encode(received))); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_FALSE(decoded->repeat); + } +} + } // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 833de19ff4f..b37f838b811 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -71,8 +71,8 @@ on_jvc: on_keeloq: then: - logger.log: - format: "on_keeloq: %lu %lu %u %u %u" - args: ["long(x.encrypted)", "long(x.address)", "x.command", "x.suffix", "x.suffix_bits"] + format: "on_keeloq: %lu %lu %u %u %u %u" + args: ["long(x.encrypted)", "long(x.address)", "x.command", "x.suffix", "x.suffix_bits", "x.suffix_bits == 6 ? x.group() : 0"] on_haier: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index f9f1beceae9..cc3ff4d4e1a 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -385,6 +385,17 @@ button: code: 0xd19ef0a9 suffix: 0x00 suffix_bits: 8 + - platform: template + name: KeeLoq TDEF group + on_press: + remote_transmitter.transmit_keeloq: + address: 0x1234500 + command: 0x8 + code: 0xF94A09F2 + group: 0x0F + repeat: + times: 2 + wait_time: 0ms - platform: template name: KeeLoq lambda with repeat on_press: diff --git a/tests/unit_tests/components/remote_base/test_keeloq.py b/tests/unit_tests/components/remote_base/test_keeloq.py index bd0f817dd1f..57204d17d84 100644 --- a/tests/unit_tests/components/remote_base/test_keeloq.py +++ b/tests/unit_tests/components/remote_base/test_keeloq.py @@ -12,6 +12,7 @@ from esphome.components.remote_base import ( _keeloq_suffix_fits, ) import esphome.config_validation as cv +from esphome.const import CONF_GROUP from esphome.core import Lambda @@ -43,3 +44,21 @@ def test_transmit_action_checks_the_width() -> None: ) with pytest.raises(cv.Invalid, match="0x100 does not fit in 8 suffix_bits"): schema({**base, CONF_SUFFIX: 0x100}) + + +def test_group_takes_the_place_of_level_and_suffix() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_keeloq"].raw_schema + base = {"address": 0x1234500, "code": 0xF94A09F2, CONF_GROUP: 0x0F} + assert schema(base)[CONF_GROUP] == 0x0F + with pytest.raises(cv.Invalid, match="group cannot be combined with suffix"): + schema({**base, CONF_SUFFIX_BITS: 6}) + with pytest.raises(cv.Invalid, match="group cannot be combined with suffix"): + schema({**base, CONF_SUFFIX: 0}) + with pytest.raises(cv.Invalid, match="remove level"): + schema({**base, "level": True}) + + +def test_group_is_one_byte() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_keeloq"].raw_schema + with pytest.raises(cv.Invalid): + schema({"address": 0x1, "code": 0x2, CONF_GROUP: 0x100})