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