Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
pairing_engine.h
Go to the documentation of this file.
1#pragma once
2
3/// @file pairing_engine.h
4/// @brief Device discovery and key-exchange engine for IO-Homecontrol pairing.
5/// @ingroup hioc_hub
6///
7/// PairingEngine encapsulates all three phases of the IO-Homecontrol pairing flow:
8///
9/// Phase 1 — Discovery (0x28 → 0x29 → 0x2C → 0x2D):
10/// Controller broadcasts a discovery packet. A device in pairing mode responds
11/// with its node ID and type/subtype metadata. The controller then sends a discover-confirm
12/// (0x2C) directly to that device and, when tuning allows, waits up to
13/// `PAIRING_DISCOVER_CONFIRM_TRIES` × `PAIRING_DISCOVER_CONFIRM_ACK_TIMEOUT_MS` for its 0x2D —
14/// every real controller in this project's corpus does this before proceeding; see
15/// `pairing_discover_confirm` (tuning_config.h) for the mode knob and `run_discover_confirm_step_()`
16/// for why the step never fails a pairing attempt.
17///
18/// Phase 2 — Authenticated Key Exchange (0x31 → 0x3C → 0x32 → 0x33):
19/// The controller sends CMD_KEY_INIT (0x31). The device challenges with 0x3C.
20/// The controller proves knowledge of the system key and simultaneously
21/// transfers the encrypted system key (0x32). The device confirms with 0x33.
22///
23/// Phase 3 — Configuration (0x6F):
24/// The controller sends SetConfig1 to enable automatic device status updates.
25///
26/// The engine is constructed once by IOHomeControlComponent. It holds double-pointer
27/// indirection for the radio driver so test assignments (`comp.radio_ = &mock`) propagate
28/// without calling setup(). All radio operations go through ExchangeEngine so that
29/// LBT, preamble selection, and frequency hopping are centralised.
30///
31/// PairingEngine is non-copyable and non-movable because it stores pointer and reference
32/// addresses that would dangle in a copy.
33
34#include "proto_frame.h"
35#include "proto_codecs.h"
36#include "hub_pairing.h"
37#include "hub_decisions.h"
38#include "exchange_engine.h"
39#include "device_registry.h"
40#include "pairing_advisor.h"
41#include "pairing_telemetry.h"
42#include "radio_interface.h"
43#include "tuning_config.h"
44
45#include <cstdint>
46#include <string>
47
48namespace esphome {
49namespace home_io_control {
50
51/// @name Pairing timing constants
52/// Timeouts and retry limits for the pairing flow's blocking waits.
53///@{
54inline constexpr uint32_t PAIRING_DISCOVERY_RESPONSE_TIMEOUT_MS = 2000; ///< Discovery wait window after sending 0x28.
55inline constexpr uint8_t PAIRING_DISCOVERY_MAX_ATTEMPTS = 3; ///< Retry discovery TX up to this many times.
56inline constexpr uint32_t PAIRING_KEY_CHALLENGE_TIMEOUT_MS = 500; ///< Wait window for the device's 0x3C challenge.
57inline constexpr uint32_t PAIRING_KEY_CONFIRM_TIMEOUT_MS = 500; ///< Wait for 0x33 key confirm after sending 0x32.
58inline constexpr uint8_t PAIRING_DISCOVER_CONFIRM_TRIES = 3; ///< Max tries for the discover-confirm (0x2C) step.
59inline constexpr uint32_t PAIRING_DISCOVER_CONFIRM_ACK_TIMEOUT_MS = 1500; ///< Wait window per discover-confirm try.
60/// How recent a RecentOneWayPairingSighting has to be, relative to discover_and_pair() starting,
61/// to still count as evidence for this attempt. Generous relative to the doc's "a few seconds"
62/// PROG-then-press guidance: real field reports (issue #27) show gaps up to ~4-7 s between the
63/// PROG gesture and pressing "Discover & Pair" in the app, so a tight window would reintroduce
64/// the same miss it's meant to fix. Not tied to ONEWAY_QUIET_PERIOD_MS (status_poll_policy.h,
65/// 700 ms) — that constant is about collapsing one remote's repeat burst, a different, much
66/// shorter timescale than "how long ago did the user press PROG."
67inline constexpr uint32_t PAIRING_RECENT_ONE_WAY_SIGHTING_WINDOW_MS = 15000;
68///@}
69
70/// Owns and drives all three phases of the IO-Homecontrol device pairing flow.
71///
72/// Constructed once by IOHomeControlComponent; collaborators (radio, exchange engine,
73/// device registry) are injected as pointers/references so the engine never outlives them.
74/// @ingroup hioc_hub
76 public:
77 /// Construct the engine with all required collaborators.
78 ///
79 /// @param radio_ptr Double pointer into the hub's `radio_` member — survives driver replacement in tests.
80 /// @param node_id Controller 3-byte node ID buffer, owned by the hub.
81 /// @param system_key 16-byte AES system key buffer, owned by the hub.
82 /// @param tuning Tuning configuration, owned by the hub.
83 /// @param engine Shared exchange engine for transmit/receive operations.
84 /// @param registry Device registry where paired devices are permanently registered.
85 /// @param telemetry Per-attempt telemetry recorder, owned by the hub.
86 /// @param recent_oneway_sighting Most recent 1W pairing-gesture sighting from the hub's normal
87 /// passive RX path, owned by the hub; see RecentOneWayPairingSighting.
88 PairingEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning,
89 ExchangeEngine &engine, DeviceRegistry &registry, PairingTelemetry &telemetry,
90 const RecentOneWayPairingSighting &recent_oneway_sighting);
91
92 /// Non-copyable — stores double-pointer and references into hub member addresses.
93 PairingEngine(const PairingEngine &) = delete;
95
96 /// Discover and pair a device currently in pairing mode (three-phase orchestrator).
97 /// @return true if all three phases completed successfully; false otherwise.
98 bool discover_and_pair();
99
100 /// Extract node ID, device type, and subtype from a CMD_DISCOVER_RESP frame.
101 /// @return The decoded extended discovery fields (manufacturer / Multi Information Byte / length
102 /// flags), so a caller can read the self-reported power class without decoding twice.
104 std::string &device_id);
105
106 protected:
107 // --- Phase helpers (protected; exposed to tests via TestablePairingEngine in test_helpers.h) ---
108
109 /// Phase 1: broadcast discovery command(s) and wait for a device response (0x29).
110 /// @param context Pairing context updated on success.
111 /// @return ACCEPT on success; NO_RESPONSE or INVALID otherwise.
113
114 /// @brief Discover-confirm step (0x2C → 0x2D): sent directly to the just-discovered device,
115 /// once per discover_and_pair() attempt, between discovery and the key-exchange retry loop.
116 ///
117 /// Every real controller in this project's corpus sends 0x2C here. The step **never fails a
118 /// pairing attempt**: a refusal, timeout, or `skip` tuning mode all still let the caller proceed
119 /// to `run_key_exchange_phase_()` — only the result and log line differ. Applies the
120 /// `pairing_key_init_delay_ms` pause afterward for every outcome except `SKIPPED` (see
121 /// `tuning_config.h`'s `pairing_discover_confirm`/`pairing_key_init_delay_ms` doc for the modes
122 /// and defaults).
123 /// @param context Pairing context populated by run_discovery_phase_(); `context.req`/`context.rx`
124 /// are reused as scratch space for the 0x2C/0x2D exchange, same as the other phases.
125 /// @return What the step actually observed — see @ref pairing::DiscoverConfirmResult. Not stored
126 /// in `context`: nothing downstream reads it.
128
129 /// Phase 2: authenticated key exchange (0x31 → 0x3C → 0x32 → 0x33).
130 /// @param context Pairing context populated by run_discovery_phase_().
131 /// @return true if key exchange completes; false on any failure.
133
134 /// Phase 3: send SetConfig1 (0x6F) once; optional. The key exchange's 0x33 already completed
135 /// the pairing, so neither the attempt's outcome nor its telemetry depends on this step: no
136 /// device on record accepts the frame (see PAIRING_SET_CONFIG1_MAX_TRIES). An unanswered try
137 /// is logged as harmless so the preceding "no first response" line isn't read as a failure.
138 /// @param context Pairing context with device information from phases 1 and 2.
140
141 /// Wait for a discovery response (0x29) within timeout_ms with per-chip frequency hopping.
142 /// @param timeout_ms Maximum wait window.
143 /// @param packet Output: raw RadioRxPacket of the accepted discovery frame.
144 /// @param response_frame Output: parsed IoFrame of the accepted discovery frame.
145 /// @return ACCEPT on success; NO_RESPONSE (no traffic) or INVALID (wrong frames) otherwise.
147 IoFrame &response_frame);
148
149 /// Wait for a key-challenge (0x3C) or direct key-confirm (0x33) from the target device.
150 /// @param timeout_ms Maximum wait window.
151 /// @param packet Output: raw RadioRxPacket of the accepted frame.
152 /// @param challenge_frame Output: parsed IoFrame.
153 /// @param device_node_id Expected source node ID (devices paired to).
154 /// @return true if a valid challenge or confirm was received; false on timeout.
155 bool wait_for_key_challenge_(uint32_t timeout_ms, RadioRxPacket &packet, IoFrame &challenge_frame,
156 const uint8_t device_node_id[NODE_ID_SIZE]);
157
158 /// Wait for one discover-confirm (0x2C) try's answer: a matching 0x2D, a matching
159 /// CMD_ERROR_RESP, or nothing recognisable before the window closes.
160 ///
161 /// Listen policy alternates by try: `discover_confirm_try_rotates(try_index)` selects
162 /// `ListenPolicy::ROTATE_ALL_CHANNELS` for the one try that hedges against an off-channel 0x2D,
163 /// and `ListenPolicy::HOLD_REQUEST_CHANNEL` (matching every other unicast pairing wait) for the
164 /// rest — see that decision's doc for why only one try rotates.
165 /// @param try_index 1-based try number, used only to pick the listen policy.
166 /// @param context Pairing context; `context.req` is the 0x2C just transmitted, `context.rx`/
167 /// `context.packet` receive the candidate reply.
168 /// @return ACK or ERROR for a matching reply; IGNORE only once the window closes with nothing
169 /// recognised — an unrelated frame arriving mid-window does not end the try, it keeps
170 /// listening.
172 pairing::PairingContext &context);
173
174 /// Transmit the 0x32 key transfer and wait for the 0x33 key confirm with retry.
175 ///
176 /// A device that challenges the key transfer (slow-turnaround chips answering with a fresh 0x3C
177 /// instead of confirming directly) is handled inline via `listen_for_key_confirm_()` and
178 /// `ExchangeEngine::answer_challenge()` — see their docs. An explicit refusal (REFUSE: a wrong
179 /// reply shape, or CMD_ERROR_RESP) must not spend the remaining retries; a challenge is not a
180 /// refusal, so it does not return false either — the try only ends without confirming.
182
183 /// Build CMD_KEY_TRANSFER against the current challenge and wait for the 0x33 confirm; see
184 /// run_key_exchange_phase_()'s doc comment for why this is a separate, replayable step.
185 /// @param context Pairing context; `context.rx.data` supplies the challenge bytes, `context.req`
186 /// is filled with the outbound 0x32, `context.resp` with the inbound 0x33 on success.
187 /// @return true if the device confirmed the key.
189
190 private:
191 /// Preamble for a directed pairing start frame (0x2C, 0x31, 0x6F) to the device discovery just
192 /// found: the shorter of ExchangeEngine::request_preamble_for()'s rule and the preamble the
193 /// discovery request went out with (`pairing_discovery_preamble`).
194 ///
195 /// The device answered a discovery request carrying that preamble moments ago, so it is
196 /// listening and demonstrably hears it; pairing follows within seconds. The long wake-up
197 /// preamble the rule would otherwise pick for a low-power target (and that 0x31/0x6F always
198 /// carry) is not just unnecessary then: some VELUX receivers never detect a frame behind a
199 /// 1024-byte preamble at all (ADR 0029). With the default discovery preamble (LONG_PREAMBLE)
200 /// the result equals the rule, so default setups transmit exactly what they did before.
201 /// @param frame The start frame about to be sent.
202 /// @return Preamble length in bytes.
203 [[nodiscard]] uint16_t pairing_start_preamble_(const IoFrame &frame) const;
204
205 /// One `ListenPolicy::HOLD_REQUEST_CHANNEL` listen for the key-transfer confirm wait, called up
206 /// to twice per try in `wait_for_key_confirm_()` — once for the initial reply, once more (a
207 /// fresh window) after answering a device challenge — so the listen spec, logging, and telemetry
208 /// have exactly one owner instead of two copies of the same lambda.
209 /// @param context Pairing context; `context.req` is the outbound 0x32 (the challenge-response
210 /// transcript), `context.resp` receives the candidate reply.
211 /// @param try_number 1-based try number, for the timeout log line only.
212 /// @param after_challenge True if this is the second, post-challenge listen within the try
213 /// (labelled in the timeout log line so it isn't mistaken for the first).
214 /// @return CONFIRM/CHALLENGE/REFUSE for a matching reply; IGNORE on a timeout. A frame from the
215 /// wrong endpoints does not end the listen — it is ignored and the wait continues.
216 decisions::PairingKeyConfirmDisposition listen_for_key_confirm_(pairing::PairingContext &context, uint8_t try_number,
217 bool after_challenge);
218
219 /// Convenience accessor returning the current radio driver (dereferences double pointer).
220 [[nodiscard]] RadioDriver *radio_() const { return *radio_ptr_; }
221
222 /// Record the final outcome, detach telemetry from the exchange engine, and log the
223 /// end-of-attempt summary. Called once at every discover_and_pair() exit point.
224 /// @param outcome Final disposition of this attempt.
225 void finish_pairing_attempt_(PairingOutcome outcome);
226
227 /// Record an RX or RX_REJECT telemetry event for a discovery-response candidate frame.
228 /// Factored out of wait_for_discovery_response_() purely to keep that function's cognitive
229 /// complexity under the clang-tidy threshold — no behavior beyond the telemetry call.
230 /// @param frame Parsed candidate frame.
231 /// @param accepted true if the frame was classified as a valid discovery response.
232 /// @param rssi RSSI of the captured frame.
233 void record_discovery_rx_telemetry_(const IoFrame &frame, bool accepted, int16_t rssi);
234
235 RadioDriver **radio_ptr_;
236 const uint8_t *node_id_;
237 const uint8_t *system_key_;
238 const TuningConfig *tuning_;
239 ExchangeEngine &engine_;
240 DeviceRegistry &registry_;
241 PairingTelemetry &telemetry_;
242 const RecentOneWayPairingSighting &recent_oneway_sighting_;
243};
244
245} // namespace home_io_control
246} // namespace esphome
Owns the per-hub device table, update callbacks, and linked-remote associations.
decisions::PairingDiscoveryDisposition run_discovery_phase_(pairing::PairingContext &context)
Phase 1: broadcast discovery command(s) and wait for a device response (0x29).
static DiscoveryResponseInfo parse_device_from_discovery(const IoFrame &frame, IoDevice &device, std::string &device_id)
Extract node ID, device type, and subtype from a CMD_DISCOVER_RESP frame.
decisions::PairingDiscoverConfirmDisposition wait_for_discover_confirm_ack_(uint8_t try_index, pairing::PairingContext &context)
Wait for one discover-confirm (0x2C) try's answer: a matching 0x2D, a matching CMD_ERROR_RESP,...
PairingEngine & operator=(const PairingEngine &)=delete
bool run_key_exchange_phase_(pairing::PairingContext &context)
Phase 2: authenticated key exchange (0x31 → 0x3C → 0x32 → 0x33).
bool wait_for_key_confirm_(pairing::PairingContext &context)
Transmit the 0x32 key transfer and wait for the 0x33 key confirm with retry.
PairingEngine(const PairingEngine &)=delete
Non-copyable — stores double-pointer and references into hub member addresses.
bool wait_for_key_challenge_(uint32_t timeout_ms, RadioRxPacket &packet, IoFrame &challenge_frame, const uint8_t device_node_id[NODE_ID_SIZE])
Wait for a key-challenge (0x3C) or direct key-confirm (0x33) from the target device.
bool discover_and_pair()
Discover and pair a device currently in pairing mode (three-phase orchestrator).
bool transfer_key_and_wait_confirm_(pairing::PairingContext &context)
Build CMD_KEY_TRANSFER against the current challenge and wait for the 0x33 confirm; see run_key_excha...
decisions::PairingDiscoveryDisposition wait_for_discovery_response_(uint32_t timeout_ms, RadioRxPacket &packet, IoFrame &response_frame)
Wait for a discovery response (0x29) within timeout_ms with per-chip frequency hopping.
pairing::DiscoverConfirmResult run_discover_confirm_step_(pairing::PairingContext &context)
Discover-confirm step (0x2C → 0x2D): sent directly to the just-discovered device, once per discover_a...
void finalize_pairing_configuration_(pairing::PairingContext &context)
Phase 3: send SetConfig1 (0x6F) once; optional.
PairingEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning, ExchangeEngine &engine, DeviceRegistry &registry, PairingTelemetry &telemetry, const RecentOneWayPairingSighting &recent_oneway_sighting)
Construct the engine with all required collaborators.
Fixed-size per-attempt telemetry recorder for the pairing flow.
Abstract radio driver for IO-Homecontrol.
Per-hub device table, update-callback fan-out, and linked-remote map.
Self-contained authenticated exchange engine for IO-Homecontrol 2W.
Pure transition helpers for hub-owned exchange and pairing frame decisions.
Internal pairing-state model for hub‑owned discovery and key‑exchange flows.
PairingDiscoveryDisposition
Disposition during pairing discovery phase.
PairingKeyConfirmDisposition
Disposition for a candidate reply to the key-transfer (0x32) confirm wait — the slow-turnaround path'...
PairingDiscoverConfirmDisposition
Disposition for a candidate reply to a discovery-confirm request (0x2C).
DiscoverConfirmResult
What PairingEngine::run_discover_confirm_step_() actually observed.
Definition hub_pairing.h:82
constexpr uint8_t PAIRING_DISCOVERY_MAX_ATTEMPTS
Retry discovery TX up to this many times.
constexpr uint32_t PAIRING_KEY_CONFIRM_TIMEOUT_MS
Wait for 0x33 key confirm after sending 0x32.
PairingOutcome
Final disposition of a pairing attempt, used by the result sensor string.
constexpr uint32_t PAIRING_DISCOVER_CONFIRM_ACK_TIMEOUT_MS
Wait window per discover-confirm try.
constexpr uint8_t PAIRING_DISCOVER_CONFIRM_TRIES
Max tries for the discover-confirm (0x2C) step.
constexpr uint32_t PAIRING_RECENT_ONE_WAY_SIGHTING_WINDOW_MS
How recent a RecentOneWayPairingSighting has to be, relative to discover_and_pair() starting,...
constexpr uint32_t PAIRING_KEY_CHALLENGE_TIMEOUT_MS
Wait window for the device's 0x3C challenge.
constexpr uint32_t PAIRING_DISCOVERY_RESPONSE_TIMEOUT_MS
Discovery wait window after sending 0x28.
Read-only advisor that turns PairingTelemetry into actionable diagnostics.
Structured per-attempt telemetry recorder for the pairing flow.
Device-name, address-classification and 1W-frame codecs.
IO-Homecontrol 2W frame container: control bytes, IoFrame and (de)serialization.
Radio abstraction layer for IO-Homecontrol.
Extended discovery-response fields (manufacturer, Multi Information Byte, backbone address,...
Runtime state of a paired IO‑Homecontrol device.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
Raw packet received from the radio.
A 1W pairing-gesture frame observed on the hub's normal passive RX path, remembered so a fresh discov...
All runtime tunable parameters for pairing and radio diagnostics.
Context object that lives for the duration of a single pairing attempt.
Definition hub_pairing.h:64
Runtime tuning configuration for pairing and radio diagnostics.