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 "roll_call.h"
37#include "hub_pairing.h"
38#include "hub_decisions.h"
39#include "exchange_engine.h"
40#include "device_registry.h"
41#include "pairing_advisor.h"
42#include "pairing_telemetry.h"
43#include "radio_interface.h"
44#include "tuning_config.h"
45
46#include <cstdint>
47#include <string>
48
49namespace esphome {
50namespace home_io_control {
51
52/// @name Pairing timing constants
53/// Timeouts and retry limits for the pairing flow's blocking waits.
54///@{
55inline constexpr uint32_t PAIRING_DISCOVERY_RESPONSE_TIMEOUT_MS = 2000; ///< Discovery wait window after sending 0x28.
56inline constexpr uint8_t PAIRING_DISCOVERY_MAX_ATTEMPTS = 3; ///< Retry discovery TX up to this many times.
57inline constexpr uint32_t PAIRING_KEY_CHALLENGE_TIMEOUT_MS = 500; ///< Wait window for the device's 0x3C challenge.
58inline constexpr uint32_t PAIRING_KEY_CONFIRM_TIMEOUT_MS = 500; ///< Wait for 0x33 key confirm after sending 0x32.
59inline constexpr uint8_t PAIRING_DISCOVER_CONFIRM_TRIES = 3; ///< Max tries for the discover-confirm (0x2C) step.
60inline constexpr uint32_t PAIRING_DISCOVER_CONFIRM_ACK_TIMEOUT_MS = 1500; ///< Wait window per discover-confirm try.
61/// Tries for the authenticated name read that checks whether a device took our key although the
62/// key exchange did not confirm. Two, not EXCHANGE_RETRY_COUNT: the device answered moments ago, so
63/// one retry covers a lost frame, and the check runs only on an attempt that is already failing.
64inline constexpr uint8_t PAIRING_KEY_VERIFY_MAX_TRIES = 2;
65/// Wait for the device's key transfer (0x32) after our key transfer request (0x38). Same window as
66/// the 0x33 wait: both are a unicast reply to a unicast pairing frame.
68/// How recent a RecentOneWayPairingSighting has to be, relative to discover_and_pair() starting,
69/// to still count as evidence for this attempt. Generous relative to the doc's "a few seconds"
70/// PROG-then-press guidance: real field reports (issue #27) show gaps up to ~4-7 s between the
71/// PROG gesture and pressing "Discover & Pair" in the app, so a tight window would reintroduce
72/// the same miss it's meant to fix. Not tied to ONEWAY_QUIET_PERIOD_MS (status_poll_policy.h,
73/// 700 ms) — that constant is about collapsing one remote's repeat burst, a different, much
74/// shorter timescale than "how long ago did the user press PROG."
75inline constexpr uint32_t PAIRING_RECENT_ONE_WAY_SIGHTING_WINDOW_MS = 15000;
76///@}
77
78/// Owns and drives all three phases of the IO-Homecontrol device pairing flow.
79///
80/// Constructed once by IOHomeControlComponent; collaborators (radio, exchange engine,
81/// device registry) are injected as pointers/references so the engine never outlives them.
82/// @ingroup hioc_hub
84 public:
85 /// @brief What run_key_pull_phase_() ended with.
86 enum class KeyPullResult : uint8_t {
87 FAILED, ///< No key received, refused, or a different key the device did not prove (not printed).
88 OUR_KEY, ///< The device sent the hub's own key: it is paired with this hub.
89 OTHER_KEY, ///< The device sent a different key and proved it holds it (`context.pulled_key`).
90 UNPROVEN, ///< The device sent a different key and no 0x3D to our challenges (`context.pulled_key`,
91 ///< not printed): only devices that answer a roll-call under it can prove it.
92 };
93
94 /// @brief What roll_call_and_register_() found under a key.
96 uint8_t answered{0}; ///< Devices that answered under the key (registered or not).
97 uint8_t registered{0}; ///< Of those, the ones registered until reboot.
98 };
99
100 /// Construct the engine with all required collaborators.
101 ///
102 /// @param radio_ptr Double pointer into the hub's `radio_` member — survives driver replacement in tests.
103 /// @param node_id Controller 3-byte node ID buffer, owned by the hub.
104 /// @param system_key 16-byte AES system key buffer, owned by the hub.
105 /// @param tuning Tuning configuration, owned by the hub.
106 /// @param engine Shared exchange engine for transmit/receive operations.
107 /// @param registry Device registry where paired devices are permanently registered.
108 /// @param telemetry Per-attempt telemetry recorder, owned by the hub.
109 /// @param recent_oneway_sighting Most recent 1W pairing-gesture sighting from the hub's normal
110 /// passive RX path, owned by the hub; see RecentOneWayPairingSighting.
111 PairingEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning,
112 ExchangeEngine &engine, DeviceRegistry &registry, PairingTelemetry &telemetry,
113 const RecentOneWayPairingSighting &recent_oneway_sighting);
114
115 /// Non-copyable — stores double-pointer and references into hub member addresses.
116 PairingEngine(const PairingEngine &) = delete;
118
119 /// Discover and pair a device currently in pairing mode (three-phase orchestrator).
120 /// @return true if all three phases completed successfully; false otherwise.
121 bool discover_and_pair();
122
123 /// Extract node ID, device type, and subtype from a CMD_DISCOVER_RESP frame.
124 /// @return The decoded extended discovery fields (manufacturer / Multi Information Byte / length
125 /// flags), so a caller can read the self-reported power class without decoding twice.
127 std::string &device_id);
128
129 protected:
130 // --- Phase helpers (protected; exposed to tests via TestablePairingEngine in test_helpers.h) ---
131
132 /// Phase 1: broadcast discovery command(s) and wait for a device response (0x29).
133 /// @param context Pairing context updated on success.
134 /// @return ACCEPT on success; NO_RESPONSE or INVALID otherwise.
136
137 /// @brief Discover-confirm step (0x2C → 0x2D): sent directly to the just-discovered device,
138 /// once per discover_and_pair() attempt, between discovery and the key-exchange retry loop.
139 ///
140 /// Every real controller in this project's corpus sends 0x2C here. The step **never fails a
141 /// pairing attempt**: a refusal, timeout, or `skip` tuning mode all still let the caller proceed
142 /// to `run_key_exchange_phase_()` — only the result and log line differ. Applies the
143 /// `pairing_key_init_delay_ms` pause afterward for every outcome except `SKIPPED` (see
144 /// `tuning_config.h`'s `pairing_discover_confirm`/`pairing_key_init_delay_ms` doc for the modes
145 /// and defaults).
146 /// @param context Pairing context populated by run_discovery_phase_(); `context.req`/`context.rx`
147 /// are reused as scratch space for the 0x2C/0x2D exchange, same as the other phases.
148 /// @return What the step actually observed — see @ref pairing::DiscoverConfirmResult. Not stored
149 /// in `context`: nothing downstream reads it.
151
152 /// Phase 2: authenticated key exchange (0x31 → 0x3C → 0x32 → 0x33).
153 /// @param context Pairing context populated by run_discovery_phase_().
154 /// @return true if key exchange completes; false on any failure.
156
157 /// Phase 3: send SetConfig1 (0x6F) once; optional. The key exchange's 0x33 already completed
158 /// the pairing, so neither the attempt's outcome nor its telemetry depends on this step: no
159 /// device on record accepts the frame (see PAIRING_SET_CONFIG1_MAX_TRIES). An unanswered try
160 /// is logged as harmless so the preceding "no first response" line isn't read as a failure.
161 /// @param context Pairing context with device information from phases 1 and 2.
163
164 /// Wait for a discovery response (0x29) within timeout_ms with per-chip frequency hopping.
165 /// @param timeout_ms Maximum wait window.
166 /// @param packet Output: raw RadioRxPacket of the accepted discovery frame.
167 /// @param response_frame Output: parsed IoFrame of the accepted discovery frame.
168 /// @return ACCEPT on success; NO_RESPONSE (no traffic) or INVALID (wrong frames) otherwise.
170 IoFrame &response_frame);
171
172 /// Wait for a key-challenge (0x3C) or direct key-confirm (0x33) from the target device.
173 /// @param timeout_ms Maximum wait window.
174 /// @param packet Output: raw RadioRxPacket of the accepted frame.
175 /// @param challenge_frame Output: parsed IoFrame.
176 /// @param device_node_id Expected source node ID (devices paired to).
177 /// @return true if a valid challenge or confirm was received; false on timeout.
178 bool wait_for_key_challenge_(uint32_t timeout_ms, RadioRxPacket &packet, IoFrame &challenge_frame,
179 const uint8_t device_node_id[NODE_ID_SIZE]);
180
181 /// Wait for one discover-confirm (0x2C) try's answer: a matching 0x2D, a matching
182 /// CMD_ERROR_RESP, or nothing recognisable before the window closes.
183 ///
184 /// Listen policy alternates by try: `discover_confirm_try_rotates(try_index)` selects
185 /// `ListenPolicy::ROTATE_ALL_CHANNELS` for the one try that hedges against an off-channel 0x2D,
186 /// and `ListenPolicy::HOLD_REQUEST_CHANNEL` (matching every other unicast pairing wait) for the
187 /// rest — see that decision's doc for why only one try rotates.
188 /// @param try_index 1-based try number, used only to pick the listen policy.
189 /// @param context Pairing context; `context.req` is the 0x2C just transmitted, `context.rx`/
190 /// `context.packet` receive the candidate reply.
191 /// @return ACK or ERROR for a matching reply; IGNORE only once the window closes with nothing
192 /// recognised — an unrelated frame arriving mid-window does not end the try, it keeps
193 /// listening.
195 pairing::PairingContext &context);
196
197 /// Transmit the 0x32 key transfer and wait for the 0x33 key confirm with retry.
198 ///
199 /// A device that challenges the key transfer (slow-turnaround chips answering with a fresh 0x3C
200 /// instead of confirming directly) is handled inline via `listen_for_key_confirm_()` and
201 /// `ExchangeEngine::answer_challenge()` — see their docs. An explicit refusal (REFUSE: a wrong
202 /// reply shape, or CMD_ERROR_RESP) must not spend the remaining retries; a challenge is not a
203 /// refusal, so it does not return false either — the try only ends without confirming.
205
206 /// Build CMD_KEY_TRANSFER against the current challenge and wait for the 0x33 confirm; see
207 /// run_key_exchange_phase_()'s doc comment for why this is a separate, replayable step.
208 /// @param context Pairing context; `context.rx.data` supplies the challenge bytes, `context.req`
209 /// is filled with the outbound 0x32, `context.resp` with the inbound 0x33 on success.
210 /// @return true if the device confirmed the key.
212
213 /// @brief Check whether the device took our key although the key exchange did not confirm it.
214 ///
215 /// A device can accept a key transfer whose 0x33 we never hear, then refuse the next one, so an
216 /// unconfirmed key exchange is not proof of failure. Sends one authenticated name read (0x50).
217 /// Only a device that challenges the read and then answers it with its name (0x51) has proven it
218 /// verifies our MAC with our key. A device that answers without challenging proves nothing, and
219 /// counts as "not held".
220 /// @param context Pairing context; `context.req`/`context.resp` are reused as scratch.
221 /// @return true if the device answered an authenticated request with our key.
223
224 /// @brief Key pull (`pairing_key_transfer: pull`, or `auto` for a remote controller): 0x38 ->
225 /// device's 0x32 -> our 0x3C -> device's 0x3D.
226 ///
227 /// Sends a key transfer request with 6 random bytes, waits for the device's 0x32 and decrypts the
228 /// key (recover_key_from_pull()). The log gets one "Key pull check" line with the key's check value,
229 /// never the key, so two attempts or two devices can be compared from the log alone.
230 ///
231 /// Every 0x38 try of the attempt carries the same challenge, so a 0x32 that answers an earlier try late
232 /// still decrypts to the key. The tries follow the command plan of the device's power class
233 /// (ExchangeEngine::plan_attempts()): to a mains device as many without a gap as fit
234 /// `exchange_total_budget_ms` (at most EXCHANGE_MAX_ATTEMPTS), the early ones listening
235 /// `exchange_early_response_wait_ms` and the last PAIRING_KEY_PULL_REPLY_TIMEOUT_MS; to a low-power
236 /// one EXCHANGE_RETRY_COUNT with the retry gap. A try that could not finish inside the budget is
237 /// not started (decisions::attempt_fits()).
238 ///
239 /// The hub's own key is accepted as is: no decrypt produces it by chance. A different key must be
240 /// proven by the device's 0x3D to our challenge (ExchangeEngine::challenge_sender() under that key, up
241 /// to EXCHANGE_RETRY_COUNT challenges while no 0x3D comes back; a 0x3D that does not verify ends it).
242 /// The challenges repeat the same bytes, so a late 0x3D still verifies; the first goes out with the
243 /// response preamble, as an immediate answer to the 0x32, the repeats with the 0x38's start preamble,
244 /// since the device may no longer be listening on the channel by then. A proven key becomes
245 /// `context.pulled_key` (OTHER_KEY); one with no 0x3D at all is kept there unprinted (UNPROVEN) for a
246 /// roll-call to prove. The hub's own key is never replaced. Hardware-validated on an Atlantic AGILIA IO
247 /// radiator (issue #165).
248 /// @param context Pairing context populated by run_discovery_phase_().
249 /// @return See KeyPullResult.
251
252 /// @brief Which direction this attempt moves the key in.
253 ///
254 /// `pull` and `push` force it; `auto` pulls when the device answered discovery as a remote
255 /// controller (node type 1023) and pushes otherwise. Logs the `auto` decision.
256 /// @param context Pairing context populated by run_discovery_phase_().
257 /// @return true to pull the device's key, false to push the hub's.
258 [[nodiscard]] bool key_pull_selected_(const pairing::PairingContext &context) const;
259
260 /// @brief Find the devices that hold `key`, log one report, register the ones that can be controlled.
261 ///
262 /// Asks the heater at the next address first (probe_heater_at_next_address_()); otherwise runs one
263 /// roll-call sweep (run_roll_call_sweep()) with `key`. Registers every device found that has a platform
264 /// and no YAML entry, until reboot (IoDevice::registered_by_pairing); a record an earlier pairing in this
265 /// boot added is replaced. With a device key the report is build_pulled_key_report() (device log
266 /// only, one banner, the responders carry the key as their own `system_key`); with the hub key it is
267 /// build_hub_key_pull_report() (no key, no banner, the responders use the hub key).
268 ///
269 /// With `key_proven` false (a pulled key its device did not prove), the devices that answer are the
270 /// proof: a device answers a roll-call, or the heater probe's authenticated read, only under the key it
271 /// holds. If none answers, nothing is reported or registered and the key stays unprinted.
272 /// @param context Pairing context of the attempt.
273 /// @param key AES_KEY_SIZE bytes the roll-call is built with.
274 /// @param device_key true when `key` is a key the device handed over, false for the hub's own.
275 /// @param key_proven false when only the answers can prove `key` (a device key the device did not prove).
276 /// @return How many devices answered, and how many of them were registered.
277 KeyHolderSearch roll_call_and_register_(pairing::PairingContext &context, const uint8_t *key, bool device_key,
278 bool key_proven);
279
280 /// @brief Ask the heater at the address after an Atlantic pairing node for its maximum setpoint.
281 ///
282 /// Runs only for a node decisions::pairing_node_has_heater_at_next_address() names, and only when the
283 /// YAML declares no device at that address. One authenticated register read under `key` (the full
284 /// retry budget); an answer means the electrical heater with an adjustable setpoint is there, and it
285 /// becomes the only entry of `state`, as if a roll-call had found it. Leaves the registry as it was.
286 /// @param context Pairing context of the attempt (the discovered node and its manufacturer).
287 /// @param key AES_KEY_SIZE bytes the read is signed with.
288 /// @param device_key true when `key` is a key the device handed over (the entry uses key slot 1).
289 /// @param[out] state Filled with the heater when it answered; untouched otherwise.
290 /// @return true when the heater answered.
291 bool probe_heater_at_next_address_(const pairing::PairingContext &context, const uint8_t *key, bool device_key,
292 ScanState &state);
293
294 /// @brief Record a pairing that ends with devices found by roll-call: `paired`, `node=` unchanged.
296
297 /// @brief Pull the key and end the attempt wherever the pull decides it.
298 ///
299 /// A foreign key, proven or not, goes to register_pulled_key_devices_(); a failed pull ends the attempt
300 /// as refused or failed. The hub's own key from a node no platform drives looks for the devices that
301 /// hold it and ends as `paired`, or as `hub_key_returned` with none found. The hub's own key from a device with a
302 /// platform is left to the caller, which pairs it as after a push.
303 /// @param context Pairing context populated by run_discovery_phase_().
304 /// @param[out] paired The attempt's result when it ended here.
305 /// @return true when the attempt ended here; false when the caller continues with the push path's ending.
306 bool run_pull_and_finish_(pairing::PairingContext &context, bool &paired);
307
308 /// @brief After a pull of a different key: find the devices that hold it, then end the attempt.
309 ///
310 /// At least one registered responder ends the attempt as `paired`; none ends it as `key_received`.
311 /// For an unproven key (`key_proven` false) the responders are the proof: with no device answering, the
312 /// attempt ends as `key_exchange_failed` and the key is never printed. Zeroes the pulled key afterwards.
313 /// @param context Pairing context holding the pulled key (`has_pulled_key`).
314 /// @param key_proven true when the device proved the key with its 0x3D.
315 /// @return true when the attempt ended as `paired`.
316 bool register_pulled_key_devices_(pairing::PairingContext &context, bool key_proven);
317
318 private:
319 /// Preamble for a directed pairing start frame (0x2C, 0x31, 0x6F) to the device discovery just
320 /// found: the shorter of ExchangeEngine::request_preamble_for()'s rule and the preamble the
321 /// discovery request went out with (`effective_discovery_preamble()`).
322 ///
323 /// The device answered a discovery request carrying that preamble moments ago, so it is
324 /// listening and demonstrably hears it; pairing follows within seconds. The long wake-up
325 /// preamble the rule would otherwise pick for a low-power target (and that 0x31/0x6F always
326 /// carry) is not just unnecessary then: some VELUX receivers never detect a frame behind a
327 /// 1024-byte preamble at all (ADR 0029). Without a tuned discovery preamble the cap is the
328 /// start preamble, so a low-power target's frames follow discovery down to it; with a
329 /// low-power discovery frame (wake-up preamble) the result equals the rule.
330 /// @param frame The start frame about to be sent.
331 /// @return Preamble length in bytes.
332 [[nodiscard]] uint16_t pairing_start_preamble_(const IoFrame &frame) const;
333
334 /// One `ListenPolicy::HOLD_REQUEST_CHANNEL` listen for the key-transfer confirm wait, called up
335 /// to twice per try in `wait_for_key_confirm_()` — once for the initial reply, once more (a
336 /// fresh window) after answering a device challenge — so the listen spec, logging, and telemetry
337 /// have exactly one owner instead of two copies of the same lambda.
338 /// @param context Pairing context; `context.req` is the outbound 0x32 (the challenge-response
339 /// transcript), `context.resp` receives the candidate reply.
340 /// @param try_number 1-based try number, for the timeout log line only.
341 /// @param after_challenge True if this is the second, post-challenge listen within the try
342 /// (labelled in the timeout log line so it isn't mistaken for the first).
343 /// @return CONFIRM/CHALLENGE/REFUSE for a matching reply; IGNORE on a timeout. A frame from the
344 /// wrong endpoints does not end the listen — it is ignored and the wait continues.
345 decisions::PairingKeyConfirmDisposition listen_for_key_confirm_(pairing::PairingContext &context, uint8_t try_number,
346 bool after_challenge);
347
348 /// Convenience accessor returning the current radio driver (dereferences double pointer).
349 [[nodiscard]] RadioDriver *radio_() const { return *radio_ptr_; }
350
351 /// Record the final outcome, detach telemetry from the exchange engine, and log the
352 /// end-of-attempt summary. Called once at every discover_and_pair() exit point.
353 /// @param outcome Final disposition of this attempt.
354 void finish_pairing_attempt_(PairingOutcome outcome);
355
356 /// Record an RX or RX_REJECT telemetry event for a discovery-response candidate frame.
357 /// Factored out of wait_for_discovery_response_() purely to keep that function's cognitive
358 /// complexity under the clang-tidy threshold — no behavior beyond the telemetry call.
359 /// @param frame Parsed candidate frame.
360 /// @param accepted true if the frame was classified as a valid discovery response.
361 /// @param rssi RSSI of the captured frame.
362 void record_discovery_rx_telemetry_(const IoFrame &frame, bool accepted, int16_t rssi);
363
364 /// Record a CMD_ERROR_RESP answer to the key step: context flag, telemetry, and a decoded log line.
365 /// @param context Pairing context to mark as refused.
366 /// @param reply The device's CMD_ERROR_RESP frame.
367 /// @param step Log label for the refused step (e.g. "Key transfer").
368 void note_key_refusal_(pairing::PairingContext &context, const IoFrame &reply, const char *step);
369
370 /// Wait for the device's 0x32 answer to our 0x38, holding the request channel.
371 /// @param context Pairing context; `context.rx` receives the accepted 0x32.
372 /// @param window_ms How long to listen.
373 /// @return true if a well-formed 0x32 from the device arrived; false on timeout or refusal
374 /// (a refusal also sets `context.key_refused`).
375 bool wait_for_pulled_key_(pairing::PairingContext &context, uint32_t window_ms);
376
377 /// Run the push key exchange with its retry loop, then the key-held check if it did not confirm.
378 /// @param context Pairing context populated by the discovery phase.
379 /// @return true if the device holds our key (confirmed, or proven by verify_device_holds_key_()).
380 bool run_push_key_step_(pairing::PairingContext &context);
381
382 RadioDriver **radio_ptr_;
383 const uint8_t *node_id_;
384 const uint8_t *system_key_;
385 const TuningConfig *tuning_;
386 ExchangeEngine &engine_;
387 DeviceRegistry &registry_;
388 PairingTelemetry &telemetry_;
389 const RecentOneWayPairingSighting &recent_oneway_sighting_;
390};
391
392} // namespace home_io_control
393} // 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).
bool probe_heater_at_next_address_(const pairing::PairingContext &context, const uint8_t *key, bool device_key, ScanState &state)
Ask the heater at the address after an Atlantic pairing node for its maximum setpoint.
bool key_pull_selected_(const pairing::PairingContext &context) const
Which direction this attempt moves the key in.
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.
KeyHolderSearch roll_call_and_register_(pairing::PairingContext &context, const uint8_t *key, bool device_key, bool key_proven)
Find the devices that hold key, log one report, register the ones that can be controlled.
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,...
bool run_pull_and_finish_(pairing::PairingContext &context, bool &paired)
Pull the key and end the attempt wherever the pull decides it.
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).
void finish_paired_by_roll_call_(pairing::PairingContext &context)
Record a pairing that ends with devices found by roll-call: paired, node= unchanged.
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.
KeyPullResult
What run_key_pull_phase_() ended with.
bool register_pulled_key_devices_(pairing::PairingContext &context, bool key_proven)
After a pull of a different key: find the devices that hold it, then end the attempt.
KeyPullResult run_key_pull_phase_(pairing::PairingContext &context)
Key pull (pairing_key_transfer: pull, or auto for a remote controller): 0x38 -> device's 0x32 -> our ...
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...
bool verify_device_holds_key_(pairing::PairingContext &context)
Check whether the device took our key although the key exchange did not confirm it.
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:94
constexpr uint8_t PAIRING_DISCOVERY_MAX_ATTEMPTS
Retry discovery TX up to this many times.
constexpr uint8_t PAIRING_KEY_VERIFY_MAX_TRIES
Tries for the authenticated name read that checks whether a device took our key although the key exch...
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.
@ FAILED
No usable reply; the device may never have heard the request.
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_PULL_REPLY_TIMEOUT_MS
Wait for the device's key transfer (0x32) after our key transfer request (0x38).
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.
The roll-call sweep: one keyed 0x2A broadcast, repeated over channels and power classes.
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
What roll_call_and_register_() found under a key.
uint8_t answered
Devices that answered under the key (registered or not).
uint8_t registered
Of those, the ones registered until reboot.
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...
Accumulated state of one scan, threaded through every sweep.
Definition roll_call.h:128
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:70
Runtime tuning configuration for pairing and radio diagnostics.