Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
radio_soft_phy.cpp
Go to the documentation of this file.
1/// @file radio_soft_phy.cpp
2/// @brief Software PHY implementation for radios without IoHomeOn hardware framing.
3/// @ingroup hioc_radio
4
5// Line-coding widths and recovery thresholds are written in the same shape as the on-air
6// framing they reproduce.
7// NOLINTBEGIN(cppcoreguidelines-avoid-magic-numbers,readability-magic-numbers)
8
9#include "radio_soft_phy.h"
10
11#include "proto_constants.h"
12
13#include <algorithm>
14#include <cstring>
15#include <utility>
16
17namespace esphome {
18namespace home_io_control {
19
20// UART_PROBE_MAX_BIT_OFFSET (the number of leading bit alignments the probe sweeps) is defined in
21// radio_soft_phy.h so the buffer-sizing static_asserts in radio_soft_phy_driver_base.h can see it.
22
23namespace {
24
25/// Extract a single bit (MSB‑first) from a byte buffer.
26/// Used by UART decoding to scan raw radio samples.
27/// @param data Input byte buffer.
28/// @param bit_pos Global bit index within buffer.
29/// @return The bit value (0 or 1).
30uint8_t get_bit_msb(const uint8_t *data, uint16_t bit_pos) { return (data[bit_pos / 8] >> (7 - (bit_pos % 8))) & 0x01; }
31
32} // namespace
33
34/// Decode a raw UART‑encoded bitstream into bytes.
35/// IO‑Homecontrol uses a UART‑like encoding over the air: each byte is represented
36/// by a 10‑bit sequence (start bit 0, 8 data bits LSB‑first, stop bit 1). This
37/// function slides a window across the raw bitstream and attempts to recover the
38/// original bytes. It stops when the sync pattern (0 followed by 1) is not found.
39/// @param raw Raw bytes from the radio buffer.
40/// @param raw_len Number of raw bytes available.
41/// @param bit_offset Initial bit position to start decoding (probe offset).
42/// @param decoded Output buffer for decoded bytes.
43/// @param decoded_max_len Capacity of decoded buffer.
44/// @return Number of bytes successfully decoded.
45uint8_t decode_uart_probe(const uint8_t *raw, uint8_t raw_len, uint8_t bit_offset, uint8_t *decoded,
46 uint8_t decoded_max_len) {
47 // Bit numbering: we read MSB-first across byte boundaries. The UART frame structure
48 // within the bitstream is: start(0), data0, data1, ..., data7, stop(1). Byte values
49 // are LSB-first within the 8 data bits (bit 0 arrives first after start).
50 // We verify the start bit is 0 and stop bit is 1; if not, the probe offset is wrong.
51 uint16_t bit_pos = bit_offset;
52 uint16_t const total_bits = raw_len * 8;
53 uint8_t decoded_len = 0;
54
55 while (bit_pos + 10 <= total_bits && decoded_len < decoded_max_len) {
56 if (get_bit_msb(raw, bit_pos) != 0 || get_bit_msb(raw, bit_pos + 9) != 1)
57 break;
58
59 uint8_t value = 0;
60 for (uint8_t index = 0; index < 8; index++)
61 value |= get_bit_msb(raw, bit_pos + 1 + index) << index;
62
63 decoded[decoded_len++] = value;
64 bit_pos += 10;
65 }
66
67 return decoded_len;
68}
69
70/// This list missing CMD_GET_GENERAL_INFO3_RESP (0x59) is exactly what turned a real Q2 probe
71/// reply into a false "no reply" timeout on real hardware (2026-08-16); the same audit also found
72/// CMD_IDENTIFY and CMD_WRITE_PRIVATE/CMD_WRITE_PRIVATE_ACK missing, unrelated to that probe but
73/// affecting the already-shipped identify_device() action (and climate writes) on SX1262/LR1121.
74/// Add every new opcode this codebase sends a request for, or expects a reply to, here as well as
75/// in proto_constants.h. Deliberately
76/// excludes CMD_TRANSFER_OPEN (0x46) and CMD_UNKNOWN4A_REQ (0x4A): this codebase only sends them, and
77/// nothing it sends draws one back, so there is no exchange for a received one to unblock. Their
78/// replies CMD_TRANSFER_OPEN_RESP (0x47) and CMD_UNKNOWN4A_RESP (0x4B) are included (the `read_object`
79/// probe, ADR 0062; 0x4B also answers CMD_GET_GENERAL_INFO3 on some devices). Declared in
80/// radio_soft_phy.h so tests can enumerate every accepted command directly.
81bool is_known_io_command(uint8_t cmd) {
82 switch (cmd) {
83 case CMD_EXECUTE:
84 case CMD_PRIVATE:
85 case CMD_PRIVATE_RESP:
86 case CMD_PRIVATE2:
87 case CMD_PRIVATE2_RESP:
88 case CMD_IDENTIFY:
89 case CMD_WRITE_PRIVATE:
90 case CMD_WRITE_PRIVATE_ACK:
91 case CMD_LIMITATION_STATUS_REQ:
92 case CMD_LIMITATION_STATUS_RESP:
93 case CMD_DISCOVER_REQ:
94 case CMD_DISCOVER_RESP:
95 case CMD_DISCOVER_SPE_REQ:
96 case CMD_DISCOVER_SPE_RESP:
97 case CMD_DISCOVER_CONFIRM:
98 case CMD_DISCOVER_CONFIRM_ACK:
99 // A foreign controller adding the key-extraction responder as a product follows its 0x36/0x37
100 // with an addressed 0x2E (issue #162, a Somfy Nina io), and a pull answers with these replies.
101 // Dropping them here turns a CRC-valid frame into an unreadable raw-byte parse_fail line.
102 case CMD_DISCOVER_ALT_REQ:
103 case CMD_DISCOVER_ALT_RESP:
104 case CMD_KEY_INIT:
105 case CMD_KEY_TRANSFER:
106 case CMD_KEY_CONFIRM:
107 case CMD_LAUNCH_KEY_TRANSFER:
108 case CMD_NODE_VERIFY_REQ:
109 case CMD_NODE_VERIFY_RESP:
110 case CMD_CHALLENGE_REQ:
111 case CMD_CHALLENGE_RESP:
112 case CMD_TRANSFER_OPEN_RESP:
113 case CMD_UNKNOWN4A_RESP:
114 case CMD_GET_NAME:
115 case CMD_GET_NAME_RESP:
116 case CMD_SET_NAME:
117 case CMD_SET_NAME_RESP:
118 case CMD_GET_INFO1:
119 case CMD_GET_INFO1_RESP:
120 case CMD_GET_INFO2:
121 case CMD_GET_INFO2_RESP:
122 case CMD_GET_GENERAL_INFO3:
123 case CMD_GET_GENERAL_INFO3_RESP:
124 case CMD_SET_CONFIG1:
125 case CMD_SET_CONFIG1_RESP:
126 case CMD_SUBSCRIBE_EVENT:
127 case CMD_STATUS_UPDATE:
128 case CMD_STATUS_UPDATE_RESP:
129 case CMD_ERROR_RESP:
130 return true;
131 default:
132 return false;
133 }
134}
135
136namespace {
137
138/// @brief Check if a UART-decoded frame is plausible as an IO-Homecontrol packet.
139/// Accepts frames at or above FRAME_MIN_SIZE (9 bytes) that contain a known command
140/// or have the 1W protocol bit set. This allows short frames like CMD_KEY_CONFIRM (9 bytes)
141/// and CMD_ERROR_RESP (10 bytes) to pass through when CRC validates.
142/// @param frame Parsed IoFrame candidate.
143/// @param candidate_len Total decoded length of the candidate.
144/// @return true if the frame looks like a real protocol packet.
145bool is_plausible_uart_frame(const IoFrame &frame, uint8_t candidate_len) {
146 if (candidate_len < FRAME_MIN_SIZE)
147 return false;
148 if (is_known_io_command(frame.cmd))
149 return true;
150 return (frame.ctrl0 & CTRL0_PROTOCOL_1W) != 0;
151}
152
153} // namespace
154
155/// @brief Try to find a CRC-valid IO-Homecontrol frame within a decoded UART byte stream.
156/// @param decoded Decoded byte buffer from UART probe.
157/// @param decoded_len Number of decoded bytes.
158/// @return Frame start index and length if found, or {0, 0} if no valid frame.
159static std::pair<uint8_t, uint8_t> find_crc_valid_frame(const uint8_t *decoded, uint8_t decoded_len) {
160 for (uint8_t start = 0; start < decoded_len; start++) {
161 // FRAME_MAX_WIRE_SIZE (declared + trailer + CRC) rather than FRAME_MAX_SIZE, so a MAC-bearing
162 // 1W frame's longer non-CRC length (see IoFrame::has_mac) is reachable by this downward
163 // search. Trying the extra candidate lengths above the old declared-only bound does not add
164 // false-match surface for other frame types: parse() only accepts a declared_len +
165 // HMAC_SIZE-shaped buffer for a command that actually carries a trailer
166 // (frame_carries_mac_trailer(), proto_frame.{h,cpp}) — for every other command those extra
167 // lengths are rejected by parse() itself, before this loop ever reaches the CRC comparison,
168 // so there is no length here that could produce a spurious CRC match against a fabricated
169 // 6-byte MAC for an existing non-trailer frame type.
170 const uint8_t max_candidate_len = std::min<uint8_t>(decoded_len - start, FRAME_MAX_WIRE_SIZE);
171 for (uint8_t candidate_len = max_candidate_len; candidate_len >= FRAME_MIN_SIZE; candidate_len--) {
172 IoFrame frame;
173 if (!parse(decoded + start, candidate_len, frame))
174 continue;
175 if (!is_plausible_uart_frame(frame, candidate_len))
176 continue;
177 if (start + candidate_len + 2 > decoded_len)
178 continue;
179 const uint16_t computed_crc = crc_ccitt(decoded + start, candidate_len);
180 const uint16_t received_crc =
181 (uint16_t) decoded[start + candidate_len] | ((uint16_t) decoded[start + candidate_len + 1] << 8);
182 if (computed_crc != received_crc)
183 continue;
184 return {start, candidate_len};
185 }
186 }
187 return {0, 0};
188}
189
190UartProbeResult find_uart_probe(const uint8_t *raw, uint8_t raw_len) {
191 // The raw RX buffer contains the demodulated bits packed as bytes. Due to unknown bit
192 // alignment, we probe up to UART_PROBE_MAX_BIT_OFFSET (10) different starting positions.
193 // For each offset we attempt UART decoding; if decoding yields a plausible frame length
194 // (>= minimum) and contains a known command ID or indicates a 1W frame, we keep it as a
195 // candidate. CRC-CCITT validation is used as the primary selection criterion: a frame that
196 // passes CRC is preferred over one that merely parses. This rejects frames corrupted by
197 // demodulator bit errors after TX→RX transitions.
198 UartProbeResult best{};
199
200 for (uint8_t bit_offset = 0; bit_offset < UART_PROBE_MAX_BIT_OFFSET; bit_offset++) {
201 uint8_t decoded[RADIO_PACKET_BUFFER_SIZE] = {0};
202 uint8_t const decoded_len = decode_uart_probe(raw, raw_len, bit_offset, decoded, sizeof(decoded));
203 if (decoded_len == 0)
204 continue;
205
206 if (decoded_len > best.decoded_len && !best.valid) {
207 best.bit_offset = bit_offset;
208 best.decoded_len = decoded_len;
209 memcpy(best.decoded, decoded, decoded_len);
210 }
211
212 auto [frame_start, frame_len] = find_crc_valid_frame(decoded, decoded_len);
213 if (frame_len > 0) {
214 best.valid = true;
215 best.bit_offset = bit_offset;
216 best.decoded_len = decoded_len;
217 best.frame_start = frame_start;
218 best.frame_len = frame_len;
219 memcpy(best.decoded, decoded, decoded_len);
220 return best;
221 }
222 }
223
224 return best;
225}
226
227// === Length-driven receive helpers ===
228
229uint8_t soft_phy_raw_bytes_for_frame(uint8_t frame_len) {
230 // The CRC is appended before UART packing (see SoftPhyDriverBase::send_packet), so it occupies
231 // two cells of its own.
232 const uint16_t cells = (uint16_t) frame_len + FRAME_CRC_SIZE;
233 const uint16_t bits = cells * UART_CELL_BITS;
234 return (uint8_t) ((bits + 7) / 8);
235}
236
237uint32_t io868_tx_air_time_us(uint16_t preamble_len, uint8_t frame_len) {
238 return soft_phy_air_time_us((uint32_t) preamble_len + IO868_SYNC_WORD_BYTES +
240}
241
242uint8_t soft_phy_peek_frame_length(const uint8_t *raw, uint8_t raw_len) {
243 uint8_t best = 0;
244 for (uint8_t bit_offset = 0; bit_offset < UART_PROBE_MAX_BIT_OFFSET; bit_offset++) {
245 uint8_t ctrl0 = 0;
246 if (decode_uart_probe(raw, raw_len, bit_offset, &ctrl0, 1) != 1)
247 continue; // start/stop bits don't frame here — wrong alignment
248 const auto frame_len = (uint8_t) ((ctrl0 & CTRL0_LENGTH_MASK) + 1);
249 if (frame_len < FRAME_MIN_SIZE || frame_len > FRAME_MAX_SIZE)
250 continue;
251 best = std::max(frame_len, best);
252 }
253 return best;
254}
255
256// === Software UART encode (TX) ===
257
258uint8_t uart_encode_packet(const uint8_t *data, uint8_t len, uint8_t *encoded, uint8_t encoded_max_len) {
259 if (len == 0 || encoded_max_len == 0)
260 return 0;
261
262 memset(encoded, 0, encoded_max_len);
263 uint16_t bit_pos = 0;
264 const uint16_t total_bits = len * 10;
265 if (((total_bits + 7) / 8) > encoded_max_len)
266 return 0;
267
268 auto write_bit = [encoded](uint16_t pos, uint8_t bit) {
269 if (bit != 0)
270 encoded[pos / 8] |= 1U << (7 - (pos % 8));
271 };
272
273 for (uint8_t byte_index = 0; byte_index < len; byte_index++) {
274 const uint8_t value = data[byte_index];
275
276 write_bit(bit_pos++, 0); // UART start bit
277 for (uint8_t bit_index = 0; bit_index < 8; bit_index++)
278 write_bit(bit_pos++, (value >> bit_index) & 0x01);
279 write_bit(bit_pos++, 1); // UART stop bit
280 }
281
282 // A 10-bit cell only lands on a byte boundary every fourth byte, so the last byte of the buffer
283 // is usually part data, part leftover — and the chip transmits it whole either way. Those
284 // leftover bits are line-idle time, and a UART line idles *high*: zero-filling them puts what
285 // looks like a start bit on air immediately after the frame's last stop bit. The SX1276's
286 // IoHomeOn coder, which this software PHY exists to reproduce, never emits that. Pad with ones.
287 const uint8_t encoded_len = (total_bits + 7) / 8;
288 for (uint16_t pad_pos = total_bits; pad_pos < (uint16_t) encoded_len * 8; pad_pos++)
289 write_bit(pad_pos, 1);
290
291 return encoded_len;
292}
293
294} // namespace home_io_control
295} // namespace esphome
296
297// NOLINTEND(cppcoreguidelines-avoid-magic-numbers,readability-magic-numbers)
uint8_t decode_uart_probe(const uint8_t *raw, uint8_t raw_len, uint8_t bit_offset, uint8_t *decoded, uint8_t decoded_max_len)
Decode a raw UART‑encoded bitstream into bytes.
constexpr uint32_t soft_phy_air_time_us(uint32_t raw_bytes)
On-air time in microseconds for raw_bytes bytes at the protocol's line rate.
bool is_known_io_command(uint8_t cmd)
This list missing CMD_GET_GENERAL_INFO3_RESP (0x59) is exactly what turned a real Q2 probe reply into...
uint8_t soft_phy_peek_frame_length(const uint8_t *raw, uint8_t raw_len)
Recover a frame's total length from the very first UART cell of a reception.
uint8_t uart_encode_packet(const uint8_t *data, uint8_t len, uint8_t *encoded, uint8_t encoded_max_len)
UART-encode a buffer of bytes (start bit 0, 8 data bits LSB-first, stop bit 1).
UartProbeResult find_uart_probe(const uint8_t *raw, uint8_t raw_len)
Search raw RX buffer for the best CRC-validated IO-Homecontrol frame.
uint16_t crc_ccitt(const uint8_t *data, uint8_t len)
CRC-CCITT used by the IO-Homecontrol protocol for frame validation.
static std::pair< uint8_t, uint8_t > find_crc_valid_frame(const uint8_t *decoded, uint8_t decoded_len)
Try to find a CRC-valid IO-Homecontrol frame within a decoded UART byte stream.
static constexpr uint8_t FRAME_MAX_WIRE_SIZE
Largest number of bytes a buffer must hold to receive or transmit any frame this project knows about,...
Definition proto_sizes.h:72
uint8_t soft_phy_raw_bytes_for_frame(uint8_t frame_len)
Raw on-air bytes needed to carry a whole frame: frame_len protocol bytes plus the two trailing CRC by...
bool parse(const uint8_t *buf, uint8_t buf_len, IoFrame &f)
Parse a wire buffer into a parsed IoFrame (validates length and CTRL0).
uint32_t io868_tx_air_time_us(uint16_t preamble_len, uint8_t frame_len)
Time on air of one 868 MHz transmission: preamble, sync word, and the frame plus its CRC,...
static constexpr uint8_t FRAME_CRC_SIZE
Size of the on-air CRC-CCITT trailer appended after every frame (declared bytes, plus the out-of-leng...
Definition proto_sizes.h:55
constexpr uint8_t RADIO_PACKET_BUFFER_SIZE
Scratch buffer size for raw radio packets and recovered frames.
IO-Homecontrol command IDs, result codes and protocol enumerations.
Software PHY for radios without IoHomeOn hardware framing.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
Result of the UART probe: best candidate frame within a raw capture.
uint8_t decoded_len
Total number of bytes decoded at that offset.
uint8_t frame_start
Index into decoded buffer where the frame begins.
bool valid
A plausible frame was found.
uint8_t bit_offset
Bit offset where the best decode started.
uint8_t frame_len
Length of the candidate IoFrame (decoded bytes).
uint8_t decoded[RADIO_PACKET_BUFFER_SIZE]
Full decoded UART stream at the chosen offset.