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