[remote_base] Add a data option and checksum handling to the Drayton protocol (#13060)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
marshn
2026-10-11 21:54:55 -10:00
committed by GitHub
co-authored by J. Nick Koston
parent d265ed2b9e
commit 061b27af02
8 changed files with 273 additions and 92 deletions
+22 -8
View File
@@ -1501,22 +1501,34 @@ async def raw_action(var, config, args):
) = declare_protocol("Drayton")
DRAYTON_SCHEMA = cv.Schema(
{
cv.Required(CONF_ADDRESS): cv.All(cv.hex_int, cv.Range(min=0, max=0xFFFF)),
cv.Required(CONF_CHANNEL): cv.All(cv.hex_int, cv.Range(min=0, max=0x1F)),
cv.Required(CONF_COMMAND): cv.All(cv.hex_int, cv.Range(min=0, max=0x7F)),
cv.Required(CONF_ADDRESS): cv.hex_uint16_t,
cv.Optional(CONF_DATA): cv.hex_int_range(min=0, max=0xF),
cv.Optional(CONF_CHANNEL): cv.hex_int_range(min=0, max=0x1F),
cv.Optional(CONF_COMMAND): cv.hex_int_range(min=0, max=0x7F),
}
)
# The legacy command is the 4 data bits followed by this many checksum bits
DRAYTON_NBITS_CHECKSUM_IN_COMMAND = 3
# Either `data`, or the legacy `channel` + `command` pair (raw packet bits)
_validate_drayton_form = cv.All(
cv.has_none_or_all_keys(CONF_CHANNEL, CONF_COMMAND),
cv.has_exactly_one_key(CONF_DATA, CONF_COMMAND),
)
@register_binary_sensor("drayton", DraytonBinarySensor, DRAYTON_SCHEMA)
@register_binary_sensor(
"drayton", DraytonBinarySensor, cv.All(DRAYTON_SCHEMA, _validate_drayton_form)
)
def drayton_binary_sensor(var, config):
# Matching compares address and data only
if (command := config.get(CONF_COMMAND)) is not None:
data = command >> DRAYTON_NBITS_CHECKSUM_IN_COMMAND
else:
data = config[CONF_DATA]
cg.add(
var.set_data(
cg.StructInitializer(
DraytonData,
("address", config[CONF_ADDRESS]),
("channel", config[CONF_CHANNEL]),
("command", config[CONF_COMMAND]),
DraytonData, ("address", config[CONF_ADDRESS]), ("data", data)
)
)
)
@@ -1537,8 +1549,10 @@ register_protocol_action(
"Drayton",
DRAYTON_SCHEMA,
(CONF_ADDRESS, "address", cg.uint16),
(CONF_DATA, "data", cg.uint8),
(CONF_CHANNEL, "channel", cg.uint8),
(CONF_COMMAND, "command", cg.uint8),
validate=_validate_drayton_form,
)
@@ -12,13 +12,12 @@ static constexpr uint8_t CARRIER_KHZ = 2;
static constexpr uint8_t NBITS_PREAMBLE = 12;
static constexpr uint8_t NBITS_SYNC = 4;
static constexpr uint8_t NBITS_ADDRESS = 16;
static constexpr uint8_t NBITS_CHANNEL = 5;
static constexpr uint8_t NBITS_DATA = 4;
static constexpr uint8_t NBITS_CHECKSUM = 8;
static constexpr uint8_t NBITS_COMMAND = 7;
static constexpr uint8_t NDATABITS = NBITS_ADDRESS + NBITS_CHANNEL + NBITS_COMMAND;
static constexpr uint8_t MIN_RX_SRC = (NDATABITS + NBITS_SYNC / 2);
static constexpr uint8_t CMD_ON = 0x41;
static constexpr uint8_t CMD_OFF = 0x02;
static constexpr uint8_t NBITS_CHANNEL = 5;
static constexpr uint8_t NBITS_PKT = NBITS_ADDRESS + NBITS_DATA + NBITS_CHECKSUM;
static constexpr uint8_t MIN_RX_SRC = (NBITS_PKT + NBITS_SYNC / 2);
/*
Drayton Protocol
@@ -31,59 +30,72 @@ The number of these bits seen at the receiver varies depending on the state
of the bias when the packet transmission starts. The receiver algoritmn takes
account of this.
The packet appears to be Manchester encoded, with a '10' tranmitted pair
representing a '1' bit and a '01' pair representing a '0' bit. Each packet is
begun with a '1100' syncronisation symbol which breaks this rule. Following
The packet is Manchester coded, with a '10' tranmitted pair representing
a '1' bit and a '01' pair representing a '0' bit. Each packet is begun
with a '1100' syncronisation symbol which breaks this rule. Following
the sync are 28 '01' or '10' pairs.
--------------------
Boiler On Command as received:
Sample Boiler On Command as received:
101010101010110001101001010101101001010101010101100101010101101001011001
ppppppppppppSSSS-0-1-1-0-0-0-0-1-1-0-0-0-0-0-0-0-1-0-0-0-0-0-1-1-0-0-1-0
(Where pppp represents the preamble bits and SSSS represents the sync symbol)
28 bits of data received 01100001100000001000001 10010 (bin) or 6180832 (hex)
28 bits received 01100001100000001000001 10010 (bin) or 6180832 (hex)
Boiler Off Command as received:
Sample Boiler Off Command as received:
101010101010110001101001010101101001010101010101010101010110011001011001
ppppppppppppSSSS-0-1-1-0-0-0-0-1-1-0-0-0-0-0-0-0-0-0-0-0-0-1-0-1-0-0-1-0
28 bits of data received 0110000110000000000001010010 (bin) or 6180052 (hex)
28 bits received 0110000110000000000001010010 (bin) or 6180052 (hex)
--------------------
I have used 'RFLink' software (RLink Firmware Version: 1.1 Revision: 48) to
capture and retransmit the Digistat packets. RFLink splits each packet into an
ID, SWITCH, and CMD field.
The 28 bits consist of 20 bits of payload and 8 bits of checksum.
0;17;Drayton;ID=c300;SWITCH=12;CMD=ON;
20;18;Drayton;ID=c300;SWITCH=12;CMD=OFF;
In order of transmission the payload consists of the following fields:
--------------------
Bits Description
12 ID of the thermostat. A Digistat receiver will only respond to a packet if its ID
matches the ID that the receiver has previously learnt.
4 Pairing field, normally 0000. The first packets sent by the thermostat after its
batteries are inserted have this field set to 0001, which lets a Digistat receiver
in pairing mode learn the ID of the thermostat.
1 Thermostat on (1) or off (0).
1 Always 0.
1 Low battery (1) or battery OK (0).
1 Always 0.
Spliting my received data into three parts of 16, 7 and 5 bits gives address,
channel and Command values of:
The YAML/lambda 'address' is the first 16 bits as sent (the 12 bit ID followed by the
pairing field, so the pairing flag is the address LSB), and 'data' is the next 4 bits
(0x8 = on, 0x2 = low battery).
On 6180832 0110000110000000 1000001 10010
address: '0x6180' channel: '0x12' command: '0x41'
Checksum: see calc_cs(); the +1 when the sum is above 0x7F is empirical. The captured
packets above give 0x32 (On) and 0x52 (Off). Packets that fail it are logged at verbose level.
Off 6180052 0110000110000000 0000010 10010
address: '0x6180' channel: '0x12' command: '0x02'
These values are slightly different to those used by RFLink (the RFLink
ID/Adress value is rotated/manipulated), and I don't know who's interpretation
is correct. A larger data sample would help (I have only found five different
packet captures online) or definitive information from Drayton.
Splitting each packet in this way works well for me with esphome. Any
corrections or additional data samples would be gratefully received.
marshn
Legacy split (from RFLink): address (16), command (7: data + top 3 checksum bits), channel (5: low checksum bits).
When channel and command are set they are sent as is, with no checksum calculated.
*/
static uint8_t calc_cs(uint32_t out_data) {
uint16_t cs = 0;
uint32_t rev = reverse_bits(out_data);
for (uint8_t i = 4; i <= 20; i += 4) {
cs <<= 1;
cs += (rev >> i) & 0x0F;
}
if (cs > 0x7F) {
cs += 1;
}
// Sent LSB first
return reverse_bits(uint8_t(cs << 1));
}
void DraytonProtocol::encode(RemoteTransmitData *dst, const DraytonData &data) {
uint16_t khz = CARRIER_KHZ;
dst->set_carrier_frequency(khz * 1000);
@@ -108,17 +120,21 @@ void DraytonProtocol::encode(RemoteTransmitData *dst, const DraytonData &data) {
}
}
ESP_LOGD(TAG, "Send Drayton: address=%04x channel=%03x cmd=%02x", data.address, data.channel, data.command);
if (data.channel || data.command) {
out_data = data.address;
out_data <<= NBITS_COMMAND;
out_data |= data.command;
out_data <<= NBITS_CHANNEL;
out_data |= data.channel;
} else {
out_data = data.address << (NBITS_DATA + NBITS_CHECKSUM);
out_data |= (data.data & ((1u << NBITS_DATA) - 1)) << NBITS_CHECKSUM; // a lambda may exceed 4 bits
out_data |= calc_cs(out_data);
}
ESP_LOGD(TAG, "addr=%04x data=%x channel=%02x command=%02x", data.address, data.data, data.channel, data.command);
ESP_LOGV(TAG, "out_data %07" PRIx32, out_data);
out_data = data.address;
out_data <<= NBITS_COMMAND;
out_data |= data.command;
out_data <<= NBITS_CHANNEL;
out_data |= data.channel;
ESP_LOGV(TAG, "Send Drayton: out_data %08" PRIx32, out_data);
for (uint32_t mask = 1UL << (NDATABITS - 1); mask != 0; mask >>= 1) {
for (uint32_t mask = 1UL << (NBITS_PKT - 1); mask != 0; mask >>= 1) {
if (out_data & mask) {
dst->mark(BIT_TIME_US);
dst->space(BIT_TIME_US);
@@ -130,21 +146,9 @@ void DraytonProtocol::encode(RemoteTransmitData *dst, const DraytonData &data) {
}
optional<DraytonData> DraytonProtocol::decode(RemoteReceiveData src) {
DraytonData out{
.address = 0,
.channel = 0,
.command = 0,
};
DraytonData out{};
while (src.size() - src.get_index() >= MIN_RX_SRC) {
ESP_LOGVV(TAG,
"Decode Drayton: %" PRId32 ", %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32
" %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32
" %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 "",
src.size() - src.get_index(), src.peek(0), src.peek(1), src.peek(2), src.peek(3), src.peek(4),
src.peek(5), src.peek(6), src.peek(7), src.peek(8), src.peek(9), src.peek(10), src.peek(11), src.peek(12),
src.peek(13), src.peek(14), src.peek(15), src.peek(16), src.peek(17), src.peek(18), src.peek(19));
// If first preamble item is a space, skip it
if (src.peek_space_at_least(1)) {
src.advance(1);
@@ -152,12 +156,12 @@ optional<DraytonData> DraytonProtocol::decode(RemoteReceiveData src) {
// Look for sync pulse, after. If sucessful index points to space of sync symbol
while (src.size() - src.get_index() >= MIN_RX_SRC) {
ESP_LOGVV(TAG, "Decode Drayton: sync search %" PRIu32 ", %" PRId32 " %" PRId32, src.size() - src.get_index(),
src.peek(), src.peek(1));
ESP_LOGVV(TAG, "sync search %" PRIu32 ", %" PRId32 " %" PRId32, src.size() - src.get_index(), src.peek(),
src.peek(1));
if (src.peek_mark(2 * BIT_TIME_US) &&
(src.peek_space(2 * BIT_TIME_US, 1) || src.peek_space(3 * BIT_TIME_US, 1))) {
src.advance(1);
ESP_LOGVV(TAG, "Decode Drayton: Found SYNC, - %" PRIu32, src.get_index());
ESP_LOGVV(TAG, "Found SYNC %" PRIu32, src.get_index());
break;
} else {
src.advance(2);
@@ -165,8 +169,8 @@ optional<DraytonData> DraytonProtocol::decode(RemoteReceiveData src) {
}
// No point continuing if not enough samples remaining to complete a packet
if (src.size() - src.get_index() < NDATABITS) {
ESP_LOGV(TAG, "Decode Drayton: Fail 1, - %" PRIu32, src.get_index());
if (src.size() - src.get_index() < NBITS_PKT) {
ESP_LOGV(TAG, "Fail1 %" PRIu32, src.get_index());
break;
}
@@ -174,17 +178,14 @@ optional<DraytonData> DraytonProtocol::decode(RemoteReceiveData src) {
// Extract first bit
// Checks next bit to leave index pointing correctly
uint32_t out_data = 0;
uint8_t bit = NDATABITS - 1;
ESP_LOGVV(TAG, "Decode Drayton: first bit %" PRId32 " %" PRId32 ", %" PRId32, src.peek(0), src.peek(1),
src.peek(2));
uint8_t bit = NBITS_PKT - 1;
if (src.expect_space(3 * BIT_TIME_US) && (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) {
out_data |= 0 << bit;
} else if (src.expect_space(2 * BIT_TIME_US) && src.expect_mark(BIT_TIME_US) &&
(src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) {
out_data |= 1 << bit;
} else {
ESP_LOGV(TAG, "Decode Drayton: Fail 2, - %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0),
src.peek(1));
ESP_LOGVV(TAG, "Fail2 %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), src.peek(1));
continue;
}
@@ -192,7 +193,7 @@ optional<DraytonData> DraytonProtocol::decode(RemoteReceiveData src) {
// if there is no transition at the start of the bit period, then the transition in the middle of
// the previous bit period.
while (--bit >= 1) {
ESP_LOGVV(TAG, "Decode Drayton: Data, %2d %08" PRIx32, bit, out_data);
ESP_LOGVV(TAG, "Data %2d %08" PRIx32, bit, out_data);
if ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) &&
(src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) {
out_data |= 0 << bit;
@@ -205,8 +206,7 @@ optional<DraytonData> DraytonProtocol::decode(RemoteReceiveData src) {
}
if (bit > 0) {
ESP_LOGVV(TAG, "Decode Drayton: Fail 3, %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0),
src.peek(1));
ESP_LOGVV(TAG, "Fail3 %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), src.peek(1));
continue;
}
@@ -218,21 +218,28 @@ optional<DraytonData> DraytonProtocol::decode(RemoteReceiveData src) {
continue;
}
ESP_LOGV(TAG, "Decode Drayton: Data, %2d %08" PRIx32, bit, out_data);
uint8_t cs = out_data & 0xFF;
uint8_t expected_cs = calc_cs(out_data);
out.channel = (uint8_t) (out_data & 0x1F);
out_data >>= NBITS_CHANNEL;
out.command = (uint8_t) (out_data & 0x7F);
out_data >>= NBITS_COMMAND;
out.address = (uint16_t) (out_data & 0xFFFF);
if (cs != expected_cs) {
ESP_LOGV(TAG, "Dropped %07" PRIx32 ": checksum 0x%02x, expected 0x%02x", out_data, cs, expected_cs);
continue;
}
ESP_LOGV(TAG, "Data %07" PRIx32, out_data);
out.data = (out_data >> NBITS_CHECKSUM) & ((1u << NBITS_DATA) - 1);
out.address = out_data >> (NBITS_CHECKSUM + NBITS_DATA);
out.channel = out_data & ((1u << NBITS_CHANNEL) - 1);
out.command = (out_data >> NBITS_CHANNEL) & ((1u << NBITS_COMMAND) - 1);
return out;
}
return {};
}
void DraytonProtocol::dump(const DraytonData &data) {
ESP_LOGI(TAG, "Received Drayton: address=0x%04X (0x%04x), channel=0x%03x command=0x%03X", data.address,
((data.address << 1) & 0xffff), data.channel, data.command);
ESP_LOGI(TAG, "Received Drayton: address=0x%04X, data=0x%X", data.address, data.data);
}
} // namespace esphome::remote_base
@@ -11,10 +11,10 @@ struct DraytonData {
uint16_t address;
uint8_t channel;
uint8_t command;
uint8_t data; // last, so positional initializers written for the legacy layout keep their meaning
bool operator==(const DraytonData &rhs) const {
return address == rhs.address && channel == rhs.channel && command == rhs.command;
}
// channel/command: legacy split of the same bits, filled on decode, ignored by operator==
bool operator==(const DraytonData &rhs) const { return address == rhs.address && data == rhs.data; }
};
class DraytonProtocol : public RemoteProtocol<DraytonData> {
+1
View File
@@ -17,6 +17,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
"nec",
"magiquest",
"rc5x",
"drayton",
"panasonic",
):
request_protocol(protocol)
@@ -0,0 +1,141 @@
#include <gtest/gtest.h>
#include <string>
#include "esphome/components/remote_base/drayton_protocol.h"
namespace esphome::remote_base::testing {
namespace {
constexpr int32_t BIT_US = 500;
constexpr size_t PACKET_BITS = 28;
// Captured packets from the header comment of drayton_protocol.cpp
constexpr uint32_t ON_PACKET = 0x6180832;
constexpr uint32_t OFF_PACKET = 0x6180052;
const char *const ON_CAPTURE = "101010101010110001101001010101101001010101010101100101010101101001011001";
const char *const OFF_CAPTURE = "101010101010110001101001010101101001010101010101010101010110011001011001";
DraytonData with_data(uint16_t address, uint8_t data) {
DraytonData out{};
out.address = address;
out.data = data;
return out;
}
DraytonData with_legacy(uint16_t address, uint8_t channel, uint8_t command) {
DraytonData out{};
out.address = address;
out.channel = channel;
out.command = command;
return out;
}
RawTimings encode(const DraytonData &data) {
RemoteTransmitData tx;
DraytonProtocol().encode(&tx, data);
return tx.get_data();
}
// The packet bits sent after the preamble and sync, one Manchester pair per bit ('10' = 1)
uint32_t sent_packet(const RawTimings &timings) {
uint32_t word = 0;
for (size_t i = timings.size() - 2 * PACKET_BITS; i < timings.size(); i += 2) {
word = (word << 1) | (timings[i] > 0 ? 1 : 0);
}
return word;
}
// A receiver reports runs of the same level as one item.
RawTimings as_received(const RawTimings &timings) {
RawTimings merged;
for (int32_t item : timings) {
if (!merged.empty() && (merged.back() < 0) == (item < 0)) {
merged.back() += item;
} else {
merged.push_back(item);
}
}
return merged;
}
RawTimings from_capture(const std::string &bits) {
RawTimings timings;
for (char bit : bits) {
timings.push_back(bit == '1' ? BIT_US : -BIT_US);
}
return as_received(timings);
}
optional<DraytonData> decode(const RawTimings &timings) {
return DraytonProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE));
}
} // namespace
TEST(DraytonProtocolTest, EncodeAppendsChecksum) {
EXPECT_EQ(sent_packet(encode(with_data(0x6180, 0x8))), ON_PACKET);
EXPECT_EQ(sent_packet(encode(with_data(0x6180, 0x0))), OFF_PACKET);
// A lambda value above 4 bits is masked instead of spilling into the address
EXPECT_EQ(sent_packet(encode(with_data(0x6180, 0x18))), ON_PACKET);
}
TEST(DraytonProtocolTest, LegacyEncodeSendsChannelAndCommandAsIs) {
EXPECT_EQ(sent_packet(encode(with_legacy(0x6180, 0x12, 0x41))), ON_PACKET);
EXPECT_EQ(sent_packet(encode(with_legacy(0x1234, 0x01, 0x05))), 0x12340A1u);
}
TEST(DraytonProtocolTest, DecodesCapturedPackets) {
auto on = decode(from_capture(ON_CAPTURE));
ASSERT_TRUE(on.has_value());
if (!on.has_value()) {
return;
}
EXPECT_EQ(on->address, 0x6180);
EXPECT_EQ(on->data, 0x8);
EXPECT_EQ(on->channel, 0x12);
EXPECT_EQ(on->command, 0x41);
auto off = decode(from_capture(OFF_CAPTURE));
ASSERT_TRUE(off.has_value());
if (!off.has_value()) {
return;
}
EXPECT_EQ(off->address, 0x6180);
EXPECT_EQ(off->data, 0x0);
EXPECT_EQ(off->channel, 0x12);
EXPECT_EQ(off->command, 0x02);
}
TEST(DraytonProtocolTest, RoundTrips) {
for (uint16_t address : {0x0000, 0x6180, 0x1231, 0xFFFF}) {
for (uint8_t data : {0x0, 0x2, 0x8, 0xA, 0xF}) {
auto decoded = decode(as_received(encode(with_data(address, data))));
ASSERT_TRUE(decoded.has_value()) << std::hex << address << " " << int(data);
if (!decoded.has_value()) {
return;
}
EXPECT_EQ(decoded->address, address);
EXPECT_EQ(decoded->data, data);
}
}
}
TEST(DraytonProtocolTest, RejectsBadChecksum) {
// On packet with the low checksum bits changed from 0x12 to 0x13
EXPECT_FALSE(decode(as_received(encode(with_legacy(0x6180, 0x13, 0x41)))).has_value());
// On packet with the data bits of the Off packet
EXPECT_FALSE(decode(as_received(encode(with_legacy(0x6180, 0x12, 0x01)))).has_value());
}
TEST(DraytonProtocolTest, BinarySensorFormsMatchDecodedPacket) {
auto on = decode(from_capture(ON_CAPTURE));
ASSERT_TRUE(on.has_value());
if (!on.has_value()) {
return;
}
// The decoded channel and command are ignored
EXPECT_TRUE(*on == with_data(0x6180, 0x8));
EXPECT_FALSE(*on == with_data(0x6181, 0x8));
}
} // namespace esphome::remote_base::testing
@@ -51,8 +51,8 @@ on_dooya:
on_drayton:
then:
- logger.log:
format: "on_drayton: %u %u %u"
args: ["x.address", "x.channel", "x.command"]
format: "on_drayton: %u %u %u %u"
args: ["x.address", "x.channel", "x.command", "x.data"]
on_dyson:
then:
- logger.log:
@@ -21,6 +21,17 @@ binary_sensor:
address: 0x00
command: 0x0D
extension: 0x03
- platform: remote_receiver
name: Drayton Thermostat On
drayton:
address: 0x6180
data: 0x8
- platform: remote_receiver
name: Drayton Thermostat On Legacy
drayton:
address: 0x6180
channel: 0x12
command: 0x41
- platform: remote_receiver
name: Panasonic 56 bit Remote Input
panasonic:
@@ -27,6 +27,13 @@ button:
on_press:
remote_transmitter.transmit_brennenstuhl:
code: 0xBD2E2C
- platform: template
name: Drayton Thermostat On
id: drayton_thermostat_on
on_press:
remote_transmitter.transmit_drayton:
address: 0x1230
data: 0x8
- platform: template
name: Dyson fan up
id: dyson_fan_up