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