Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
proto_frame.cpp
Go to the documentation of this file.
1/// @file proto_frame.cpp
2/// @brief IO-Homecontrol 2W frame container implementation.
3/// @ingroup hioc_protocol
4
5#include "proto_frame.h"
6
7#include "proto_constants.h"
8
9#include <cctype>
10#include <cstdio>
11#include <cstring>
12
13namespace esphome {
14namespace home_io_control {
15
16namespace {
17
18constexpr int HEX_ALPHA_OFFSET = 10;
19
20} // namespace
21
22static int hex_nibble(char ch) {
23 if (ch >= '0' && ch <= '9')
24 return ch - '0';
25 ch = static_cast<char>(std::toupper(static_cast<unsigned char>(ch)));
26 if (ch >= 'A' && ch <= 'F')
27 return HEX_ALPHA_OFFSET + (ch - 'A');
28 return -1;
29}
30
31bool hex_to_bytes(const std::string &hex, uint8_t *out, uint8_t len) {
32 if (out == nullptr)
33 return false;
34
35 memset(out, 0, len);
36 if (hex.length() != static_cast<size_t>(len) * 2)
37 return false;
38
39 for (uint8_t i = 0; i < len; i++) {
40 const int high = hex_nibble(hex[i * 2]);
41 const int low = hex_nibble(hex[(i * 2) + 1]);
42 if (high < 0 || low < 0)
43 return false;
44 out[i] = static_cast<uint8_t>((high << 4) | low);
45 }
46
47 return true;
48}
49
50std::string node_id_to_string(const uint8_t id[NODE_ID_SIZE]) {
51 char buf[NODE_ID_STRING_SIZE];
52 snprintf(buf, sizeof(buf), "%02X%02X%02X", id[0], id[1], id[2]);
53 return std::string(buf);
54}
55
56/// CRC-CCITT used by the IO-Homecontrol protocol for frame validation.
57/// Polynomial: 0x1021 (reversed 0x8408), initial value: 0x0000.
58/// Radio chips with native IO-Homecontrol framing compute this in hardware;
59/// drivers for other chips call this helper instead.
60uint16_t crc_ccitt(const uint8_t *data, uint8_t len) {
61 uint16_t crc = 0x0000;
62 for (uint8_t i = 0; i < len; i++) {
63 crc ^= data[i];
64 for (uint8_t j = 0; j < BITS_PER_BYTE; j++)
65 crc = ((crc & CRC_LSB_MASK) != 0) ? (crc >> 1) ^ CRC_POLYNOMIAL_REVERSED : crc >> 1;
66 }
67 return crc;
68}
69
70void init_frame(IoFrame &f, bool is_2w, bool start, bool end, bool low_power) {
71 f = IoFrame();
72 if (end)
73 f.ctrl0 |= CTRL0_END;
74 if (start)
75 f.ctrl0 |= CTRL0_START;
76 if (!is_2w)
78 if (low_power)
80}
81
82void set_dst(IoFrame &f, const uint8_t id[NODE_ID_SIZE]) { memcpy(f.dst, id, NODE_ID_SIZE); }
83void set_src(IoFrame &f, const uint8_t id[NODE_ID_SIZE]) { memcpy(f.src, id, NODE_ID_SIZE); }
84
85bool set_cmd(IoFrame &f, uint8_t cmd, const uint8_t *params, uint8_t params_len) {
86 if (params_len > FRAME_MAX_DATA_SIZE)
87 return false;
88 f.cmd = cmd;
89 f.data_len = params_len;
90 if (params != nullptr && params_len > 0)
91 memcpy(f.data, params, params_len);
92 uint8_t const total = FRAME_MIN_SIZE + f.data_len;
93 // Refuse to encode inconsistent frame metadata here so malformed commands never make it onto
94 // the radio path and later confuse the serializer or on-air retries. This is a declared-length
95 // guard: CTRL0's 5-bit length field cannot describe more than FRAME_MAX_DECLARED_SIZE bytes,
96 // regardless of any out-of-length trailer a frame might separately carry (see IoFrame::has_mac).
97 if (total > FRAME_MAX_DECLARED_SIZE)
98 return false;
99 f.ctrl0 = (f.ctrl0 & ~CTRL0_LENGTH_MASK) | ((total - 1) & CTRL0_LENGTH_MASK);
100 return true;
101}
102
103uint8_t frame_length(const IoFrame &f) { return (f.ctrl0 & CTRL0_LENGTH_MASK) + 1; }
104bool is_start(const IoFrame &f) { return (f.ctrl0 & CTRL0_START) != 0; }
105bool is_end(const IoFrame &f) { return (f.ctrl0 & CTRL0_END) != 0; }
106
107bool frame_carries_mac_trailer(uint8_t cmd) {
108 // Only CMD_ONEWAY_ADD_CONTROLLER's declared payload plus its 6-byte MAC overflows CTRL0's
109 // 5-bit length field (see that constant's Doxygen in proto_constants.h) — every other command
110 // this project models keeps its authenticator, if any, inside the declared length.
111 return cmd == CMD_ONEWAY_ADD_CONTROLLER;
112}
113
114uint8_t serialize(const IoFrame &f, uint8_t *buf, uint8_t buf_size) {
115 if (buf == nullptr)
116 return 0;
117 uint8_t const len = frame_length(f);
118 if (len < FRAME_MIN_SIZE || len > FRAME_MAX_DECLARED_SIZE)
119 return 0;
121 return 0;
122 // Keep the wire length derived from ctrl0 and the explicit payload length in lockstep. This
123 // catches partially initialized frames before they are transmitted.
124 if ((uint8_t) (FRAME_MIN_SIZE + f.data_len) != len)
125 return 0;
126 // Keep serialize() and parse() agreeing on which commands may carry a trailer at all, so this
127 // function can never emit a frame parse() would then reject. Without it a caller could set
128 // has_mac on any command and produce 6 trailing bytes no receiver would read back.
130 return 0;
131 // The MAC trailer (when present) rides after the declared length and is counted in the
132 // returned length, so a caller's CRC — computed over serialize()'s return value, not a
133 // separately recomputed frame_length() — covers it too.
134 uint8_t const total_len = f.has_mac ? (uint8_t) (len + HMAC_SIZE) : len;
135 if (buf_size < total_len)
136 return 0;
137 uint8_t offset = 0;
138 buf[offset++] = f.ctrl0;
139 buf[offset++] = f.ctrl1;
140 memcpy(&buf[offset], f.dst, NODE_ID_SIZE);
141 offset += NODE_ID_SIZE;
142 memcpy(&buf[offset], f.src, NODE_ID_SIZE);
143 offset += NODE_ID_SIZE;
144 buf[offset++] = f.cmd;
145 memcpy(&buf[offset], f.data, f.data_len);
146 offset += f.data_len;
147 if (f.has_mac) {
148 memcpy(&buf[offset], f.mac, HMAC_SIZE);
149 offset += HMAC_SIZE;
150 }
151 return offset;
152}
153
154bool parse(const uint8_t *buf, uint8_t buf_len, IoFrame &f) {
155 if (buf == nullptr)
156 return false;
157 if (buf_len < FRAME_MIN_SIZE)
158 return false;
159 f = IoFrame();
160 uint8_t offset = 0;
161 f.ctrl0 = buf[offset++];
162 f.ctrl1 = buf[offset++];
163 uint8_t const len = frame_length(f);
164 if (len < FRAME_MIN_SIZE || len > FRAME_MAX_DECLARED_SIZE)
165 return false;
166 // buf_len must be either exactly the CTRL0-declared length (the common case, no trailer), or
167 // that length plus the out-of-length MAC trailer (see IoFrame::has_mac) — any other length
168 // means buf isn't this frame at all, not a frame with an unusual trailer size. The wider shape
169 // is additionally gated on the command byte (frame_carries_mac_trailer()): without that gate,
170 // any ordinary frame arriving with 6 extra trailing bytes for whatever reason would also match
171 // this shape and be accepted as a fabricated MAC trailer, with only a ~1-in-65536 CRC check
172 // downstream to catch it. Reading buf[FRAME_CMD_OFFSET] here is safe — buf_len is already
173 // known to be >= FRAME_MIN_SIZE (== FRAME_CMD_OFFSET + 1) from the guard above.
174 if (buf_len == len) {
175 f.has_mac = false;
176 } else if (buf_len == (uint8_t) (len + HMAC_SIZE) && frame_carries_mac_trailer(buf[FRAME_CMD_OFFSET])) {
177 f.has_mac = true;
178 } else {
179 return false;
180 }
181 if (offset + NODE_ID_SIZE > buf_len)
182 return false;
183 memcpy(f.dst, &buf[offset], NODE_ID_SIZE);
184 offset += NODE_ID_SIZE;
185 if (offset + NODE_ID_SIZE > buf_len)
186 return false;
187 memcpy(f.src, &buf[offset], NODE_ID_SIZE);
188 offset += NODE_ID_SIZE;
189 if (offset >= buf_len)
190 return false;
191 f.cmd = buf[offset++];
192 // data_len is always derived from the declared length alone — the trailer is never part of
193 // data[], so FRAME_MIN_SIZE + data_len == frame_length(f) holds whether or not has_mac is set.
194 f.data_len = len - FRAME_MIN_SIZE;
196 return false;
197 if (offset + f.data_len > buf_len)
198 return false;
199 memcpy(f.data, &buf[offset], f.data_len);
200 offset += f.data_len;
201 if (f.has_mac)
202 memcpy(f.mac, &buf[offset], HMAC_SIZE);
203 return true;
204}
205
206} // namespace home_io_control
207} // namespace esphome
bool set_cmd(IoFrame &f, uint8_t cmd, const uint8_t *params, uint8_t params_len)
Set command and payload.
static constexpr uint8_t FRAME_MAX_DECLARED_SIZE
Largest frame length CTRL0's 5-bit length field (bits [4:0], length - 1) can express.
Definition proto_sizes.h:38
static constexpr uint8_t BITS_PER_BYTE
Number of bits in one protocol byte.
Definition proto_sizes.h:27
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
static constexpr uint8_t FRAME_MIN_SIZE
Minimum frame: CTRL0+CTRL1+DST(3)+SRC(3)+CMD(1).
Definition proto_sizes.h:29
static constexpr uint8_t CTRL0_END
Control byte 0 (CTRL0) bit definitions.
Definition proto_frame.h:41
static constexpr uint8_t FRAME_MAX_DATA_SIZE
Maximum data bytes after command ID (declared length - header).
Definition proto_sizes.h:46
bool is_start(const IoFrame &f)
Check START flag.
static constexpr uint8_t CTRL0_PROTOCOL_1W
Bit 5: 1=OneWay protocol, 0=TwoWay protocol.
Definition proto_frame.h:43
static constexpr uint8_t CTRL0_START
Bit 6: first frame in exchange.
Definition proto_frame.h:42
uint16_t crc_ccitt(const uint8_t *data, uint8_t len)
CRC-CCITT used by the IO-Homecontrol protocol for frame validation.
static constexpr uint8_t HMAC_SIZE
Authentication HMAC is 6 bytes (truncated AES output).
Definition proto_sizes.h:22
void init_frame(IoFrame &f, bool is_2w, bool start, bool end, bool low_power)
Initialize an IoFrame header (ctrl0/ctrl1) with flags.
uint8_t frame_length(const IoFrame &f)
Get total frame length from ctrl0.
bool frame_carries_mac_trailer(uint8_t cmd)
Whether wire frames for a command carry the out-of-length MAC trailer described on IoFrame::has_mac/I...
void set_dst(IoFrame &f, const uint8_t id[NODE_ID_SIZE])
Set destination node ID.
static constexpr uint8_t CMD_ONEWAY_ADD_CONTROLLER
1W "add controller" — a 1W device broadcasts this while its key-copy gesture is active,...
static constexpr uint8_t NODE_ID_STRING_SIZE
Uppercase hex node ID plus null terminator.
Definition proto_sizes.h:21
bool is_end(const IoFrame &f)
Check END flag.
static constexpr uint16_t CRC_LSB_MASK
Least-significant-bit mask for reflected CRC update.
bool parse(const uint8_t *buf, uint8_t buf_len, IoFrame &f)
Parse a wire buffer into a parsed IoFrame (validates length and CTRL0).
std::string node_id_to_string(const uint8_t id[NODE_ID_SIZE])
Format a 3‑byte node ID as a 6‑character uppercase hex string.
static constexpr uint8_t CTRL0_LENGTH_MASK
Bits [4:0]: frame length - 1.
Definition proto_frame.h:44
static int hex_nibble(char ch)
static constexpr uint8_t CTRL1_LOW_POWER
Bit 5: low-power device (e.g., solar-powered).
Definition proto_frame.h:66
static constexpr uint16_t CRC_POLYNOMIAL_REVERSED
Reversed CRC-CCITT polynomial used by IO-homecontrol.
bool hex_to_bytes(const std::string &hex, uint8_t *out, uint8_t len)
Convert a hex string (e.g., "123ABC") to a byte array.
uint8_t serialize(const IoFrame &f, uint8_t *buf, uint8_t buf_size)
Serialize a parsed frame into a wire buffer (without CRC).
void set_src(IoFrame &f, const uint8_t id[NODE_ID_SIZE])
Set source node ID.
static constexpr uint8_t FRAME_CMD_OFFSET
Byte offset of the command ID in a raw wire buffer.
Definition proto_sizes.h:47
IO-Homecontrol command IDs, result codes and protocol enumerations.
IO-Homecontrol 2W frame container: control bytes, IoFrame and (de)serialization.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:88
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:94
uint8_t mac[HMAC_SIZE]
Out-of-length authenticator trailer; meaningful only when has_mac.
Definition proto_frame.h:97
bool has_mac
True if this frame carries the mac trailer (see struct doc).
Definition proto_frame.h:98
uint8_t ctrl0
Control byte 0: flags + length.
Definition proto_frame.h:89
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:92
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:91
uint8_t data_len
Actual length of data.
Definition proto_frame.h:95
uint8_t ctrl1
Control byte 1: low power, beacon, etc.
Definition proto_frame.h:90