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 =
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 =
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_5S | POWER_SAVE_ALWAYS_ALIVE (0x00) would be an honest-looking but false claim: it
62/// tells a hub to expect a reply within 5s 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. A real device with that profile signals ATT_CLASS_40S instead.
65///
66/// 0xDD = 1101_1101: bits [7:6] are ATT_CLASS_40S, bit [0] is POWER_SAVE_LOW_POWER, bit [3] is
67/// DISCOVERY_FLAGS_RF_SUPPORT, and bits [4]/[2] have no named constant here yet. All of this byte
68/// except the power-save bit mirrors the exact byte a real Somfy Izymo dimmer advertised in this
69/// project's own corpus
70/// (tests/corpus/captures/pairing/somfy_izymo_dimmer_pairing_full_sx1276.yaml: flags=0xDC, ATT_CLASS_40S |
71/// POWER_SAVE_ALWAYS_ALIVE), on the theory that matching a real device's full byte is safer than
72/// guessing at bits this codebase doesn't yet have a documented meaning for — the unnamed bits and
73/// the RF-support bit are carried over unmodified for exactly 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_5S | 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/// @brief Shared assembly for both 1W execute builders: header, MAC span/HMAC, and the 14-byte
173/// payload. create_1w_execute_position() and create_1w_execute_command() differ only in how they
174/// derive `main0`/`main1`; every other byte on the wire is identical, so this is the one place
175/// that wiring lives — a second copy would risk drifting from the published IV vector this
176/// span is pinned against.
177///
178/// ctrl1 is deliberately left at 0 (LOW_POWER / CTRL1_LOW_POWER NOT set), unlike every 2W builder
179/// in this file. The reference `forgePacket` sets it, but five independently captured real 1W
180/// frames all disagree: this project's own Somfy awning remote
181/// (tests/corpus/captures/oneway/somfy_smoove_oneway_{open,close,stop}_sx1276.yaml), an
182/// unidentified 1W remote (tests/corpus/captures/oneway/unidentified_1w_remote_oneway_execute.yaml), and
183/// the published vector (tests/corpus/captures/oneway/reference_1w_oneway_execute_iv_vector.yaml)
184/// all carry ctrl1=0x00. Followed the captures over the reference source on this point.
185/// Shared frame-header setup for every 1W broadcast builder (build_1w_execute(),
186/// create_1w_add_controller(), create_1w_remove_controller()): all three address a device-class
187/// broadcast rather than an individual node, and none set LOW_POWER.
188void init_1w_broadcast_frame(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type) {
189 init_frame(f, /*is_2w=*/false, /*start=*/true, /*end=*/true, /*low_power=*/false);
190
191 uint8_t dst[NODE_ID_SIZE];
192 encode_broadcast_address(target_type, dst);
193 set_dst(f, dst);
194 set_src(f, src);
195}
196
197bool build_1w_execute(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t main0, uint8_t main1,
198 uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], uint8_t acei, bool broadcast_all) {
199 uint8_t payload[ONEWAY_EXECUTE_PAYLOAD_SIZE] = {EXECUTE_ORIGINATOR, acei, main0, main1, ONEWAY_EXECUTE_FP1,
200 ONEWAY_EXECUTE_FP2};
201
202 // The span is the command byte followed by exactly the parameter bytes that go on air, so it
203 // is copied out of `payload` rather than restated — a restatement could drift from the wire
204 // and produce a signature that verifies against nothing.
205 uint8_t mac_span[ONEWAY_EXECUTE_MAC_SPAN_SIZE];
206 mac_span[0] = CMD_EXECUTE;
207 memcpy(&mac_span[1], payload, ONEWAY_EXECUTE_PARAMS_SIZE);
208
209 // Sign before touching `f`, so a failure leaves the caller's frame exactly as it found it.
210 // 1W is fire-and-forget: a caller that ignored the return value and transmitted a half-built
211 // frame would get no error back from anywhere — the failure would be silent and on air.
212 if (!crypto::create_1w_hmac(mac_span, sizeof(mac_span), sequence, controller_key,
213 &payload[ONEWAY_EXECUTE_MAC_OFFSET]))
214 return false;
215
216 payload[ONEWAY_EXECUTE_SEQUENCE_OFFSET] = static_cast<uint8_t>(sequence >> BITS_PER_BYTE);
217 payload[ONEWAY_EXECUTE_SEQUENCE_OFFSET + 1] = static_cast<uint8_t>(sequence);
218
219 // A handheld cover remote of either vendor broadcasts open/close/stop to the all-devices
220 // address; only a class-bound identity uses the typed one. encode_broadcast_address(UNKNOWN)
221 // is already `00 00 3F` (only DeviceType 0 yields it) — do not add a second address path.
222 init_1w_broadcast_frame(f, src, broadcast_all ? DeviceType::UNKNOWN : target_type);
223
224 return set_cmd(f, CMD_EXECUTE, payload, sizeof(payload));
225}
226
227// === 1W Enrollment (CMD 0x30 add-controller / CMD 0x39 remove-controller) ===
228
229/// CMD 0x30 declared-payload layout: enc_key[16] + man_id[1] + data[1] + sequence[2] = 20 bytes.
230/// Offsets mirror decode_1w_add_controller()'s ONEWAY_ADD_CONTROLLER_* constants (proto_codecs.cpp)
231/// exactly, since the two must agree on the wire shape by construction.
232constexpr uint8_t ONEWAY_ADD_ENC_KEY_OFFSET = 0;
233constexpr uint8_t ONEWAY_ADD_MANUFACTURER_OFFSET = AES_KEY_SIZE; // 16
234constexpr uint8_t ONEWAY_ADD_DATA_OFFSET = AES_KEY_SIZE + 1; // 17
235constexpr uint8_t ONEWAY_ADD_SEQUENCE_OFFSET = AES_KEY_SIZE + 2; // 18
236constexpr uint8_t ONEWAY_ADD_PAYLOAD_SIZE = AES_KEY_SIZE + 4; // 20
237/// `data` field of CMD 0x30's payload — every source (published vector, reference `Add` case)
238/// shows 0x01 here; its meaning beyond "add" is undocumented.
239constexpr uint8_t ONEWAY_ADD_DATA_VALUE = 0x01;
240/// CMD 0x30's MAC span: cmd + enc_key only (17 bytes) — verified against the published vector
241/// (create_1w_hmac()'s `@warning`), NOT the whole declared payload.
242constexpr uint8_t ONEWAY_ADD_MAC_SPAN_SIZE = 1 + AES_KEY_SIZE;
243
244/// CMD 0x39 declared-payload layout, matching the reference `_p0x2e` struct: data[1] + sequence[2]
245/// + mac[6] = 9 bytes, MAC inside the declared length (unlike CMD 0x30's out-of-length trailer).
246constexpr uint8_t ONEWAY_REMOVE_DATA_OFFSET = 0;
247constexpr uint8_t ONEWAY_REMOVE_SEQUENCE_OFFSET = 1;
248constexpr uint8_t ONEWAY_REMOVE_MAC_OFFSET = 3;
249constexpr uint8_t ONEWAY_REMOVE_PAYLOAD_SIZE = 9;
250/// `data` field observed in every source for CMD 0x39.
251constexpr uint8_t ONEWAY_REMOVE_DATA_VALUE = 0x00;
252/// CMD 0x39's MAC span. No known-answer vector pins this — see create_1w_remove_controller()'s
253/// `@warning` (proto_commands.h) for why `cmd + data` was chosen over the alternatives.
254constexpr uint8_t ONEWAY_REMOVE_MAC_SPAN_SIZE = 2;
255
256/// Build a no-payload, addressed, authenticated request for `cmd` — the shape every
257/// device-info read (CMD_GET_NAME, CMD_GET_INFO1/2, CMD_GET_GENERAL_INFO3) uses.
258bool create_no_payload_request(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t cmd) {
259 init_frame(f, true, true, false, low_power);
260 set_dst(f, dst);
261 set_src(f, own);
262 return set_cmd(f, cmd);
263}
264
265} // namespace
266
267/// Build a position execute command (0x00) to move a device to a numeric position.
268bool create_execute_position(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t position,
269 bool silent) {
270 if (position > POSITION_PERCENT_MAX)
271 return false;
272 init_frame(f, true, true, false, low_power);
273 set_dst(f, dst);
274 set_src(f, own);
275 const auto payload = make_position_payload(EXECUTE_ACEI, position, silent);
276 return set_cmd(f, CMD_EXECUTE, payload.data(), payload.size());
277}
278
279/// Build a named-command execute frame (0x00) for STOP, FAVORITE, or VENT.
280///
281/// FORCE_OPEN is deliberately not handled here — unlike these three, it needs to know the
282/// device's wire-scale "fully open" position (0 or 100 depending on IoDevice::inverted, e.g.
283/// horizontal awnings), which this builder has no way to know. Use create_force_open() instead;
284/// see its comments for why.
285bool create_execute_command(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, CoverCommand cmd,
286 bool silent) {
287 uint8_t main_byte = 0;
288 uint8_t modifier_byte = 0;
289 switch (cmd) {
291 main_byte = POS_STOP;
292 modifier_byte = 0x00;
293 break;
295 main_byte = POS_FAVORITE;
296 modifier_byte = 0x00;
297 break;
299 main_byte = POS_FAVORITE;
300 modifier_byte = POS_VENT_MODIFIER;
301 break;
302 default:
303 return false;
304 }
305 init_frame(f, true, true, false, low_power);
306 set_dst(f, dst);
307 set_src(f, own);
308 // FAVORITE is the one command here the capture covers: a Somfy hub sends the plain 6-byte form
309 // for a normal "My" press and the extended 8-byte form with the silent profile when the toggle
310 // is on, which is exactly the pair below. STOP is excluded because stopping has no travel speed,
311 // and VENT because nothing has been captured for it — every extended frame observed so far has
312 // byte 3 clear, whereas VENT puts its modifier there, so extending it would be a guess.
313 if (silent && cmd == CoverCommand::FAVORITE) {
314 const uint8_t extended[EXECUTE_PAYLOAD_SIZE] = {
315 EXECUTE_ORIGINATOR, EXECUTE_ACEI, main_byte, modifier_byte, EXECUTE_POSITION_LAYOUT_FLAG,
316 POS_FAVORITE, EXECUTE_PROFILE_SILENT, 0x00};
317 return set_cmd(f, CMD_EXECUTE, extended, sizeof(extended));
318 }
319 const uint8_t payload[EXECUTE_SPECIAL_PAYLOAD_SIZE] = {EXECUTE_ORIGINATOR, EXECUTE_ACEI, main_byte,
320 modifier_byte, 0x00, 0x00};
321 return set_cmd(f, CMD_EXECUTE, payload, sizeof(payload));
322}
323
324/// Build a force-open execute frame (0x00): an ordinary position command to the device's
325/// wire-scale "fully open" value, sent at elevated ACEI priority (see EXECUTE_ACEI_FORCE_OPEN).
326///
327/// Takes the target position explicitly rather than assuming 0, because "fully open" is not
328/// always wire-position 0: IoDevice::inverted devices (e.g. horizontal awnings) have open/close
329/// swapped, so their fully-open wire position is 100. Hardcoding 0 here would, on a real inverted
330/// awning, target its already-*closed* resting position — a confirmed no-op rather than a lock
331/// bypass. The caller (execute_device_command_() in hub_operations.cpp) is responsible for
332/// resolving the correct value from the target IoDevice.
333bool create_force_open(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t open_position) {
334 init_frame(f, true, true, false, low_power);
335 set_dst(f, dst);
336 set_src(f, own);
337 const auto payload = make_position_payload(EXECUTE_ACEI_FORCE_OPEN, open_position);
338 return set_cmd(f, CMD_EXECUTE, payload.data(), payload.size());
339}
340
341/// Build a 1W position execute frame (CMD 0x00) targeting a device class. See proto_commands.h
342/// for the full contract.
343bool create_1w_execute_position(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t position,
344 uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], uint8_t acei,
345 bool broadcast_all) {
346 if (position > POSITION_PERCENT_MAX)
347 return false;
348 return build_1w_execute(f, src, target_type, static_cast<uint8_t>(POSITION_WIRE_SCALE * position), 0x00, sequence,
349 controller_key, acei, broadcast_all);
350}
351
352/// Build a 1W named-command execute frame (CMD 0x00) targeting a device class. See
353/// proto_commands.h for the full contract, including why 1W has no FORCE_OPEN: the only known
354/// wire encoding for the label (POS_FORCE_OPEN, main=0x64) was hardware-tested as an ordinary
355/// move-to-50% command, not a lock bypass — see the POS_FORCE_OPEN doc comment in
356/// proto_constants.h. Passing CoverCommand::FORCE_OPEN here falls through to `default` and
357/// returns false, matching 2W's create_execute_command().
358bool create_1w_execute_command(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, CoverCommand cmd,
359 uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], uint8_t acei,
360 bool broadcast_all) {
361 uint8_t main0 = 0;
362 uint8_t main1 = 0;
363 switch (cmd) {
365 main0 = POS_STOP;
366 break;
368 main0 = POS_FAVORITE;
369 break;
371 main0 = POS_FAVORITE;
372 main1 = POS_VENT_MODIFIER;
373 break;
374 default:
375 return false;
376 }
377 return build_1w_execute(f, src, target_type, main0, main1, sequence, controller_key, acei, broadcast_all);
378}
379
380/// Build a 1W add-controller frame (CMD 0x30). See proto_commands.h for the full contract,
381/// including why the MAC is an optional, out-of-length trailer here and not inside the declared
382/// payload, and why `with_mac` exists at all (real hardware omits it; the published vector
383/// doesn't).
384bool create_1w_add_controller(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t manufacturer,
385 uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], bool with_mac) {
386 uint8_t payload[ONEWAY_ADD_PAYLOAD_SIZE] = {0};
387
388 // Self-inverse wrap (crypto::crypt_1w_key()'s doxygen) -- the same call decode_1w_add_controller()
389 // uses to unwrap an overheard 0x30 also wraps our own key for transmission here.
390 if (!crypto::crypt_1w_key(src, controller_key, &payload[ONEWAY_ADD_ENC_KEY_OFFSET]))
391 return false;
392
393 payload[ONEWAY_ADD_MANUFACTURER_OFFSET] = manufacturer;
394 payload[ONEWAY_ADD_DATA_OFFSET] = ONEWAY_ADD_DATA_VALUE;
395 payload[ONEWAY_ADD_SEQUENCE_OFFSET] = static_cast<uint8_t>(sequence >> BITS_PER_BYTE);
396 payload[ONEWAY_ADD_SEQUENCE_OFFSET + 1] = static_cast<uint8_t>(sequence);
397
398 // The span is command + enc_key only, copied out of `payload` rather than restated -- see
399 // build_1w_execute()'s comment for why a restatement risks drifting from the wire. Computed
400 // even when with_mac is false so a caller flipping the flag later doesn't also have to
401 // reconsider whether signing itself can fail -- crypto::create_1w_hmac() is still the "sign
402 // before touching f" guard for the whole builder either way.
403 uint8_t mac_span[ONEWAY_ADD_MAC_SPAN_SIZE];
404 mac_span[0] = CMD_ONEWAY_ADD_CONTROLLER;
405 memcpy(&mac_span[1], &payload[ONEWAY_ADD_ENC_KEY_OFFSET], AES_KEY_SIZE);
406
407 uint8_t mac[HMAC_SIZE];
408 // Sign before touching `f`, same rule as build_1w_execute(): 1W is fire-and-forget, so a
409 // half-built frame a caller transmitted anyway would fail silently with nobody to report it.
410 if (!crypto::create_1w_hmac(mac_span, sizeof(mac_span), sequence, controller_key, mac))
411 return false;
412
413 init_1w_broadcast_frame(f, src, target_type);
414
415 if (!set_cmd(f, CMD_ONEWAY_ADD_CONTROLLER, payload, sizeof(payload)))
416 return false;
417
418 // The MAC is an out-of-length trailer for this command only (frame_carries_mac_trailer()) --
419 // set after set_cmd() succeeds, since init_frame() above would otherwise reset it right back
420 // off. Left false (real Somfy hardware's own shape) when with_mac is false.
421 if (with_mac) {
422 f.has_mac = true;
423 memcpy(f.mac, mac, HMAC_SIZE);
424 }
425 return true;
426}
427
428/// Build a 1W remove-controller frame (CMD 0x39). See proto_commands.h for the full contract,
429/// including the @warning that no vector pins this command's MAC span.
430bool create_1w_remove_controller(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint16_t sequence,
431 const uint8_t controller_key[AES_KEY_SIZE]) {
432 uint8_t payload[ONEWAY_REMOVE_PAYLOAD_SIZE] = {0};
433 payload[ONEWAY_REMOVE_DATA_OFFSET] = ONEWAY_REMOVE_DATA_VALUE;
434 payload[ONEWAY_REMOVE_SEQUENCE_OFFSET] = static_cast<uint8_t>(sequence >> BITS_PER_BYTE);
435 payload[ONEWAY_REMOVE_SEQUENCE_OFFSET + 1] = static_cast<uint8_t>(sequence);
436
437 // Unpinned span (see the @warning in proto_commands.h): cmd + data, the same "everything before
438 // the sequence" shape CMD 0x00's span follows.
439 uint8_t mac_span[ONEWAY_REMOVE_MAC_SPAN_SIZE] = {CMD_ONEWAY_REMOVE, payload[ONEWAY_REMOVE_DATA_OFFSET]};
440
441 // Sign before touching `f` -- same rule as every other 1W builder in this file.
442 if (!crypto::create_1w_hmac(mac_span, sizeof(mac_span), sequence, controller_key, &payload[ONEWAY_REMOVE_MAC_OFFSET]))
443 return false;
444
445 init_1w_broadcast_frame(f, src, target_type);
446
447 return set_cmd(f, CMD_ONEWAY_REMOVE, payload, sizeof(payload));
448}
449
450/// Build a CMD_PRIVATE (0x03) request for an arbitrary function ID. function_id = 0x06/0x09
451/// reads battery state; create_get_status() below is this builder frozen at function_id =
452/// PRIVATE_GET_POSITION_STATUS (0x03), the only function ID this codebase has ever captured on
453/// its own wire.
454bool create_private_function(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t function_id,
455 uint8_t sub_index) {
456 init_frame(f, true, true, false, low_power);
457 set_dst(f, dst);
458 set_src(f, own);
459 uint8_t d[3] = {function_id, sub_index, 0x00};
460 return set_cmd(f, CMD_PRIVATE, d, sizeof(d));
461}
462
463/// Build a get-status request (0x03). The device responds with its current position.
464bool create_get_status(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power) {
465 return create_private_function(f, own, dst, low_power, PRIVATE_GET_POSITION_STATUS);
466}
467
468bool create_get_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power) {
469 return create_no_payload_request(f, own, dst, low_power, CMD_GET_NAME);
470}
471
472/// Build a CMD_GET_GENERAL_INFO3 (0x58) request. No payload — delegates to the shared
473/// create_no_payload_request() helper, the shape every device-info read uses.
474bool create_general_info3(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power) {
475 return create_no_payload_request(f, own, dst, low_power, CMD_GET_GENERAL_INFO3);
476}
477
478/// Build a CMD_GET_INFO1 (0x54) request. No payload. See proto_commands.h for the evidence note.
479bool create_get_info1(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_INFO1);
481}
482
483/// Build a CMD_GET_INFO2 (0x56) request. No payload. See proto_commands.h for the evidence note.
484bool create_get_info2(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_INFO2);
486}
487
488bool create_set_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power,
489 const uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE]) {
490 init_frame(f, true, true, false, low_power);
491 set_dst(f, dst);
492 set_src(f, own);
494}
495
496/// Build an authenticated device-identify request (0x1E).
497bool create_identify(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power) {
498 init_frame(f, true, true, false, low_power);
499 set_dst(f, dst);
500 set_src(f, own);
501 const uint8_t payload[2] = {ORIGINATOR_USER_REMOTE, IDENTIFY_PARAMETER};
502 return set_cmd(f, CMD_IDENTIFY, payload, sizeof(payload));
503}
504
505/// Build a generic CMD_WRITE_PRIVATE (0x20) frame around a caller-supplied payload — the one
506/// builder behind every heating/climate function. Framing per the iohomecontrol reference
507/// implementation's `forgePacket()` (start=1, end=0) and the
508/// atlantic_thermor_exchange_write_private_param.yaml capture (frame 1: start, not end).
509bool create_write_private(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, const uint8_t *payload,
510 size_t payload_len) {
511 if (payload == nullptr || payload_len == 0 || payload_len > FRAME_MAX_DATA_SIZE)
512 return false;
513 init_frame(f, /*is_2w=*/true, /*start=*/true, /*end=*/false, low_power);
514 set_dst(f, dst);
515 set_src(f, own);
516 return set_cmd(f, CMD_WRITE_PRIVATE, payload, static_cast<uint8_t>(payload_len));
517}
518
519/// Build a tilt execute command (0x00) for devices that support slat angle control.
520bool create_execute_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t tilt_percent) {
521 init_frame(f, true, true, false, low_power);
522 set_dst(f, dst);
523 set_src(f, own);
524
525 auto const tilt_value =
526 static_cast<uint16_t>((POSITION_PERCENT_MAX - tilt_percent) * STATUS_POS_MAX / POSITION_PERCENT_MAX);
527 uint8_t d[EXECUTE_PAYLOAD_SIZE] = {EXECUTE_ORIGINATOR,
528 EXECUTE_ACEI,
530 0x00,
532 static_cast<uint8_t>(tilt_value >> BITS_PER_BYTE),
533 static_cast<uint8_t>(tilt_value),
534 0x00};
535 return set_cmd(f, CMD_EXECUTE, d, sizeof(d));
536}
537
538/// Build a combined position-and-tilt execute command (0x00) — setClosureAndOrientation.
539bool create_execute_position_and_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power,
540 uint8_t position, uint8_t tilt_percent) {
541 if (position > POSITION_PERCENT_MAX)
542 return false;
543 init_frame(f, true, true, false, low_power);
544 set_dst(f, dst);
545 set_src(f, own);
546
547 auto const tilt_value =
548 static_cast<uint16_t>((POSITION_PERCENT_MAX - tilt_percent) * STATUS_POS_MAX / POSITION_PERCENT_MAX);
549 uint8_t d[EXECUTE_PAYLOAD_SIZE] = {EXECUTE_ORIGINATOR,
550 EXECUTE_ACEI,
551 static_cast<uint8_t>(2 * position),
552 0x00,
554 static_cast<uint8_t>(tilt_value >> BITS_PER_BYTE),
555 static_cast<uint8_t>(tilt_value),
556 0x00};
557 return set_cmd(f, CMD_EXECUTE, d, sizeof(d));
558}
559
560/// Build an extended CMD_PRIVATE (0x03) request with a selector/block pair — the shape real
561/// hubs use for both the tilt block (selector STATUS_TILT_SELECTOR) and the field-observed
562/// selector 0x80 (tests/corpus/captures/probe/multi_somfy_probe_extended_private_both_selectors.yaml),
563/// which this codebase has never decoded. `block` is the field-observed name for the byte that
564/// varies (0x00/0x01) for selector 0x80; create_get_status_tilt() below is this builder frozen
565/// at selector = STATUS_TILT_SELECTOR, block = 0x01. `function_id` defaults to
566/// PRIVATE_GET_POSITION_STATUS (0x03) — the only value ever seen on air in this shape; other
567/// values are diagnostic probes into an undecoded function ID.
568bool create_get_status_extended(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t selector,
569 uint8_t block, uint8_t function_id) {
570 init_frame(f, true, true, false, low_power);
571 set_dst(f, dst);
572 set_src(f, own);
573 uint8_t d[4] = {function_id, selector, block, 0x00};
574 return set_cmd(f, CMD_PRIVATE, d, sizeof(d));
575}
576
577/// Build a tilt-aware get-status request (0x03) that returns the extended 16-byte tilt payload.
578bool create_get_status_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power) {
579 return create_get_status_extended(f, own, dst, low_power, STATUS_TILT_SELECTOR, 0x01);
580}
581
582/// Build a CMD_PRIVATE2 (0x0C) request in either of the two field-observed shapes. The payload
583/// is CMD_EXECUTE's POS_FAVORITE/POS_VENT_MODIFIER stored-position selector with the execution
584/// prefix stripped — `modifier` is that same selector byte (e.g. POS_VENT_MODIFIER for vent).
585///
586/// `low_power` is a separate parameter, not derived from `long_form`: the two captured fixtures
587/// (tests/corpus/captures/probe/multi_somfy_probe_private2_{long_form,short_form}.yaml)
588/// do carry CTRL1_LOW_POWER set on the long-form request and clear on the short-form ones, but
589/// that tracks the *target device's* power class in each capture (a solar shutter vs. a
590/// mains-powered switch), not the payload shape — the same relationship every other
591/// device-addressed builder in this file has to `low_power`. Deriving it from `long_form` would
592/// silently clear the flag on a short-form probe sent to a solar device.
593bool create_private2_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t modifier, bool long_form,
594 bool low_power) {
595 init_frame(f, true, true, false, low_power);
596 set_dst(f, dst);
597 set_src(f, own);
598 if (long_form) {
599 uint8_t d[PRIVATE2_LONG_PAYLOAD_SIZE] = {POS_UNKNOWN, 0x00, PRIVATE2_EXTENDED_BLOCK_FLAG,
600 POS_FAVORITE, modifier, 0x00};
601 return set_cmd(f, CMD_PRIVATE2, d, sizeof(d));
602 }
603 uint8_t d[4] = {POS_FAVORITE, modifier, 0x00, 0x00};
604 return set_cmd(f, CMD_PRIVATE2, d, sizeof(d));
605}
606
607/// Build a discovery broadcast (0x28). Sent to the broadcast address 0x00003B.
608/// Only devices in pairing mode (PROG button pressed) will respond.
609bool create_discover(IoFrame &f, const uint8_t *own) {
610 // start+end: single broadcast frame.
611 init_frame(f, true, true, true, false);
613 set_src(f, own);
614 return set_cmd(f, CMD_DISCOVER_REQ);
615}
616
617/// Build a configurable discovery request command (0x28, 0x2A, or 0x2E).
618///
619/// For 0x2A (Discover SPE), the payload is a 6-byte random nonce followed by a 6-byte
620/// HMAC over the command byte alone, using that nonce as the challenge and the supplied
621/// system key — one frame carrying a whole challenge-response, which is what lets a
622/// broadcast be authenticated. This requires a valid system key; it will not work for a
623/// motor that has never been paired with this controller's key.
624bool create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power,
625 bool payload_enabled, uint8_t payload, const uint8_t *system_key) {
626 init_frame(f, true, true, true, low_power);
627 set_dst(f, dst);
628 set_src(f, own);
629
630 switch (command) {
631 case CMD_DISCOVER_REQ:
632 return set_cmd(f, CMD_DISCOVER_REQ);
633
635 if (system_key == nullptr)
636 return false;
637 uint8_t nonce[HMAC_SIZE];
639 // The HMAC covers the command byte *alone*, with the nonce as the challenge — not the
640 // nonce as transcript data, which is what this built until real bytes settled it (a Velux
641 // KLR200's own 0x2A, tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml, recomputed
642 // under that installation's key). The old [cmd, nonce] transcript produced an HMAC no
643 // device could verify, so every 0x2A we emitted was silently unanswerable.
644 uint8_t hmac[HMAC_SIZE];
645 if (!crypto::create_hmac(&command, 1, nonce, system_key, hmac))
646 return false;
647 uint8_t payload_buf[HMAC_SIZE * 2];
648 memcpy(payload_buf, nonce, HMAC_SIZE);
649 memcpy(payload_buf + HMAC_SIZE, hmac, HMAC_SIZE);
650 return set_cmd(f, CMD_DISCOVER_SPE_REQ, payload_buf, sizeof(payload_buf));
651 }
652
654 // 0x2E may carry an optional single-byte payload (e.g., 0x00) or be sent with no payload.
655 if (payload_enabled)
656 return set_cmd(f, CMD_DISCOVER_ALT_REQ, &payload, 1);
657 return set_cmd(f, CMD_DISCOVER_ALT_REQ);
658 }
659
660 default:
661 return false;
662 }
663}
664
665/// Build a discovery response (0x29) — device side, used only by the key-extraction responder.
666/// See proto_commands.h for the full contract and the real-capture cross-check.
667bool create_discover_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst, DeviceType type, uint8_t subtype,
668 uint8_t manufacturer_id) {
669 init_frame(f, true, true, true, false);
670 set_dst(f, dst);
671 set_src(f, own);
672
673 uint8_t payload[DISCOVERY_RESP_FULL_SIZE] = {0};
674 encode_packed_device_type(type, subtype, payload[0], payload[1]);
675 // Backbone address: the captured real Somfy 0x29 (see proto_commands.h doxygen) reports the
676 // device's own node ID here, so we mirror that rather than inventing a separate address.
677 memcpy(&payload[DISCOVERY_RESP_BACKBONE_OFFSET], own, NODE_ID_SIZE);
678 payload[DISCOVERY_RESP_MANUFACTURER_OFFSET] = manufacturer_id;
679 // See KEY_EXTRACTION_DISCOVER_RESP_FLAGS/_TIMESTAMP above for the derivation of these two values.
680 payload[DISCOVERY_RESP_FLAGS_OFFSET] = KEY_EXTRACTION_DISCOVER_RESP_FLAGS;
682 static_cast<uint8_t>(KEY_EXTRACTION_DISCOVER_RESP_TIMESTAMP >> BITS_PER_BYTE);
684 static_cast<uint8_t>(KEY_EXTRACTION_DISCOVER_RESP_TIMESTAMP & LOW_BYTE_MASK);
685 return set_cmd(f, CMD_DISCOVER_RESP, payload, sizeof(payload));
686}
687
688/// Build a bare device→hub terminal acknowledgement: no payload, END set, START and LOW_POWER
689/// clear. Shared by create_key_confirm() and create_discover_confirm_ack(), which are the same
690/// frame shape and differ only in command byte — real captures of both
691/// (tests/corpus/captures/pairing/somfy_izymo_dimmer_pairing_full_sx1276.yaml's 0x33 `88 00 …`,
692/// velux_kux100_pairing_full.yaml's 0x2D `88 08 …`, and this project's own key-extraction
693/// responder against a real hub in
694/// tests/corpus/captures/pairing/velux_kig300_pairing_key_extraction_success.yaml, both 0x2D and
695/// 0x33 as `88 00 …`) show a device closing its half of a two-frame handshake this way. LOW_POWER
696/// stays clear because that bit describes the *target* of a controller-originated frame (see the
697/// header's convention note); a device does not flag a frame it sends *to* the hub as low-power.
698static bool create_device_terminal_ack(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t cmd) {
699 init_frame(f, true, false, true, false);
700 set_dst(f, dst);
701 set_src(f, own);
702 return set_cmd(f, cmd);
703}
704
705/// Build a key-confirm frame (0x33) — device side, used only by the key-extraction responder.
706/// See proto_commands.h for the full contract and the real-capture cross-check.
707bool create_key_confirm(IoFrame &f, const uint8_t *own, const uint8_t *dst) {
708 return create_device_terminal_ack(f, own, dst, CMD_KEY_CONFIRM);
709}
710
711/// Build a discovery-confirm acknowledgement (0x2D) — device side, used only by the
712/// key-extraction responder. See proto_commands.h for the full contract.
713bool create_discover_confirm_ack(IoFrame &f, const uint8_t *own, const uint8_t *dst) {
715}
716
717/// Recover the system key from a CMD_KEY_TRANSFER payload. See proto_commands.h for the full
718/// contract; this is the single place the IV-`data` convention (`{CMD_KEY_INIT}, len 1`) lives
719/// for the decode direction, mirroring create_key_transfer()'s encode side below.
720bool recover_system_key_from_transfer(const uint8_t transfer_payload[AES_KEY_SIZE], const uint8_t challenge[HMAC_SIZE],
721 uint8_t out_key[AES_KEY_SIZE]) {
722 const uint8_t key_init_cmd = CMD_KEY_INIT;
723 return crypto::crypt_key(&key_init_cmd, 1, challenge, transfer_payload, out_key);
724}
725
726/// Build a key-init request (0x31) to start the pairing key exchange with a discovered device.
727bool create_key_init(IoFrame &f, const uint8_t *own, const uint8_t *dst) {
728 init_frame(f, true, true, false, true);
729 set_dst(f, dst);
730 set_src(f, own);
731 return set_cmd(f, CMD_KEY_INIT);
732}
733
734/// Build a key-transfer frame (0x32) containing the system key encrypted with the transfer key.
735bool create_key_transfer(IoFrame &f, IoFrame &old_frame, const uint8_t *dst, const uint8_t *src,
736 const uint8_t key[AES_KEY_SIZE], const uint8_t challenge[HMAC_SIZE]) {
737 // low_power=true: the pairing key-transfer keeps a fixed frame shape, hardware-validated as-is.
738 // It is a non-start continuation frame and stays outside the per-device low_power rule (ADR 0029).
739 init_frame(f, true, false, false, true);
740 set_dst(f, dst);
741 set_src(f, src);
742 // The pairing capture we matched derives the IV from the previous command byte only. Treating
743 // the key-init frame that narrowly keeps our key transfer aligned with real controllers.
744 uint8_t enc_key[AES_KEY_SIZE];
745 if (!crypto::crypt_key(&old_frame.cmd, 1, challenge, key, enc_key))
746 return false;
747 return set_cmd(f, CMD_KEY_TRANSFER, enc_key, AES_KEY_SIZE);
748}
749
750/// Build a challenge request (0x3C) with caller-chosen framing bits. Shared by the
751/// controller-role and device-role builders below, which differ only in those bits.
752static bool create_challenge_req_framed(IoFrame &f, const uint8_t *dst, const uint8_t *src,
753 const uint8_t challenge[HMAC_SIZE], bool start, bool low_power) {
754 init_frame(f, true, start, false, low_power);
755 set_dst(f, dst);
756 set_src(f, src);
757 return set_cmd(f, CMD_CHALLENGE_REQ, challenge, HMAC_SIZE);
758}
759
760/// Build a challenge request (0x3C) using a caller-supplied challenge. See proto_commands.h.
761///
762/// Framed exactly like the device-role builder below (`0E 00`, no START, no LOW_POWER) — matches
763/// every 0x3C observed on air across multiple actuators and vendors. Kept as a separate entry
764/// point from create_challenge_req_device_role() because the call sites and rationale differ
765/// (inbound authentication vs the key-extraction responder). If devices ever stop answering our
766/// challenges, START/LOW_POWER framing is the first thing to try restoring here.
767bool create_challenge_req(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE]) {
768 return create_challenge_req_framed(f, dst, src, challenge, /*start=*/false, /*low_power=*/false);
769}
770
771/// Build a challenge request (0x3C) containing 6 random bytes.
772/// Used when WE need to authenticate an incoming request from a device.
773bool create_challenge_req(IoFrame &f, const uint8_t *dst, const uint8_t *src) {
774 uint8_t challenge[HMAC_SIZE];
776 return create_challenge_req(f, dst, src, challenge);
777}
778
779/// Build a device-role challenge request (0x3C) — device side, used only by the key-extraction
780/// responder. See proto_commands.h for why the framing bits differ from the controller-role
781/// builders above.
782bool create_challenge_req_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src,
783 const uint8_t challenge[HMAC_SIZE]) {
784 return create_challenge_req_framed(f, dst, src, challenge, /*start=*/false, /*low_power=*/false);
785}
786
787/// Build a challenge response (0x3D) with caller-chosen framing bits. Shared by the
788/// controller-role and device-role builders below, which differ only in those bits — see
789/// create_challenge_req_framed() above for the identical pattern on the request side.
790static bool create_challenge_resp_framed(IoFrame &f, const uint8_t *dst, const uint8_t *src,
791 const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key,
792 bool end, bool low_power) {
793 init_frame(f, true, /*start=*/false, end, low_power);
794 set_dst(f, dst);
795 set_src(f, src);
796 // The authenticated transcript covers the original request, not the 0x3D wrapper. Using the
797 // origin command byte and payload here was one of the key interoperability findings.
798 uint8_t frame_data[FRAME_MAX_SIZE];
799 frame_data[0] = origin.cmd;
800 memcpy(frame_data + 1, origin.data, origin.data_len);
801 uint8_t hmac[HMAC_SIZE];
802 if (!crypto::create_hmac(frame_data, origin.data_len + 1, challenge, key, hmac))
803 return false;
804 return set_cmd(f, CMD_CHALLENGE_RESP, hmac, HMAC_SIZE);
805}
806
807/// Build a challenge response (0x3D) proving we know the system key.
808/// The HMAC is computed over [original_command_id + original_data] using the challenge.
809bool create_challenge_resp(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE],
810 const IoFrame &origin, const uint8_t *key) {
811 return create_challenge_resp_framed(f, dst, src, challenge, origin, key, /*end=*/false, /*low_power=*/true);
812}
813
814/// Build an address response (0x37) — device side, used only by the key-extraction responder.
815/// See proto_commands.h for the full contract, including why the payload (our own node ID, not a
816/// separately-tracked backbone identity) is a known simplification rather than a confirmed match
817/// to real-device behavior.
818///
819/// TODO(hardware-verify): ctrl1 is 0x00 here (no CTRL1_PRIORITY), matching every other device-role
820/// builder in this file, but the one real capture of this command
821/// (tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml line 87) shows CTRL1_PRIORITY set on the
822/// KLR200's 0x37. In that same capture the device also mirrors CTRL1_PRIORITY from whatever the
823/// hub's preceding request set, and its 0x36 request is the one request in the whole exchange that
824/// sets PRIORITY — but every other device-role builder here also emits ctrl1=0 against frames that
825/// same capture shows with reserved/other bits set, and those builders are hardware-confirmed
826/// working (issue #45's own captures), so this one bit's necessity is unproven rather than known
827/// missing. Not mirroring the request's PRIORITY bit until a second real capture settles it either
828/// way.
829bool create_address_resp_device_role(IoFrame &f, const uint8_t *own, const uint8_t *dst) {
830 init_frame(f, true, /*start=*/false, /*end=*/false, /*low_power=*/false);
831 set_dst(f, dst);
832 set_src(f, own);
833 return set_cmd(f, CMD_ADDRESS_RESP, own, NODE_ID_SIZE);
834}
835
836/// Build a device-role challenge response (0x3D) — device side, used only by the key-extraction
837/// responder answering a hub-issued 0x3C challenging our own 0x37. See proto_commands.h for why
838/// the framing bits differ from the controller-role builder above.
839bool create_challenge_resp_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src,
840 const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key) {
841 return create_challenge_resp_framed(f, dst, src, challenge, origin, key, /*end=*/true, /*low_power=*/false);
842}
843
844/// Build a status-update acknowledgment (0x72). Sent after authenticating a device's status update.
845/// The response is sent on all 3 channels to ensure the device receives it.
846bool create_status_update_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst) {
847 // end=true: final frame. low_power=true: this device-role response keeps a fixed shape,
848 // hardware-validated as-is, and stays outside the per-device low_power rule (ADR 0029).
849 init_frame(f, true, false, true, true);
850 set_dst(f, dst);
851 set_src(f, own);
852 // Status update acknowledgment payload matched from working controller captures.
853 return set_cmd(f, CMD_STATUS_UPDATE_RESP, STATUS_UPDATE_ACK_PAYLOAD, sizeof(STATUS_UPDATE_ACK_PAYLOAD));
854}
855
856/// Build a set-config command (0x6F) to tell the device to automatically send status updates
857/// when controlled by any remote (not just us). Not all devices support this.
858bool create_set_config1(IoFrame &f, const uint8_t *own, const uint8_t *dst) {
859 // low_power=true: this pairing phase-3 config write keeps the fixed long-preamble shape it was
860 // hardware-validated at; the per-device low_power rule deliberately does not reach pairing
861 // frames (ADR 0029).
862 init_frame(f, true, true, false, true);
863 set_dst(f, dst);
864 set_src(f, own);
865 // Set-config payload matched from working controller captures.
866 return set_cmd(f, CMD_SET_CONFIG1, SET_CONFIG1_STATUS_BROADCAST_PAYLOAD,
867 sizeof(SET_CONFIG1_STATUS_BROADCAST_PAYLOAD));
868}
869
870} // namespace home_io_control
871} // 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 constexpr uint8_t BITS_PER_BYTE
Number of bits in one protocol byte.
Definition proto_sizes.h:27
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.
static constexpr uint8_t CMD_DISCOVER_REQ
Broadcast discovery request.
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
static constexpr uint8_t CMD_SET_CONFIG1
Configure device to auto-send status updates.
static constexpr uint16_t STATUS_POS_MAX
In status responses, position is encoded as a 16-bit value where 0x0000 = fully open (0%) and 0xC800 ...
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.
static constexpr uint8_t CMD_KEY_TRANSFER
Send encrypted system key to device.
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...
static constexpr uint8_t CMD_DISCOVER_CONFIRM_ACK
Device acknowledges confirmation.
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...
static constexpr uint8_t ACEI_LEVEL_USER_DEFAULT
Default remote controller priority.
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.
static constexpr uint8_t DISCOVERY_RESP_MANUFACTURER_OFFSET
Manufacturer ID at data[5].
static constexpr uint8_t FRAME_MAX_DATA_SIZE
Maximum data bytes after command ID (declared length - header).
Definition proto_sizes.h:46
static constexpr uint8_t POS_UNKNOWN
Wire value: position unknown / keep current.
static constexpr uint8_t CMD_ADDRESS_RESP
Address assignment response: the device returns its own 3-byte backbone address, byte-identical to th...
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).
bool create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power, bool payload_enabled, uint8_t payload, const uint8_t *system_key)
Build a configurable discovery request command (0x28, 0x2A, or 0x2E).
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.
static constexpr uint8_t CMD_DISCOVER_ALT_REQ
Alternate discovery.
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.
static constexpr uint8_t POSITION_WIRE_SCALE
Scale factor between a 0-100 percent position and its CMD_EXECUTE main-byte wire value.
static constexpr uint8_t CMD_GET_NAME
Request device name.
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.
static constexpr uint8_t HMAC_SIZE
Authentication HMAC is 6 bytes (truncated AES output).
Definition proto_sizes.h:22
static constexpr uint8_t ACEI_EXTENDED_SHIFT
Shift for extended field extraction.
void init_frame(IoFrame &f, bool is_2w, bool start, bool end, bool low_power)
Initialize an IoFrame header (ctrl0/ctrl1) with flags.
static constexpr uint8_t CMD_WRITE_PRIVATE
Write private register (climate/heating devices).
bool create_address_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.
static constexpr uint8_t DISCOVERY_RESP_BACKBONE_OFFSET
Byte offsets within CMD_DISCOVER_RESP (0x29) payload data.
static constexpr uint8_t FRAME_MAX_SIZE
Historical name for FRAME_MAX_DECLARED_SIZE, kept as an alias rather than a second literal so the two...
Definition proto_sizes.h:44
static constexpr uint8_t DEVICE_NAME_WRITE_PAYLOAD_SIZE
Fixed write payload: 15 visible chars plus trailing null/padding.
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.
static constexpr uint8_t CMD_KEY_CONFIRM
Device confirms key was received.
static constexpr uint8_t ACEI_LEVEL_PROTECTION_HUMAN
ACEI priority level values (0–7).
static constexpr uint8_t CMD_KEY_INIT
Initiate key transfer to device.
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.
static constexpr uint8_t POS_VENT_MODIFIER
Modifier byte for the ventilation command.
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.
static constexpr uint8_t CMD_ONEWAY_ADD_CONTROLLER
1W "add controller" — a 1W device broadcasts this while its key-copy gesture is active,...
static constexpr uint8_t DISCOVERY_RESP_FLAGS_OFFSET
Flags byte at data[6].
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_CHALLENGE_REQ
6-byte random challenge.
static constexpr uint8_t CMD_STATUS_UPDATE_RESP
Acknowledge status update.
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...
static constexpr uint8_t CMD_SET_NAME
Set device name (authenticated).
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 constexpr uint8_t ACEI_LEVEL_SHIFT
Shift for priority level extraction.
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.
static constexpr uint8_t CMD_ONEWAY_REMOVE
1W "remove controller" (un-pair a 1W remote from a device); same payload shape as 0x2E.
bool create_discover(IoFrame &f, const uint8_t *own)
Build a discovery broadcast (0x28).
static constexpr uint8_t POS_FAVORITE
Wire value: move to favorite/"My" position.
static constexpr uint8_t BROADCAST_DISCOVER[NODE_ID_SIZE]
Broadcast address for device discovery (0x00003B).
static constexpr uint8_t CMD_DISCOVER_RESP
Device responds with its ID and type.
static constexpr uint8_t DISCOVERY_RESP_FULL_SIZE
Full discovery response payload size.
static constexpr uint8_t DISCOVERY_RESP_TIMESTAMP_OFFSET
Timestamp starts at data[7] (2 bytes).
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.
static constexpr uint8_t STATUS_TILT_SELECTOR
Extended status payload marker for tilt-capable devices.
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...
static constexpr uint8_t CMD_GET_GENERAL_INFO3
Observed on the wire (tests/corpus/captures/probe/velux_kig300_probe_capability_burst....
static constexpr uint8_t PRIVATE_GET_POSITION_STATUS
CMD_PRIVATE (0x03) function ID for a position-status request — data[0] of the payload.
static constexpr uint8_t CMD_IDENTIFY
Device physical identification / jog — requires authentication.
static constexpr uint8_t CMD_PRIVATE
Get device status — no authentication needed.
static constexpr uint8_t CMD_PRIVATE2
Content otherwise undecoded by the wire parser.
static constexpr uint8_t AES_KEY_SIZE
AES-128 key size.
Definition proto_sizes.h:23
static constexpr uint8_t POS_STOP
Position values in the IO protocol.
static constexpr uint8_t ORIGINATOR_USER_REMOTE
User sent command from a remote control.
static constexpr uint8_t CMD_CHALLENGE_RESP
HMAC proof answering a 0x3C.
static constexpr uint8_t CMD_GET_INFO2
Request device type/model info.
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...
static constexpr uint8_t CMD_GET_INFO1
Request device general info 1.
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 constexpr uint8_t ACEI_VALID_BIT
ACEI byte bit-field definitions.
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:88
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:94
uint8_t mac[HMAC_SIZE]
Out-of-length authenticator trailer; meaningful only when has_mac.
Definition proto_frame.h:97
bool has_mac
True if this frame carries the mac trailer (see struct doc).
Definition proto_frame.h:98
uint8_t data_len
Actual length of data.
Definition proto_frame.h:95