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, CoverCommand::PEDESTRIAN, CoverCommand::PARTIAL.
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 or a named position.
76///
77/// The named positions are the entries of NAMED_POSITIONS (proto_device_types.h); each maps to a
78/// specific wire encoding in the 6-byte special CMD_EXECUTE payload:
79/// - STOP: main=0xD2, modifier=0x00
80/// - FAVORITE: main=0xD8, modifier=0x00
81/// - VENT: main=0xD8, modifier=0x03
82/// - PEDESTRIAN: main=0xD8, modifier=0x07 (experimental, gate openers)
83/// - PARTIAL: main=0xD8, modifier=0x09 (garage openers)
84///
85/// This cleanly separates "move to position X" from "execute named action"
86/// without overloading a single numeric parameter.
87///
88/// FORCE_OPEN is NOT handled here — see create_force_open() instead. Unlike these, it
89/// needs a device-specific "fully open" wire position (0 or 100 depending on inversion), which
90/// this generic dispatch has no way to supply; passing CoverCommand::FORCE_OPEN returns false.
91/// @param f IoFrame to populate.
92/// @param own Controller's 3‑byte node ID (source address).
93/// @param dst Target device's 3‑byte node ID (destination address).
94/// @param low_power True if target is battery/solar‑powered (sets CTRL1_LOW_POWER).
95/// @param cmd Named command to execute (STOP or a named position).
96/// @return true on success; false for invalid/unsupported command.
97/// @param silent Use the silent (slower) travel profile. Honoured for FAVORITE only — STOP has no
98/// travel speed, and nothing has been captured for the other named positions.
99bool create_execute_command(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, CoverCommand cmd,
100 bool silent = false);
101
102/// @brief Build a force-open execute frame (0x00): move to the device's wire-scale "fully open"
103/// position at elevated ACEI priority (level 0, protection_human) instead of the usual
104/// user_high level — see EXECUTE_ACEI_FORCE_OPEN in proto_commands.cpp for why priority
105/// elevation, not a special position byte, is the protocol's real mechanism for getting past an
106/// environmental soft lock.
107///
108/// @param f IoFrame to populate.
109/// @param own Controller's 3‑byte node ID (source address).
110/// @param dst Target device's 3‑byte node ID (destination address).
111/// @param low_power True if target is battery/solar‑powered (sets CTRL1_LOW_POWER).
112/// @param open_position The device's wire-scale position value that means "fully open": 0 for
113/// ordinary devices, 100 for IoDevice::inverted ones (e.g. horizontal awnings) — the
114/// caller must resolve this from the target device, this builder does not have access
115/// to device state. Getting this wrong sends an ordinary, harmless-looking position
116/// command to the device's already-resting position instead of moving it anywhere.
117/// @return true on success.
118/// @note The elevated-priority override has not yet been confirmed against a real *active*
119/// environmental lock.
120bool create_force_open(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t open_position);
121
122/// @brief Build a position execute frame (0x00) that carries the main parameter only: the 6-byte
123/// form `originator, ACEI, MP, MP, 00, 00`, with no functional-parameter block.
124///
125/// For devices that refuse the block create_execute_position() appends. A Wisniowski / Somfy
126/// MOTO 1000 io garage opener answers that block with RESULT_PARAMETER_UNKNOWN before any
127/// challenge, accepts a 6-byte STOP, and is opened by its own 1W remote with exactly this shape
128/// (`01 43 00 00 00 00`, issue #163). Sent to devices without device_feature::EXTENDED_POSITION_FRAME,
129/// and to covers set to TravelSpeed::MOTOR_DEFAULT: a VELUX KLR 200 control pad moves a window with
130/// this form, and the window then travels at its own speed rather than at a profile's (ADR 0065).
131/// @param f IoFrame to populate.
132/// @param own Controller's 3‑byte node ID (source address).
133/// @param dst Target device's 3‑byte node ID (destination address).
134/// @param low_power True if target is battery/solar‑powered (sets CTRL1_LOW_POWER).
135/// @param position Target position 0..100 (IO scale: 0 = open, 100 = closed).
136/// @param force_open_priority True to send at the force-open ACEI (see create_force_open()) instead
137/// of the normal one.
138/// @return true on success; false for a position above 100.
139bool create_execute_main_parameter(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t position,
140 bool force_open_priority = false);
141
142/// @brief Highest heating level low byte create_execute_heating_level() will frame (the secured level).
143static constexpr uint8_t HEATING_LEVEL_MAX = 0x3F;
144
145/// @brief Build an EXECUTE (0x00) that sets a heater's heating level.
146///
147/// The heating level sits in the main parameter (high byte HEATING_LEVEL_MP_PREFIX, low byte the
148/// level; see proto_heating_profiles.h). The frame has the eight data bytes a vendor bridge sends with
149/// every heating level (corpus `thermor_pilot_wire_interface_exchange_heating_level_execute`, issue #228):
150/// ACEI 0xE7, FP11 present in the FP9-FP16 bitmap, FP11 = 0xFFFF. That evidence does not establish what
151/// FP11 = 0xFFFF means; it is sent as the bridge sends it.
152/// \todo Experimental: no level change has been seen on a heater's display or read back yet, so the
153/// frame stays unconfirmed on hardware until a reporter's heater does.
154/// @param f IoFrame to populate.
155/// @param own Controller's 3-byte node ID (source address).
156/// @param dst Target device's 3-byte node ID (destination address).
157/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
158/// @param level Heating level low byte (heating_level::*).
159/// @return true on success; false for a level above HEATING_LEVEL_MAX.
160bool create_execute_heating_level(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t level);
161
162/// @brief Build a main-parameter-only EXECUTE (0x00) whose main parameter is a temperature or an alias.
163///
164/// Same 6-byte shape as create_execute_main_parameter(); the caller encodes the value
165/// (encode_temperature_main_parameter(), or heating_prefixed_main_parameter() for an alias).
166/// Experimental: unconfirmed on hardware.
167/// @param f IoFrame to populate.
168/// @param own Controller's 3-byte node ID (source address).
169/// @param dst Target device's 3-byte node ID (destination address).
170/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
171/// @param main_parameter The 16-bit main parameter.
172/// @return true on success.
173bool create_execute_heating_main_parameter(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power,
174 uint16_t main_parameter);
175
176/// @brief Build an EXECUTE (0x00) with a temperature or alias main parameter and functional parameter 9.
177///
178/// Payload: originator, ACEI, MP(2), `00` (no FP1-FP8), `80` (FP9 present), FP9 as two bytes, high
179/// byte first (every functional-parameter value is two bytes wide, like the main parameter). FP9
180/// carries the active mode (heating_mode_flags::*). No other functional parameter is sent.
181/// Experimental: unconfirmed on hardware.
182/// @param f IoFrame to populate.
183/// @param own Controller's 3-byte node ID (source address).
184/// @param dst Target device's 3-byte node ID (destination address).
185/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
186/// @param main_parameter The 16-bit main parameter.
187/// @param mode_flags FP9 value.
188/// @return true on success.
189bool create_execute_heating_with_mode_flags(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power,
190 uint16_t main_parameter, uint16_t mode_flags);
191
192/// @brief Build an EXECUTE (0x00) with a temperature or alias main parameter and functional parameter 16.
193///
194/// Payload: originator, ACEI, MP(2), `00` (no FP1-FP8), `01` (FP16 present, the last bit of the second
195/// bitmap), FP16 (two bytes, big-endian like the main parameter). No other functional parameter is sent.
196/// Experimental: unconfirmed on hardware.
197/// @param f IoFrame to populate.
198/// @param own Controller's 3-byte node ID (source address).
199/// @param dst Target device's 3-byte node ID (destination address).
200/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
201/// @param main_parameter The 16-bit main parameter.
202/// @param fp16 FP16 value.
203/// @return true on success.
204bool create_execute_heating_with_fp16(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power,
205 uint16_t main_parameter, uint16_t fp16);
206
207/// @brief Build a CMD_PRIVATE (0x03) request for an arbitrary function ID.
208///
209/// function_id = 0x06 (battery-status) or 0x09 (battery-state) is reported elsewhere to read the
210/// CMD_PRIVATE_RESP reply as data[1]==0x60 => battery/solar powered, value = data[2]<<8|data[3].
211/// Unverified here -- both devices this project has probed are mains-powered and answered
212/// data[1]==0x00 (tests/corpus/captures/probe/somfy_awning_probe_private_fn_lr1121.yaml,
213/// tests/corpus/captures/probe/somfy_izymo_dimmer_probe_private_fn_lr1121.yaml).
214/// create_get_status() below is this builder frozen at function_id =
215/// PRIVATE_GET_POSITION_STATUS (0x03), the only function ID this codebase has ever captured on
216/// its own wire.
217/// @param f IoFrame to populate.
218/// @param own Controller's 3-byte node ID.
219/// @param dst Target device's 3-byte node ID.
220/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER;
221/// the exchange layer then also uses the long wake-up preamble).
222/// @param function_id Private function ID (data[0] of the CMD_PRIVATE payload).
223/// @param sub_index Second payload byte (data[1]). Defaults to 0x00 — the only value any project
224/// has ever transmitted in this 3-byte form, and what every existing capture of ours
225/// contains. A non-zero value is a diagnostic probe into an undecoded parameter encoding:
226/// reference material describes a two-field (main parameter, functional parameter)
227/// addressing scheme here, but the field->byte mapping is **not known** — at least three
228/// encodings fit every payload observed to date equally well, because every one of them
229/// has both fields zero.
230/// @return true on success.
231bool create_private_function(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t function_id,
232 uint8_t sub_index = 0x00);
233
234// ============================================================================
235// 1W Execute (fire-and-forget class broadcast)
236// ============================================================================
237//
238// 1W has no reply and no challenge: a controller transmits once and the frame either lands or it
239// doesn't — there is no ACK to retry on and no 0x3C/0x3D to authenticate through, so the MAC
240// inside the payload (create_1w_hmac(), sequence-keyed rather than challenge-keyed) is the only
241// authentication a receiving device gets. The destination is a device *class*
242// (encode_broadcast_address(), proto_codecs.h), never an individual node — 1W has no addressed
243// unicast form at all. Both builders below are pure: they take `sequence` and `controller_key` as
244// parameters and neither increment, persist, nor look either up. The sequence store and the
245// identity registry that own those concerns (OneWayControllerRegistry, oneway_controller.h)
246// arrive in a later step; until then callers are responsible for supplying both.
247
248/// @brief Build a 1W position execute frame (CMD 0x00) targeting a device class.
249///
250/// Encodes a 0–100 position into the 2-byte main field (wire value is `2 * position`, matching
251/// the 2W numeric encoding) inside 1W's 6-byte "special" payload form — 1W uses this short form
252/// even for numeric positions, not the 8-byte layout create_execute_position() (2W) uses. The MAC
253/// span is command-specific (see create_1w_execute_command()'s doxygen); there is no default, so
254/// this builder assembles it itself via the shared internal helper.
255///
256/// @note Position 50 encodes as `2 * 50 = 0x64` on the wire, the same byte
257/// decode_1w_main_intent() labels POS_FORCE_OPEN when reading overheard traffic. That label
258/// is a decode-side diagnostic choice only (see POS_FORCE_OPEN in proto_constants.h) — it
259/// does not change what this builder sends or what a device does with it. Position 50 is an
260/// ordinary position command.
261/// @param f IoFrame to populate.
262/// @param src Our 3-byte controller node address (the 1W controller identity's `node_id`).
263/// @param target_type Device class to address; encoded via encode_broadcast_address().
264/// @param position Desired position 0–100 (0=fully open, 100=fully closed).
265/// @param sequence 2-byte rolling sequence for this transmission (big-endian on wire); the
266/// caller's identity/sequence store owns incrementing and persisting this, not this
267/// builder — see the section note above.
268/// @param controller_key 16-byte key held by the transmitting controller identity: the hub's own
269/// `system_key` for its own network, or a foreign key adopted via CMD 0x30 (Phase 3A "key
270/// adoption") when transmitting as an adopted identity.
271/// @param acei ACEI byte to place at payload[1] — `ONEWAY_EXECUTE_ACEI` (Somfy-shaped) by
272/// default, `ONEWAY_EXECUTE_ACEI_VELUX` for a VELUX identity. Resolved per identity by
273/// oneway_controller.h's effective_execute_acei(); this builder just writes it.
274/// @param broadcast_all When true, address the all-devices broadcast `00 00 3F` instead of the
275/// typed `target_type` class — what a handheld cover remote does (`execute_broadcast: all`).
276/// @return true on success; false if `position > 100` or if crypto::create_1w_hmac() fails — no
277/// partially-populated frame is left behind on failure.
278bool create_1w_execute_position(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t position,
279 uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE],
280 uint8_t acei = ONEWAY_EXECUTE_ACEI, bool broadcast_all = false);
281
282/// @brief Build a 1W named-command execute frame (CMD 0x00) targeting a device class.
283///
284/// Covers three of CoverCommand's values — STOP, FAVORITE, VENT; PARTIAL and PEDESTRIAN have no 1W
285/// encoding and return false like FORCE_OPEN. FORCE_OPEN is not handled here:
286/// the only wire code this project has for that label, POS_FORCE_OPEN (main=0x64), was
287/// hardware-tested as an outbound CMD_EXECUTE command and found to move a real device to 50%
288/// open, not bypass anything (see POS_FORCE_OPEN in proto_constants.h). There is no known 1W
289/// force-open encoding, so passing CoverCommand::FORCE_OPEN here falls to `default` and returns
290/// false, the same way create_execute_command()'s 2W dispatch rejects it.
291/// - STOP: main=POS_STOP (0xD2), modifier=0x00
292/// - FAVORITE: main=POS_FAVORITE (0xD8), modifier=0x00
293/// - VENT: main=POS_FAVORITE (0xD8), modifier=POS_VENT_MODIFIER (0x03)
294///
295/// @warning **These three do not rest on the same evidence, even though they read as a uniform
296/// list.** STOP is the only one a real frame pins: own captures
297/// (tests/corpus/captures/oneway/somfy_smoove_oneway_stop_sx1276.yaml) and the published IV vector
298/// (tests/corpus/captures/oneway/reference_1w_oneway_execute_iv_vector.yaml) carry main=0xD2. VENT
299/// is not captured anywhere in this project, so main=0xD8/mod=0x03 is assumed. FAVORITE has
300/// no support either: main=0xD8/mod=0x00 here is extrapolated purely by analogy with the
301/// 2W builder's FAVORITE encoding. Worse, the one real capture
302/// this project has of an actual My/favorite button press —
303/// tests/corpus/captures/oneway/somfy_smoove_oneway_favorite_sx1276.yaml, pinned by
304/// `OneWayCommands.FavoriteButtonCaptureIsWritePrivateNotExecute` in tests/oneway/oneway_commands_test.cpp
305/// — contradicts it directly: that remote's My button is CMD_WRITE_PRIVATE (0x20) with a 16-byte
306/// payload, not CMD_EXECUTE with main=0xD8.
307///
308/// A real enrolled device does act on main=0xD8/mod=0x00, though: it changed the brightness of a
309/// Somfy Izymo dimmer, so the byte is accepted, not ignored. What position it targets — a stored
310/// "My" position vs. some fixed value — is unconfirmed; a 2W status-poll readback would settle
311/// it. Treat FAVORITE as "does something, target unverified", not "plausibly wrong".
312///
313/// FORCE_OPEN is deliberately absent rather than merely untested. Its natural encoding would be
314/// main=0x64/mod=0x00 — the same bytes this project labels POS_FORCE_OPEN — but this project
315/// hardware-tested that exact main byte as an outbound CMD_EXECUTE and confirmed it moves a real
316/// device to 50% open (see POS_FORCE_OPEN in proto_constants.h), not past any lock. There is no
317/// known 1W encoding for a real force-open, so it is unimplemented rather than shipped on a
318/// mislabeled byte.
319///
320/// The MAC span is the 7 bytes `cmd, origin, acei, main0, main1, fp1, fp2` — the command byte
321/// through fp2, stopping before the sequence, which is not frame data and never enters the MAC.
322/// Pinned by the published IV vector at
323/// tests/corpus/captures/oneway/reference_1w_oneway_execute_iv_vector.yaml.
324/// This span is command-specific and does not generalise: CMD 0x30's span is `cmd + enc_key`
325/// only — see crypto::create_1w_hmac()'s `@warning`.
326/// @param f IoFrame to populate.
327/// @param src Our 3-byte controller node address (the 1W controller identity's `node_id`).
328/// @param target_type Device class to address; encoded via encode_broadcast_address().
329/// @param cmd Named command to execute (STOP, FAVORITE, or VENT). CoverCommand::FORCE_OPEN
330/// returns false — see the @warning above.
331/// @param sequence 2-byte rolling sequence for this transmission (big-endian on wire); the
332/// caller's identity/sequence store owns incrementing and persisting this, not this
333/// builder — see the section note above.
334/// @param controller_key 16-byte key held by the transmitting controller identity: the hub's own
335/// `system_key` for its own network, or a foreign key adopted via CMD 0x30 (Phase 3A "key
336/// adoption") when transmitting as an adopted identity.
337/// @param acei ACEI byte for payload[1] — see create_1w_execute_position().
338/// @param broadcast_all Address `00 00 3F` instead of the typed class — see
339/// create_1w_execute_position().
340/// @return true on success; false if `cmd` is CoverCommand::FORCE_OPEN (no 1W encoding exists) or
341/// if crypto::create_1w_hmac() fails.
342bool create_1w_execute_command(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, CoverCommand cmd,
343 uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE],
344 uint8_t acei = ONEWAY_EXECUTE_ACEI, bool broadcast_all = false);
345
346// ============================================================================
347// 1W Enrollment (CMD 0x30 add-controller / CMD 0x39 remove-controller)
348// ============================================================================
349//
350// Enrollment is the precondition the two builders above assume away: a device obeys a 1W frame
351// only from a *registered* controller, so a correctly-keyed, correctly-signed execute from an
352// unknown source node is silently ignored (see ADR 0026). These two builders are how a
353// controller identity registers itself (0x30) or un-registers (0x39). Like the execute builders,
354// both are pure — `sequence` and `controller_key` are parameters, never looked up or persisted —
355// and both broadcast to the typed "all" address the same way create_1w_execute_command() does:
356// there is no addressed/unicast form, and for a virgin 1W-only device there is usually no address
357// to unicast to anyway.
358
359/// @brief Build a 1W add-controller frame (CMD 0x30) that registers this identity as a controller
360/// on every device of `target_type` currently in association mode — a physical 2 s PROG hold on
361/// the receiver, which only the device's owner can trigger (see ADR 0026).
362///
363/// The payload wraps `controller_key` for transmission rather than sending it in the clear:
364/// `enc_key = crypto::crypt_1w_key(src, controller_key)`, the same self-inverse primitive
365/// decode_1w_add_controller() uses to unwrap an overheard 0x30 (crypt_1w_key() is its own
366/// inverse, so this call and that one are literally the same function). The wrap key is the
367/// public TRANSFER_KEY
368/// (proto_constants.h) and the wrap IV derives only from `src` — see crypt_1w_key()'s doxygen for
369/// why that is not a weakness: the receiver decrypts `enc_key` first, then checks this frame's MAC
370/// under the key it just recovered, which is what makes the MAC meaningful (it proves the sender
371/// holds the key it just sent), not the wrap's secrecy.
372///
373/// Declared payload (20 bytes): `enc_key[16] + manufacturer[1] + data[1]=0x01 + sequence[2]`.
374/// **When `with_mac` is true, the MAC is an out-of-length trailer, not part of the declared
375/// payload** — CTRL0's 5-bit length field cannot express 29 declared bytes plus a 6-byte MAC
376/// together (see `IoFrame::has_mac`, `frame_carries_mac_trailer()` in proto_frame.h), so this
377/// builder sets `f.has_mac = true` and fills `f.mac[]` directly; `serialize()` appends it after
378/// the declared length and folds it into the length it returns, so a caller's CRC (computed over
379/// that return value) covers it automatically. **When present, this is the opposite placement
380/// from create_1w_remove_controller() below, whose MAC sits inside the declared payload** —
381/// copying one builder's shape to make the other is the most likely bug a future edit introduces
382/// here.
383///
384/// @warning **Real hardware and the published documentation vector disagree on whether the MAC
385/// trailer exists at all — this is why `with_mac` is a parameter, not a fixed choice.** The
386/// published known-answer vector
387/// (tests/corpus/captures/enrollment/reference_1w_enrollment_add_controller_kat.yaml,
388/// `with_mac=true`, 35 bytes) is what `with_mac` defaults to,
389/// for compatibility with that vector's own pinning tests. Most real hardware captures instead
390/// show 29 bytes with **no trailer at all** — see
391/// `tests/corpus/captures/enrollment/somfy_smoove_enrollment_add_and_remove_controller_sx1276.yaml`. **The enroll
392/// button calls this with `with_mac=false`** to match that shape, but this is a preference rather than a hardware
393/// requirement: a real Somfy Izymo dimmer has separately been shown to accept the `with_mac=true` form as well,
394/// enrolling successfully and remaining controllable afterward — both shapes are safe to send.
395///
396/// @warning The key-wrap IV comes from `src`, not the destination: a broadcast destination carries
397/// no identity, and `src` is the only value that reproduces the published frame this builder is
398/// pinned against. That frame's MAC is genuine and verifies, so a missing trailer on real hardware
399/// is not evidence that the frame is unauthenticated.
400/// @param f IoFrame to populate.
401/// @param src Our 3-byte controller node address (the 1W controller identity's `node_id`) — also
402/// the key-wrap IV's only input, so this must be the identity actually being registered.
403/// @param target_type Device class to address; encoded via encode_broadcast_address(). Only
404/// devices of this class currently in association mode react.
405/// @param manufacturer Manufacturer ID byte to advertise (`man_id`); echoed back verbatim by
406/// anything that later decodes this frame with decode_1w_add_controller().
407/// @param sequence 2-byte rolling sequence for this transmission (big-endian on wire); the caller's
408/// identity/sequence store owns incrementing and persisting this, not this builder.
409/// @param controller_key 16-byte key this identity registers itself with — normally the hub's own
410/// `system_key`, wrapped here for transmission, never sent in the clear.
411/// @param with_mac True to append the out-of-length MAC trailer (the published vector's shape,
412/// and this parameter's default for backward compatibility); false to omit it entirely
413/// (the shape most real hardware captures this project holds actually use, and what the
414/// enroll button passes). Real hardware has been shown to accept both — see the
415/// `@warning` above.
416/// @return true on success; false if crypto::crypt_1w_key() fails, or (when `with_mac` is true)
417/// crypto::create_1w_hmac() fails — no partially-populated frame is left behind on failure.
418bool create_1w_add_controller(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t manufacturer,
419 uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], bool with_mac = true);
420
421/// @brief Build a 1W remove-controller frame (CMD 0x39) that un-registers this identity from every
422/// device of `target_type` currently in association mode — the un-enroll counterpart to
423/// create_1w_add_controller(), and (the `0x39` → `0x30` flow real remotes send)
424/// also the prelude OneWayTransmitter::send_enrollment() fires immediately before it.
425///
426/// Declared payload (9 bytes), shape: `data[1]=0x00 +
427/// sequence[2] + mac[6]`. **The MAC sits inside the declared payload here** — the opposite
428/// placement from create_1w_add_controller() above, whose MAC is an out-of-length trailer because
429/// its longer payload has no room left in CTRL0's 5-bit length field. `f.has_mac` stays false.
430///
431/// The MAC span is `cmd + data` (2 bytes) — the same "everything before the sequence" shape CMD
432/// 0x00's span follows, though spans are command-specific and do not generalise on their own (see
433/// create_1w_hmac()'s `@warning`; CMD 0x30's span is `cmd + enc_key`, a different rule). This one
434/// is verified against real captures, not merely plausible: every `0x39` frame in
435/// `tests/corpus/captures/enrollment/somfy_smoove_enrollment_add_and_remove_controller_sx1276.yaml` verifies
436/// under this exact span, and `scripts/corpus/validate.py` re-checks that on every corpus
437/// validation run — a regression here would fail `make corpus-validate`, not just look wrong.
438/// @param f IoFrame to populate.
439/// @param src Our 3-byte controller node address (the 1W controller identity's `node_id`).
440/// @param target_type Device class to address; encoded via encode_broadcast_address().
441/// @param sequence 2-byte rolling sequence for this transmission (big-endian on wire); the caller's
442/// identity/sequence store owns incrementing and persisting this, not this builder.
443/// @param controller_key 16-byte key held by the identity being removed — the same key
444/// create_1w_execute_command()/create_1w_add_controller() would use for this identity.
445/// @return true on success; false if crypto::create_1w_hmac() fails — no partially-populated frame
446/// is left behind on failure.
447bool create_1w_remove_controller(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint16_t sequence,
448 const uint8_t controller_key[AES_KEY_SIZE]);
449
450/// Build a get‑status request (0x03). The device responds with its current position.
451/// @param f IoFrame to populate.
452/// @param own Controller's 3‑byte node ID.
453/// @param dst Target device's 3‑byte node ID.
454/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
455/// @return true on success.
456bool create_get_status(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power);
457
458/// @brief Build a CMD_GET_GENERAL_INFO3 (0x58) request. No payload.
459///
460/// Shares the no-payload addressed-request shape with create_get_name(), create_get_info1() and
461/// create_get_info2() via a common file-local helper (proto_commands.cpp).
462/// @param f IoFrame to populate.
463/// @param own Controller's 3-byte node ID.
464/// @param dst Target device's 3-byte node ID.
465/// @param low_power True if target is battery/solar-powered (sets CTRL1_LOW_POWER).
466/// @return true on success.
467bool create_general_info3(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power);
468
469/// @brief Build a CMD_GET_INFO1 (0x54) request. No payload.
470///
471/// The request is field-observed — a real hub sends it — but no `0x55` answer has ever been
472/// captured. Diagnostic probe only; nothing in this codebase decodes a `0x55`.
473/// @param f IoFrame to populate.
474/// @param own Controller's 3-byte node ID.
475/// @param dst Target device's 3-byte node ID.
476/// @param low_power True if target is battery/solar-powered (sets CTRL1_LOW_POWER).
477/// @return true on success.
478bool create_get_info1(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power);
479
480/// @brief Build a CMD_GET_INFO2 (0x56) request. No payload.
481///
482/// The request has **never** been captured on air; it rests on the even=request / odd=answer
483/// pairing rule, and on the fact that a `0x57` answer *is* captured (carrying a leading ASCII
484/// reference string plus the packed type/subtype bytes hub_status.cpp already reads at offsets
485/// 10/11).
486/// @param f IoFrame to populate.
487/// @param own Controller's 3-byte node ID.
488/// @param dst Target device's 3-byte node ID.
489/// @param low_power True if target is battery/solar-powered (sets CTRL1_LOW_POWER).
490/// @return true on success.
491bool create_get_info2(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power);
492
493/// Build a get-name request (0x50). The device responds with its stored display name.
494/// @param f IoFrame to populate.
495/// @param own Controller's 3-byte node ID.
496/// @param dst Target device's 3-byte node ID.
497/// @param low_power True if target is battery/solar-powered (sets CTRL1_LOW_POWER).
498/// @return true on success.
499bool create_get_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power);
500
501/// Build an authenticated set-name request (0x52) using a fixed zero-padded Latin-1 payload.
502/// @param f IoFrame to populate.
503/// @param own Controller's 3-byte node ID.
504/// @param dst Target device's 3-byte node ID.
505/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
506/// @param payload Pre-validated fixed payload produced by encode_device_name_payload().
507/// @return true on success.
508bool create_set_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power,
509 const uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE]);
510
511/// @brief Build an authenticated device-identify request (0x1E) that makes a device
512/// physically identify itself (brief jog / flash).
513///
514/// @param f IoFrame to populate.
515/// @param own Controller's 3-byte node ID (source address).
516/// @param dst Target device's 3-byte node ID (destination address).
517/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
518/// @note The device may reply with CMD_ERROR_RESP instead of a dedicated identify response;
519/// callers should treat that reply as an expected, non-fatal outcome rather than a failure.
520/// @return true on success.
521bool create_identify(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power);
522
523/// @brief Build a generic CMD_WRITE_PRIVATE (0x20) frame carrying a caller-supplied payload.
524///
525/// The single builder behind every heating/climate register read and write (power, setpoint, mode,
526/// presence, open-window detection, the measurement and sensor reads). The payload is produced by
527/// encode_heating_payload(), encode_heating_read() or encode_heating_write() in proto_heating.h; this
528/// builder only frames it. Framing (StartFrame=1, EndFrame=0, Protocol=0) is cross-checked against real
529/// Atlantic AGILIA heater writes
530/// (tests/corpus/captures/exchange/atlantic_agilia_exchange_setpoint_and_off_read_back.yaml: start,
531/// not end). The device answers with CMD_WRITE_PRIVATE_ACK (0x21) after an
532/// authenticated 0x3C/0x3D leg the exchange engine handles transparently.
533/// @param f IoFrame to populate.
534/// @param own Controller's 3-byte node ID (source address).
535/// @param dst Target device's 3-byte node ID (destination address).
536/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER;
537/// the exchange layer then also uses the long wake-up preamble). Per-device, from the
538/// target's YAML `low_power:` class (ADR 0029) — not hardcoded.
539/// @param payload Payload bytes (from encode_heating_payload(), encode_heating_read() or encode_heating_write()).
540/// @param payload_len Payload length in bytes. Rejected if 0 or > FRAME_MAX_DATA_SIZE.
541/// @return true on success; false if payload_len is out of range or set_cmd() fails.
542bool create_write_private(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, const uint8_t *payload,
543 size_t payload_len);
544
545/// Build an execute‑tilt command (0x00) for slat angle control.
546/// @param f IoFrame to populate.
547/// @param own Controller node ID.
548/// @param dst Target device node ID.
549/// @param low_power True if target is battery/solar‑powered (sets CTRL1_LOW_POWER).
550/// @param tilt_percent 0 = fully closed, 100 = fully open.
551/// @note This uses the same command (0x00) as position control but with a different
552/// payload format indicating a tilt operation. The receiver infers tilt from
553/// the payload structure. Only devices with device_feature::TILT will honor this.
554/// @return true on success.
555bool create_execute_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t tilt_percent);
556
557/// Build a combined position‑and‑tilt execute command (0x00).
558/// Sets both the cover position and the slat angle atomically in one frame,
559/// corresponding to the protocol's setClosureAndOrientation use case.
560/// @param f IoFrame to populate.
561/// @param own Controller node ID.
562/// @param dst Target device node ID.
563/// @param low_power True if target is battery/solar‑powered (sets CTRL1_LOW_POWER).
564/// @param position Desired position 0–100 (open→closed).
565/// @param tilt_percent 0 = fully closed, 100 = fully open.
566/// @return true on success; false if position exceeds limits.
567bool create_execute_position_and_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power,
568 uint8_t position, uint8_t tilt_percent);
569
570/// @brief Build an extended CMD_PRIVATE (0x03) request with a selector/block pair.
571///
572/// The shape real hubs use for both the tilt block (selector STATUS_TILT_SELECTOR: FPI1 bit 5 = FP3,
573/// see proto_constants.h) and the
574/// field-observed selector 0x80 (tests/corpus/captures/probe/multi_somfy_probe_extended_private_both_selectors.yaml),
575/// which this codebase has never decoded. create_get_status_tilt() below is this builder frozen at selector =
576/// STATUS_TILT_SELECTOR, block = 0x01.
577/// @param f IoFrame to populate.
578/// @param own Controller's 3-byte node ID.
579/// @param dst Target device's 3-byte node ID.
580/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
581/// @param selector Extended-status selector byte (data[1]).
582/// @param block Selector-specific block/index byte (data[2]) — the field-observed name for
583/// this byte is "N" for selector 0x80, where the corpus has only ever observed 0x00/0x01.
584/// @param function_id CMD_PRIVATE function ID at data[0]. Defaults to PRIVATE_GET_POSITION_STATUS
585/// (0x03) — the only value ever observed on air in this 4-byte extended shape. Other values
586/// are diagnostic probes: the *shape* `03 80 00 00` / `03 80 01 00` is field-observed from
587/// a real hub, but no other function ID has ever been seen in it.
588/// @return true on success.
589bool create_get_status_extended(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t selector,
590 uint8_t block, uint8_t function_id = PRIVATE_GET_POSITION_STATUS);
591
592/// Build the status read of the main parameter plus functional parameters: an unauthenticated
593/// CMD_PRIVATE (0x03) request with function ID 0x01, the FPI1 bitmap (bit 7 = FP1 ... bit 0 = FP8),
594/// one "current value" status type per FPI1 bit, the FPI2 bitmap (bit 7 = FP9 ... bit 0 = FP16) and
595/// one status type per FPI2 bit (spec order: each type run follows its bitmap). With the default
596/// arguments this is the exact 10 bytes a VELUX KLF 200 gateway sends (public reference: KLF 200
597/// API specification v3.18 §10.3.1.4; the same bytes were heard on air from a live KLF 200 at
598/// 868 MHz and 2.4 GHz). A non-default FPI2 follows the same layout but has not been seen on air.
599/// The reply layout is not decoded here.
600/// @param f IoFrame to populate.
601/// @param own Controller's 3-byte node ID.
602/// @param dst Target device's 3-byte node ID.
603/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
604/// @param fpi1 FP1-FP8 bitmap; default requests FP1-FP7.
605/// @param fpi2 FP9-FP16 bitmap; default requests none.
606/// @return true on success; false (nothing built) when no parameter is requested or more than
607/// STATUS_MP_FP_PARAM_COUNT are.
608bool create_status_mp_fp_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power,
609 uint8_t fpi1 = STATUS_MP_FP_FPI1_MASK, uint8_t fpi2 = STATUS_MP_FP_FPI2);
610
611/// Build the read of a device's resulting limitation: an unauthenticated CMD_LIMITATION_STATUS_REQ
612/// (0x25) with data `{selector, 0x00, 0x00}`, byte for byte what a VELUX KLF 200 gateway was
613/// heard sending (issue #98; public reference: KLF 200 API specification v3.18 section 10.5). The
614/// reply's value layout is confirmed under rain on a VELUX window (minimum 93 %); its originator and
615/// remaining-time bytes read 00 even then, so they stay unverified. Read-only: the
616/// selector is an enum with two values, so this builder cannot produce a limitation write.
617/// @param f IoFrame to populate.
618/// @param own Controller's 3-byte node ID.
619/// @param dst Target device's 3-byte node ID.
620/// @param type Which resulting limit to read.
621/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
622/// @return true on success.
623bool create_limitation_status_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, LimitationType type,
624 bool low_power);
625
626/// Build a subscribe-to-event request (0x73) for the `subscribe_event` diagnostic probe. The payload
627/// is `01 <event hi> <event lo>`. This is the one builder here whose request changes state in the
628/// device (a stored subscription), so it accepts only the two allowlisted ids,
629/// EVENT_BATTERY_LEVEL_CHANGE and EVENT_LOCAL_SENSOR, and returns false for anything else.
630/// \todo The request shape is unconfirmed on hardware: no device has been captured answering it.
631/// @param f IoFrame to populate.
632/// @param own Controller's 3-byte node ID.
633/// @param dst Target device's 3-byte node ID.
634/// @param event_id EVENT_BATTERY_LEVEL_CHANGE or EVENT_LOCAL_SENSOR.
635/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
636/// @return true on success, false for an event id outside the allowlist.
637bool create_subscribe_event(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint16_t event_id, bool low_power);
638
639/// Build an unsubscribe request for the `unsubscribe_event` diagnostic probe: CMD_SUBSCRIBE_EVENT
640/// (0x73) with payload `00 <event hi> <event lo>`, the subscribe request with its action byte
641/// cleared. Same two-id allowlist as create_subscribe_event().
642/// \todo The action value 0x00 is a guess from the frame's shape: no device has been seen to stop
643/// reporting after it. It exists to test whether a subscription can be undone.
644/// @param f IoFrame to populate.
645/// @param own Controller's 3-byte node ID.
646/// @param dst Target device's 3-byte node ID.
647/// @param event_id EVENT_BATTERY_LEVEL_CHANGE or EVENT_LOCAL_SENSOR.
648/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
649/// @return true on success, false for an event id outside the allowlist.
650bool create_unsubscribe_event(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint16_t event_id, bool low_power);
651
652/// Build the request that opens a read of one device object (CMD_TRANSFER_OPEN 0x46) for the
653/// `read_object` diagnostic probe (ADR 0062). The payload is
654/// `<ORIGINATOR_SAAC> <class> 00 <id hi> <id lo> 00 00 00 00`, the form Somfy controllers use to read
655/// a whole object; Somfy actuators answered it for class-0 objects (issue #156). Only the class/id pairs
656/// in TRANSFER_READABLE_OBJECTS are accepted, so this builder cannot name any other object, and there
657/// is no builder for the write twin (0x48).
658/// @param f IoFrame to populate.
659/// @param own Controller's 3-byte node ID.
660/// @param dst Target device's 3-byte node ID.
661/// @param object The class and id to open; must be an entry of TRANSFER_READABLE_OBJECTS.
662/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
663/// @return true on success, false for an object outside the allowlist.
664bool create_transfer_open(IoFrame &f, const uint8_t *own, const uint8_t *dst, const TransferObject &object,
665 bool low_power);
666
667/// Build the request that reads one chunk of an opened transfer (CMD_UNKNOWN4A_REQ 0x4A):
668/// `<handle> <chunk hi> <chunk lo>`, nothing else. Chunk numbers count down from ceil(length/18) to 1;
669/// TRANSFER_CHUNK_CLOSE closes the transfer. The payload is fixed at these three bytes, so the write
670/// direction of the block (which carries data) cannot be built from here.
671/// @param f IoFrame to populate.
672/// @param own Controller's 3-byte node ID.
673/// @param dst Target device's 3-byte node ID.
674/// @param handle Handle byte from the CMD_TRANSFER_OPEN_RESP.
675/// @param chunk Chunk number, or TRANSFER_CHUNK_CLOSE.
676/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
677/// @return true on success.
678bool create_transfer_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t handle, uint16_t chunk,
679 bool low_power);
680
681/// Build a tilt‑aware get‑status request (0x03 with extended payload) that returns
682/// the 16‑byte tilt block in the response.
683/// @param f IoFrame to populate.
684/// @param own Controller node ID.
685/// @param dst Target device node ID.
686/// @param low_power True if the target is a low-power / duty-cycled device (sets CTRL1_LOW_POWER).
687/// @return true on success.
688bool create_get_status_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power);
689
690/// @brief Build a CMD_PRIVATE2 (0x0C) request in either of the two field-observed shapes.
691///
692/// The payload is CMD_EXECUTE's POS_FAVORITE/POS_VENT_MODIFIER stored-position selector with
693/// the execution prefix stripped. `low_power` is an explicit parameter like every other
694/// device-addressed builder in this file, not derived from `long_form`: the two captures this
695/// builder is pinned against happen to carry CTRL1_LOW_POWER set on the long-form request and
696/// clear on the short-form one, but that tracks each capture's *target device's* power class
697/// (solar shutter vs. mains switch), not the payload shape — see proto_commands.cpp for the
698/// full reasoning.
699/// @param f IoFrame to populate.
700/// @param own Controller's 3-byte node ID.
701/// @param dst Target device's 3-byte node ID.
702/// @param modifier The POS_FAVORITE/POS_VENT_MODIFIER-family selector byte to read back.
703/// @param long_form True for the 6-byte long form (data = POS_UNKNOWN, 0x00, 0x80,
704/// POS_FAVORITE, modifier, 0x00); false for the 4-byte short form (data = POS_FAVORITE,
705/// modifier, 0x00, 0x00).
706/// @param low_power True if target is battery/solar-powered (sets CTRL1_LOW_POWER).
707/// @return true on success.
708bool create_private2_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t modifier, bool long_form,
709 bool low_power);
710
711/// @brief Build a discovery request with configurable command, destination, and payload.
712///
713/// Supports the command codes 0x28 (DISCOVER_REQ), 0x2A (DISCOVER_SPE_REQ), and
714/// 0x2E (DISCOVER_ALT_REQ, alternate discovery). For 0x2A the payload is a 6-byte random nonce
715/// followed by a 6-byte HMAC computed over the command byte alone, using the nonce as the
716/// challenge and the supplied system key.
717///
718/// @param f IoFrame to populate.
719/// @param own Controller's 3-byte node ID.
720/// @param command Discovery command code (0x28, 0x2A, or 0x2E).
721/// @param dst Destination node ID (broadcast or explicit).
722/// @param low_power True to set the LOW_POWER flag in CTRL1.
723/// @param ack_capable True to set the ACK flag (CTRL1_ACK) in CTRL1 for this one broadcast. Each
724/// caller decides per frame: the `pairing_discovery_ack_capable` tuning knob gates it for
725/// Discover & Pair's discovery broadcast (opt-in, default off — see the init_frame()
726/// doc in proto_frame.h for why CTRL1_ACK must never become an unconditional default), and
727/// the SPE roll-call's low-power frame shape (`CTRL1 = LOW_POWER | ACK`) always passes
728/// true.
729/// @param payload_enabled True when the optional payload byte is enabled.
730/// @param payload Optional payload byte (only used when command requires a payload).
731/// @param system_key 16-byte system key; only used for 0x2A HMAC computation.
732/// @return true on success; false for unsupported command or missing key for 0x2A.
733bool create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power,
734 bool ack_capable, bool payload_enabled, uint8_t payload, const uint8_t *system_key);
735
736/// Build a discovery broadcast (0x28). Sent to the broadcast address; only devices
737/// in pairing mode (PROG button pressed) will respond.
738/// @param f IoFrame to populate.
739/// @param own Controller node ID.
740/// @note Destination is BROADCAST_DISCOVER (0x00003B). The device responds with
741/// CMD_DISCOVER_RESP (0x29) containing its node ID and type/subtype. The
742/// controller then switches to point‑to‑point communication for phases 2 and 3.
743/// @return true on success.
744bool create_discover(IoFrame &f, const uint8_t *own);
745
746/// @brief Build a discovery response (0x29) — the device side of discovery, used by the
747/// key-extraction responder (see pairing_responder.h) to emulate an unpaired device.
748///
749/// Almost every builder in this file speaks the *controller* side of the protocol; this one,
750/// create_key_confirm(), create_discover_confirm_ack(), and create_challenge_req_device_role()
751/// below speak the *device* side, needed only for that one reverse-role feature. Device-side
752/// frames never set CTRL1_LOW_POWER: that bit describes the *target* of a controller-originated
753/// frame, and a device's replies are addressed to a mains-powered hub.
754/// Payload layout matches the full 9-byte discovery response format documented at
755/// DISCOVERY_RESP_BACKBONE_OFFSET/_MANUFACTURER_OFFSET/_FLAGS_OFFSET/_TIMESTAMP_OFFSET
756/// (proto_constants.h), cross-checked against a real Somfy actuator's captured 0x29
757/// (tests/corpus/captures/discovery/somfy_awning_discovery_lab_response.yaml): backbone address
758/// equals the device's own node ID, start+end set, low_power clear.
759/// @param f IoFrame to populate.
760/// @param own Our advertised (throwaway) node ID — used as both src and the backbone address.
761/// @param dst Destination node ID (the discovering hub's real node ID, from its 0x28's src).
762/// @param type Device type to advertise.
763/// @param subtype Device subtype to advertise.
764/// @param manufacturer_id Manufacturer ID to advertise (see MANUFACTURER_* in proto_constants.h).
765/// @note The timestamp is a best-effort placeholder copied from a real captured device; see
766/// KEY_EXTRACTION_DISCOVER_RESP_TIMESTAMP (proto_commands.cpp).
767/// @param flags Multi-information byte to advertise (turnaround class, power-save mode); default
768/// KEY_EXTRACTION_DISCOVER_RESP_FLAGS, the `key_extraction_multi_info` tuning value in use.
769/// @return true on success.
770bool create_discover_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst, DeviceType type, uint8_t subtype,
771 uint8_t manufacturer_id, uint8_t flags = KEY_EXTRACTION_DISCOVER_RESP_FLAGS);
772
773/// @brief Build a key-confirm frame (0x33) — the device's acknowledgement that it received and
774/// installed the system key, sent after decrypting a CMD_KEY_TRANSFER (0x32).
775///
776/// Device-side counterpart to create_key_transfer(); used only by the key-extraction responder
777/// (see create_discover_resp() above for why this direction exists at all). No payload and END
778/// set, matching real devices' 0x33 in
779/// tests/corpus/captures/pairing/somfy_izymo_dimmer_pairing_full_sx1276.yaml,
780/// tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml, and (this project's own key-extraction responder
781/// against a real hub) tests/corpus/captures/pairing/velux_kig300_pairing_key_extraction_success.yaml — 0x33 closes the
782/// key-exchange sequence that CMD_KEY_INIT (0x31) opened with START.
783/// @param f IoFrame to populate.
784/// @param own Our advertised (throwaway) node ID.
785/// @param dst Destination node ID (the hub that sent the key transfer).
786/// @return true on success.
787bool create_key_confirm(IoFrame &f, const uint8_t *own, const uint8_t *dst);
788
789/// @brief Build a discovery-confirm request (0x2C) — sent by a controller directly to a
790/// freshly-discovered device, before proceeding to the key exchange (0x31).
791///
792/// Every real controller in this project's corpus sends 0x2C between the device's discovery
793/// response (0x29) and its own key-init (0x31): KLR200→KUX100
794/// (tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml), TaHoma→VELUX SSL
795/// (tests/corpus/captures/discovery/velux_ssl_discovery_tahoma_pairing.yaml), and this project's
796/// own key-extraction responder answering KIG300/KLR200/KLR300/Somfy TaHoma/Somfy Connectivity
797/// Kit (tests/corpus/captures/pairing/velux_kig300_pairing_key_extraction_success.yaml and
798/// siblings). Every one of those real hubs sets CTRL1_ACK for an always-alive target (`48 10`) and
799/// clears it for a low-power one (`48 20`, CTRL1_LOW_POWER only, no ACK) — see the `ack` param doc
800/// for what this builder does with that split. Gated by the `pairing_discover_confirm` tuning knob
801/// (`DiscoverConfirmMode::SKIP`/`SEND`/`SEND_WITH_ACK`, tuning_config.h) — `skip` reproduces the
802/// sequence without this step: no 0x2C, no post-step pause.
803/// @param f IoFrame to populate.
804/// @param own Controller's 3-byte node ID.
805/// @param dst The freshly-discovered device's 3-byte node ID (from its 0x29's src).
806/// @param low_power Set CTRL1_LOW_POWER. See this header's "Low-power flag and preamble
807/// handling" note above: unlike most pairing builders, this one is NOT a fixed value —
808/// pass the device's own self-reported power-save class from discovery
809/// (`pairing::PairingContext::discovery_low_power`).
810/// @param ack Set CTRL1_ACK ("sender can handle 2W responses"). The builder is literal — it does
811/// not derive `ack` from `low_power` itself; that mirror rule belongs to the caller
812/// (`PairingEngine::run_discover_confirm_step_()`), which never asks for `ack` when
813/// `low_power` is true. Every corpus hub's own 0x2C sets CTRL1_ACK for an always-alive
814/// target (`48 10`) and clears it for a low-power one (`48 20`); this codebase's `send`
815/// default instead clears it for an always-alive target too (`48 00`, no corpus hub sends
816/// that shape, but a Somfy Izymo dimmer answers it with 0x2D and ignores `48 10`), and
817/// `send_with_ack` switches to the hub-observed `48 10`.
818/// @return true on success.
819bool create_discover_confirm(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, bool ack);
820
821/// @brief Build a discovery-confirm acknowledgement (0x2D) — the device's answer to a hub's
822/// CMD_DISCOVER_CONFIRM (0x2C), which a hub sends directly to a freshly-discovered device before
823/// it will proceed to the key exchange.
824///
825/// Device-side only, like create_discover_resp()/create_key_confirm() above. Its controller-role
826/// counterpart is create_discover_confirm() just above.
827/// No payload and END set, matching real devices' 0x2D in
828/// tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml; for a second independent hub,
829/// tests/corpus/captures/pairing/somfy_connectivity_kit_pairing_key_extraction_stall.yaml, where
830/// an already-paired device answers the same hub the key-extraction responder was talking to; and
831/// for this exact builder exercised against a real hub,
832/// tests/corpus/captures/pairing/velux_kig300_pairing_key_extraction_success.yaml.
833/// @param f IoFrame to populate.
834/// @param own Our advertised (throwaway) node ID.
835/// @param dst Destination node ID (the hub that sent the discovery confirm).
836/// @return true on success.
837bool create_discover_confirm_ack(IoFrame &f, const uint8_t *own, const uint8_t *dst);
838
839/// @brief Recover the system key from an inbound CMD_KEY_TRANSFER (0x32) payload — the decode
840/// counterpart to create_key_transfer()'s encode.
841///
842/// Centralizes the IV-`data` convention in one place: create_key_transfer() derives its IV from
843/// the *preceding* CMD_KEY_INIT (0x31) command byte only (see its own doxygen), so decoding must
844/// use that same single-byte `{CMD_KEY_INIT}` — not the 0x32 frame's own command byte, and not
845/// the discovery frame. crypt_key() is symmetric, so this is the same primitive in reverse.
846/// @param transfer_payload 16-byte CMD_KEY_TRANSFER payload (frame.data).
847/// @param challenge The 6-byte challenge *we* generated and sent in our own CMD_CHALLENGE_REQ
848/// (0x3C) — the far side mixes this into its IV, so decoding requires the exact same bytes.
849/// @param out_key Output: recovered 16-byte system key.
850/// @return true on success (crypt_key() AES failure is the only false case).
851bool recover_system_key_from_transfer(const uint8_t transfer_payload[AES_KEY_SIZE], const uint8_t challenge[HMAC_SIZE],
852 uint8_t out_key[AES_KEY_SIZE]);
853
854/// @brief Build a key transfer request (0x38, "pull") asking a discovered device for its key.
855///
856/// START set, END clear, CTRL1_LOW_POWER clear, CTRL1 bit 2 (CTRL1_PRIORITY) set: the shape vendor
857/// controllers use for this request. An Atlantic AGILIA IO radiator answers it with a CMD_KEY_TRANSFER
858/// (0x32) (issue #165); recover_key_from_pull() decrypts that.
859/// @param f IoFrame to populate.
860/// @param own Controller node ID.
861/// @param dst Discovered device node ID.
862/// @param challenge 6 random bytes, kept by the caller for the decrypt.
863/// @return true on success.
864bool create_launch_key_transfer(IoFrame &f, const uint8_t *own, const uint8_t *dst, const uint8_t challenge[HMAC_SIZE]);
865
866/// @brief Recover a device's key from the CMD_KEY_TRANSFER (0x32) it sent in answer to our
867/// CMD_LAUNCH_KEY_TRANSFER (0x38).
868///
869/// Same construction as recover_system_key_from_transfer(), with the IV taken from the request
870/// that asked for the key: its `data` is the whole request, `{CMD_LAUNCH_KEY_TRANSFER, r0..r5}`, and
871/// its challenge the request's 6 bytes. Checked against a documented pull exchange in
872/// proto_crypto_test.cpp, and on an Atlantic AGILIA IO radiator, whose 0x3D proved the key (issue #165).
873/// @param request Our 0x38, as built by create_launch_key_transfer().
874/// @param transfer_payload 16-byte CMD_KEY_TRANSFER payload from the device.
875/// @param out_key Output: the device's 16-byte key.
876/// @return true on success; false when `request` carries fewer than 6 bytes or AES fails.
877bool recover_key_from_pull(const IoFrame &request, const uint8_t transfer_payload[AES_KEY_SIZE],
878 uint8_t out_key[AES_KEY_SIZE]);
879
880/// Build a key‑init request (0x31) to start pairing key exchange with a discovered device.
881/// @param f IoFrame to populate.
882/// @param own Controller node ID.
883/// @param dst Discovered device node ID.
884/// @return true on success.
885bool create_key_init(IoFrame &f, const uint8_t *own, const uint8_t *dst);
886
887/// Build a key‑transfer frame (0x32) containing the system key encrypted with the transfer key.
888/// @param f IoFrame to populate.
889/// @param old_frame The key‑init frame (used to derive the encryption IV).
890/// @param dst Target device node ID.
891/// @param src Controller node ID.
892/// @param key The 16‑byte system key to transfer.
893/// @param challenge 6‑byte challenge received from device in its 0x3C response.
894/// @note The system key is obfuscated via the XOR‑AES construction in crypt_key().
895/// The transfer key (hardcoded in proto_frame.h) is the same for all IO‑Homecontrol
896/// devices worldwide; its purpose is to protect the system key in transit during
897/// initial pairing. Once transferred, the device uses the system key for all
898/// subsequent authenticated exchanges.
899/// @return true on success.
900bool create_key_transfer(IoFrame &f, IoFrame &old_frame, const uint8_t *dst, const uint8_t *src,
901 const uint8_t key[AES_KEY_SIZE], const uint8_t challenge[HMAC_SIZE]);
902
903/// Build a challenge request (0x3C) containing 6 random bytes. Used when we need to
904/// authenticate an incoming request from a device.
905/// @param f IoFrame to populate.
906/// @param dst Target device node ID (device we're challenging).
907/// @param src Controller node ID.
908/// @return true on success.
909bool create_challenge_req(IoFrame &f, const uint8_t *dst, const uint8_t *src);
910
911/// @brief Build a challenge request (0x3C) using a caller-supplied challenge instead of
912/// generating a fresh one internally.
913///
914/// The no-challenge overload above generates its own random bytes and does not expose them,
915/// which is fine for the normal inbound-auth path (the challenge is only ever needed once, to
916/// build this same frame). A caller that needs the *exact* bytes again later — the key-extraction
917/// responder decrypting the corresponding CMD_KEY_TRANSFER (0x32) — generates the challenge itself
918/// and passes it in here, keeping the transmitted 0x3C and the later decrypt on one source of
919/// truth. Note that responder uses create_challenge_req_device_role() below, not this overload.
920/// @param f IoFrame to populate.
921/// @param dst Target device node ID (device we're challenging).
922/// @param src Our own node ID.
923/// @param challenge Caller-supplied 6-byte challenge (e.g. from crypto::generate_challenge()).
924/// @return true on success.
925bool create_challenge_req(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE]);
926
927/// @brief Build a challenge request (0x3C) in the *device* direction — used only by the
928/// key-extraction responder to challenge a foreign hub that sent us CMD_KEY_INIT (0x31).
929///
930/// Same command and payload as the controller-role builders above, but framed the way a real
931/// device frames it: START clear and LOW_POWER clear. Both controller-role overloads set both
932/// bits, which is correct for their direction — LOW_POWER describes the *target* of a
933/// controller-originated frame (a device that may be battery/solar powered, see this header's
934/// convention note), and the controller's 0x3C opens its own inbound-auth exchange. Neither holds
935/// for a device answering a hub's key-init: real devices' pairing 0x3C frames in
936/// tests/corpus/captures/pairing/somfy_izymo_dimmer_pairing_full_sx1276.yaml (`0E 00 …`) and
937/// tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml carry neither bit, because the frame is a continuation
938/// of the hub's already-open exchange and is addressed to a mains-powered hub — and this exact builder, exercised
939/// against a real hub, produces the identical `0E 00` shape in
940/// tests/corpus/captures/pairing/velux_kig300_pairing_key_extraction_success.yaml.
941/// @param f IoFrame to populate.
942/// @param dst The foreign hub's node ID (from the inbound 0x31's src).
943/// @param src Our advertised (throwaway) node ID.
944/// @param challenge Caller-supplied 6-byte challenge, retained for the later 0x32 decrypt.
945/// @return true on success.
946bool create_challenge_req_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src,
947 const uint8_t challenge[HMAC_SIZE]);
948
949/// Build a challenge response (0x3D) proving we know the system key.
950/// HMAC is computed over [original_command_id + original_data] using the challenge.
951/// @param f IoFrame to populate.
952/// @param dst Target device node ID.
953/// @param src Controller node ID.
954/// @param challenge 6‑byte challenge from the device.
955/// @param origin Original request frame that triggered the challenge.
956/// @param key System key (16 bytes).
957/// @note The HMAC derivation uses the challenge as IV salt; see create_hmac() in
958/// proto_crypto.h for the exact construction. This frame authenticates the
959/// controller to the device for the current exchange.
960/// @return true on success.
961bool create_challenge_resp(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE],
962 const IoFrame &origin, const uint8_t *key);
963
964/// Compute the 6-byte MAC a challenge response (0x3D) carries for `origin`: create_hmac() over the
965/// challenged frame's command byte and payload. Used for our own 0x3D (create_challenge_resp()).
966/// @param origin The challenged frame.
967/// @param challenge 6-byte challenge from the 0x3C.
968/// @param key 16-byte key.
969/// @param hmac Output: 6-byte MAC.
970/// @return true on success.
971bool challenge_resp_hmac(const IoFrame &origin, const uint8_t challenge[HMAC_SIZE], const uint8_t *key,
972 uint8_t hmac[HMAC_SIZE]);
973
974/// Check a device's challenge response (0x3D) for `origin`, the frame it sent and we challenged.
975/// The device signs its own frame, the same transcript as challenge_resp_hmac().
976/// @param origin The device's challenged frame.
977/// @param challenge The 6 bytes we sent in the 0x3C.
978/// @param mac The 6 bytes of the device's 0x3D.
979/// @param key 16-byte key the MAC must verify under.
980/// @return true if the MAC matches.
981bool verify_challenge_resp(const IoFrame &origin, const uint8_t challenge[HMAC_SIZE], const uint8_t mac[HMAC_SIZE],
982 const uint8_t *key);
983
984/// @brief Build a node verification response (0x37) — device side, answering a hub's CMD_NODE_VERIFY_REQ
985/// (0x36).
986///
987/// Payload is our own advertised node ID — the only identity this emulated device has to offer —
988/// the same value create_discover_resp() reports at DISCOVERY_RESP_BACKBONE_OFFSET.
989/// CorpusDeviceRoleBuilders.NodeVerifyRespPayloadMatchesOwnDiscoverRespBackboneAddress
990/// (tests/corpus/corpus_device_role_builder_test.cpp) pins that these two builders agree with *each
991/// other*, not that this matches a real device's own backbone value: the one real capture of this
992/// exchange (tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml) shows a genuine device whose 0x37
993/// payload — a persistent identity the io-homecontrol wire format tracks separately from a device's
994/// node/session address — does NOT equal that device's own node ID. Our emulated device only ever
995/// generates one identity per arm cycle, so it structurally cannot reproduce a real device's
996/// separate backbone value; whether any real hub requires the two to differ, or even inspects this
997/// field at all rather than treating it as informational, is unconfirmed. See
998/// docs/key-extraction.md's "Known limitations" for the field-facing version of this note.
999/// Also unverified: the full CTRL1 framing. This builder's ctrl1 is 0x00 (see init_frame() call in
1000/// the .cpp), but the KLR200 capture's 0x37 carries CTRL1_PRIORITY set. See the
1001/// TODO(hardware-verify) on the .cpp definition for why that bit is not mirrored here.
1002/// No controller-role counterpart exists to share with: this codebase has never sent 0x36, so there
1003/// is no analogous controller-role code that receives a 0x37 to keep in sync with.
1004/// @param f IoFrame to populate.
1005/// @param own Our advertised (throwaway) node ID — used as both src and the payload.
1006/// @param dst Destination node ID (the hub that sent the node verification request, from its 0x36's src).
1007/// @return true on success.
1008bool create_node_verify_resp_device_role(IoFrame &f, const uint8_t *own, const uint8_t *dst);
1009
1010/// @brief Build a challenge response (0x3D) in the *device* direction — used only by the
1011/// key-extraction responder to answer a hub-issued CMD_CHALLENGE_REQ (0x3C) challenging our own
1012/// CMD_NODE_VERIFY_RESP (0x37).
1013///
1014/// Same transcript rule as create_challenge_resp() above (the challenged party HMACs its own
1015/// preceding frame's cmd+data), but END is set: this 0x3D closes the node-verification round the
1016/// hub opened with 0x36 (tests/corpus/captures/pairing/velux_kux100_pairing_full.yaml's `8E 08 …`, END
1017/// set). LOW_POWER is clear because CTRL1_LOW_POWER describes a controller-originated frame's
1018/// *target*, not the sender — irrelevant here, since this is a device-role frame sent to a
1019/// mains-powered hub. Unrelated to the power-save bits of create_discover_resp()'s Multi Information
1020/// Byte (KEY_EXTRACTION_DISCOVER_RESP_FLAGS), which describe the responder itself — the two bits
1021/// answer different questions and are not expected to match. Neither
1022/// bit is a blanket "device role" convention — real devices also send mid-exchange 0x3D frames with
1023/// END clear and LOW_POWER set (somfy_oximo40_statuspoll_sx1262.yaml) — so this framing is
1024/// specific to the terminal shape this feature needs, not a general device-role rule.
1025/// @param f IoFrame to populate.
1026/// @param dst The hub's node ID (from the inbound 0x3C's src).
1027/// @param src Our advertised (throwaway) node ID.
1028/// @param challenge 6-byte challenge from the hub's 0x3C.
1029/// @param origin Our own preceding CMD_NODE_VERIFY_RESP (0x37) frame — its cmd+data is the transcript.
1030/// @param key System key (16 bytes).
1031/// @return true on success.
1032bool create_challenge_resp_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src,
1033 const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key);
1034
1035/// Build a status‑update acknowledgment (0x72). Sent after authenticating a device's
1036/// status update; broadcast on all 3 channels for reliability.
1037/// @param f IoFrame to populate.
1038/// @param own Controller node ID.
1039/// @param dst Device node ID that sent the update.
1040/// @return true on success.
1041bool create_status_update_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst);
1042
1043/// Build a set‑config command (0x6F) telling the device to automatically send
1044/// status updates when controlled by any remote.
1045/// @param f IoFrame to populate.
1046/// @param own Controller node ID.
1047/// @param dst Target device node ID.
1048/// @note This configures the device to emit CMD_STATUS_UPDATE (0x71) frames whenever
1049/// it is controlled by any remote (including the paired controller). This enables
1050/// HA to receive unsolicited position updates. The controller must still
1051/// authenticate the status update using the inbound auth flow (hub_exchange.h).
1052/// @todo Confirm on real hardware which device families actually honor this SetConfig1
1053/// payload and emit unsolicited status updates after pairing.
1054/// @return true on success.
1055bool create_set_config1(IoFrame &f, const uint8_t *own, const uint8_t *dst);
1056
1057} // namespace home_io_control
1058} // 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...
bool create_discover_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst, DeviceType type, uint8_t subtype, uint8_t manufacturer_id, uint8_t flags)
Build a discovery response (0x29) — device side, used only by the key-extraction responder.
bool create_node_verify_resp_device_role(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build an address response (0x37) — device side, used only by the key-extraction responder.
bool create_private2_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t modifier, bool long_form, bool low_power)
Build a CMD_PRIVATE2 (0x0C) request in either of the two field-observed shapes.
bool verify_challenge_resp(const IoFrame &origin, const uint8_t challenge[HMAC_SIZE], const uint8_t mac[HMAC_SIZE], const uint8_t *key)
Check a device's 0x3D for origin, in constant time (crypto::verify_hmac()).
bool create_limitation_status_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, LimitationType type, bool low_power)
Build the limitation read (see the header for the evidence and the read-only guarantee).
CoverCommand
Named device commands for cover-type actuators.
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_transfer_open(IoFrame &f, const uint8_t *own, const uint8_t *dst, const TransferObject &object, bool low_power)
Build the transfer-open request (see the header for the allowlist and the byte layout).
bool recover_system_key_from_transfer(const uint8_t transfer_payload[AES_KEY_SIZE], const uint8_t challenge[HMAC_SIZE], uint8_t out_key[AES_KEY_SIZE])
Recover the system key from a CMD_KEY_TRANSFER payload.
bool create_execute_heating_level(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t level)
Build a heating-level execute frame (0x00). See proto_commands.h.
bool create_discover_confirm(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, bool ack)
Build a discovery-confirm request (0x2C) — controller side, sent directly to a freshly-discovered dev...
bool create_get_info1(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_INFO1 (0x54) request. No payload. See proto_commands.h for the evidence note.
DeviceType
IO-Homecontrol node types.
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 HEATING_LEVEL_MAX
Highest heating level low byte create_execute_heating_level() will frame (the secured level).
bool create_key_confirm(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a key-confirm frame (0x33) — device side, used only by the key-extraction responder.
bool create_execute_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t tilt_percent)
Build a tilt execute command (0x00) for devices that support slat angle control.
bool create_set_config1(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a set-config command (0x6F) to tell the device to automatically send status updates when contro...
bool create_execute_heating_main_parameter(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint16_t main_parameter)
Build a temperature / alias execute frame (0x00), main parameter only. See proto_commands....
bool create_challenge_resp(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key)
Build a challenge response (0x3D) proving we know the system key.
bool create_key_init(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a key-init request (0x31) to start the pairing key exchange with a discovered device.
bool create_execute_position(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t position, bool silent)
Build a position execute command (0x00) to move a device to a numeric position.
bool create_general_info3(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_GENERAL_INFO3 (0x58) request.
bool create_execute_heating_with_fp16(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint16_t main_parameter, uint16_t fp16)
Build a temperature / alias execute frame (0x00) carrying functional parameter 16....
bool create_get_info2(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_INFO2 (0x56) request. No payload. See proto_commands.h for the evidence note.
bool create_execute_command(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, CoverCommand cmd, bool silent)
Build a named-command execute frame (0x00) for STOP or a named position (NAMED_POSITIONS).
bool create_unsubscribe_event(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint16_t event_id, bool low_power)
Build the unsubscribe request (see the header: the action value is untested).
bool create_transfer_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t handle, uint16_t chunk, bool low_power)
Build the transfer-read request (see the header: exactly handle and chunk, no data).
bool challenge_resp_hmac(const IoFrame &origin, const uint8_t challenge[HMAC_SIZE], const uint8_t *key, uint8_t hmac[HMAC_SIZE])
The 0x3D MAC for origin.
bool recover_key_from_pull(const IoFrame &request, const uint8_t transfer_payload[AES_KEY_SIZE], uint8_t out_key[AES_KEY_SIZE])
Recover a device's key from its answer to our 0x38.
bool create_status_mp_fp_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t fpi1, uint8_t fpi2)
Build the KLF-style status read of the main parameter plus functional parameters (see the header for ...
bool create_discover_confirm_ack(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a discovery-confirm acknowledgement (0x2D) — device side, used only by the key-extraction respo...
bool create_subscribe_event(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint16_t event_id, bool low_power)
Build the subscribe request (see the header for the allowlist).
bool create_challenge_req_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE])
Build a device-role challenge request (0x3C) — device side, used only by the key-extraction responder...
bool create_discover(IoFrame &f, const uint8_t *own)
Build a discovery broadcast (0x28).
bool create_private_function(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t function_id, uint8_t sub_index)
Build a CMD_PRIVATE (0x03) request for an arbitrary function ID.
bool create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power, bool ack_capable, bool payload_enabled, uint8_t payload, const uint8_t *system_key)
Build a configurable discovery request command (0x28, 0x2A, or 0x2E).
bool create_get_status_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a tilt-aware get-status request (0x03) that returns the extended 16-byte tilt payload.
bool create_get_status_extended(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t selector, uint8_t block, uint8_t function_id)
Build an extended CMD_PRIVATE (0x03) request with a selector/block pair — the shape real hubs use for...
LimitationType
On-air selector byte (request data[0]) choosing which resulting limit to read.
bool create_launch_key_transfer(IoFrame &f, const uint8_t *own, const uint8_t *dst, const uint8_t challenge[HMAC_SIZE])
Build a key transfer request (0x38). See proto_commands.h for the frame shape.
bool create_execute_heating_with_mode_flags(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint16_t main_parameter, uint16_t mode_flags)
Build a temperature / alias execute frame (0x00) carrying functional parameter 9. See proto_commands....
bool create_challenge_resp_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key)
Build a device-role challenge response (0x3D) — device side, used only by the key-extraction responde...
bool create_1w_execute_position(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t position, uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], uint8_t acei, bool broadcast_all)
Build a 1W position execute frame (CMD 0x00) targeting a device class.
bool create_challenge_req(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE])
Build a challenge request (0x3C) using a caller-supplied challenge.
bool create_execute_main_parameter(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t position, bool force_open_priority)
Build a main-parameter-only position execute frame (0x00). See proto_commands.h.
bool create_status_update_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a status-update acknowledgment (0x72).
bool create_1w_add_controller(IoFrame &f, const uint8_t src[NODE_ID_SIZE], DeviceType target_type, uint8_t manufacturer, uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], bool with_mac)
Build a 1W add-controller frame (CMD 0x30).
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
One object a CMD_TRANSFER_OPEN can name: its class byte and its 16-bit id.