[remote_base] Send the 72-bit Jarolift TDEF group frame with transmit_keeloq (#20927)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Bascht74
2026-10-17 09:52:01 -10:00
committed by GitHub
co-authored by J. Nick Koston
parent 792d650fb2
commit a576092f25
9 changed files with 181 additions and 10 deletions
+18 -1
View File
@@ -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),
)
@@ -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<KeeloqData> 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);
}
@@ -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<uint8_t>((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) {
@@ -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
@@ -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<uint16_t>(0x2A);" in lambda_
assert "apply_call.data.suffix_bits = static_cast<uint8_t>(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
+90 -1
View File
@@ -1,4 +1,5 @@
#include <gtest/gtest.h>
#include <vector>
#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<bool> sent_bits(const RawTimings &timings) {
std::vector<bool> bits;
size_t i = 0;
while (i < timings.size() && timings[i] != -static_cast<int32_t>(10 * BIT_US)) {
i++;
}
for (i++; i < timings.size(); i++) {
if (timings[i] > 0) {
bits.push_back(timings[i] == static_cast<int32_t>(BIT_US));
}
}
return bits;
}
uint32_t field(const std::vector<bool> &bits, size_t first, size_t count) {
uint32_t value = 0;
for (size_t i = 0; i < count; i++) {
value |= static_cast<uint32_t>(bits[first + i]) << i;
}
return value;
}
optional<KeeloqData> 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<bool> 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<bool> 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<bool> 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<bool> 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
@@ -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:
@@ -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:
@@ -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})