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/// Maximum bit offset to search for valid UART decode start position.
21/// The UART frame is 10 bits (start + 8 data). If the sync word is not aligned,
22/// we probe up to 10 bits offset to recover the correct framing.
23static const uint8_t UART_PROBE_MAX_BIT_OFFSET = 10;
24
25namespace {
26
27/// Extract a single bit (MSB‑first) from a byte buffer.
28/// Used by UART decoding to scan raw radio samples.
29/// @param data Input byte buffer.
30/// @param bit_pos Global bit index within buffer.
31/// @return The bit value (0 or 1).
32uint8_t get_bit_msb(const uint8_t *data, uint16_t bit_pos) { return (data[bit_pos / 8] >> (7 - (bit_pos % 8))) & 0x01; }
33
34} // namespace
35
36/// Decode a raw UART‑encoded bitstream into bytes.
37/// IO‑Homecontrol uses a UART‑like encoding over the air: each byte is represented
38/// by a 10‑bit sequence (start bit 0, 8 data bits LSB‑first, stop bit 1). This
39/// function slides a window across the raw bitstream and attempts to recover the
40/// original bytes. It stops when the sync pattern (0 followed by 1) is not found.
41/// @param raw Raw bytes from the radio buffer.
42/// @param raw_len Number of raw bytes available.
43/// @param bit_offset Initial bit position to start decoding (probe offset).
44/// @param decoded Output buffer for decoded bytes.
45/// @param decoded_max_len Capacity of decoded buffer.
46/// @return Number of bytes successfully decoded.
47uint8_t decode_uart_probe(const uint8_t *raw, uint8_t raw_len, uint8_t bit_offset, uint8_t *decoded,
48 uint8_t decoded_max_len) {
49 // Bit numbering: we read MSB-first across byte boundaries. The UART frame structure
50 // within the bitstream is: start(0), data0, data1, ..., data7, stop(1). Byte values
51 // are LSB-first within the 8 data bits (bit 0 arrives first after start).
52 // We verify the start bit is 0 and stop bit is 1; if not, the probe offset is wrong.
53 uint16_t bit_pos = bit_offset;
54 uint16_t const total_bits = raw_len * 8;
55 uint8_t decoded_len = 0;
56
57 while (bit_pos + 10 <= total_bits && decoded_len < decoded_max_len) {
58 if (get_bit_msb(raw, bit_pos) != 0 || get_bit_msb(raw, bit_pos + 9) != 1)
59 break;
60
61 uint8_t value = 0;
62 for (uint8_t index = 0; index < 8; index++)
63 value |= get_bit_msb(raw, bit_pos + 1 + index) << index;
64
65 decoded[decoded_len++] = value;
66 bit_pos += 10;
67 }
68
69 return decoded_len;
70}
71
72namespace {
73
74/// @brief Check if a command ID is one of the known IO‑Homecontrol commands.
75/// @param cmd Command byte.
76/// @return true if cmd matches a known command constant.
77bool is_known_io_command(uint8_t cmd) {
78 switch (cmd) {
79 case CMD_EXECUTE:
80 case CMD_PRIVATE:
88 case CMD_KEY_INIT:
90 case CMD_KEY_CONFIRM:
93 case CMD_GET_NAME:
95 case CMD_SET_NAME:
97 case CMD_GET_INFO2:
99 case CMD_SET_CONFIG1:
103 case CMD_ERROR_RESP:
104 return true;
105 default:
106 return false;
107 }
108}
109
110/// @brief Check if a UART-decoded frame is plausible as an IO-Homecontrol packet.
111/// Accepts frames at or above FRAME_MIN_SIZE (9 bytes) that contain a known command
112/// or have the 1W protocol bit set. This allows short frames like CMD_KEY_CONFIRM (9 bytes)
113/// and CMD_ERROR_RESP (10 bytes) to pass through when CRC validates.
114/// @param frame Parsed IoFrame candidate.
115/// @param candidate_len Total decoded length of the candidate.
116/// @return true if the frame looks like a real protocol packet.
117bool is_plausible_uart_frame(const IoFrame &frame, uint8_t candidate_len) {
118 if (candidate_len < FRAME_MIN_SIZE)
119 return false;
120 if (is_known_io_command(frame.cmd))
121 return true;
122 return (frame.ctrl0 & CTRL0_PROTOCOL_1W) != 0;
123}
124
125} // namespace
126
127/// @brief Try to find a CRC-valid IO-Homecontrol frame within a decoded UART byte stream.
128/// @param decoded Decoded byte buffer from UART probe.
129/// @param decoded_len Number of decoded bytes.
130/// @return Frame start index and length if found, or {0, 0} if no valid frame.
131static std::pair<uint8_t, uint8_t> find_crc_valid_frame(const uint8_t *decoded, uint8_t decoded_len) {
132 for (uint8_t start = 0; start < decoded_len; start++) {
133 const uint8_t max_candidate_len = std::min<uint8_t>(decoded_len - start, FRAME_MAX_SIZE);
134 for (uint8_t candidate_len = max_candidate_len; candidate_len >= FRAME_MIN_SIZE; candidate_len--) {
135 IoFrame frame;
136 if (!parse(decoded + start, candidate_len, frame))
137 continue;
138 if (!is_plausible_uart_frame(frame, candidate_len))
139 continue;
140 if (start + candidate_len + 2 > decoded_len)
141 continue;
142 const uint16_t computed_crc = crc_ccitt(decoded + start, candidate_len);
143 const uint16_t received_crc =
144 (uint16_t) decoded[start + candidate_len] | ((uint16_t) decoded[start + candidate_len + 1] << 8);
145 if (computed_crc != received_crc)
146 continue;
147 return {start, candidate_len};
148 }
149 }
150 return {0, 0};
151}
152
153UartProbeResult find_uart_probe(const uint8_t *raw, uint8_t raw_len) {
154 // The raw RX buffer contains the demodulated bits packed as bytes. Due to unknown bit
155 // alignment, we probe up to UART_PROBE_MAX_BIT_OFFSET (10) different starting positions.
156 // For each offset we attempt UART decoding; if decoding yields a plausible frame length
157 // (>= minimum) and contains a known command ID or indicates a 1W frame, we keep it as a
158 // candidate. CRC-CCITT validation is used as the primary selection criterion: a frame that
159 // passes CRC is preferred over one that merely parses. This rejects frames corrupted by
160 // demodulator bit errors after TX→RX transitions.
161 UartProbeResult best{};
162
163 for (uint8_t bit_offset = 0; bit_offset < UART_PROBE_MAX_BIT_OFFSET; bit_offset++) {
164 uint8_t decoded[RADIO_PACKET_BUFFER_SIZE] = {0};
165 uint8_t const decoded_len = decode_uart_probe(raw, raw_len, bit_offset, decoded, sizeof(decoded));
166 if (decoded_len == 0)
167 continue;
168
169 if (decoded_len > best.decoded_len && !best.valid) {
170 best.bit_offset = bit_offset;
171 best.decoded_len = decoded_len;
172 memcpy(best.decoded, decoded, decoded_len);
173 }
174
175 auto [frame_start, frame_len] = find_crc_valid_frame(decoded, decoded_len);
176 if (frame_len > 0) {
177 best.valid = true;
178 best.bit_offset = bit_offset;
179 best.decoded_len = decoded_len;
180 best.frame_start = frame_start;
181 best.frame_len = frame_len;
182 memcpy(best.decoded, decoded, decoded_len);
183 return best;
184 }
185 }
186
187 return best;
188}
189
190// === Length-driven receive helpers ===
191
192uint8_t soft_phy_raw_bytes_for_frame(uint8_t frame_len) {
193 // The CRC is appended before UART packing (see SoftPhyDriverBase::send_packet), so it occupies
194 // two cells of its own.
195 const uint16_t cells = (uint16_t) frame_len + FRAME_CRC_SIZE;
196 const uint16_t bits = cells * UART_CELL_BITS;
197 return (uint8_t) ((bits + 7) / 8);
198}
199
200uint8_t soft_phy_peek_frame_length(const uint8_t *raw, uint8_t raw_len) {
201 uint8_t best = 0;
202 for (uint8_t bit_offset = 0; bit_offset < UART_PROBE_MAX_BIT_OFFSET; bit_offset++) {
203 uint8_t ctrl0 = 0;
204 if (decode_uart_probe(raw, raw_len, bit_offset, &ctrl0, 1) != 1)
205 continue; // start/stop bits don't frame here — wrong alignment
206 const auto frame_len = (uint8_t) ((ctrl0 & CTRL0_LENGTH_MASK) + 1);
207 if (frame_len < FRAME_MIN_SIZE || frame_len > FRAME_MAX_SIZE)
208 continue;
209 best = std::max(frame_len, best);
210 }
211 return best;
212}
213
214// === Software UART encode (TX) ===
215
216uint8_t uart_encode_packet(const uint8_t *data, uint8_t len, uint8_t *encoded, uint8_t encoded_max_len) {
217 if (len == 0 || encoded_max_len == 0)
218 return 0;
219
220 memset(encoded, 0, encoded_max_len);
221 uint16_t bit_pos = 0;
222 const uint16_t total_bits = len * 10;
223 if (((total_bits + 7) / 8) > encoded_max_len)
224 return 0;
225
226 auto write_bit = [encoded](uint16_t pos, uint8_t bit) {
227 if (bit != 0)
228 encoded[pos / 8] |= 1U << (7 - (pos % 8));
229 };
230
231 for (uint8_t byte_index = 0; byte_index < len; byte_index++) {
232 const uint8_t value = data[byte_index];
233
234 write_bit(bit_pos++, 0); // UART start bit
235 for (uint8_t bit_index = 0; bit_index < 8; bit_index++)
236 write_bit(bit_pos++, (value >> bit_index) & 0x01);
237 write_bit(bit_pos++, 1); // UART stop bit
238 }
239
240 // A 10-bit cell only lands on a byte boundary every fourth byte, so the last byte of the buffer
241 // is usually part data, part leftover — and the chip transmits it whole either way. Those
242 // leftover bits are line-idle time, and a UART line idles *high*: zero-filling them puts what
243 // looks like a start bit on air immediately after the frame's last stop bit. The SX1276's
244 // IoHomeOn coder, which this software PHY exists to reproduce, never emits that. Pad with ones.
245 const uint8_t encoded_len = (total_bits + 7) / 8;
246 for (uint16_t pad_pos = total_bits; pad_pos < (uint16_t) encoded_len * 8; pad_pos++)
247 write_bit(pad_pos, 1);
248
249 return encoded_len;
250}
251
252} // namespace home_io_control
253} // namespace esphome
254
255// NOLINTEND(cppcoreguidelines-avoid-magic-numbers,readability-magic-numbers)
static constexpr uint8_t CMD_DISCOVER_REQ
Broadcast discovery request.
static constexpr uint8_t CMD_SET_CONFIG1
Configure device to auto-send status updates.
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.
static constexpr uint8_t CMD_KEY_TRANSFER
Send encrypted system key to device.
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 CMD_ERROR_RESP
Error response to any command.
static constexpr uint8_t CMD_GET_NAME_RESP
Device name response.
static constexpr uint8_t CMD_DISCOVER_CONFIRM_ACK
Device acknowledges confirmation.
static constexpr uint8_t CMD_STATUS_UPDATE
Device-initiated status update (needs auth).
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).
static constexpr uint8_t CMD_GET_NAME
Request device name.
static constexpr uint8_t CTRL0_PROTOCOL_1W
Bit 5: 1=OneWay protocol, 0=TwoWay protocol.
Definition proto_frame.h:42
static constexpr uint8_t CMD_DISCOVER_SPE_RESP
Roll-call reply to CMD_DISCOVER_SPE_REQ, sent only by devices that already hold the requesting contro...
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_SIZE
Maximum frame size (9 header + 23 data).
Definition proto_sizes.h:30
static constexpr uint8_t CMD_KEY_CONFIRM
Device confirms key was received.
static constexpr uint8_t CMD_KEY_INIT
Initiate key transfer to device.
static constexpr uint8_t CMD_SET_NAME_RESP
Device-name write response.
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).
static constexpr uint8_t CMD_DISCOVER_SPE_REQ
Broadcast roll-call answered by every device that already holds this controller's system key,...
static constexpr uint8_t CMD_EXECUTE
Set position/open/close/stop — requires authentication.
static constexpr uint8_t CMD_PRIVATE_RESP
Response to 0x00 and 0x03 (contains position data).
static constexpr uint8_t CMD_CHALLENGE_REQ
6-byte random challenge.
static constexpr uint8_t CMD_STATUS_UPDATE_RESP
Acknowledge status update.
static constexpr uint8_t CMD_SET_NAME
Set device name (authenticated).
static constexpr uint8_t CMD_SET_CONFIG1_RESP
Config response.
static constexpr uint8_t CMD_DISCOVER_CONFIRM
Confirm discovery to device.
static constexpr uint8_t FRAME_CRC_SIZE
CRC-CCITT trailer appended after the frame body.
Definition proto_sizes.h:33
static constexpr uint8_t CMD_DISCOVER_RESP
Device responds with its ID and type.
static constexpr uint8_t CMD_PRIVATE
Get device status — no authentication needed.
static constexpr uint8_t CTRL0_LENGTH_MASK
Bits [4:0]: frame length - 1.
Definition proto_frame.h:43
static constexpr uint8_t CMD_GET_INFO2_RESP
Device type/model response.
static constexpr uint8_t CMD_CHALLENGE_RESP
HMAC proof answering a 0x3C.
static constexpr uint8_t CMD_GET_INFO2
Request device type/model info.
constexpr uint8_t RADIO_PACKET_BUFFER_SIZE
Scratch buffer size for raw radio packets and recovered frames.
static constexpr uint8_t UART_CELL_BITS
Bits an on-air UART cell spends per protocol byte: start(1) + data(8) + stop(1).
static const uint8_t UART_PROBE_MAX_BIT_OFFSET
Maximum bit offset to search for valid UART decode start position.
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:71
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.