Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
proto_frame.h
Go to the documentation of this file.
1#pragma once
2
3/// @file proto_frame.h
4/// @brief IO-Homecontrol 2W frame container: control bytes, IoFrame and (de)serialization.
5/// @ingroup hioc_protocol
6///
7/// IO-Homecontrol is a proprietary wireless protocol used by Somfy, Velux, and other
8/// manufacturers for controlling shutters, awnings, blinds, and similar devices.
9/// "2W" means two-way: the controller sends commands and receives status feedback.
10///
11/// The protocol uses FSK modulation at 868 MHz with frequency hopping across 3 channels.
12/// Communication is encrypted with AES-128 and authenticated with a 6-byte HMAC.
13/// Each installation has a unique 16-byte "system key" shared between controller and devices.
14///
15/// This header owns only the frame container itself. The rest of the protocol model lives in
16/// cohesive headers (proto_sizes/proto_timing/proto_constants/proto_device_model/proto_codecs).
17/// New code should include the specific header it needs.
18
19#include "proto_sizes.h"
20
21#include <cstdint>
22#include <cstring>
23#include <string>
24
25namespace esphome {
26namespace home_io_control {
27
28// ============================================================================
29// Control Bytes
30// ============================================================================
31
32/// Control byte 0 (CTRL0) bit definitions.
33/// CTRL0 encodes frame flags and the total frame length.
34/// Bits [4:0] = frame_length - 1 (so 0x08 means 9 bytes total).
35/// - START (bit 6): first frame in an exchange. Its TX preamble depends on CTRL1_LOW_POWER, not on
36/// START alone: a low-power target gets the 1024-byte wake-up burst (unless it is believed awake,
37/// see ADR 0040), others a normal preamble.
38/// - END (bit 7): last frame in an exchange; set on responses and command completions.
39/// - 1W (bit 5): 1=OneWay protocol (no response expected), 0=TwoWay (response expected).
40/// For 2W operation, the controller sets START on initial command and device replies with END; subsequent frames in an
41/// authenticated exchange also carry END.
42static constexpr uint8_t CTRL0_END = 0x80; ///< Bit 7: last frame in exchange
43static constexpr uint8_t CTRL0_START = 0x40; ///< Bit 6: first frame in exchange
44static constexpr uint8_t CTRL0_PROTOCOL_1W = 0x20; ///< Bit 5: 1=OneWay protocol, 0=TwoWay protocol
45static constexpr uint8_t CTRL0_LENGTH_MASK = 0x1F; ///< Bits [4:0]: frame length - 1
46
47/// Ties `FRAME_MAX_DECLARED_SIZE` (proto_sizes.h) to the mask that actually defines it. The two
48/// can't be expressed as one expression across the header boundary (proto_sizes.h can't include
49/// this header back without a cycle), so this assert is the drift guard instead.
50static_assert(FRAME_MAX_DECLARED_SIZE == CTRL0_LENGTH_MASK + 1,
51 "FRAME_MAX_DECLARED_SIZE must track CTRL0_LENGTH_MASK's 5-bit field");
52
53/// Control byte 1 (CTRL1) bit definitions.
54/// CTRL1 carries protocol metadata flags that describe the frame's routing,
55/// power mode, and priority characteristics.
56/// - VERSION (bits [1:0]): protocol version number (usually 0 for current devices).
57/// - PRIORITY (bit 2): marks a high-priority frame (e.g., discovery, security commands).
58/// - ACK (bit 4): sender can handle 2W responses. NOT set by default on any outbound frame — see
59/// init_frame() below for why. Scoped exceptions: the discovery broadcast phase, gated by the
60/// opt-in `pairing_discovery_ack_capable` tuning knob (tuning_config.h), off by default;
61/// and create_discover_confirm()'s `ack` parameter, gated by the `pairing_discover_confirm`
62/// tuning knob (`send_with_ack`, off by default).
63/// - LOW_POWER (bit 5): device is battery/solar powered; may sleep and requires long preamble to
64/// wake. Set from the target's per-device YAML `low_power` class (default false); drives both
65/// this bit and the start-frame preamble (see proto_commands.h, exchange_engine.cpp).
66/// - ROUTED (bit 6): frame was relayed through a repeater node rather than direct.
67/// - BEACON (bit 7): beacon announcement frame (device presence advertisement).
68static constexpr uint8_t CTRL1_VERSION_MASK = 0x03; ///< Bits [1:0]: protocol version (usually 0).
69static constexpr uint8_t CTRL1_PRIORITY = 0x04; ///< Bit 2: high-priority frame.
70static constexpr uint8_t CTRL1_ACK = 0x10; ///< Bit 4: sender can handle 2W responses (ACK-capable).
71static constexpr uint8_t CTRL1_LOW_POWER = 0x20; ///< Bit 5: low-power device (e.g., solar-powered).
72static constexpr uint8_t CTRL1_ROUTED = 0x40; ///< Bit 6: frame was relayed through a repeater.
73static constexpr uint8_t CTRL1_BEACON = 0x80; ///< Bit 7: beacon announcement frame.
74
75// ============================================================================
76// Frame Structure
77// ============================================================================
78
79/// @brief Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
80/// @ingroup hioc_protocol
81///
82/// Over the air layout: [CTRL0][CTRL1][DST 3B][SRC 3B][CMD][DATA 0-23B][MAC 0/6B][CRC 2B].
83/// The on-air CRC is the radio driver's responsibility (hardware or software,
84/// depending on the chip); it is not included in this struct.
85///
86/// The MAC trailer exists because at least one frame shape's authenticator does not fit inside
87/// CTRL0's 5-bit length field alongside its payload: a 1W CMD 0x30 "add controller" frame is 29
88/// declared bytes plus a 6-byte MAC, and 35 has no 5-bit encoding. That MAC rides after the
89/// declared length instead — still under the CRC, but outside what CTRL0's length bits describe
90/// — so it is modeled as a distinct trailer rather than folded into `data[]`. `has_mac` is false
91/// for every frame shape that predates this (i.e. everything except a MAC-bearing 1W frame), so
92/// `data_len`/`frame_length()` keep meaning exactly what they meant before this field existed.
93struct IoFrame {
94 uint8_t ctrl0; ///< Control byte 0: flags + length.
95 uint8_t ctrl1; ///< Control byte 1: low power, beacon, etc.
96 uint8_t dst[NODE_ID_SIZE]; ///< Destination node ID (3 bytes).
97 uint8_t src[NODE_ID_SIZE]; ///< Source node ID (3 bytes).
98 uint8_t cmd; ///< Command ID.
99 uint8_t data[FRAME_MAX_DATA_SIZE]; ///< Command parameters (0–23 bytes). Never includes `mac`.
100 uint8_t data_len; ///< Actual length of data. `FRAME_MIN_SIZE + data_len == frame_length()`
101 ///< always holds, trailer or not — see `mac`/`has_mac`.
102 uint8_t mac[HMAC_SIZE]; ///< Out-of-length authenticator trailer; meaningful only when `has_mac`.
103 bool has_mac = false; ///< True if this frame carries the `mac` trailer (see struct doc).
104};
105
106// --- Frame construction and parsing ---
107/// Initialize an IoFrame header (ctrl0/ctrl1) with flags.
108///
109/// Note: CTRL1_ACK is NOT automatically set on outbound frames. Some real-world
110/// devices reject frames with unexpected CTRL1 bits, causing total communication
111/// failure. The ACK constant is retained for inbound frame parsing and logging, and for three
112/// scoped exceptions: create_discovery_request()'s `ack_capable` parameter (proto_commands.h) can
113/// set it on the discovery broadcast, opt-in and gated by the `pairing_discovery_ack_capable`
114/// tuning knob (default off, tuning_config.h); the SPE roll-call's low-power frame shape
115/// (`CTRL1 = LOW_POWER | ACK`) sets it unconditionally, matching the header a real VELUX hub uses
116/// for its own roll-call; and create_discover_confirm()'s `ack` parameter, opt-in and gated by the
117/// `pairing_discover_confirm` tuning knob (`send_with_ack`, default off).
118/// @param f Frame to initialize.
119/// @param is_2w True for 2‑way (default), false for 1‑way.
120/// @param start Set START flag (first frame in exchange).
121/// @param end Set END flag (final frame in exchange).
122/// @param low_power Set LOW_POWER flag.
123void init_frame(IoFrame &f, bool is_2w = true, bool start = false, bool end = false, bool low_power = false);
124/// Set destination node ID.
125/// @param f Frame to modify.
126/// @param id 3‑byte destination address.
127void set_dst(IoFrame &f, const uint8_t id[NODE_ID_SIZE]);
128/// Set source node ID.
129/// @param f Frame to modify.
130/// @param id 3‑byte source address.
131void set_src(IoFrame &f, const uint8_t id[NODE_ID_SIZE]);
132/// Set command and payload.
133/// @param f Frame to modify.
134/// @param cmd Command ID.
135/// @param params Pointer to payload bytes (may be nullptr for zero‑length).
136/// @param params_len Payload length (0–23).
137/// @return true if frame fits within size limits; false otherwise.
138bool set_cmd(IoFrame &f, uint8_t cmd, const uint8_t *params = nullptr, uint8_t params_len = 0);
139/// Get total frame length from ctrl0.
140/// @param f Parsed frame.
141/// @return Length in bytes.
142uint8_t frame_length(const IoFrame &f);
143/// Check START flag.
144/// @param f Parsed frame.
145/// @return true if START flag is set.
146bool is_start(const IoFrame &f);
147/// Check END flag.
148/// @param f Parsed frame.
149/// @return true if END flag is set.
150bool is_end(const IoFrame &f);
151/// Whether wire frames for a command carry the out-of-length MAC trailer described on
152/// `IoFrame::has_mac`/`IoFrame::mac`. This exists because CTRL0's 5-bit length field cannot
153/// describe every command's declared payload plus a 6-byte authenticator in one span — one
154/// command's authenticator is carried outside the declared length instead (see the command's own
155/// Doxygen in proto_constants.h for why). `parse()` consults this before it will accept the
156/// wider `declared_len + HMAC_SIZE` buffer shape for a given command, so an unrelated frame that
157/// merely happens to arrive with 6 extra trailing bytes is never mistaken for a trailer-bearing
158/// one — only a command that genuinely carries a trailer gets the wider shape considered at all.
159/// @param cmd Command byte (`IoFrame::cmd`, or the raw byte at `FRAME_CMD_OFFSET` in a wire buffer).
160/// @return true if `cmd`'s wire frames carry the `mac` trailer after the declared length.
161bool frame_carries_mac_trailer(uint8_t cmd);
162/// Serialize a parsed frame into a wire buffer (without CRC).
163/// @param f Parsed frame. When `f.has_mac`, the 6-byte `mac` trailer is appended after the
164/// declared payload and counted in the returned length, so a caller's CRC (computed over the
165/// returned length) covers it.
166/// @param buf Output buffer (must be at least frame_length(f) bytes, or +HMAC_SIZE when `f.has_mac`).
167/// @param buf_size Size of buf.
168/// @return Number of bytes written (declared length, plus HMAC_SIZE when `f.has_mac`), or 0 on failure.
169uint8_t serialize(const IoFrame &f, uint8_t *buf, uint8_t buf_size);
170/// Parse a wire buffer into a parsed IoFrame (validates length and CTRL0).
171/// @param buf Raw byte buffer.
172/// @param buf_len Number of bytes in buf. Accepted shapes: exactly the CTRL0-declared length
173/// (`has_mac` comes out false), or — only for a command where `frame_carries_mac_trailer()` is
174/// true — that length plus HMAC_SIZE (the trailing bytes are copied into `f.mac` and `has_mac`
175/// comes out true). Any other length, or that same wider length for a command that doesn't
176/// carry a trailer, is rejected. `data_len` is always `buf_len`'s declared portion minus
177/// FRAME_MIN_SIZE — the trailer is never data.
178/// @param f Output parsed frame.
179/// @return true if parse succeeded; false otherwise.
180bool parse(const uint8_t *buf, uint8_t buf_len, IoFrame &f);
181
182// ============================================================================
183// Node ID Helpers
184// ============================================================================
185
186/// @brief Convert a hex string (e.g., "123ABC") to a byte array.
187/// @param hex Hex string (must be exactly len*2 characters).
188/// @param out Output buffer (at least len bytes).
189/// @param len Number of bytes to produce.
190/// @return true on success; false if hex length mismatch or non‑hex characters.
191bool hex_to_bytes(const std::string &hex, uint8_t *out, uint8_t len);
192/// @brief Format a 3‑byte node ID as a 6‑character uppercase hex string.
193/// @param id 3‑byte node ID.
194/// @return Hex string (e.g., "123ABC").
195std::string node_id_to_string(const uint8_t id[NODE_ID_SIZE]);
196/// @brief Check whether a node ID is usable as an address: not all-zero and not all-0xFF, the two
197/// patterns blank storage and unset fields produce.
198/// @param id 3‑byte node ID buffer.
199/// @return true if the ID is non-zero and non-0xFF.
200inline bool stored_node_id_is_valid(const uint8_t id[NODE_ID_SIZE]) {
201 bool all_zero = true;
202 bool all_ff = true;
203 for (uint8_t i = 0; i < NODE_ID_SIZE; i++) {
204 all_zero = all_zero && id[i] == 0;
205 all_ff = all_ff && id[i] == UINT8_MAX;
206 }
207 return !all_zero && !all_ff;
208}
209
210// ============================================================================
211// CRC
212// ============================================================================
213
214/// @brief Compute CRC‑CCITT (poly 0x1021, init 0x0000) over a buffer.
215/// Used by radio drivers without hardware IO-Homecontrol CRC support and by
216/// frame validation in tests.
217/// @param data Pointer to data bytes.
218/// @param len Number of bytes.
219/// @return 16‑bit CRC value.
220uint16_t crc_ccitt(const uint8_t *data, uint8_t len);
221
222} // namespace home_io_control
223} // 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 CTRL0_END
Control byte 0 (CTRL0) bit definitions.
Definition proto_frame.h:42
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:44
static constexpr uint8_t CTRL0_START
Bit 6: first frame in exchange.
Definition proto_frame.h:43
static constexpr uint8_t CTRL1_ROUTED
Bit 6: frame was relayed through a repeater.
Definition proto_frame.h:72
uint16_t crc_ccitt(const uint8_t *data, uint8_t len)
CRC-CCITT used by the IO-Homecontrol protocol for frame validation.
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.
bool is_end(const IoFrame &f)
Check END flag.
bool stored_node_id_is_valid(const uint8_t id[NODE_ID_SIZE])
Check whether a node ID is usable as an address: not all-zero and not all-0xFF, the two patterns blan...
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 CTRL1_PRIORITY
Bit 2: high-priority frame.
Definition proto_frame.h:69
static constexpr uint8_t CTRL1_ACK
Bit 4: sender can handle 2W responses (ACK-capable).
Definition proto_frame.h:70
static constexpr uint8_t CTRL0_LENGTH_MASK
Bits [4:0]: frame length - 1.
Definition proto_frame.h:45
static constexpr uint8_t CTRL1_VERSION_MASK
Ties FRAME_MAX_DECLARED_SIZE (proto_sizes.h) to the mask that actually defines it.
Definition proto_frame.h:68
static constexpr uint8_t CTRL1_LOW_POWER
Bit 5: low-power device (e.g., solar-powered).
Definition proto_frame.h:71
static constexpr uint8_t CTRL1_BEACON
Bit 7: beacon announcement frame.
Definition proto_frame.h:73
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.
Fundamental IO-Homecontrol frame and crypto size constants.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:99
uint8_t mac[HMAC_SIZE]
Out-of-length authenticator trailer; meaningful only when has_mac.
bool has_mac
True if this frame carries the mac trailer (see struct doc).
uint8_t ctrl0
Control byte 0: flags + length.
Definition proto_frame.h:94
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:97
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:96
uint8_t data_len
Actual length of data.
uint8_t ctrl1
Control byte 1: low power, beacon, etc.
Definition proto_frame.h:95