Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
proto_commands.h
Go to the documentation of this file.
1#pragma once
2
3/// @file proto_commands.h
4/// @brief Command builders for the IO‑Homecontrol protocol.
5/// @ingroup hioc_protocol
6///
7/// This module provides builder functions that populate IoFrame structures for
8/// the various commands used in discovery, pairing, control, and status operations.
9/// All builders follow the same pattern: fill an IoFrame with CTRL0/CTRL1 flags,
10/// addresses, command ID, and optional payload.
11///
12/// Position encoding:
13/// - IO protocol position values: 0 = fully open, 100 = fully closed, for a non-inverted
14/// device — but this is per-device, not a universal wire constant: IoDevice::inverted
15/// devices (e.g. horizontal awnings) have it backwards (0 = fully closed, 100 = fully
16/// open). Never hardcode "0 = open" in caller code without checking inversion first.
17/// - Use create_execute_position() for numeric positions (0–100).
18/// - Use create_execute_command() for named commands: CoverCommand::STOP,
19/// CoverCommand::FAVORITE, CoverCommand::VENT.
20/// - Use create_force_open() for CoverCommand::FORCE_OPEN — it takes the target "fully open"
21/// position explicitly rather than assuming 0
22/// - The Home Assistant layer maps HA's 1.0=open/0.0=closed to the IO scale via
23/// ha_position = 1.0 - (io_position / 100.0), or the inverted form for IoDevice::inverted
24/// devices; see platform_cover.h.
25///
26/// Low‑power flag and preamble handling:
27/// - CTRL1_LOW_POWER is a per‑device property on the command path. Every device‑addressed
28/// builder on that path takes an explicit `low_power` argument, set from the target's
29/// YAML‑declared `low_power` class (default false: an always‑listening receiver).
30/// Battery/solar devices that duty‑cycle their receiver get the flag; mains devices do not,
31/// matching a reference hub's own traffic. The pairing and device‑role builders
32/// (`create_key_init`, `create_key_transfer`, `create_set_config1`,
33/// `create_status_update_resp`) keep a fixed value instead — see ADR 0029's out‑of‑scope note.
34/// `create_discover_confirm()` is the one pairing builder that does NOT keep a fixed value: its
35/// `low_power` follows the discovered device's own self-reported power-save class instead, the
36/// same per-device-property treatment as every non-pairing builder above — every real hub in
37/// this project's corpus mirrors that class exactly on its own 0x2C.
38/// - The preamble follows the flag. For a start frame the exchange engine uses LONG_PREAMBLE
39/// (1024 bytes) when CTRL1_LOW_POWER is set — the wake‑up burst for a sleeping receiver —
40/// and the runtime‑tunable `normal_start_preamble` otherwise; follow‑up frames use the
41/// driver's response_preamble() (exchange_engine.cpp). Both derive from the same
42/// per‑device property, so the flag never promises a wake-up burst the device does not need,
43/// with two exceptions. Pairing's directed start frames (0x2C, 0x31, 0x6F) never use a longer
44/// preamble than the discovery request the device just answered
45/// (PairingEngine::pairing_start_preamble_()). And send_and_receive() may lead a low-power
46/// target's tries with the short preamble while the target is believed awake (ADR 0040), with
47/// the frame bytes — flag included — unchanged.
48
49#include "proto_codecs.h"
50#include "proto_constants.h"
51#include "proto_device_model.h"
52#include "proto_frame.h"
53
54namespace esphome {
55namespace home_io_control {
56
57/// @brief Build a position execute command (0x00) to move a device to a numeric position.
58///
59/// Encodes a 0–100 position value into the standard 8-byte execute payload with
60/// originator, ACEI, and functional parameter fields. The wire encoding doubles
61/// the position value (0→0x00, 100→0xC8).
62/// @param f IoFrame to populate.
63/// @param own Controller's 3‑byte node ID (source address).
64/// @param dst Target device's 3‑byte node ID (destination address).
65/// @param low_power True if target is battery/solar‑powered (sets CTRL1_LOW_POWER).
66/// @param position Desired position 0–100 (0=fully open, 100=fully closed).
67/// @return true on success; false if position > 100.
68/// @param silent Send the reference hub's "silent operation" extended block, which makes the motor
69/// travel more slowly. Applies to position moves only — STOP has no speed, and no capture
70/// yet shows what the toggle does to FAVORITE/VENT or tilt, so those are left alone rather
71/// than guessed at.
72bool create_execute_position(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t position,
73 bool silent = false);
74
75/// @brief Build a named-command execute frame (0x00) for STOP, FAVORITE, or VENT.
76///
77/// Each maps to a specific wire encoding in the 6-byte special CMD_EXECUTE payload:
78/// - STOP: main=0xD2, modifier=0x00
79/// - FAVORITE: main=0xD8, modifier=0x00
80/// - VENT: main=0xD8, modifier=0x03
81///
82/// This cleanly separates "move to position X" from "execute named action"
83/// without overloading a single numeric parameter.
84///
85/// FORCE_OPEN is NOT handled here — see create_force_open() instead. Unlike these three, it
86/// needs a device-specific "fully open" wire position (0 or 100 depending on inversion), which
87/// this generic dispatch has no way to supply; passing CoverCommand::FORCE_OPEN returns false.
88/// @param f IoFrame to populate.
89/// @param own Controller's 3‑byte node ID (source address).
90/// @param dst Target device's 3‑byte node ID (destination address).
91/// @param low_power True if target is battery/solar‑powered (sets CTRL1_LOW_POWER).
92/// @param cmd Named command to execute (STOP, FAVORITE, or VENT).
93/// @return true on success; false for invalid/unsupported command.
94/// @param silent Use the silent (slower) travel profile. Honoured for FAVORITE only — STOP has no
95/// travel speed, and nothing has been captured for VENT.
96bool create_execute_command(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, CoverCommand cmd,
97 bool silent = false);
98
99/// @brief Build a force-open execute frame (0x00): move to the device's wire-scale "fully open"
100/// position at elevated ACEI priority (level 0, protection_human) instead of the usual
101/// user_high level — see EXECUTE_ACEI_FORCE_OPEN in proto_commands.cpp for why priority
102/// elevation, not a special position byte, is the protocol's real mechanism for getting past an
103/// environmental soft lock.
104///
105/// @param f IoFrame to populate.
106/// @param own Controller's 3‑byte node ID (source address).
107/// @param dst Target device's 3‑byte node ID (destination address).
108/// @param low_power True if target is battery/solar‑powered (sets CTRL1_LOW_POWER).
109/// @param open_position The device's wire-scale position value that means "fully open": 0 for
110/// ordinary devices, 100 for IoDevice::inverted ones (e.g. horizontal awnings) — the
111/// caller must resolve this from the target device, this builder does not have access
112/// to device state. Getting this wrong sends an ordinary, harmless-looking position
113/// command to the device's already-resting position instead of moving it anywhere.
114/// @return true on success.
115/// @note The elevated-priority override has not yet been confirmed against a real *active*
116/// environmental lock.
117bool create_force_open(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t open_position);
118
119/// @brief Build a CMD_PRIVATE (0x03) request for an arbitrary function ID.
120///
121/// function_id = 0x06 (battery-status) or 0x09 (battery-state) is reported elsewhere to read the
122/// CMD_PRIVATE_RESP reply as data[1]==0x60 => battery/solar powered, value = data[2]<<8|data[3].
123/// Unverified here -- both devices this project has probed are mains-powered and answered
124/// data[1]==0x00 (tests/corpus/captures/probe/somfy_awning_probe_private_fn_lr1121.yaml,
125/// tests/corpus/captures/probe/somfy_izymo_dimmer_probe_private_fn_lr1121.yaml).
126/// create_get_status() below is this builder frozen at function_id =
127/// PRIVATE_GET_POSITION_STATUS (0x03), the only function ID this codebase has ever captured on
128/// its own wire.
129/// @param f IoFrame to populate.
130/// @param own Controller's 3-byte node ID.
131/// @param dst Target device's 3-byte node ID.
132/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER;
133/// the exchange layer then also uses the long wake-up preamble).
134/// @param function_id Private function ID (data[0] of the CMD_PRIVATE payload).
135/// @param sub_index Second payload byte (data[1]). Defaults to 0x00 — the only value any project
136/// has ever transmitted in this 3-byte form, and what every existing capture of ours
137/// contains. A non-zero value is a diagnostic probe into an undecoded parameter encoding:
138/// reference material describes a two-field (main parameter, functional parameter)
139/// addressing scheme here, but the field->byte mapping is **not known** — at least three
140/// encodings fit every payload observed to date equally well, because every one of them
141/// has both fields zero.
142/// @return true on success.
143bool create_private_function(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t function_id,
144 uint8_t sub_index = 0x00);
145
146// ============================================================================
147// 1W Execute (fire-and-forget class broadcast)
148// ============================================================================
149//
150// 1W has no reply and no challenge: a controller transmits once and the frame either lands or it
151// doesn't — there is no ACK to retry on and no 0x3C/0x3D to authenticate through, so the MAC
152// inside the payload (create_1w_hmac(), sequence-keyed rather than challenge-keyed) is the only
153// authentication a receiving device gets. The destination is a device *class*
154// (encode_broadcast_address(), proto_codecs.h), never an individual node — 1W has no addressed
155// unicast form at all. Both builders below are pure: they take `sequence` and `controller_key` as
156// parameters and neither increment, persist, nor look either up. The sequence store and the
157// identity registry that own those concerns (OneWayControllerRegistry, oneway_controller.h)
158// arrive in a later step; until then callers are responsible for supplying both.
159
160/// @brief Build a 1W position execute frame (CMD 0x00) targeting a device class.
161///
162/// Encodes a 0–100 position into the 2-byte main field (wire value is `2 * position`, matching
163/// the 2W numeric encoding) inside 1W's 6-byte "special" payload form — 1W uses this short form
164/// even for numeric positions, not the 8-byte layout create_execute_position() (2W) uses. The MAC
165/// span is command-specific (see create_1w_execute_command()'s doxygen); there is no default, so
166/// this builder assembles it itself via the shared internal helper.
167///
168/// @note Position 50 encodes as `2 * 50 = 0x64` on the wire, the same byte
169/// decode_1w_main_intent() labels POS_FORCE_OPEN when reading overheard traffic. That label
170/// is a decode-side diagnostic choice only (see POS_FORCE_OPEN in proto_constants.h) — it
171/// does not change what this builder sends or what a device does with it. Position 50 is an
172/// ordinary position command.
173/// @param f IoFrame to populate.
174/// @param src Our 3-byte controller node address (the 1W controller identity's `node_id`).
175/// @param target_type Device class to address; encoded via encode_broadcast_address().
176/// @param position Desired position 0–100 (0=fully open, 100=fully closed).
177/// @param sequence 2-byte rolling sequence for this transmission (big-endian on wire); the
178/// caller's identity/sequence store owns incrementing and persisting this, not this
179/// builder — see the section note above.
180/// @param controller_key 16-byte key held by the transmitting controller identity: the hub's own
181/// `system_key` for its own network, or a foreign key adopted via CMD 0x30 (Phase 3A "key
182/// adoption") when transmitting as an adopted identity.
183/// @param acei ACEI byte to place at payload[1] — `ONEWAY_EXECUTE_ACEI` (Somfy-shaped) by
184/// default, `ONEWAY_EXECUTE_ACEI_VELUX` for a VELUX identity. Resolved per identity by
185/// oneway_controller.h's effective_execute_acei(); this builder just writes it.
186/// @param broadcast_all When true, address the all-devices broadcast `00 00 3F` instead of the
187/// typed `target_type` class — what a handheld cover remote does (`execute_broadcast: all`).
188/// @return true on success; false if `position > 100` or if crypto::create_1w_hmac() fails — no
189/// partially-populated frame is left behind on failure.
190bool create_1w_execute_position(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t position,
191 uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE],
192 uint8_t acei = ONEWAY_EXECUTE_ACEI, bool broadcast_all = false);
193
194/// @brief Build a 1W named-command execute frame (CMD 0x00) targeting a device class.
195///
196/// Covers three of CoverCommand's values — STOP, FAVORITE, VENT. FORCE_OPEN is not handled here:
197/// the only wire code this project has for that label, POS_FORCE_OPEN (main=0x64), was
198/// hardware-tested as an outbound CMD_EXECUTE command and found to move a real device to 50%
199/// open, not bypass anything (see POS_FORCE_OPEN in proto_constants.h). There is no known 1W
200/// force-open encoding, so passing CoverCommand::FORCE_OPEN here falls to `default` and returns
201/// false, the same way create_execute_command()'s 2W dispatch rejects it.
202/// - STOP: main=POS_STOP (0xD2), modifier=0x00
203/// - FAVORITE: main=POS_FAVORITE (0xD8), modifier=0x00
204/// - VENT: main=POS_FAVORITE (0xD8), modifier=POS_VENT_MODIFIER (0x03)
205///
206/// @warning **These three do not rest on the same evidence, even though they read as a uniform
207/// list.** STOP is the only one a real frame pins: the published IV vector
208/// (tests/corpus/captures/oneway/reference_1w_oneway_execute_iv_vector.yaml) is a documented
209/// worked example carrying main=0xD2. VENT is not captured anywhere in this project, but it does
210/// match the reference implementation's own 1W remote byte-for-byte — its `RemoteButton::Vent`
211/// emits exactly main=0xD8/mod=0x03. FAVORITE has
212/// neither kind of support: that same reference remote has no distinct favorite/My button at all
213/// (its RemoteButton set is Open/Close/Stop/Vent/ForceOpen/Position/Absolute/Pair/Add/Remove/
214/// Mode1-4 — no Favorite), so main=0xD8/mod=0x00 here is extrapolated purely by analogy with the
215/// 2W builder's FAVORITE encoding, with no source behind it at all. Worse, the one real capture
216/// this project has of an actual My/favorite button press —
217/// tests/corpus/captures/oneway/somfy_smoove_oneway_favorite_sx1276.yaml, pinned by
218/// `OneWayCommands.FavoriteButtonCaptureIsWritePrivateNotExecute` in tests/oneway/oneway_commands_test.cpp
219/// — contradicts it directly: that remote's My button is CMD_WRITE_PRIVATE (0x20) with a 16-byte
220/// payload, not CMD_EXECUTE with main=0xD8.
221///
222/// A real enrolled device does act on main=0xD8/mod=0x00, though: it changed the brightness of a
223/// Somfy Izymo dimmer, so the byte is accepted, not ignored. What position it targets — a stored
224/// "My" position vs. some fixed value — is unconfirmed; a 2W status-poll readback would settle
225/// it. Treat FAVORITE as "does something, target unverified", not "plausibly wrong".
226///
227/// FORCE_OPEN is deliberately absent rather than merely untested. The reference remote's
228/// `RemoteButton::ForceOpen` emits main=0x64/mod=0x00 — the same bytes this project labels
229/// POS_FORCE_OPEN — so it would "match the reference implementation byte-for-byte" the same way
230/// VENT does above. But matching the reference is not evidence it works: this project
231/// hardware-tested that exact main byte as an outbound CMD_EXECUTE and confirmed it moves a real
232/// device to 50% open (see POS_FORCE_OPEN in proto_constants.h), not past any lock. There is no
233/// known 1W encoding for a real force-open, so it is unimplemented rather than shipped on a
234/// mislabeled byte.
235///
236/// The MAC span is the 7 bytes `cmd, origin, acei, main0, main1, fp1, fp2` — the command byte
237/// through fp2, stopping before the sequence, which is not frame data and never enters the MAC.
238/// Pinned by the published IV vector at
239/// tests/corpus/captures/oneway/reference_1w_oneway_execute_iv_vector.yaml and by the reference
240/// implementation's own 1W-remote span (`toAdd = 6 + 1`).
241/// This span is command-specific and does not generalise: CMD 0x30's span is `cmd + enc_key`
242/// only — see crypto::create_1w_hmac()'s `@warning`.
243/// @param f IoFrame to populate.
244/// @param src Our 3-byte controller node address (the 1W controller identity's `node_id`).
245/// @param target_type Device class to address; encoded via encode_broadcast_address().
246/// @param cmd Named command to execute (STOP, FAVORITE, or VENT). CoverCommand::FORCE_OPEN
247/// returns false — see the @warning above.
248/// @param sequence 2-byte rolling sequence for this transmission (big-endian on wire); the
249/// caller's identity/sequence store owns incrementing and persisting this, not this
250/// builder — see the section note above.
251/// @param controller_key 16-byte key held by the transmitting controller identity: the hub's own
252/// `system_key` for its own network, or a foreign key adopted via CMD 0x30 (Phase 3A "key
253/// adoption") when transmitting as an adopted identity.
254/// @param acei ACEI byte for payload[1] — see create_1w_execute_position().
255/// @param broadcast_all Address `00 00 3F` instead of the typed class — see
256/// create_1w_execute_position().
257/// @return true on success; false if `cmd` is CoverCommand::FORCE_OPEN (no 1W encoding exists) or
258/// if crypto::create_1w_hmac() fails.
259bool create_1w_execute_command(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, CoverCommand cmd,
260 uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE],
261 uint8_t acei = ONEWAY_EXECUTE_ACEI, bool broadcast_all = false);
262
263// ============================================================================
264// 1W Enrollment (CMD 0x30 add-controller / CMD 0x39 remove-controller)
265// ============================================================================
266//
267// Enrollment is the precondition the two builders above assume away: a device obeys a 1W frame
268// only from a *registered* controller, so a correctly-keyed, correctly-signed execute from an
269// unknown source node is silently ignored (see ADR 0026). These two builders are how a
270// controller identity registers itself (0x30) or un-registers (0x39). Like the execute builders,
271// both are pure — `sequence` and `controller_key` are parameters, never looked up or persisted —
272// and both broadcast to the typed "all" address the same way create_1w_execute_command() does:
273// there is no addressed/unicast form, and for a virgin 1W-only device there is usually no address
274// to unicast to anyway.
275
276/// @brief Build a 1W add-controller frame (CMD 0x30) that registers this identity as a controller
277/// on every device of `target_type` currently in association mode — a physical 2 s PROG hold on
278/// the receiver, which only the device's owner can trigger (see ADR 0026).
279///
280/// The payload wraps `controller_key` for transmission rather than sending it in the clear:
281/// `enc_key = crypto::crypt_1w_key(src, controller_key)`, the same self-inverse primitive
282/// decode_1w_add_controller() uses to unwrap an overheard 0x30 (crypt_1w_key() is its own
283/// inverse, so this call and that one are literally the same function). The wrap key is the
284/// public TRANSFER_KEY
285/// (proto_constants.h) and the wrap IV derives only from `src` — see crypt_1w_key()'s doxygen for
286/// why that is not a weakness: the receiver decrypts `enc_key` first, then checks this frame's MAC
287/// under the key it just recovered, which is what makes the MAC meaningful (it proves the sender
288/// holds the key it just sent), not the wrap's secrecy.
289///
290/// Declared payload (20 bytes): `enc_key[16] + manufacturer[1] + data[1]=0x01 + sequence[2]`.
291/// **When `with_mac` is true, the MAC is an out-of-length trailer, not part of the declared
292/// payload** — CTRL0's 5-bit length field cannot express 29 declared bytes plus a 6-byte MAC
293/// together (see `IoFrame::has_mac`, `frame_carries_mac_trailer()` in proto_frame.h), so this
294/// builder sets `f.has_mac = true` and fills `f.mac[]` directly; `serialize()` appends it after
295/// the declared length and folds it into the length it returns, so a caller's CRC (computed over
296/// that return value) covers it automatically. **When present, this is the opposite placement
297/// from create_1w_remove_controller() below, whose MAC sits inside the declared payload** —
298/// copying one builder's shape to make the other is the most likely bug a future edit introduces
299/// here.
300///
301/// @warning **Real hardware and the published documentation vector disagree on whether the MAC
302/// trailer exists at all — this is why `with_mac` is a parameter, not a fixed choice.** The
303/// published `linklayer.md` vector (`with_mac=true`, 35 bytes) is what `with_mac` defaults to,
304/// for compatibility with that vector's own pinning tests. Most real hardware captures instead
305/// show 29 bytes with **no trailer at all** — see
306/// `tests/corpus/captures/enrollment/somfy_smoove_enrollment_add_and_remove_controller_sx1276.yaml` — matching
307/// the reference `_p0x30` struct (`iohcPacket.h`, no `hmac` field). **The enroll button calls
308/// this with `with_mac=false`** to match that shape, but this is a preference rather than a
309/// hardware requirement: a real Somfy Izymo dimmer has separately been shown to accept the
310/// `with_mac=true` form as well, enrolling successfully and remaining controllable afterward —
311/// both shapes are safe to send.
312///
313/// @warning Reference divergences, deliberately not followed:
314/// the iown-homecontrol project's `create_key_transfer_1w` derives the key-wrap
315/// IV from the *destination* node, which contradicts the published frame this builder is pinned
316/// against (a broadcast destination carries no identity, so `src` is the only value that
317/// reproduces it); and its comment claims "key transfer carries no MAC", which the same published
318/// frame also contradicts (its MAC is genuine and verifies) — that claim is a coincidental match
319/// for what real hardware turned out to do, not evidence the comment's reasoning was right.
320/// @param f IoFrame to populate.
321/// @param src Our 3-byte controller node address (the 1W controller identity's `node_id`) — also
322/// the key-wrap IV's only input, so this must be the identity actually being registered.
323/// @param target_type Device class to address; encoded via encode_broadcast_address(). Only
324/// devices of this class currently in association mode react.
325/// @param manufacturer Manufacturer ID byte to advertise (`man_id`); echoed back verbatim by
326/// anything that later decodes this frame with decode_1w_add_controller().
327/// @param sequence 2-byte rolling sequence for this transmission (big-endian on wire); the caller's
328/// identity/sequence store owns incrementing and persisting this, not this builder.
329/// @param controller_key 16-byte key this identity registers itself with — normally the hub's own
330/// `system_key`, wrapped here for transmission, never sent in the clear.
331/// @param with_mac True to append the out-of-length MAC trailer (the published vector's shape,
332/// and this parameter's default for backward compatibility); false to omit it entirely
333/// (the shape most real hardware captures this project holds actually use, and what the
334/// enroll button passes). Real hardware has been shown to accept both — see the
335/// `@warning` above.
336/// @return true on success; false if crypto::crypt_1w_key() fails, or (when `with_mac` is true)
337/// crypto::create_1w_hmac() fails — no partially-populated frame is left behind on failure.
338bool create_1w_add_controller(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t manufacturer,
339 uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], bool with_mac = true);
340
341/// @brief Build a 1W remove-controller frame (CMD 0x39) that un-registers this identity from every
342/// device of `target_type` currently in association mode — the un-enroll counterpart to
343/// create_1w_add_controller(), and (per the documented `0x39` → `0x30` flow, `linklayer.md:396`)
344/// also the prelude OneWayTransmitter::send_enrollment() fires immediately before it.
345///
346/// Declared payload (9 bytes), shape matching the reference `_p0x2e` struct: `data[1]=0x00 +
347/// sequence[2] + mac[6]`. **The MAC sits inside the declared payload here** — the opposite
348/// placement from create_1w_add_controller() above, whose MAC is an out-of-length trailer because
349/// its longer payload has no room left in CTRL0's 5-bit length field. `f.has_mac` stays false.
350///
351/// The MAC span is `cmd + data` (2 bytes) — the same "everything before the sequence" shape CMD
352/// 0x00's span follows, though spans are command-specific and do not generalise on their own (see
353/// create_1w_hmac()'s `@warning`; CMD 0x30's span is `cmd + enc_key`, a different rule). This one
354/// is verified against real captures, not merely plausible: every `0x39` frame in
355/// `tests/corpus/captures/enrollment/somfy_smoove_enrollment_add_and_remove_controller_sx1276.yaml` verifies
356/// under this exact span, and `scripts/corpus/validate.py` re-checks that on every corpus
357/// validation run — a regression here would fail `make corpus-validate`, not just look wrong.
358/// @param f IoFrame to populate.
359/// @param src Our 3-byte controller node address (the 1W controller identity's `node_id`).
360/// @param target_type Device class to address; encoded via encode_broadcast_address().
361/// @param sequence 2-byte rolling sequence for this transmission (big-endian on wire); the caller's
362/// identity/sequence store owns incrementing and persisting this, not this builder.
363/// @param controller_key 16-byte key held by the identity being removed — the same key
364/// create_1w_execute_command()/create_1w_add_controller() would use for this identity.
365/// @return true on success; false if crypto::create_1w_hmac() fails — no partially-populated frame
366/// is left behind on failure.
367bool create_1w_remove_controller(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint16_t sequence,
368 const uint8_t controller_key[AES_KEY_SIZE]);
369
370/// Build a get‑status request (0x03). The device responds with its current position.
371/// @param f IoFrame to populate.
372/// @param own Controller's 3‑byte node ID.
373/// @param dst Target device's 3‑byte node ID.
374/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
375/// @return true on success.
376bool create_get_status(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power);
377
378/// @brief Build a CMD_GET_GENERAL_INFO3 (0x58) request. No payload.
379///
380/// Shares the no-payload addressed-request shape with create_get_name(), create_get_info1() and
381/// create_get_info2() via a common file-local helper (proto_commands.cpp).
382/// @param f IoFrame to populate.
383/// @param own Controller's 3-byte node ID.
384/// @param dst Target device's 3-byte node ID.
385/// @param low_power True if target is battery/solar-powered (sets CTRL1_LOW_POWER).
386/// @return true on success.
387bool create_general_info3(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power);
388
389/// @brief Build a CMD_GET_INFO1 (0x54) request. No payload.
390///
391/// The request is field-observed — a real hub sends it — but no `0x55` answer has ever been
392/// captured. Diagnostic probe only; nothing in this codebase decodes a `0x55`.
393/// @param f IoFrame to populate.
394/// @param own Controller's 3-byte node ID.
395/// @param dst Target device's 3-byte node ID.
396/// @param low_power True if target is battery/solar-powered (sets CTRL1_LOW_POWER).
397/// @return true on success.
398bool create_get_info1(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power);
399
400/// @brief Build a CMD_GET_INFO2 (0x56) request. No payload.
401///
402/// The request has **never** been captured on air; it rests on the even=request / odd=answer
403/// pairing rule, and on the fact that a `0x57` answer *is* captured (carrying a leading ASCII
404/// reference string plus the packed type/subtype bytes hub_status.cpp already reads at offsets
405/// 10/11).
406/// @param f IoFrame to populate.
407/// @param own Controller's 3-byte node ID.
408/// @param dst Target device's 3-byte node ID.
409/// @param low_power True if target is battery/solar-powered (sets CTRL1_LOW_POWER).
410/// @return true on success.
411bool create_get_info2(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power);
412
413/// Build a get-name request (0x50). The device responds with its stored display name.
414/// @param f IoFrame to populate.
415/// @param own Controller's 3-byte node ID.
416/// @param dst Target device's 3-byte node ID.
417/// @param low_power True if target is battery/solar-powered (sets CTRL1_LOW_POWER).
418/// @return true on success.
419bool create_get_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power);
420
421/// Build an authenticated set-name request (0x52) using a fixed zero-padded Latin-1 payload.
422/// @param f IoFrame to populate.
423/// @param own Controller's 3-byte node ID.
424/// @param dst Target device's 3-byte node ID.
425/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
426/// @param payload Pre-validated fixed payload produced by encode_device_name_payload().
427/// @return true on success.
428bool create_set_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power,
429 const uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE]);
430
431/// @brief Build an authenticated device-identify request (0x1E) that makes a device
432/// physically identify itself (brief jog / flash).
433///
434/// @param f IoFrame to populate.
435/// @param own Controller's 3-byte node ID (source address).
436/// @param dst Target device's 3-byte node ID (destination address).
437/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
438/// @note The device may reply with CMD_ERROR_RESP instead of a dedicated identify response;
439/// callers should treat that reply as an expected, non-fatal outcome rather than a failure.
440/// @return true on success.
441bool create_identify(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power);
442
443/// @brief Build a generic CMD_WRITE_PRIVATE (0x20) frame carrying a caller-supplied payload.
444///
445/// The single builder behind every heating/climate function (power, setpoint, mode, presence,
446/// window, midnight time-sync). The payload is produced by encode_heating_payload() in
447/// proto_heating.h; this builder only frames it. Framing is fixed by the reference implementation's
448/// `forgePacket()` (StartFrame=1, EndFrame=0, Protocol=0) and cross-checked against the real
449/// Atlantic Thermor exchange capture
450/// tests/corpus/captures/exchange/atlantic_thermor_exchange_write_private_param.yaml (frame 1:
451/// start=true, end=false). The device answers with CMD_WRITE_PRIVATE_ACK (0x21) after an
452/// authenticated 0x3C/0x3D leg the exchange engine handles transparently.
453/// @param f IoFrame to populate.
454/// @param own Controller's 3-byte node ID (source address).
455/// @param dst Target device's 3-byte node ID (destination address).
456/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER;
457/// the exchange layer then also uses the long wake-up preamble). Per-device, from the
458/// target's YAML `low_power:` class (ADR 0029) — not hardcoded.
459/// @param payload Payload bytes (from encode_heating_payload()).
460/// @param payload_len Payload length in bytes. Rejected if 0 or > FRAME_MAX_DATA_SIZE.
461/// @return true on success; false if payload_len is out of range or set_cmd() fails.
462bool create_write_private(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, const uint8_t *payload,
463 size_t payload_len);
464
465/// Build an execute‑tilt command (0x00) for slat angle control.
466/// @param f IoFrame to populate.
467/// @param own Controller node ID.
468/// @param dst Target device node ID.
469/// @param low_power True if target is battery/solar‑powered (sets CTRL1_LOW_POWER).
470/// @param tilt_percent 0 = fully closed, 100 = fully open.
471/// @note This uses the same command (0x00) as position control but with a different
472/// payload format indicating a tilt operation. The receiver infers tilt from
473/// the payload structure. Only devices that advertise tilt support (see
474/// device_supports_tilt in proto_frame.h) will honor this.
475/// @return true on success.
476bool create_execute_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t tilt_percent);
477
478/// Build a combined position‑and‑tilt execute command (0x00).
479/// Sets both the cover position and the slat angle atomically in one frame,
480/// corresponding to the protocol's setClosureAndOrientation use case.
481/// @param f IoFrame to populate.
482/// @param own Controller node ID.
483/// @param dst Target device node ID.
484/// @param low_power True if target is battery/solar‑powered (sets CTRL1_LOW_POWER).
485/// @param position Desired position 0–100 (open→closed).
486/// @param tilt_percent 0 = fully closed, 100 = fully open.
487/// @return true on success; false if position exceeds limits.
488bool create_execute_position_and_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power,
489 uint8_t position, uint8_t tilt_percent);
490
491/// @brief Build an extended CMD_PRIVATE (0x03) request with a selector/block pair.
492///
493/// The shape real hubs use for both the tilt block (selector STATUS_TILT_SELECTOR: FPI1 bit 5 = FP3,
494/// see proto_constants.h) and the
495/// field-observed selector 0x80 (tests/corpus/captures/probe/multi_somfy_probe_extended_private_both_selectors.yaml),
496/// which this codebase has never decoded. create_get_status_tilt() below is this builder frozen at selector =
497/// STATUS_TILT_SELECTOR, block = 0x01.
498/// @param f IoFrame to populate.
499/// @param own Controller's 3-byte node ID.
500/// @param dst Target device's 3-byte node ID.
501/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
502/// @param selector Extended-status selector byte (data[1]).
503/// @param block Selector-specific block/index byte (data[2]) — the field-observed name for
504/// this byte is "N" for selector 0x80, where the corpus has only ever observed 0x00/0x01.
505/// @param function_id CMD_PRIVATE function ID at data[0]. Defaults to PRIVATE_GET_POSITION_STATUS
506/// (0x03) — the only value ever observed on air in this 4-byte extended shape. Other values
507/// are diagnostic probes: the *shape* `03 80 00 00` / `03 80 01 00` is field-observed from
508/// a real hub, but no other function ID has ever been seen in it.
509/// @return true on success.
510bool create_get_status_extended(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t selector,
511 uint8_t block, uint8_t function_id = PRIVATE_GET_POSITION_STATUS);
512
513/// Build a tilt‑aware get‑status request (0x03 with extended payload) that returns
514/// the 16‑byte tilt block in the response.
515/// @param f IoFrame to populate.
516/// @param own Controller node ID.
517/// @param dst Target device node ID.
518/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
519/// @return true on success.
520bool create_get_status_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power);
521
522/// @brief Build a CMD_PRIVATE2 (0x0C) request in either of the two field-observed shapes.
523///
524/// The payload is CMD_EXECUTE's POS_FAVORITE/POS_VENT_MODIFIER stored-position selector with
525/// the execution prefix stripped. `low_power` is an explicit parameter like every other
526/// device-addressed builder in this file, not derived from `long_form`: the two captures this
527/// builder is pinned against happen to carry CTRL1_LOW_POWER set on the long-form request and
528/// clear on the short-form one, but that tracks each capture's *target device's* power class
529/// (solar shutter vs. mains switch), not the payload shape — see proto_commands.cpp for the
530/// full reasoning.
531/// @param f IoFrame to populate.
532/// @param own Controller's 3-byte node ID.
533/// @param dst Target device's 3-byte node ID.
534/// @param modifier The POS_FAVORITE/POS_VENT_MODIFIER-family selector byte to read back.
535/// @param long_form True for the 6-byte long form (data = POS_UNKNOWN, 0x00, 0x80,
536/// POS_FAVORITE, modifier, 0x00); false for the 4-byte short form (data = POS_FAVORITE,
537/// modifier, 0x00, 0x00).
538/// @param low_power True if target is battery/solar-powered (sets CTRL1_LOW_POWER).
539/// @return true on success.
540bool create_private2_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t modifier, bool long_form,
541 bool low_power);
542
543/// @brief Build a discovery request with configurable command, destination, and payload.
544///
545/// Supports the command codes 0x28 (DISCOVER_REQ), 0x2A (DISCOVER_SPE_REQ), and
546/// 0x2E (DISCOVER_ALT_REQ, alternate discovery). For 0x2A the payload is a 6-byte random nonce
547/// followed by a 6-byte HMAC computed over the command byte alone, using the nonce as the
548/// challenge and the supplied system key.
549///
550/// @param f IoFrame to populate.
551/// @param own Controller's 3-byte node ID.
552/// @param command Discovery command code (0x28, 0x2A, or 0x2E).
553/// @param dst Destination node ID (broadcast or explicit).
554/// @param low_power True to set the LOW_POWER flag in CTRL1.
555/// @param ack_capable True to set the ACK flag (CTRL1_ACK) in CTRL1 for this one broadcast. Each
556/// caller decides per frame: the `pairing_discovery_ack_capable` tuning knob gates it for
557/// Discover & Pair's discovery broadcast (opt-in, default off — see the init_frame()
558/// doc in proto_frame.h for why CTRL1_ACK must never become an unconditional default), and
559/// the SPE roll-call's low-power frame shape (`CTRL1 = LOW_POWER | ACK`) always passes
560/// true.
561/// @param payload_enabled True when the optional payload byte is enabled.
562/// @param payload Optional payload byte (only used when command requires a payload).
563/// @param system_key 16-byte system key; only used for 0x2A HMAC computation.
564/// @return true on success; false for unsupported command or missing key for 0x2A.
565bool create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power,
566 bool ack_capable, bool payload_enabled, uint8_t payload, const uint8_t *system_key);
567
568/// Build a discovery broadcast (0x28). Sent to the broadcast address; only devices
569/// in pairing mode (PROG button pressed) will respond.
570/// @param f IoFrame to populate.
571/// @param own Controller node ID.
572/// @note Destination is BROADCAST_DISCOVER (0x00003B). The device responds with
573/// CMD_DISCOVER_RESP (0x29) containing its node ID and type/subtype. The
574/// controller then switches to point‑to‑point communication for phases 2 and 3.
575/// @return true on success.
576bool create_discover(IoFrame &f, const uint8_t *own);
577
578/// @brief Build a discovery response (0x29) — the device side of discovery, used by the
579/// key-extraction responder (see pairing_responder.h) to emulate an unpaired device.
580///
581/// Almost every builder in this file speaks the *controller* side of the protocol; this one,
582/// create_key_confirm(), create_discover_confirm_ack(), and create_challenge_req_device_role()
583/// below speak the *device* side, needed only for that one reverse-role feature. Device-side
584/// frames never set CTRL1_LOW_POWER: that bit describes the *target* of a controller-originated
585/// frame, and a device's replies are addressed to a mains-powered hub.
586/// Payload layout matches the full 9-byte discovery response format documented at
587/// DISCOVERY_RESP_BACKBONE_OFFSET/_MANUFACTURER_OFFSET/_FLAGS_OFFSET/_TIMESTAMP_OFFSET
588/// (proto_constants.h), cross-checked against a real Somfy actuator's captured 0x29
589/// (tests/corpus/captures/discovery/somfy_awning_discovery_lab_response.yaml): backbone address
590/// equals the device's own node ID, start+end set, low_power clear.
591/// @param f IoFrame to populate.
592/// @param own Our advertised (throwaway) node ID — used as both src and the backbone address.
593/// @param dst Destination node ID (the discovering hub's real node ID, from its 0x28's src).
594/// @param type Device type to advertise.
595/// @param subtype Device subtype to advertise.
596/// @param manufacturer_id Manufacturer ID to advertise (see MANUFACTURER_* in proto_constants.h).
597/// @note The flags byte (turnaround class, power-save) and timestamp are best-effort placeholder
598/// values — ATT_CLASS_40MS/POWER_SAVE_LOW_POWER and a non-zero timestamp, matching a real
599/// captured device rather than the honest-but-misleading ATT_CLASS_5MS/POWER_SAVE_ALWAYS_ALIVE
600/// (0x00) — see KEY_EXTRACTION_DISCOVER_RESP_FLAGS/_TIMESTAMP in proto_commands.cpp for the
601/// full reasoning. Still unverified against a real hub.
602/// @return true on success.
603bool create_discover_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst, DeviceType type, uint8_t subtype,
604 uint8_t manufacturer_id);
605
606/// @brief Build a key-confirm frame (0x33) — the device's acknowledgement that it received and
607/// installed the system key, sent after decrypting a CMD_KEY_TRANSFER (0x32).
608///
609/// Device-side counterpart to create_key_transfer(); used only by the key-extraction responder
610/// (see create_discover_resp() above for why this direction exists at all). No payload and END
611/// set, matching real devices' 0x33 in
612/// tests/corpus/captures/pairing/somfy_izymo_dimmer_pairing_full_sx1276.yaml,
613/// tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml, and (this project's own key-extraction responder
614/// against a real hub) tests/corpus/captures/pairing/velux_kig300_pairing_key_extraction_success.yaml — 0x33 closes the
615/// key-exchange sequence that CMD_KEY_INIT (0x31) opened with START.
616/// @param f IoFrame to populate.
617/// @param own Our advertised (throwaway) node ID.
618/// @param dst Destination node ID (the hub that sent the key transfer).
619/// @return true on success.
620bool create_key_confirm(IoFrame &f, const uint8_t *own, const uint8_t *dst);
621
622/// @brief Build a discovery-confirm request (0x2C) — sent by a controller directly to a
623/// freshly-discovered device, before proceeding to the key exchange (0x31).
624///
625/// Every real controller in this project's corpus sends 0x2C between the device's discovery
626/// response (0x29) and its own key-init (0x31): KLR200→KUX100
627/// (tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml), TaHoma→VELUX SSL
628/// (tests/corpus/captures/discovery/velux_ssl_discovery_tahoma_pairing.yaml), and this project's
629/// own key-extraction responder answering KIG300/KLR200/KLR300/Somfy TaHoma/Somfy Connectivity
630/// Kit (tests/corpus/captures/pairing/velux_kig300_pairing_key_extraction_success.yaml and
631/// siblings). Every one of those real hubs sets CTRL1_ACK for an always-alive target (`48 10`) and
632/// clears it for a low-power one (`48 20`, CTRL1_LOW_POWER only, no ACK) — see the `ack` param doc
633/// for what this builder does with that split. Gated by the `pairing_discover_confirm` tuning knob
634/// (`DiscoverConfirmMode::SKIP`/`SEND`/`SEND_WITH_ACK`, tuning_config.h) — `skip` reproduces the
635/// sequence without this step: no 0x2C, no post-step pause.
636/// @param f IoFrame to populate.
637/// @param own Controller's 3-byte node ID.
638/// @param dst The freshly-discovered device's 3-byte node ID (from its 0x29's src).
639/// @param low_power Set CTRL1_LOW_POWER. See this header's "Low-power flag and preamble
640/// handling" note above: unlike most pairing builders, this one is NOT a fixed value —
641/// pass the device's own self-reported power-save class from discovery
642/// (`pairing::PairingContext::discovery_low_power`).
643/// @param ack Set CTRL1_ACK ("sender can handle 2W responses"). The builder is literal — it does
644/// not derive `ack` from `low_power` itself; that mirror rule belongs to the caller
645/// (`PairingEngine::run_discover_confirm_step_()`), which never asks for `ack` when
646/// `low_power` is true. Every corpus hub's own 0x2C sets CTRL1_ACK for an always-alive
647/// target (`48 10`) and clears it for a low-power one (`48 20`); this codebase's `send`
648/// default instead clears it for an always-alive target too (`48 00`, no corpus hub sends
649/// that shape, but a Somfy Izymo dimmer answers it with 0x2D and ignores `48 10`), and
650/// `send_with_ack` switches to the hub-observed `48 10`.
651/// @return true on success.
652bool create_discover_confirm(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, bool ack);
653
654/// @brief Build a discovery-confirm acknowledgement (0x2D) — the device's answer to a hub's
655/// CMD_DISCOVER_CONFIRM (0x2C), which a hub sends directly to a freshly-discovered device before
656/// it will proceed to the key exchange.
657///
658/// Device-side only, like create_discover_resp()/create_key_confirm() above. Its controller-role
659/// counterpart is create_discover_confirm() just above.
660/// No payload and END set, matching real devices' 0x2D in
661/// tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml; for a second independent hub,
662/// tests/corpus/captures/pairing/somfy_connectivity_kit_pairing_key_extraction_stall.yaml, where
663/// an already-paired device answers the same hub the key-extraction responder was talking to; and
664/// for this exact builder exercised against a real hub,
665/// tests/corpus/captures/pairing/velux_kig300_pairing_key_extraction_success.yaml.
666/// @param f IoFrame to populate.
667/// @param own Our advertised (throwaway) node ID.
668/// @param dst Destination node ID (the hub that sent the discovery confirm).
669/// @return true on success.
670bool create_discover_confirm_ack(IoFrame &f, const uint8_t *own, const uint8_t *dst);
671
672/// @brief Recover the system key from an inbound CMD_KEY_TRANSFER (0x32) payload — the decode
673/// counterpart to create_key_transfer()'s encode.
674///
675/// Centralizes the IV-`data` convention in one place: create_key_transfer() derives its IV from
676/// the *preceding* CMD_KEY_INIT (0x31) command byte only (see its own doxygen), so decoding must
677/// use that same single-byte `{CMD_KEY_INIT}` — not the 0x32 frame's own command byte, and not
678/// the discovery frame. crypt_key() is symmetric, so this is the same primitive in reverse.
679/// @param transfer_payload 16-byte CMD_KEY_TRANSFER payload (frame.data).
680/// @param challenge The 6-byte challenge *we* generated and sent in our own CMD_CHALLENGE_REQ
681/// (0x3C) — the far side mixes this into its IV, so decoding requires the exact same bytes.
682/// @param out_key Output: recovered 16-byte system key.
683/// @return true on success (crypt_key() AES failure is the only false case).
684bool recover_system_key_from_transfer(const uint8_t transfer_payload[AES_KEY_SIZE], const uint8_t challenge[HMAC_SIZE],
685 uint8_t out_key[AES_KEY_SIZE]);
686
687/// Build a key‑init request (0x31) to start pairing key exchange with a discovered device.
688/// @param f IoFrame to populate.
689/// @param own Controller node ID.
690/// @param dst Discovered device node ID.
691/// @return true on success.
692bool create_key_init(IoFrame &f, const uint8_t *own, const uint8_t *dst);
693
694/// Build a key‑transfer frame (0x32) containing the system key encrypted with the transfer key.
695/// @param f IoFrame to populate.
696/// @param old_frame The key‑init frame (used to derive the encryption IV).
697/// @param dst Target device node ID.
698/// @param src Controller node ID.
699/// @param key The 16‑byte system key to transfer.
700/// @param challenge 6‑byte challenge received from device in its 0x3C response.
701/// @note The system key is obfuscated via the XOR‑AES construction in crypt_key().
702/// The transfer key (hardcoded in proto_frame.h) is the same for all IO‑Homecontrol
703/// devices worldwide; its purpose is to protect the system key in transit during
704/// initial pairing. Once transferred, the device uses the system key for all
705/// subsequent authenticated exchanges.
706/// @return true on success.
707bool create_key_transfer(IoFrame &f, IoFrame &old_frame, const uint8_t *dst, const uint8_t *src,
708 const uint8_t key[AES_KEY_SIZE], const uint8_t challenge[HMAC_SIZE]);
709
710/// Build a challenge request (0x3C) containing 6 random bytes. Used when we need to
711/// authenticate an incoming request from a device.
712/// @param f IoFrame to populate.
713/// @param dst Target device node ID (device we're challenging).
714/// @param src Controller node ID.
715/// @return true on success.
716bool create_challenge_req(IoFrame &f, const uint8_t *dst, const uint8_t *src);
717
718/// @brief Build a challenge request (0x3C) using a caller-supplied challenge instead of
719/// generating a fresh one internally.
720///
721/// The no-challenge overload above generates its own random bytes and does not expose them,
722/// which is fine for the normal inbound-auth path (the challenge is only ever needed once, to
723/// build this same frame). A caller that needs the *exact* bytes again later — the key-extraction
724/// responder decrypting the corresponding CMD_KEY_TRANSFER (0x32) — generates the challenge itself
725/// and passes it in here, keeping the transmitted 0x3C and the later decrypt on one source of
726/// truth. Note that responder uses create_challenge_req_device_role() below, not this overload.
727/// @param f IoFrame to populate.
728/// @param dst Target device node ID (device we're challenging).
729/// @param src Our own node ID.
730/// @param challenge Caller-supplied 6-byte challenge (e.g. from crypto::generate_challenge()).
731/// @return true on success.
732bool create_challenge_req(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE]);
733
734/// @brief Build a challenge request (0x3C) in the *device* direction — used only by the
735/// key-extraction responder to challenge a foreign hub that sent us CMD_KEY_INIT (0x31).
736///
737/// Same command and payload as the controller-role builders above, but framed the way a real
738/// device frames it: START clear and LOW_POWER clear. Both controller-role overloads set both
739/// bits, which is correct for their direction — LOW_POWER describes the *target* of a
740/// controller-originated frame (a device that may be battery/solar powered, see this header's
741/// convention note), and the controller's 0x3C opens its own inbound-auth exchange. Neither holds
742/// for a device answering a hub's key-init: real devices' pairing 0x3C frames in
743/// tests/corpus/captures/pairing/somfy_izymo_dimmer_pairing_full_sx1276.yaml (`0E 00 …`) and
744/// tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml carry neither bit, because the frame is a continuation
745/// of the hub's already-open exchange and is addressed to a mains-powered hub — and this exact builder, exercised
746/// against a real hub, produces the identical `0E 00` shape in
747/// tests/corpus/captures/pairing/velux_kig300_pairing_key_extraction_success.yaml.
748/// @param f IoFrame to populate.
749/// @param dst The foreign hub's node ID (from the inbound 0x31's src).
750/// @param src Our advertised (throwaway) node ID.
751/// @param challenge Caller-supplied 6-byte challenge, retained for the later 0x32 decrypt.
752/// @return true on success.
753bool create_challenge_req_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src,
754 const uint8_t challenge[HMAC_SIZE]);
755
756/// Build a challenge response (0x3D) proving we know the system key.
757/// HMAC is computed over [original_command_id + original_data] using the challenge.
758/// @param f IoFrame to populate.
759/// @param dst Target device node ID.
760/// @param src Controller node ID.
761/// @param challenge 6‑byte challenge from the device.
762/// @param origin Original request frame that triggered the challenge.
763/// @param key System key (16 bytes).
764/// @note The HMAC derivation uses the challenge as IV salt; see create_hmac() in
765/// proto_crypto.h for the exact construction. This frame authenticates the
766/// controller to the device for the current exchange.
767/// @return true on success.
768bool create_challenge_resp(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE],
769 const IoFrame &origin, const uint8_t *key);
770
771/// @brief Build a node verification response (0x37) — device side, answering a hub's CMD_NODE_VERIFY_REQ
772/// (0x36).
773///
774/// Payload is our own advertised node ID — the only identity this emulated device has to offer —
775/// the same value create_discover_resp() reports at DISCOVERY_RESP_BACKBONE_OFFSET.
776/// CorpusDeviceRoleBuilders.NodeVerifyRespPayloadMatchesOwnDiscoverRespBackboneAddress
777/// (tests/corpus/corpus_device_role_builder_test.cpp) pins that these two builders agree with *each
778/// other*, not that this matches a real device's own backbone value: the one real capture of this
779/// exchange (tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml) shows a genuine device whose 0x37
780/// payload — a persistent identity the io-homecontrol wire format tracks separately from a device's
781/// node/session address — does NOT equal that device's own node ID. Our emulated device only ever
782/// generates one identity per arm cycle, so it structurally cannot reproduce a real device's
783/// separate backbone value; whether any real hub requires the two to differ, or even inspects this
784/// field at all rather than treating it as informational, is unconfirmed. See
785/// docs/key-extraction.md's "Known limitations" for the field-facing version of this note.
786/// Also unverified: the full CTRL1 framing. This builder's ctrl1 is 0x00 (see init_frame() call in
787/// the .cpp), but the KLR200 capture's 0x37 carries CTRL1_PRIORITY set. See the
788/// TODO(hardware-verify) on the .cpp definition for why that bit is not mirrored here.
789/// No controller-role counterpart exists to share with: this codebase has never sent 0x36, so there
790/// is no analogous controller-role code that receives a 0x37 to keep in sync with.
791/// @param f IoFrame to populate.
792/// @param own Our advertised (throwaway) node ID — used as both src and the payload.
793/// @param dst Destination node ID (the hub that sent the node verification request, from its 0x36's src).
794/// @return true on success.
795bool create_node_verify_resp_device_role(IoFrame &f, const uint8_t *own, const uint8_t *dst);
796
797/// @brief Build a challenge response (0x3D) in the *device* direction — used only by the
798/// key-extraction responder to answer a hub-issued CMD_CHALLENGE_REQ (0x3C) challenging our own
799/// CMD_NODE_VERIFY_RESP (0x37).
800///
801/// Same transcript rule as create_challenge_resp() above (the challenged party HMACs its own
802/// preceding frame's cmd+data), but END is set: this 0x3D closes the node-verification round the
803/// hub opened with 0x36 (tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml's `8E 08 …`, END
804/// set). LOW_POWER is clear because CTRL1_LOW_POWER describes a controller-originated frame's
805/// *target*, not the sender — irrelevant here, since this is a device-role frame sent to a
806/// mains-powered hub. Unrelated to create_discover_resp()'s own Multi Information Byte, which as of
807/// KEY_EXTRACTION_DISCOVER_RESP_FLAGS (proto_commands.cpp) deliberately advertises
808/// POWER_SAVE_LOW_POWER for this responder's own (different) power-save self-description — the two
809/// bits answer different questions and are not expected to match. Neither
810/// bit is a blanket "device role" convention — real devices also send mid-exchange 0x3D frames with
811/// END clear and LOW_POWER set (somfy_oximo40_statuspoll_sx1262.yaml) — so this framing is
812/// specific to the terminal shape this feature needs, not a general device-role rule.
813/// @param f IoFrame to populate.
814/// @param dst The hub's node ID (from the inbound 0x3C's src).
815/// @param src Our advertised (throwaway) node ID.
816/// @param challenge 6-byte challenge from the hub's 0x3C.
817/// @param origin Our own preceding CMD_NODE_VERIFY_RESP (0x37) frame — its cmd+data is the transcript.
818/// @param key System key (16 bytes).
819/// @return true on success.
820bool create_challenge_resp_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src,
821 const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key);
822
823/// Build a status‑update acknowledgment (0x72). Sent after authenticating a device's
824/// status update; broadcast on all 3 channels for reliability.
825/// @param f IoFrame to populate.
826/// @param own Controller node ID.
827/// @param dst Device node ID that sent the update.
828/// @return true on success.
829bool create_status_update_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst);
830
831/// Build a set‑config command (0x6F) telling the device to automatically send
832/// status updates when controlled by any remote.
833/// @param f IoFrame to populate.
834/// @param own Controller node ID.
835/// @param dst Target device node ID.
836/// @note This configures the device to emit CMD_STATUS_UPDATE (0x71) frames whenever
837/// it is controlled by any remote (including the paired controller). This enables
838/// HA to receive unsolicited position updates. The controller must still
839/// authenticate the status update using the inbound auth flow (hub_exchange.h).
840/// @todo Confirm on real hardware which device families actually honor this SetConfig1
841/// payload and emit unsolicited status updates after pairing.
842/// @return true on success.
843bool create_set_config1(IoFrame &f, const uint8_t *own, const uint8_t *dst);
844
845} // namespace home_io_control
846} // namespace esphome
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 create_identify(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build an authenticated device-identify request (0x1E).
bool create_get_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a get-name request (0x50).
bool create_get_status(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a get-status request (0x03). The device responds with its current position.
bool create_set_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, const uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE])
Build an authenticated set-name request (0x52) using a fixed zero-padded Latin-1 payload.
bool create_write_private(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, const uint8_t *payload, size_t payload_len)
Build a generic CMD_WRITE_PRIVATE (0x20) frame around a caller-supplied payload — the one builder beh...
DeviceType
Device type identifiers reported by IO‑Homecontrol products.
bool create_node_verify_resp_device_role(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build an address response (0x37) — device side, used only by the key-extraction responder.
bool create_private2_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t modifier, bool long_form, bool low_power)
Build a CMD_PRIVATE2 (0x0C) request in either of the two field-observed shapes.
bool create_discover_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst, DeviceType type, uint8_t subtype, uint8_t manufacturer_id)
Build a discovery response (0x29) — device side, used only by the key-extraction responder.
CoverCommand
Named device commands for cover-type actuators.
bool create_1w_remove_controller(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE])
Build a 1W remove-controller frame (CMD 0x39).
bool recover_system_key_from_transfer(const uint8_t transfer_payload[AES_KEY_SIZE], const uint8_t challenge[HMAC_SIZE], uint8_t out_key[AES_KEY_SIZE])
Recover the system key from a CMD_KEY_TRANSFER payload.
bool create_discover_confirm(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, bool ack)
Build a discovery-confirm request (0x2C) — controller side, sent directly to a freshly-discovered dev...
bool create_get_info1(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_INFO1 (0x54) request. No payload. See proto_commands.h for the evidence note.
bool create_execute_position_and_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t position, uint8_t tilt_percent)
Build a combined position-and-tilt execute command (0x00) — setClosureAndOrientation.
bool create_1w_execute_command(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, CoverCommand cmd, uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], uint8_t acei, bool broadcast_all)
Build a 1W named-command execute frame (CMD 0x00) targeting a device class.
bool create_key_confirm(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a key-confirm frame (0x33) — device side, used only by the key-extraction responder.
bool create_execute_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t tilt_percent)
Build a tilt execute command (0x00) for devices that support slat angle control.
bool create_set_config1(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a set-config command (0x6F) to tell the device to automatically send status updates when contro...
bool create_challenge_resp(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key)
Build a challenge response (0x3D) proving we know the system key.
bool create_key_init(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a key-init request (0x31) to start the pairing key exchange with a discovered device.
bool create_execute_position(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t position, bool silent)
Build a position execute command (0x00) to move a device to a numeric position.
bool create_general_info3(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_GENERAL_INFO3 (0x58) request.
bool create_get_info2(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_INFO2 (0x56) request. No payload. See proto_commands.h for the evidence note.
bool create_execute_command(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, CoverCommand cmd, bool silent)
Build a named-command execute frame (0x00) for STOP, FAVORITE, or VENT.
bool create_discover_confirm_ack(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a discovery-confirm acknowledgement (0x2D) — device side, used only by the key-extraction respo...
bool create_challenge_req_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE])
Build a device-role challenge request (0x3C) — device side, used only by the key-extraction responder...
bool create_discover(IoFrame &f, const uint8_t *own)
Build a discovery broadcast (0x28).
bool create_private_function(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t function_id, uint8_t sub_index)
Build a CMD_PRIVATE (0x03) request for an arbitrary function ID.
bool create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power, bool ack_capable, bool payload_enabled, uint8_t payload, const uint8_t *system_key)
Build a configurable discovery request command (0x28, 0x2A, or 0x2E).
bool create_get_status_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a tilt-aware get-status request (0x03) that returns the extended 16-byte tilt payload.
bool create_get_status_extended(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t selector, uint8_t block, uint8_t function_id)
Build an extended CMD_PRIVATE (0x03) request with a selector/block pair — the shape real hubs use for...
bool create_challenge_resp_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key)
Build a device-role challenge response (0x3D) — device side, used only by the key-extraction responde...
bool create_1w_execute_position(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t position, uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], uint8_t acei, bool broadcast_all)
Build a 1W position execute frame (CMD 0x00) targeting a device class.
bool create_challenge_req(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE])
Build a challenge request (0x3C) using a caller-supplied challenge.
bool create_status_update_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a status-update acknowledgment (0x72).
bool create_1w_add_controller(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t manufacturer, uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], bool with_mac)
Build a 1W add-controller frame (CMD 0x30).
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.
Device-name, address-classification and 1W-frame codecs.
IO-Homecontrol command IDs, result codes and protocol enumerations.
IO-Homecontrol device-type model, capabilities and runtime device state.
IO-Homecontrol 2W frame container: control bytes, IoFrame and (de)serialization.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93