Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
proto_commands.cpp
Go to the documentation of this file.
1/// @file proto_commands.cpp
2/// @brief Command builders for the IO-Homecontrol protocol.
3/// @ingroup hioc_protocol
4
5#include "proto_commands.h"
6
7#include "proto_constants.h"
8#include "proto_crypto.h"
9
10#include <array>
11#include <cstring>
12
13namespace esphome {
14namespace home_io_control {
15
16namespace {
17
18// === Command payload templates ===
19
20/// The protocol uses 0-100 for percentage-style position inputs before encoding them on wire.
21constexpr uint8_t POSITION_PERCENT_MAX = 100;
22/// Byte 0 in execute-family payloads identifies a user-originated remote action.
23/// Uses the public ORIGINATOR_USER_REMOTE constant from proto_frame.h.
24constexpr uint8_t EXECUTE_ORIGINATOR = ORIGINATOR_USER_REMOTE;
25/// ACEI byte for execute commands — composed from priority and validity bits.
26///
27/// Level=3 (user_default), matching what a real 2W hub puts on the air: a third-party capture of a
28/// Velux KIG300 commanding a Somfy RS100 shows its EXECUTE payload as `01 63 C8 00 80 32 00 00` —
29/// ACEI 0x63, level 3. The 0x43 (user_high) alternative comes from a 1W remote reference vector
30/// (tests/corpus/captures/oneway/reference_1w_oneway_execute_iv_vector.yaml), not a 2W hub, and a
31/// handheld remote claiming a higher priority than a home-automation hub is unsurprising.
32///
33/// A device locked at level 3 rejects this with RESULT_PRIORITY_LOCKED_NON_EXEC (0x38) rather than
34/// silence. Watch command results for 0x38 after touching this value; if it appears, level=2
35/// (0x43) is the fallback and the priority/reliability tradeoff is real.
36///
37/// Composition: (ACEI_LEVEL_USER_DEFAULT << 5) | (0 << 3) | (1 << 1) | 1 = 0x63.
38constexpr uint8_t EXECUTE_ACEI =
39 (ACEI_LEVEL_USER_DEFAULT << ACEI_LEVEL_SHIFT) | (1 << ACEI_EXTENDED_SHIFT) | ACEI_VALID_BIT;
40/// @brief ACEI byte for the force-open command — same bit layout as EXECUTE_ACEI but at the
41/// highest priority level instead of user_high.
42///
43/// The protocol's only documented override mechanism for an environmental soft lock (e.g. a
44/// wind/rain sensor holding a device at its secured position) is ACEI priority elevation: a node
45/// locked at some priority level rejects any command at that level or below (result codes
46/// RESULT_PRIORITY_LEVEL_LOCKED / RESULT_PRIORITY_LOCKED_NON_EXEC) and only a strictly
47/// higher-priority command gets through. Real captures of this repo's own wind/rain sensor
48/// traffic show it locks at ACEI_LEVEL_PROTECTION_SENSOR (1), so force-open uses level 0
49/// (protection_human, the highest level)
50/// Composition: (ACEI_LEVEL_PROTECTION_HUMAN << 5) | (1 << 1) | 1 = 0x03.
51/// @note Real-hardware testing confirmed this correctly moves a device to fully open (see
52/// create_force_open()'s position-inversion note), but elevation to level 0 has not yet
53/// been confirmed to actually override an *active* lock — only that the device accepts
54/// the frame when nothing is locking it.
55constexpr uint8_t EXECUTE_ACEI_FORCE_OPEN =
56 (ACEI_LEVEL_PROTECTION_HUMAN << ACEI_LEVEL_SHIFT) | (1 << ACEI_EXTENDED_SHIFT) | ACEI_VALID_BIT;
57
58/// Multi Information Byte for our device-role CMD_DISCOVER_RESP (0x29) — the turnaround-time
59/// class and power-save mode this responder advertises to a foreign hub.
60///
61/// ATT_CLASS_5MS | POWER_SAVE_ALWAYS_ALIVE (0x00) would be an honest-looking but false claim: it
62/// promises the tightest 5 ms turnaround from a device that never sleeps, when this responder
63/// actually hops across 3 channels on the ESPHome loop cadence and is briefly deaf while
64/// transmitting its own replies. The loosest class, ATT_CLASS_40MS, is the honest claim for that profile.
65///
66/// 0xDD = 1101_1101: bits [7:6] are ATT_CLASS_40MS, bit [0] is POWER_SAVE_LOW_POWER, bit [3] is
67/// DISCOVERY_FLAGS_RF_SUPPORT (node has its own RF), bit [2] is DISCOVERY_FLAGS_IO_MEMBERSHIP
68/// (always 1), and bit [4] has no named constant (it is not in the VELUX KLF 200 API table). All of
69/// this byte except the power-save bit mirrors the exact byte a real Somfy Izymo dimmer advertised in
70/// this project's own corpus
71/// (tests/corpus/captures/pairing/somfy_izymo_dimmer_pairing_full_sx1276.yaml: flags=0xDC, ATT_CLASS_40MS |
72/// POWER_SAVE_ALWAYS_ALIVE), on the theory that matching a real device's full byte is safer than
73/// guessing at bits with no documented meaning — bit [4] is carried over unmodified for that reason.
74///
75/// The power-save bit is the one deliberate departure from that real capture: POWER_SAVE_LOW_POWER
76/// here, not the captured device's POWER_SAVE_ALWAYS_ALIVE. That is a reasoned choice, not a
77/// guess — this responder's own true behavior (hopping across 3 channels, briefly deaf while
78/// transmitting its own replies) is genuinely closer to a low-power device's profile than to an
79/// always-listening one, so mirroring the capture's power-save bit would make the same false claim
80/// ATT_CLASS_5MS | POWER_SAVE_ALWAYS_ALIVE made above, just with the opposite polarity.
81/// TODO(hardware-verify): unconfirmed against a real hub — see create_discover_resp()'s callers'
82/// file-level @warning (key_extraction_responder.cpp) for the general caveat this falls under.
83constexpr uint8_t KEY_EXTRACTION_DISCOVER_RESP_FLAGS = 0xDD;
84
85/// Timestamp for our device-role CMD_DISCOVER_RESP (0x29), alongside the flags constant above. Set
86/// to the same Somfy Izymo dimmer capture's value (0x000E) for the same reason: an unverified
87/// field is safer matched to a real device's exact bytes than left at the one value no real
88/// capture in this project's corpus ever shows (0x0000 — see KEY_EXTRACTION_DISCOVER_RESP_FLAGS
89/// above for the flags half of the same reasoning).
90/// TODO(hardware-verify): unconfirmed against a real hub — same caveat as the flags constant above.
91constexpr uint16_t KEY_EXTRACTION_DISCOVER_RESP_TIMESTAMP = 0x000E;
92
93/// Mask for the low byte of a 16-bit field split across 2 wire bytes — same big-endian split
94/// proto_codecs.cpp's decode side uses (DISCOVERY_TIMESTAMP_MSB_SHIFT there).
95///
96/// This is one of several file-local 0xFF low-byte masks in this codebase (see also
97/// RANDOM_LOW_BYTE_MASK in key_extraction_responder.cpp and the inline `& 0xFF` uses in
98/// proto_crypto.cpp's construct_iv_1w_sequence()) — a recurring pattern with no shared constant.
99/// That is consistent with how this codebase treats single-purpose local masks generally (kept
100/// file-local rather than centralized), not an oversight specific to this one.
101constexpr uint16_t LOW_BYTE_MASK = 0xFF;
102/// Standard payload length for full execute-family commands.
103constexpr size_t EXECUTE_PAYLOAD_SIZE = 8;
104/// Bit flag that marks the standard position payload layout after the encoded position byte.
105constexpr uint8_t EXECUTE_POSITION_LAYOUT_FLAG = 0x80;
106/// Travel-profile byte for a normal-speed move — the last field of the extended execute block.
107constexpr uint8_t EXECUTE_POSITION_PROFILE = 0x06;
108/// Travel-profile byte for a silent (slow) move — same field, selecting reduced motor speed.
109///
110/// A Somfy hub commanding one RS100, same command and direction, with only the app's "silent
111/// operation" toggle flipped, differs by exactly this one byte (`... D8 06 00` normal vs.
112/// `... D8 05 00` silent); this hub follows Somfy's encoding rather than Velux's (which uses a
113/// different byte, `80 32 00 00`, and omits the extended block entirely for a normal move) because
114/// this hub's own hardware matches Somfy byte for byte.
115constexpr uint8_t EXECUTE_PROFILE_SILENT = 0x05;
116/// Short payload length for special execute commands such as stop/favorite.
117constexpr size_t EXECUTE_SPECIAL_PAYLOAD_SIZE = 6;
118/// Long-form CMD_PRIVATE2 (0x0C) payload length. Coincides numerically with
119/// EXECUTE_SPECIAL_PAYLOAD_SIZE but is a distinct command's payload shape — do not merge the two
120/// constants; a change to one must not silently change the other.
121constexpr size_t PRIVATE2_LONG_PAYLOAD_SIZE = 6;
122/// Extended-block flag at data[2] of the long-form CMD_PRIVATE2 payload — the same 0x80 byte
123/// value the field-observed extended-CMD_PRIVATE selector uses (create_get_status_extended()),
124/// but a distinct field on a distinct command. EXECUTE_POSITION_LAYOUT_FLAG above is CMD_EXECUTE's
125/// unrelated data[4] flag and must not be reused here even though it is also 0x80.
126constexpr uint8_t PRIVATE2_EXTENDED_BLOCK_FLAG = 0x80;
127/// Status-update acknowledgement payload matched from controller traffic.
128constexpr uint8_t STATUS_UPDATE_ACK_PAYLOAD[] = {0x05, 0x00};
129/// Set-config payload that enables automatic status updates from the device.
130constexpr uint8_t SET_CONFIG1_STATUS_BROADCAST_PAYLOAD[] = {0xE0, 0x10, 0x0A, 0x08, 0x00};
131/// Identify-request parameter byte (data[1] of the CMD_IDENTIFY payload).
132constexpr uint8_t IDENTIFY_PARAMETER = 0xFF;
133
134/// @brief Build the standard 8-byte position payload shared by create_execute_position() and
135/// create_force_open() — identical except for the ACEI byte.
136inline std::array<uint8_t, EXECUTE_PAYLOAD_SIZE> make_position_payload(uint8_t acei, uint8_t position,
137 bool silent = false) {
138 // Only the profile byte changes; the non-silent form is untouched and is byte-identical to what
139 // a Somfy hub sends for a normal-speed move.
140 return {EXECUTE_ORIGINATOR, acei, static_cast<uint8_t>(POSITION_WIRE_SCALE * position), 0x00,
141 EXECUTE_POSITION_LAYOUT_FLAG, POS_FAVORITE, silent ? EXECUTE_PROFILE_SILENT : EXECUTE_POSITION_PROFILE, 0x00};
142}
143
144// === 1W execute (CMD 0x00) frame assembly ===
145
146/// 1W execute command parameters: origin(1) + acei(1) + main[2] + fp1(1) + fp2(1). This is the
147/// 6-byte "special" payload form, which 1W uses even for numeric positions (unlike 2W's 8-byte
148/// create_execute_position() layout).
149constexpr uint8_t ONEWAY_EXECUTE_PARAMS_SIZE = 6;
150// ONEWAY_EXECUTE_ACEI (the Somfy-shaped default) and ONEWAY_EXECUTE_ACEI_VELUX live in
151// proto_constants.h so oneway_controller.h's resolve_oneway_wire_profile() can name them without
152// the protocol layer depending on the controller layer. build_1w_execute() takes the ACEI as a
153// parameter now, defaulting to ONEWAY_EXECUTE_ACEI.
154/// Rolling sequence width; big-endian on the wire, and never part of the signed span.
155constexpr uint8_t ONEWAY_SEQUENCE_SIZE = 2;
156/// 1W execute MAC span: the command byte followed by all ONEWAY_EXECUTE_PARAMS_SIZE parameter
157/// bytes, stopping before the sequence. See create_1w_execute_command()'s doxygen
158/// (proto_commands.h) for the published-vector and reference-implementation citations pinning
159/// this span; it is command-specific and must not be reused for any other 1W command.
160constexpr uint8_t ONEWAY_EXECUTE_MAC_SPAN_SIZE = 1 + ONEWAY_EXECUTE_PARAMS_SIZE;
161/// Offsets of the sequence and MAC within the payload, derived from the field widths above so
162/// the layout cannot be restated inconsistently.
163constexpr uint8_t ONEWAY_EXECUTE_SEQUENCE_OFFSET = ONEWAY_EXECUTE_PARAMS_SIZE;
164constexpr uint8_t ONEWAY_EXECUTE_MAC_OFFSET = ONEWAY_EXECUTE_SEQUENCE_OFFSET + ONEWAY_SEQUENCE_SIZE;
165/// 1W execute declared payload: parameters + sequence + MAC = 14 bytes, matching the reference
166/// `_p0x00_14` struct.
167constexpr uint8_t ONEWAY_EXECUTE_PAYLOAD_SIZE = ONEWAY_EXECUTE_MAC_OFFSET + HMAC_SIZE;
168/// 1W execute functional-parameter bytes; always zero for the frames this codebase builds.
169constexpr uint8_t ONEWAY_EXECUTE_FP1 = 0x00;
170constexpr uint8_t ONEWAY_EXECUTE_FP2 = 0x00;
171
172/// Shared frame-header setup for every 1W broadcast builder (build_1w_execute(),
173/// create_1w_add_controller(), create_1w_remove_controller()): all three address a device-class
174/// broadcast rather than an individual node, and none set LOW_POWER here.
175///
176/// ctrl1 is deliberately left at 0 (LOW_POWER / CTRL1_LOW_POWER NOT set) by every builder in this
177/// file, unlike every 2W builder. The reference `forgePacket` sets it, but five independently
178/// captured real 1W frames all disagree: this project's own Somfy awning remote
179/// (tests/corpus/captures/oneway/somfy_smoove_oneway_{open,close,stop}_sx1276.yaml), an
180/// unidentified 1W remote (tests/corpus/captures/oneway/unidentified_1w_remote_oneway_execute.yaml), and
181/// the published vector (tests/corpus/captures/oneway/reference_1w_oneway_execute_iv_vector.yaml)
182/// all carry ctrl1=0x00. Followed the captures over the reference source on this point.
183///
184/// `OneWayTransmitter::send_burst()` (ADR 0038) is what may later put CTRL1_LOW_POWER on the wire
185/// for a `low_power: true` identity — on copy 1 of the burst only, never here: this builder layer
186/// stays chip- and burst-agnostic and always hands back a plain ctrl1=0 frame.
187void init_1w_broadcast_frame(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type) {
188 init_frame(f, /*is_2w=*/false, /*start=*/true, /*end=*/true, /*low_power=*/false);
189
190 uint8_t dst[NODE_ID_SIZE];
191 encode_broadcast_address(target_type, dst);
192 set_dst(f, dst);
193 set_src(f, src);
194}
195
196/// @brief Shared assembly for both 1W execute builders: header, MAC span/HMAC, and the 14-byte
197/// payload. create_1w_execute_position() and create_1w_execute_command() differ only in how they
198/// derive `main0`/`main1`; every other byte on the wire is identical, so this is the one place
199/// that wiring lives — a second copy would risk drifting from the published IV vector this
200/// span is pinned against. See init_1w_broadcast_frame() above for why ctrl1 always comes out 0
201/// here.
202bool build_1w_execute(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t main0, uint8_t main1,
203 uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], uint8_t acei, bool broadcast_all) {
204 uint8_t payload[ONEWAY_EXECUTE_PAYLOAD_SIZE] = {EXECUTE_ORIGINATOR, acei, main0, main1, ONEWAY_EXECUTE_FP1,
205 ONEWAY_EXECUTE_FP2};
206
207 // The span is the command byte followed by exactly the parameter bytes that go on air, so it
208 // is copied out of `payload` rather than restated — a restatement could drift from the wire
209 // and produce a signature that verifies against nothing.
210 uint8_t mac_span[ONEWAY_EXECUTE_MAC_SPAN_SIZE];
211 mac_span[0] = CMD_EXECUTE;
212 memcpy(&mac_span[1], payload, ONEWAY_EXECUTE_PARAMS_SIZE);
213
214 // Sign before touching `f`, so a failure leaves the caller's frame exactly as it found it.
215 // 1W is fire-and-forget: a caller that ignored the return value and transmitted a half-built
216 // frame would get no error back from anywhere — the failure would be silent and on air.
217 if (!crypto::create_1w_hmac(mac_span, sizeof(mac_span), sequence, controller_key,
218 &payload[ONEWAY_EXECUTE_MAC_OFFSET]))
219 return false;
220
221 payload[ONEWAY_EXECUTE_SEQUENCE_OFFSET] = static_cast<uint8_t>(sequence >> BITS_PER_BYTE);
222 payload[ONEWAY_EXECUTE_SEQUENCE_OFFSET + 1] = static_cast<uint8_t>(sequence);
223
224 // A handheld cover remote of either vendor broadcasts open/close/stop to the all-devices
225 // address; only a class-bound identity uses the typed one. encode_broadcast_address(UNKNOWN)
226 // is already `00 00 3F` (only DeviceType 0 yields it) — do not add a second address path.
227 init_1w_broadcast_frame(f, src, broadcast_all ? DeviceType::UNKNOWN : target_type);
228
229 return set_cmd(f, CMD_EXECUTE, payload, sizeof(payload));
230}
231
232// === 1W Enrollment (CMD 0x30 add-controller / CMD 0x39 remove-controller) ===
233
234/// CMD 0x30 declared-payload layout: enc_key[16] + man_id[1] + data[1] + sequence[2] = 20 bytes.
235/// Offsets mirror decode_1w_add_controller()'s ONEWAY_ADD_CONTROLLER_* constants (proto_codecs.cpp)
236/// exactly, since the two must agree on the wire shape by construction.
237constexpr uint8_t ONEWAY_ADD_ENC_KEY_OFFSET = 0;
238constexpr uint8_t ONEWAY_ADD_MANUFACTURER_OFFSET = AES_KEY_SIZE; // 16
239constexpr uint8_t ONEWAY_ADD_DATA_OFFSET = AES_KEY_SIZE + 1; // 17
240constexpr uint8_t ONEWAY_ADD_SEQUENCE_OFFSET = AES_KEY_SIZE + 2; // 18
241constexpr uint8_t ONEWAY_ADD_PAYLOAD_SIZE = AES_KEY_SIZE + 4; // 20
242/// `data` field of CMD 0x30's payload — every source (published vector, reference `Add` case)
243/// shows 0x01 here; its meaning beyond "add" is undocumented.
244constexpr uint8_t ONEWAY_ADD_DATA_VALUE = 0x01;
245/// CMD 0x30's MAC span: cmd + enc_key only (17 bytes) — verified against the published vector
246/// (create_1w_hmac()'s `@warning`), NOT the whole declared payload.
247constexpr uint8_t ONEWAY_ADD_MAC_SPAN_SIZE = 1 + AES_KEY_SIZE;
248
249/// CMD 0x39 declared-payload layout, matching the reference `_p0x2e` struct: data[1] + sequence[2]
250/// + mac[6] = 9 bytes, MAC inside the declared length (unlike CMD 0x30's out-of-length trailer).
251constexpr uint8_t ONEWAY_REMOVE_DATA_OFFSET = 0;
252constexpr uint8_t ONEWAY_REMOVE_SEQUENCE_OFFSET = 1;
253constexpr uint8_t ONEWAY_REMOVE_MAC_OFFSET = 3;
254constexpr uint8_t ONEWAY_REMOVE_PAYLOAD_SIZE = 9;
255/// `data` field observed in every source for CMD 0x39.
256constexpr uint8_t ONEWAY_REMOVE_DATA_VALUE = 0x00;
257/// CMD 0x39's MAC span. No known-answer vector pins this — see create_1w_remove_controller()'s
258/// `@warning` (proto_commands.h) for why `cmd + data` was chosen over the alternatives.
259constexpr uint8_t ONEWAY_REMOVE_MAC_SPAN_SIZE = 2;
260
261/// Build a no-payload, addressed, authenticated request for `cmd` — the shape every
262/// device-info read (CMD_GET_NAME, CMD_GET_INFO1/2, CMD_GET_GENERAL_INFO3) uses.
263bool create_no_payload_request(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t cmd) {
264 init_frame(f, true, true, false, low_power);
265 set_dst(f, dst);
266 set_src(f, own);
267 return set_cmd(f, cmd);
268}
269
270} // namespace
271
272/// Build a position execute command (0x00) to move a device to a numeric position.
273bool create_execute_position(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t position,
274 bool silent) {
275 if (position > POSITION_PERCENT_MAX)
276 return false;
277 init_frame(f, true, true, false, low_power);
278 set_dst(f, dst);
279 set_src(f, own);
280 const auto payload = make_position_payload(EXECUTE_ACEI, position, silent);
281 return set_cmd(f, CMD_EXECUTE, payload.data(), payload.size());
282}
283
284/// Build a named-command execute frame (0x00) for STOP, FAVORITE, or VENT.
285///
286/// FORCE_OPEN is deliberately not handled here — unlike these three, it needs to know the
287/// device's wire-scale "fully open" position (0 or 100 depending on IoDevice::inverted, e.g.
288/// horizontal awnings), which this builder has no way to know. Use create_force_open() instead;
289/// see its comments for why.
290bool create_execute_command(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, CoverCommand cmd,
291 bool silent) {
292 uint8_t main_byte = 0;
293 uint8_t modifier_byte = 0;
294 switch (cmd) {
296 main_byte = POS_STOP;
297 modifier_byte = 0x00;
298 break;
300 main_byte = POS_FAVORITE;
301 modifier_byte = 0x00;
302 break;
304 main_byte = POS_FAVORITE;
305 modifier_byte = POS_VENT_MODIFIER;
306 break;
307 default:
308 return false;
309 }
310 init_frame(f, true, true, false, low_power);
311 set_dst(f, dst);
312 set_src(f, own);
313 // FAVORITE is the one command here the capture covers: a Somfy hub sends the plain 6-byte form
314 // for a normal "My" press and the extended 8-byte form with the silent profile when the toggle
315 // is on, which is exactly the pair below. STOP is excluded because stopping has no travel speed,
316 // and VENT because nothing has been captured for it — every extended frame observed so far has
317 // byte 3 clear, whereas VENT puts its modifier there, so extending it would be a guess.
318 if (silent && cmd == CoverCommand::FAVORITE) {
319 const uint8_t extended[EXECUTE_PAYLOAD_SIZE] = {
320 EXECUTE_ORIGINATOR, EXECUTE_ACEI, main_byte, modifier_byte, EXECUTE_POSITION_LAYOUT_FLAG,
321 POS_FAVORITE, EXECUTE_PROFILE_SILENT, 0x00};
322 return set_cmd(f, CMD_EXECUTE, extended, sizeof(extended));
323 }
324 const uint8_t payload[EXECUTE_SPECIAL_PAYLOAD_SIZE] = {EXECUTE_ORIGINATOR, EXECUTE_ACEI, main_byte,
325 modifier_byte, 0x00, 0x00};
326 return set_cmd(f, CMD_EXECUTE, payload, sizeof(payload));
327}
328
329/// Build a force-open execute frame (0x00): an ordinary position command to the device's
330/// wire-scale "fully open" value, sent at elevated ACEI priority (see EXECUTE_ACEI_FORCE_OPEN).
331///
332/// Takes the target position explicitly rather than assuming 0, because "fully open" is not
333/// always wire-position 0: IoDevice::inverted devices (e.g. horizontal awnings) have open/close
334/// swapped, so their fully-open wire position is 100. Hardcoding 0 here would, on a real inverted
335/// awning, target its already-*closed* resting position — a confirmed no-op rather than a lock
336/// bypass. The caller (execute_device_command_() in hub_operations.cpp) is responsible for
337/// resolving the correct value from the target IoDevice.
338bool create_force_open(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t open_position) {
339 init_frame(f, true, true, false, low_power);
340 set_dst(f, dst);
341 set_src(f, own);
342 const auto payload = make_position_payload(EXECUTE_ACEI_FORCE_OPEN, open_position);
343 return set_cmd(f, CMD_EXECUTE, payload.data(), payload.size());
344}
345
346/// Build a 1W position execute frame (CMD 0x00) targeting a device class. See proto_commands.h
347/// for the full contract.
348bool create_1w_execute_position(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t position,
349 uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], uint8_t acei,
350 bool broadcast_all) {
351 if (position > POSITION_PERCENT_MAX)
352 return false;
353 return build_1w_execute(f, src, target_type, static_cast<uint8_t>(POSITION_WIRE_SCALE * position), 0x00, sequence,
354 controller_key, acei, broadcast_all);
355}
356
357/// Build a 1W named-command execute frame (CMD 0x00) targeting a device class. See
358/// proto_commands.h for the full contract, including why 1W has no FORCE_OPEN: the only known
359/// wire encoding for the label (POS_FORCE_OPEN, main=0x64) was hardware-tested as an ordinary
360/// move-to-50% command, not a lock bypass — see the POS_FORCE_OPEN doc comment in
361/// proto_constants.h. Passing CoverCommand::FORCE_OPEN here falls through to `default` and
362/// returns false, matching 2W's create_execute_command().
363bool create_1w_execute_command(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, CoverCommand cmd,
364 uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], uint8_t acei,
365 bool broadcast_all) {
366 uint8_t main0 = 0;
367 uint8_t main1 = 0;
368 switch (cmd) {
370 main0 = POS_STOP;
371 break;
373 main0 = POS_FAVORITE;
374 break;
376 main0 = POS_FAVORITE;
377 main1 = POS_VENT_MODIFIER;
378 break;
379 default:
380 return false;
381 }
382 return build_1w_execute(f, src, target_type, main0, main1, sequence, controller_key, acei, broadcast_all);
383}
384
385/// Build a 1W add-controller frame (CMD 0x30). See proto_commands.h for the full contract,
386/// including why the MAC is an optional, out-of-length trailer here and not inside the declared
387/// payload, and why `with_mac` exists at all (real hardware omits it; the published vector
388/// doesn't).
389bool create_1w_add_controller(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t manufacturer,
390 uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], bool with_mac) {
391 uint8_t payload[ONEWAY_ADD_PAYLOAD_SIZE] = {0};
392
393 // Self-inverse wrap (crypto::crypt_1w_key()'s doxygen) -- the same call decode_1w_add_controller()
394 // uses to unwrap an overheard 0x30 also wraps our own key for transmission here.
395 if (!crypto::crypt_1w_key(src, controller_key, &payload[ONEWAY_ADD_ENC_KEY_OFFSET]))
396 return false;
397
398 payload[ONEWAY_ADD_MANUFACTURER_OFFSET] = manufacturer;
399 payload[ONEWAY_ADD_DATA_OFFSET] = ONEWAY_ADD_DATA_VALUE;
400 payload[ONEWAY_ADD_SEQUENCE_OFFSET] = static_cast<uint8_t>(sequence >> BITS_PER_BYTE);
401 payload[ONEWAY_ADD_SEQUENCE_OFFSET + 1] = static_cast<uint8_t>(sequence);
402
403 // The span is command + enc_key only, copied out of `payload` rather than restated -- see
404 // build_1w_execute()'s comment for why a restatement risks drifting from the wire. Computed
405 // even when with_mac is false so a caller flipping the flag later doesn't also have to
406 // reconsider whether signing itself can fail -- crypto::create_1w_hmac() is still the "sign
407 // before touching f" guard for the whole builder either way.
408 uint8_t mac_span[ONEWAY_ADD_MAC_SPAN_SIZE];
409 mac_span[0] = CMD_ONEWAY_ADD_CONTROLLER;
410 memcpy(&mac_span[1], &payload[ONEWAY_ADD_ENC_KEY_OFFSET], AES_KEY_SIZE);
411
412 uint8_t mac[HMAC_SIZE];
413 // Sign before touching `f`, same rule as build_1w_execute(): 1W is fire-and-forget, so a
414 // half-built frame a caller transmitted anyway would fail silently with nobody to report it.
415 if (!crypto::create_1w_hmac(mac_span, sizeof(mac_span), sequence, controller_key, mac))
416 return false;
417
418 init_1w_broadcast_frame(f, src, target_type);
419
420 if (!set_cmd(f, CMD_ONEWAY_ADD_CONTROLLER, payload, sizeof(payload)))
421 return false;
422
423 // The MAC is an out-of-length trailer for this command only (frame_carries_mac_trailer()) --
424 // set after set_cmd() succeeds, since init_frame() above would otherwise reset it right back
425 // off. Left false (real Somfy hardware's own shape) when with_mac is false.
426 if (with_mac) {
427 f.has_mac = true;
428 memcpy(f.mac, mac, HMAC_SIZE);
429 }
430 return true;
431}
432
433/// Build a 1W remove-controller frame (CMD 0x39). See proto_commands.h for the full contract,
434/// including the @warning that no vector pins this command's MAC span.
435bool create_1w_remove_controller(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint16_t sequence,
436 const uint8_t controller_key[AES_KEY_SIZE]) {
437 uint8_t payload[ONEWAY_REMOVE_PAYLOAD_SIZE] = {0};
438 payload[ONEWAY_REMOVE_DATA_OFFSET] = ONEWAY_REMOVE_DATA_VALUE;
439 payload[ONEWAY_REMOVE_SEQUENCE_OFFSET] = static_cast<uint8_t>(sequence >> BITS_PER_BYTE);
440 payload[ONEWAY_REMOVE_SEQUENCE_OFFSET + 1] = static_cast<uint8_t>(sequence);
441
442 // Unpinned span (see the @warning in proto_commands.h): cmd + data, the same "everything before
443 // the sequence" shape CMD 0x00's span follows.
444 uint8_t mac_span[ONEWAY_REMOVE_MAC_SPAN_SIZE] = {CMD_ONEWAY_REMOVE, payload[ONEWAY_REMOVE_DATA_OFFSET]};
445
446 // Sign before touching `f` -- same rule as every other 1W builder in this file.
447 if (!crypto::create_1w_hmac(mac_span, sizeof(mac_span), sequence, controller_key, &payload[ONEWAY_REMOVE_MAC_OFFSET]))
448 return false;
449
450 init_1w_broadcast_frame(f, src, target_type);
451
452 return set_cmd(f, CMD_ONEWAY_REMOVE, payload, sizeof(payload));
453}
454
455/// Build a CMD_PRIVATE (0x03) request for an arbitrary function ID. function_id = 0x06/0x09
456/// reads battery state; create_get_status() below is this builder frozen at function_id =
457/// PRIVATE_GET_POSITION_STATUS (0x03), the only function ID this codebase has ever captured on
458/// its own wire.
459bool create_private_function(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t function_id,
460 uint8_t sub_index) {
461 init_frame(f, true, true, false, low_power);
462 set_dst(f, dst);
463 set_src(f, own);
464 uint8_t d[3] = {function_id, sub_index, 0x00};
465 return set_cmd(f, CMD_PRIVATE, d, sizeof(d));
466}
467
468/// Build a get-status request (0x03). The device responds with its current position.
469bool create_get_status(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power) {
470 return create_private_function(f, own, dst, low_power, PRIVATE_GET_POSITION_STATUS);
471}
472
473bool create_get_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power) {
474 return create_no_payload_request(f, own, dst, low_power, CMD_GET_NAME);
475}
476
477/// Build a CMD_GET_GENERAL_INFO3 (0x58) request. No payload — delegates to the shared
478/// create_no_payload_request() helper, the shape every device-info read uses.
479bool create_general_info3(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power) {
480 return create_no_payload_request(f, own, dst, low_power, CMD_GET_GENERAL_INFO3);
481}
482
483/// Build a CMD_GET_INFO1 (0x54) request. No payload. See proto_commands.h for the evidence note.
484bool create_get_info1(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power) {
485 return create_no_payload_request(f, own, dst, low_power, CMD_GET_INFO1);
486}
487
488/// Build a CMD_GET_INFO2 (0x56) request. No payload. See proto_commands.h for the evidence note.
489bool create_get_info2(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power) {
490 return create_no_payload_request(f, own, dst, low_power, CMD_GET_INFO2);
491}
492
493bool create_set_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power,
494 const uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE]) {
495 init_frame(f, true, true, false, low_power);
496 set_dst(f, dst);
497 set_src(f, own);
498 return set_cmd(f, CMD_SET_NAME, payload, DEVICE_NAME_WRITE_PAYLOAD_SIZE);
499}
500
501/// Build an authenticated device-identify request (0x1E).
502bool create_identify(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power) {
503 init_frame(f, true, true, false, low_power);
504 set_dst(f, dst);
505 set_src(f, own);
506 const uint8_t payload[2] = {ORIGINATOR_USER_REMOTE, IDENTIFY_PARAMETER};
507 return set_cmd(f, CMD_IDENTIFY, payload, sizeof(payload));
508}
509
510/// Build a generic CMD_WRITE_PRIVATE (0x20) frame around a caller-supplied payload — the one
511/// builder behind every heating/climate function. Framing per the iohomecontrol reference
512/// implementation's `forgePacket()` (start=1, end=0) and the
513/// atlantic_thermor_exchange_write_private_param.yaml capture (frame 1: start, not end).
514bool create_write_private(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, const uint8_t *payload,
515 size_t payload_len) {
516 if (payload == nullptr || payload_len == 0 || payload_len > FRAME_MAX_DATA_SIZE)
517 return false;
518 init_frame(f, /*is_2w=*/true, /*start=*/true, /*end=*/false, low_power);
519 set_dst(f, dst);
520 set_src(f, own);
521 return set_cmd(f, CMD_WRITE_PRIVATE, payload, static_cast<uint8_t>(payload_len));
522}
523
524/// Build a tilt execute command (0x00) for devices that support slat angle control.
525bool create_execute_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t tilt_percent) {
526 init_frame(f, true, true, false, low_power);
527 set_dst(f, dst);
528 set_src(f, own);
529
530 auto const tilt_value =
531 static_cast<uint16_t>((POSITION_PERCENT_MAX - tilt_percent) * STATUS_POS_MAX / POSITION_PERCENT_MAX);
532 uint8_t d[EXECUTE_PAYLOAD_SIZE] = {EXECUTE_ORIGINATOR,
533 EXECUTE_ACEI,
534 POS_UNKNOWN,
535 0x00,
536 STATUS_TILT_SELECTOR,
537 static_cast<uint8_t>(tilt_value >> BITS_PER_BYTE),
538 static_cast<uint8_t>(tilt_value),
539 0x00};
540 return set_cmd(f, CMD_EXECUTE, d, sizeof(d));
541}
542
543/// Build a combined position-and-tilt execute command (0x00) — setClosureAndOrientation.
544bool create_execute_position_and_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power,
545 uint8_t position, uint8_t tilt_percent) {
546 if (position > POSITION_PERCENT_MAX)
547 return false;
548 init_frame(f, true, true, false, low_power);
549 set_dst(f, dst);
550 set_src(f, own);
551
552 auto const tilt_value =
553 static_cast<uint16_t>((POSITION_PERCENT_MAX - tilt_percent) * STATUS_POS_MAX / POSITION_PERCENT_MAX);
554 uint8_t d[EXECUTE_PAYLOAD_SIZE] = {EXECUTE_ORIGINATOR,
555 EXECUTE_ACEI,
556 static_cast<uint8_t>(2 * position),
557 0x00,
558 STATUS_TILT_SELECTOR,
559 static_cast<uint8_t>(tilt_value >> BITS_PER_BYTE),
560 static_cast<uint8_t>(tilt_value),
561 0x00};
562 return set_cmd(f, CMD_EXECUTE, d, sizeof(d));
563}
564
565/// Build an extended CMD_PRIVATE (0x03) request with a selector/block pair — the shape real
566/// hubs use for both the tilt block (selector STATUS_TILT_SELECTOR) and the field-observed
567/// selector 0x80 (tests/corpus/captures/probe/multi_somfy_probe_extended_private_both_selectors.yaml),
568/// which this codebase has never decoded. `block` is the field-observed name for the byte that
569/// varies (0x00/0x01) for selector 0x80; create_get_status_tilt() below is this builder frozen
570/// at selector = STATUS_TILT_SELECTOR, block = 0x01. `function_id` defaults to
571/// PRIVATE_GET_POSITION_STATUS (0x03) — the only value ever seen on air in this shape; other
572/// values are diagnostic probes into an undecoded function ID.
573bool create_get_status_extended(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t selector,
574 uint8_t block, uint8_t function_id) {
575 init_frame(f, true, true, false, low_power);
576 set_dst(f, dst);
577 set_src(f, own);
578 uint8_t d[4] = {function_id, selector, block, 0x00};
579 return set_cmd(f, CMD_PRIVATE, d, sizeof(d));
580}
581
582/// Build a tilt-aware get-status request (0x03) that returns the extended 16-byte tilt payload.
583bool create_get_status_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power) {
584 return create_get_status_extended(f, own, dst, low_power, STATUS_TILT_SELECTOR, 0x01);
585}
586
587/// Build a CMD_PRIVATE2 (0x0C) request in either of the two field-observed shapes. The payload
588/// is CMD_EXECUTE's POS_FAVORITE/POS_VENT_MODIFIER stored-position selector with the execution
589/// prefix stripped — `modifier` is that same selector byte (e.g. POS_VENT_MODIFIER for vent).
590///
591/// `low_power` is a separate parameter, not derived from `long_form`: the two captured fixtures
592/// (tests/corpus/captures/probe/multi_somfy_probe_private2_{long_form,short_form}.yaml)
593/// do carry CTRL1_LOW_POWER set on the long-form request and clear on the short-form ones, but
594/// that tracks the *target device's* power class in each capture (a solar shutter vs. a
595/// mains-powered switch), not the payload shape — the same relationship every other
596/// device-addressed builder in this file has to `low_power`. Deriving it from `long_form` would
597/// silently clear the flag on a short-form probe sent to a solar device.
598bool create_private2_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t modifier, bool long_form,
599 bool low_power) {
600 init_frame(f, true, true, false, low_power);
601 set_dst(f, dst);
602 set_src(f, own);
603 if (long_form) {
604 uint8_t d[PRIVATE2_LONG_PAYLOAD_SIZE] = {POS_UNKNOWN, 0x00, PRIVATE2_EXTENDED_BLOCK_FLAG,
605 POS_FAVORITE, modifier, 0x00};
606 return set_cmd(f, CMD_PRIVATE2, d, sizeof(d));
607 }
608 uint8_t d[4] = {POS_FAVORITE, modifier, 0x00, 0x00};
609 return set_cmd(f, CMD_PRIVATE2, d, sizeof(d));
610}
611
612/// Build a discovery broadcast (0x28). Sent to the broadcast address 0x00003B.
613/// Only devices in pairing mode (PROG button pressed) will respond.
614bool create_discover(IoFrame &f, const uint8_t *own) {
615 // start+end: single broadcast frame.
616 init_frame(f, true, true, true, false);
617 set_dst(f, BROADCAST_DISCOVER);
618 set_src(f, own);
619 return set_cmd(f, CMD_DISCOVER_REQ);
620}
621
622/// Build a configurable discovery request command (0x28, 0x2A, or 0x2E).
623///
624/// For 0x2A (Discover SPE), the payload is a 6-byte random nonce followed by a 6-byte
625/// HMAC over the command byte alone, using that nonce as the challenge and the supplied
626/// system key — one frame carrying a whole challenge-response, which is what lets a
627/// broadcast be authenticated. This requires a valid system key; it will not work for a
628/// motor that has never been paired with this controller's key.
629bool create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power,
630 bool ack_capable, bool payload_enabled, uint8_t payload, const uint8_t *system_key) {
631 init_frame(f, true, true, true, low_power);
632 // CTRL1_ACK is deliberately not a parameter of init_frame() itself — see that function's doc for
633 // why it must never be set unconditionally. Scoped here to this discovery broadcast only.
634 if (ack_capable)
635 f.ctrl1 |= CTRL1_ACK;
636 set_dst(f, dst);
637 set_src(f, own);
638
639 switch (command) {
640 case CMD_DISCOVER_REQ:
641 return set_cmd(f, CMD_DISCOVER_REQ);
642
643 case CMD_DISCOVER_SPE_REQ: {
644 if (system_key == nullptr)
645 return false;
646 uint8_t nonce[HMAC_SIZE];
648 // The HMAC covers the command byte *alone*, with the nonce as the challenge — not the
649 // nonce as transcript data, which is what this built until real bytes settled it (a Velux
650 // KLR200's own 0x2A, tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml, recomputed
651 // under that installation's key). The old [cmd, nonce] transcript produced an HMAC no
652 // device could verify, so every 0x2A we emitted was silently unanswerable.
653 uint8_t hmac[HMAC_SIZE];
654 if (!crypto::create_hmac(&command, 1, nonce, system_key, hmac))
655 return false;
656 uint8_t payload_buf[HMAC_SIZE * 2];
657 memcpy(payload_buf, nonce, HMAC_SIZE);
658 memcpy(payload_buf + HMAC_SIZE, hmac, HMAC_SIZE);
659 return set_cmd(f, CMD_DISCOVER_SPE_REQ, payload_buf, sizeof(payload_buf));
660 }
661
662 case CMD_DISCOVER_ALT_REQ: {
663 // 0x2E may carry an optional single-byte payload (e.g., 0x00) or be sent with no payload.
664 if (payload_enabled)
665 return set_cmd(f, CMD_DISCOVER_ALT_REQ, &payload, 1);
666 return set_cmd(f, CMD_DISCOVER_ALT_REQ);
667 }
668
669 default:
670 return false;
671 }
672}
673
674/// Build a discovery response (0x29) — device side, used only by the key-extraction responder.
675/// See proto_commands.h for the full contract and the real-capture cross-check.
676bool create_discover_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst, DeviceType type, uint8_t subtype,
677 uint8_t manufacturer_id) {
678 init_frame(f, true, true, true, false);
679 set_dst(f, dst);
680 set_src(f, own);
681
682 uint8_t payload[DISCOVERY_RESP_FULL_SIZE] = {0};
683 encode_packed_device_type(type, subtype, payload[0], payload[1]);
684 // Backbone address: the captured real Somfy 0x29 (see proto_commands.h doxygen) reports the
685 // device's own node ID here, so we mirror that rather than inventing a separate address.
686 memcpy(&payload[DISCOVERY_RESP_BACKBONE_OFFSET], own, NODE_ID_SIZE);
687 payload[DISCOVERY_RESP_MANUFACTURER_OFFSET] = manufacturer_id;
688 // See KEY_EXTRACTION_DISCOVER_RESP_FLAGS/_TIMESTAMP above for the derivation of these two values.
689 payload[DISCOVERY_RESP_FLAGS_OFFSET] = KEY_EXTRACTION_DISCOVER_RESP_FLAGS;
690 payload[DISCOVERY_RESP_TIMESTAMP_OFFSET] =
691 static_cast<uint8_t>(KEY_EXTRACTION_DISCOVER_RESP_TIMESTAMP >> BITS_PER_BYTE);
692 payload[DISCOVERY_RESP_TIMESTAMP_OFFSET + 1] =
693 static_cast<uint8_t>(KEY_EXTRACTION_DISCOVER_RESP_TIMESTAMP & LOW_BYTE_MASK);
694 return set_cmd(f, CMD_DISCOVER_RESP, payload, sizeof(payload));
695}
696
697/// Build a bare device→hub terminal acknowledgement: no payload, END set, START and LOW_POWER
698/// clear. Shared by create_key_confirm() and create_discover_confirm_ack(), which are the same
699/// frame shape and differ only in command byte — real captures of both
700/// (tests/corpus/captures/pairing/somfy_izymo_dimmer_pairing_full_sx1276.yaml's 0x33 `88 00 …`,
701/// velux_kux100_pairing_full.yaml's 0x2D `88 08 …`, and this project's own key-extraction
702/// responder against a real hub in
703/// tests/corpus/captures/pairing/velux_kig300_pairing_key_extraction_success.yaml, both 0x2D and
704/// 0x33 as `88 00 …`) show a device closing its half of a two-frame handshake this way. LOW_POWER
705/// stays clear because that bit describes the *target* of a controller-originated frame (see the
706/// header's convention note); a device does not flag a frame it sends *to* the hub as low-power.
707static bool create_device_terminal_ack(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t cmd) {
708 init_frame(f, true, false, true, false);
709 set_dst(f, dst);
710 set_src(f, own);
711 return set_cmd(f, cmd);
712}
713
714/// Build a key-confirm frame (0x33) — device side, used only by the key-extraction responder.
715/// See proto_commands.h for the full contract and the real-capture cross-check.
716bool create_key_confirm(IoFrame &f, const uint8_t *own, const uint8_t *dst) {
717 return create_device_terminal_ack(f, own, dst, CMD_KEY_CONFIRM);
718}
719
720/// Build a bare controller→device start frame: no payload, START set, END clear, LOW_POWER and
721/// ACK as the caller decides. Shared by create_discover_confirm() and create_key_init(), which are
722/// the same frame shape and differ only in command byte and in which CTRL1 bits they pass — the
723/// controller-side counterpart of create_device_terminal_ack() above.
724static bool create_controller_start_frame(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t cmd,
725 bool low_power, bool ack) {
726 init_frame(f, true, /*start=*/true, /*end=*/false, low_power);
727 // CTRL1_ACK is deliberately not a parameter of init_frame() itself — see that function's doc
728 // for why it must never be set unconditionally. Scoped to the frame each caller builds, same
729 // pattern as create_discovery_request()'s ack_capable.
730 if (ack)
731 f.ctrl1 |= CTRL1_ACK;
732 set_dst(f, dst);
733 set_src(f, own);
734 return set_cmd(f, cmd);
735}
736
737/// Build a discovery-confirm request (0x2C) — controller side, sent directly to a
738/// freshly-discovered device before the key exchange. See proto_commands.h for the full contract
739/// and the real-capture cross-check.
740bool create_discover_confirm(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, bool ack) {
741 return create_controller_start_frame(f, own, dst, CMD_DISCOVER_CONFIRM, low_power, ack);
742}
743
744/// Build a discovery-confirm acknowledgement (0x2D) — device side, used only by the
745/// key-extraction responder. See proto_commands.h for the full contract.
746bool create_discover_confirm_ack(IoFrame &f, const uint8_t *own, const uint8_t *dst) {
747 return create_device_terminal_ack(f, own, dst, CMD_DISCOVER_CONFIRM_ACK);
748}
749
750/// Recover the system key from a CMD_KEY_TRANSFER payload. See proto_commands.h for the full
751/// contract; this is the single place the IV-`data` convention (`{CMD_KEY_INIT}, len 1`) lives
752/// for the decode direction, mirroring create_key_transfer()'s encode side below.
753bool recover_system_key_from_transfer(const uint8_t transfer_payload[AES_KEY_SIZE], const uint8_t challenge[HMAC_SIZE],
754 uint8_t out_key[AES_KEY_SIZE]) {
755 const uint8_t key_init_cmd = CMD_KEY_INIT;
756 return crypto::crypt_key(&key_init_cmd, 1, challenge, transfer_payload, out_key);
757}
758
759/// Build a key-init request (0x31) to start the pairing key exchange with a discovered device.
760bool create_key_init(IoFrame &f, const uint8_t *own, const uint8_t *dst) {
761 // low_power=true, no ACK: the pairing key-init keeps the fixed `48 20` shape it was
762 // hardware-validated at (ADR 0029's out-of-scope note).
763 return create_controller_start_frame(f, own, dst, CMD_KEY_INIT, /*low_power=*/true, /*ack=*/false);
764}
765
766/// Build a key-transfer frame (0x32) containing the system key encrypted with the transfer key.
767bool create_key_transfer(IoFrame &f, IoFrame &old_frame, const uint8_t *dst, const uint8_t *src,
768 const uint8_t key[AES_KEY_SIZE], const uint8_t challenge[HMAC_SIZE]) {
769 // low_power=true: the pairing key-transfer keeps a fixed frame shape, hardware-validated as-is.
770 // It is a non-start continuation frame and stays outside the per-device low_power rule (ADR 0029).
771 init_frame(f, true, false, false, true);
772 set_dst(f, dst);
773 set_src(f, src);
774 // The pairing capture we matched derives the IV from the previous command byte only. Treating
775 // the key-init frame that narrowly keeps our key transfer aligned with real controllers.
776 uint8_t enc_key[AES_KEY_SIZE];
777 if (!crypto::crypt_key(&old_frame.cmd, 1, challenge, key, enc_key))
778 return false;
779 return set_cmd(f, CMD_KEY_TRANSFER, enc_key, AES_KEY_SIZE);
780}
781
782/// Build a challenge request (0x3C) with caller-chosen framing bits. Shared by the
783/// controller-role and device-role builders below, which differ only in those bits.
784static bool create_challenge_req_framed(IoFrame &f, const uint8_t *dst, const uint8_t *src,
785 const uint8_t challenge[HMAC_SIZE], bool start, bool low_power) {
786 init_frame(f, true, start, false, low_power);
787 set_dst(f, dst);
788 set_src(f, src);
789 return set_cmd(f, CMD_CHALLENGE_REQ, challenge, HMAC_SIZE);
790}
791
792/// Build a challenge request (0x3C) using a caller-supplied challenge. See proto_commands.h.
793///
794/// Framed exactly like the device-role builder below (`0E 00`, no START, no LOW_POWER) — matches
795/// every 0x3C observed on air across multiple actuators and vendors. Kept as a separate entry
796/// point from create_challenge_req_device_role() because the call sites and rationale differ
797/// (inbound authentication vs the key-extraction responder). If devices ever stop answering our
798/// challenges, START/LOW_POWER framing is the first thing to try restoring here.
799bool create_challenge_req(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE]) {
800 return create_challenge_req_framed(f, dst, src, challenge, /*start=*/false, /*low_power=*/false);
801}
802
803/// Build a challenge request (0x3C) containing 6 random bytes.
804/// Used when WE need to authenticate an incoming request from a device.
805bool create_challenge_req(IoFrame &f, const uint8_t *dst, const uint8_t *src) {
806 uint8_t challenge[HMAC_SIZE];
808 return create_challenge_req(f, dst, src, challenge);
809}
810
811/// Build a device-role challenge request (0x3C) — device side, used only by the key-extraction
812/// responder. See proto_commands.h for why the framing bits differ from the controller-role
813/// builders above.
814bool create_challenge_req_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src,
815 const uint8_t challenge[HMAC_SIZE]) {
816 return create_challenge_req_framed(f, dst, src, challenge, /*start=*/false, /*low_power=*/false);
817}
818
819/// Build a challenge response (0x3D) with caller-chosen framing bits. Shared by the
820/// controller-role and device-role builders below, which differ only in those bits — see
821/// create_challenge_req_framed() above for the identical pattern on the request side.
822static bool create_challenge_resp_framed(IoFrame &f, const uint8_t *dst, const uint8_t *src,
823 const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key,
824 bool end, bool low_power) {
825 init_frame(f, true, /*start=*/false, end, low_power);
826 set_dst(f, dst);
827 set_src(f, src);
828 // The authenticated transcript covers the original request, not the 0x3D wrapper. Using the
829 // origin command byte and payload here was one of the key interoperability findings.
830 uint8_t frame_data[FRAME_MAX_SIZE];
831 frame_data[0] = origin.cmd;
832 memcpy(frame_data + 1, origin.data, origin.data_len);
833 uint8_t hmac[HMAC_SIZE];
834 if (!crypto::create_hmac(frame_data, origin.data_len + 1, challenge, key, hmac))
835 return false;
836 return set_cmd(f, CMD_CHALLENGE_RESP, hmac, HMAC_SIZE);
837}
838
839/// Build a challenge response (0x3D) proving we know the system key.
840/// The HMAC is computed over [original_command_id + original_data] using the challenge.
841bool create_challenge_resp(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE],
842 const IoFrame &origin, const uint8_t *key) {
843 return create_challenge_resp_framed(f, dst, src, challenge, origin, key, /*end=*/false, /*low_power=*/true);
844}
845
846/// Build an address response (0x37) — device side, used only by the key-extraction responder.
847/// See proto_commands.h for the full contract, including why the payload (our own node ID, not a
848/// separately-tracked backbone identity) is a known simplification rather than a confirmed match
849/// to real-device behavior.
850///
851/// TODO(hardware-verify): ctrl1 is 0x00 here (no CTRL1_PRIORITY), matching every other device-role
852/// builder in this file, but the one real capture of this command
853/// (tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml line 87) shows CTRL1_PRIORITY set on the
854/// KLR200's 0x37. In that same capture the device also mirrors CTRL1_PRIORITY from whatever the
855/// hub's preceding request set, and its 0x36 request is the one request in the whole exchange that
856/// sets PRIORITY — but every other device-role builder here also emits ctrl1=0 against frames that
857/// same capture shows with reserved/other bits set, and those builders are hardware-confirmed
858/// working (issue #45's own captures), so this one bit's necessity is unproven rather than known
859/// missing. Not mirroring the request's PRIORITY bit until a second real capture settles it either
860/// way.
861bool create_node_verify_resp_device_role(IoFrame &f, const uint8_t *own, const uint8_t *dst) {
862 init_frame(f, true, /*start=*/false, /*end=*/false, /*low_power=*/false);
863 set_dst(f, dst);
864 set_src(f, own);
865 return set_cmd(f, CMD_NODE_VERIFY_RESP, own, NODE_ID_SIZE);
866}
867
868/// Build a device-role challenge response (0x3D) — device side, used only by the key-extraction
869/// responder answering a hub-issued 0x3C challenging our own 0x37. See proto_commands.h for why
870/// the framing bits differ from the controller-role builder above.
871bool create_challenge_resp_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src,
872 const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key) {
873 return create_challenge_resp_framed(f, dst, src, challenge, origin, key, /*end=*/true, /*low_power=*/false);
874}
875
876/// Build a status-update acknowledgment (0x72). Sent after authenticating a device's status update.
877/// The response is sent on all 3 channels to ensure the device receives it.
878bool create_status_update_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst) {
879 // end=true: final frame. low_power=true: this device-role response keeps a fixed shape,
880 // hardware-validated as-is, and stays outside the per-device low_power rule (ADR 0029).
881 init_frame(f, true, false, true, true);
882 set_dst(f, dst);
883 set_src(f, own);
884 // Status update acknowledgment payload matched from working controller captures.
885 return set_cmd(f, CMD_STATUS_UPDATE_RESP, STATUS_UPDATE_ACK_PAYLOAD, sizeof(STATUS_UPDATE_ACK_PAYLOAD));
886}
887
888/// Build a set-config command (0x6F) to tell the device to automatically send status updates
889/// when controlled by any remote (not just us). Not all devices support this.
890bool create_set_config1(IoFrame &f, const uint8_t *own, const uint8_t *dst) {
891 // low_power=true: this pairing phase-3 config write keeps the fixed `4D 20` CTRL bytes it was
892 // hardware-validated at; the per-device low_power rule deliberately does not reach pairing
893 // frames (ADR 0029). Its preamble is chosen by the pairing engine, not by this flag alone.
894 init_frame(f, true, true, false, true);
895 set_dst(f, dst);
896 set_src(f, own);
897 // Set-config payload matched from working controller captures.
898 return set_cmd(f, CMD_SET_CONFIG1, SET_CONFIG1_STATUS_BROADCAST_PAYLOAD,
899 sizeof(SET_CONFIG1_STATUS_BROADCAST_PAYLOAD));
900}
901
902} // namespace home_io_control
903} // namespace esphome
void generate_challenge(uint8_t out[HMAC_SIZE])
Generate 6 random bytes for a challenge using the ESP32 hardware RNG.
bool create_hmac(const uint8_t *data, uint8_t len, const uint8_t challenge[HMAC_SIZE], const uint8_t key[AES_KEY_SIZE], uint8_t hmac[HMAC_SIZE])
Create a 6-byte HMAC for authentication (proprietary IO-Homecontrol scheme).
bool crypt_1w_key(const uint8_t node[NODE_ID_SIZE], const uint8_t in[AES_KEY_SIZE], uint8_t out[AES_KEY_SIZE])
Encrypt or decrypt a 1W controller key during add-controller key adoption (CMD 0x30).
bool crypt_key(const uint8_t *data, uint8_t len, const uint8_t challenge[HMAC_SIZE], const uint8_t in[AES_KEY_SIZE], uint8_t out[AES_KEY_SIZE])
Encrypt or decrypt a system key during pairing.
bool create_1w_hmac(const uint8_t *data, uint8_t len, uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], uint8_t hmac[HMAC_SIZE])
Create the 6-byte authenticator for a 1W frame.
bool create_force_open(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t open_position)
Build a force-open execute frame (0x00): an ordinary position command to the device's wire-scale "ful...
bool set_cmd(IoFrame &f, uint8_t cmd, const uint8_t *params, uint8_t params_len)
Set command and payload.
bool create_identify(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build an authenticated device-identify request (0x1E).
bool create_get_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a get-name request (0x50).
void encode_packed_device_type(DeviceType type, uint8_t subtype, uint8_t &type_msb, uint8_t &type_subtype)
Encode a DeviceType/subtype pair into the two-byte packed metadata format used by discovery responses...
static bool create_device_terminal_ack(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t cmd)
Build a bare device→hub terminal acknowledgement: no payload, END set, START and LOW_POWER clear.
bool create_get_status(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a get-status request (0x03). The device responds with its current position.
bool create_set_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, const uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE])
Build an authenticated set-name request (0x52) using a fixed zero-padded Latin-1 payload.
bool create_write_private(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, const uint8_t *payload, size_t payload_len)
Build a generic CMD_WRITE_PRIVATE (0x20) frame around a caller-supplied payload — the one builder beh...
DeviceType
Device type identifiers reported by IO‑Homecontrol products.
@ UNKNOWN
Unknown/unspecified device.
void encode_broadcast_address(DeviceType type, uint8_t out[NODE_ID_SIZE])
Encode a device type into its typed-broadcast destination address — the exact inverse of broadcast_ta...
bool create_node_verify_resp_device_role(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build an address response (0x37) — device side, used only by the key-extraction responder.
bool create_private2_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t modifier, bool long_form, bool low_power)
Build a CMD_PRIVATE2 (0x0C) request in either of the two field-observed shapes.
bool create_discover_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst, DeviceType type, uint8_t subtype, uint8_t manufacturer_id)
Build a discovery response (0x29) — device side, used only by the key-extraction responder.
CoverCommand
Named device commands for cover-type actuators.
@ FAVORITE
Move to stored favorite/"My" position.
@ VENT
Move to ventilation position (window-type devices).
bool create_1w_remove_controller(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE])
Build a 1W remove-controller frame (CMD 0x39).
static bool create_challenge_req_framed(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE], bool start, bool low_power)
Build a challenge request (0x3C) with caller-chosen framing bits.
bool recover_system_key_from_transfer(const uint8_t transfer_payload[AES_KEY_SIZE], const uint8_t challenge[HMAC_SIZE], uint8_t out_key[AES_KEY_SIZE])
Recover the system key from a CMD_KEY_TRANSFER payload.
bool create_discover_confirm(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, bool ack)
Build a discovery-confirm request (0x2C) — controller side, sent directly to a freshly-discovered dev...
bool create_get_info1(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_INFO1 (0x54) request. No payload. See proto_commands.h for the evidence note.
bool create_execute_position_and_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t position, uint8_t tilt_percent)
Build a combined position-and-tilt execute command (0x00) — setClosureAndOrientation.
bool create_1w_execute_command(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, CoverCommand cmd, uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], uint8_t acei, bool broadcast_all)
Build a 1W named-command execute frame (CMD 0x00) targeting a device class.
void init_frame(IoFrame &f, bool is_2w, bool start, bool end, bool low_power)
Initialize an IoFrame header (ctrl0/ctrl1) with flags.
bool create_key_confirm(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a key-confirm frame (0x33) — device side, used only by the key-extraction responder.
bool create_execute_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t tilt_percent)
Build a tilt execute command (0x00) for devices that support slat angle control.
bool create_set_config1(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a set-config command (0x6F) to tell the device to automatically send status updates when contro...
bool create_challenge_resp(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key)
Build a challenge response (0x3D) proving we know the system key.
void set_dst(IoFrame &f, const uint8_t id[NODE_ID_SIZE])
Set destination node ID.
bool create_key_init(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a key-init request (0x31) to start the pairing key exchange with a discovered device.
bool create_execute_position(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t position, bool silent)
Build a position execute command (0x00) to move a device to a numeric position.
bool create_general_info3(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_GENERAL_INFO3 (0x58) request.
bool create_get_info2(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_INFO2 (0x56) request. No payload. See proto_commands.h for the evidence note.
bool create_execute_command(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, CoverCommand cmd, bool silent)
Build a named-command execute frame (0x00) for STOP, FAVORITE, or VENT.
bool create_discover_confirm_ack(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a discovery-confirm acknowledgement (0x2D) — device side, used only by the key-extraction respo...
bool create_challenge_req_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE])
Build a device-role challenge request (0x3C) — device side, used only by the key-extraction responder...
static bool create_challenge_resp_framed(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key, bool end, bool low_power)
Build a challenge response (0x3D) with caller-chosen framing bits.
bool create_discover(IoFrame &f, const uint8_t *own)
Build a discovery broadcast (0x28).
bool create_private_function(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t function_id, uint8_t sub_index)
Build a CMD_PRIVATE (0x03) request for an arbitrary function ID.
bool create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power, bool ack_capable, bool payload_enabled, uint8_t payload, const uint8_t *system_key)
Build a configurable discovery request command (0x28, 0x2A, or 0x2E).
bool create_get_status_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a tilt-aware get-status request (0x03) that returns the extended 16-byte tilt payload.
bool create_get_status_extended(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t selector, uint8_t block, uint8_t function_id)
Build an extended CMD_PRIVATE (0x03) request with a selector/block pair — the shape real hubs use for...
bool create_challenge_resp_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key)
Build a device-role challenge response (0x3D) — device side, used only by the key-extraction responde...
bool create_1w_execute_position(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t position, uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], uint8_t acei, bool broadcast_all)
Build a 1W position execute frame (CMD 0x00) targeting a device class.
bool create_challenge_req(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE])
Build a challenge request (0x3C) using a caller-supplied challenge.
bool create_status_update_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a status-update acknowledgment (0x72).
bool create_1w_add_controller(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t manufacturer, uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], bool with_mac)
Build a 1W add-controller frame (CMD 0x30).
static bool create_controller_start_frame(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t cmd, bool low_power, bool ack)
Build a bare controller→device start frame: no payload, START set, END clear, LOW_POWER and ACK as th...
bool create_key_transfer(IoFrame &f, IoFrame &old_frame, const uint8_t *dst, const uint8_t *src, const uint8_t key[AES_KEY_SIZE], const uint8_t challenge[HMAC_SIZE])
Build a key-transfer frame (0x32) containing the system key encrypted with the transfer key.
void set_src(IoFrame &f, const uint8_t id[NODE_ID_SIZE])
Set source node ID.
Command builders for the IO‑Homecontrol protocol.
IO-Homecontrol command IDs, result codes and protocol enumerations.
Cryptographic helpers for the IO‑Homecontrol protocol.
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 data_len
Actual length of data.
uint8_t ctrl1
Control byte 1: low power, beacon, etc.
Definition proto_frame.h:95