Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
key_extraction_responder.h
Go to the documentation of this file.
1#pragma once
2
3/// @file key_extraction_responder.h
4/// @brief "Recover System Key" (key extraction) — device-role responder collaborator.
5/// @ingroup hioc_hub
6///
7/// The impure side of the key-extraction feature: arming/disarming, throwaway node-ID generation,
8/// the 10-minute auto-off timer, the post-extraction grace window, transmitting device-role
9/// replies, and the security-sensitive result log block. The pure state-transition decisions live
10/// in pairing_responder.h/.cpp (unchanged); this collaborator owns a
11/// pairing_responder::ResponderContext and dispatches the six RX branches through
12/// try_handle_frame(), called from process_received_packet_() (hub_status.cpp).
13
14#include "hub_hooks.h"
15#include "pairing_responder.h"
16#include "proto_commands.h"
17#include "proto_constants.h"
18#include "proto_frame.h"
19#include "proto_sizes.h"
20#include "proto_timing.h"
21
22#include "esphome/core/hal.h" // millis() for the inline awaiting_reply()
23
24#include <cstdint>
25#include <functional>
26#include <string>
27
28namespace esphome {
29namespace home_io_control {
30
31/// How long after receiving a frame it answers the responder stays on that frame's channel
32/// (SESSION_HOLD_BASE_MS plus the turnaround time the responder advertises in its Multi
33/// Information Byte). The
34/// peer's in-session frame (the `0x32` after our `0x3C`, the `0x3C` after our `0x37`) comes on that
35/// channel within it, with a short preamble. After it the responder scans all three channels again,
36/// as a device does once its session ends: the peer's next *request* is a new transmission that may
37/// go out on any channel.
38/// \todo Measure on hardware how soon, and on which channel, a third-party hub's in-session frame
39/// follows our reply. Two passive community captures show a controller's 0x3D on another channel
40/// than the device's 0x3C; a hub that does so would be heard only once the hold ends.
41/// @param multi_info The Multi Information Byte the responder advertises.
42/// @return The session window in milliseconds.
43static constexpr uint32_t key_extraction_session_window_ms(uint8_t multi_info) {
44 return SESSION_HOLD_BASE_MS + att_class_ms(discovery_att_class(multi_info));
45}
46
47/// The session window for the default Multi Information Byte (KEY_EXTRACTION_DISCOVER_RESP_FLAGS).
48static constexpr uint32_t KEY_EXTRACTION_SESSION_WINDOW_MS =
49 key_extraction_session_window_ms(KEY_EXTRACTION_DISCOVER_RESP_FLAGS);
50
51// Forward declarations — full definitions included only in key_extraction_responder.cpp.
52struct TuningConfig;
53class RadioDriver;
54class DeviceRegistry;
55
56/// @brief Device-role responder for the "Recover System Key" feature.
57///
58/// Constructed once by IOHomeControlComponent; non-copyable because it is wired with injected
59/// callbacks and references into hub member addresses (mirrors ManagementActions / ExchangeEngine).
60/// @ingroup hioc_hub
62 public:
63 /// @param node_id Hub's real 3-byte node ID (throwaway-ID collision check).
64 /// @param radio Double pointer to the hub's active radio driver, so a test's
65 /// `comp.radio_ = &mock` propagates (mirrors ExchangeEngine).
66 /// @param tuning Runtime tuning config, owned by the hub
67 /// (`cold_broadcast_reply_preamble`).
68 /// @param registry Device registry, for the throwaway-ID collision check only.
69 /// @param transmit How to put a reply frame on air (see TransmitFrameFn).
70 /// @param schedule_auto_off Named-timeout scheduler for the 10-minute arm window and the
71 /// post-extraction grace window (see NamedTimeoutFn).
72 KeyExtractionResponder(const uint8_t *node_id, RadioDriver **radio, const TuningConfig *tuning,
73 DeviceRegistry &registry, TransmitFrameFn transmit, NamedTimeoutFn schedule_auto_off);
74
75 /// Non-copyable — holds injected callbacks and references into hub member addresses.
78
79 /// @brief Arm or disarm the "Recover System Key" (key extraction) responder.
80 ///
81 /// Arming picks a fresh throwaway node ID, resets the pairing_responder state machine to
82 /// ARMED_IDLE, and schedules a 10-minute auto-off. While armed, the 0x28/0x2C/0x31/0x32 branches
83 /// in process_received_packet_() emulate an unpaired device so a user's existing hub can pair to
84 /// it and hand over its node_id/system_key (see pairing_responder.h). Disarming — manual, via
85 /// the HA switch, on successful extraction, or on auto-off — immediately stops those branches
86 /// from responding; it never touches the real device registry or the hub's own node_id_/
87 /// system_key_. This is the body that was IOHomeControlComponent::set_key_extraction_armed().
88 /// @param armed Desired state.
89 void set_armed(bool armed);
90
91 /// Register a callback invoked whenever the key-extraction armed state changes — manual
92 /// toggle, successful extraction, or auto-off timeout — so the switch entity can keep its
93 /// displayed state in sync when the responder disarms itself rather than the user. Single-slot.
94 /// @param cb Callable receiving the new armed state.
95 void set_armed_callback(std::function<void(bool)> cb) { this->armed_callback_ = std::move(cb); }
96
97 /// Dispatch a frame to the responder if it's one of its 0x28/0x2C/0x31/0x32/0x36/0x3C frames and
98 /// the responder is armed. Kept a separate function (rather than inlined into
99 /// process_received_packet_()) purely to keep that function's cognitive complexity under the
100 /// clang-tidy threshold, mirroring PairingEngine::record_discovery_rx_telemetry_()'s reason for
101 /// existing.
102 /// @param frame Parsed inbound frame.
103 /// @param rx_freq_hz Channel the frame was received on (RadioRxPacket::freq_hz); 0 if unknown,
104 /// which counts as CH2. Answering the frame makes it the session channel.
105 /// @return true if the frame was handled (caller should stop further dispatch for it).
106 [[nodiscard]] bool try_handle_frame(const IoFrame &frame, uint32_t rx_freq_hz = 0);
107
108 /// Channel of the session the responder last replied in: the channel the frame it answered came
109 /// in on, CH2 until it has answered anything. Replies go out on it (the `0x29` ends on it), and
110 /// loop() stays on it while in_session_hold().
111 [[nodiscard]] uint32_t session_freq() const { return this->session_freq_; }
112
113 /// True while awaiting_reply() and the frame the responder last answered was received less than
114 /// the session window ago (key_extraction_session_window_ms() of the byte the last discovery answer
115 /// advertised): loop() then stays on session_freq() for the peer's in-session frame. Once false
116 /// (while still awaiting a reply), loop() scans all three channels.
117 [[nodiscard]] bool in_session_hold() const {
118 return this->awaiting_reply() && (millis() - this->session_rx_ms_) < this->session_window_ms_;
119 }
120
121 /// Session window for the Multi Information Byte the last discovery answer advertised.
122 [[nodiscard]] uint32_t session_window_ms() const { return this->session_window_ms_; }
123
124 /// True while a discovery answer waits for its slot (schedule_discover_reply_()).
125 [[nodiscard]] bool discover_reply_pending() const {
126 return this->discover_reply_pending_ &&
128 }
129
130 /// Milliseconds until the pending discovery answer's slot, 0 once it is due. The answer goes out
131 /// from the scheduler, which runs only between loop() passes, so a blocking listen must end by
132 /// then or the answer slips past its slot and, for the last slots, out of the searcher's window.
133 /// Meaningful only while discover_reply_pending().
134 [[nodiscard]] uint32_t ms_until_discover_reply() const {
135 const auto remaining = static_cast<int32_t>(this->discover_reply_due_ms_ - millis());
136 return remaining > 0 ? static_cast<uint32_t>(remaining) : 0;
137 }
138
139 /// Generate a random throwaway node ID for one key-extraction arm cycle, avoiding collisions
140 /// with the broadcast addresses, this hub's own real node ID, and any registered device.
141 /// @param out Output: 3-byte node ID.
142 void generate_throwaway_id(uint8_t out[NODE_ID_SIZE]);
143
144 /// (Re)arm the post-extraction grace window that replaces the old immediate disarm-on-extraction:
145 /// called once when the key is first recovered, and again on every sign of hub progress after
146 /// that (an inbound 0x36, an outbound 0x3D) so a slow multi-retry hub isn't cut off mid-round.
147 /// Uses the same named-timer replace-on-reschedule idiom as the 10-minute auto-off timer — see
148 /// key_extraction_responder.cpp for why a naive "only disarm if DISARMED" guard inside the
149 /// callback is not enough once a manual disarm-and-rearm can happen inside the window.
151
152 /// True whenever the responder has replied at least once, is waiting on the hub's next step, and
153 /// that wait is still within its bounded hold window — i.e. `key_extraction_ctx_.state` is
154 /// neither DISARMED (feature unused) nor ARMED_IDLE (armed, but no discovery request seen yet),
155 /// AND `key_extraction_hold_deadline_ms_` has not yet passed. While it is true, loop() defers
156 /// background status polls and, instead of the generic idle hop, listens for the peer's next frame:
157 /// on session_freq() during in_session_hold(), then with a rotating listen over all three channels.
158 ///
159 /// The deadline is a plain timestamp, not a named `set_timeout()` timer: ending this listen is
160 /// purely a radio-scheduling optimization (see key_extraction_hold_deadline_ms_'s own doc
161 /// comment for why it is deliberately decoupled from `key_extraction_ctx_.state` itself), so
162 /// nothing needs to fire a callback when it lapses — the next loop() iteration simply stops
163 /// taking the responder-listen branch on its own. Default-constructed, the deadline is 0, which
164 /// is always in the past relative to any real `millis()` reading once the device has been running — so a
165 /// mid-exchange state reached without the deadline having been (re)set (e.g. a reply-builder
166 /// failure between the state guard and the deadline update) safely never takes the radio, rather
167 /// than holding it unboundedly.
168 ///
169 /// Defined inline: defer_background_poll_() calls this every loop() iteration.
175
176 /// State for the current "Accept Foreign Pairing" (key-extraction) arm cycle; DISARMED by
177 /// default so a fresh boot never responds to foreign pairing traffic. See pairing_responder.h.
178 /// Public so the host tests that script individual RX branches can preset and inspect it.
180 /// Deadline (millis()) until which loop() listens for the key-extraction responder's peer,
181 /// deliberately independent of `key_extraction_ctx_.state` itself. Set (not "armed" — this is a
182 /// plain timestamp, not a named `set_timeout()` timer) on every sign of hub progress: the three
183 /// pre-extraction reply handlers (handle_discover_(), handle_discover_confirm_(),
184 /// handle_key_init_(), key_extraction_responder.cpp) push it out by
185 /// KEY_EXTRACTION_MID_ATTEMPT_TIMEOUT_MS, and arm_post_extraction_grace() pushes it out by
186 /// KEY_EXTRACTION_POST_EXTRACT_GRACE_MS so the hold also covers the (much longer)
187 /// post-extraction node-verification phase it governs.
188 ///
189 /// Deliberately independent of `key_extraction_ctx_.state`: the pure guards in
190 /// pairing_responder.cpp decide whether an inbound frame is accepted by checking `state` alone,
191 /// never this deadline, so a real (slower) hub's next frame still completes the exchange
192 /// correctly even if it arrives after the hold has expired. Coupling the two — letting the
193 /// deadline also force `state` back to ARMED_IDLE — would silently discard that live protocol
194 /// progress instead of just releasing the radio hold. See awaiting_reply()'s doc comment for how
195 /// this is consumed.
197
198 private:
199 /// Handle an inbound CMD_DISCOVER_REQ (0x28) while the responder is armed.
200 void handle_discover_(const IoFrame &frame);
201 /// Advertise the type word from `key_extraction_type_word` (the answer to an open discovery).
202 void set_configured_type_();
203 /// Handle an inbound CMD_DISCOVER_CONFIRM (0x2C) addressed to our throwaway node ID while armed.
204 void handle_discover_confirm_(const IoFrame &frame);
205 /// Handle an inbound CMD_KEY_INIT (0x31) addressed to our throwaway node ID while armed.
206 void handle_key_init_(const IoFrame &frame);
207 /// Handle an inbound CMD_KEY_TRANSFER (0x32) addressed to our throwaway node ID while armed.
208 void handle_key_transfer_(const IoFrame &frame);
209 /// Handle an inbound CMD_NODE_VERIFY_REQ (0x36) addressed to our throwaway node ID while armed.
210 /// Some hubs (Velux KLR200) send this after completing the key exchange, to verify the backbone
211 /// address they were given — see pairing_responder::on_node_verify_req().
212 void handle_node_verify_req_(const IoFrame &frame);
213 /// Handle an inbound CMD_CHALLENGE_REQ (0x3C) addressed to our throwaway node ID while armed and
214 /// in SENT_NODE_VERIFY_RESP — the hub-issued challenge against our own CMD_NODE_VERIFY_RESP, closing the
215 /// node-verification round a CMD_NODE_VERIFY_REQ opened. See
216 /// pairing_responder::on_node_verify_challenge().
217 void handle_node_verify_challenge_(const IoFrame &frame);
218 /// Transmit a key-extraction reply to a frame addressed to us and open the session hold
219 /// (session_freq(), in_session_hold()). It goes out once, on the channel that frame came in on,
220 /// with the driver's response_preamble().
221 void send_reply_(const IoFrame &frame);
222 /// Answer a broadcast search the way a device does: once, in a random slot from 1 to N of the
223 /// search's window (discovery_slot_count()), on a random one of the two channels the search did
224 /// not come in on, with `cold_broadcast_reply_preamble`. Timed through the named-timeout
225 /// scheduler, so the loop is never blocked while the slot runs down. The answer is dropped if
226 /// the attempt has moved on (disarm, re-arm) before its slot comes.
227 /// \todo Confirm on hardware that third-party controllers (TaHoma, KIG 300, KLR 200, Freebox)
228 /// accept a slotted answer.
229 void schedule_discover_reply_(const IoFrame &search, const IoFrame &resp);
230 /// Emit the security-sensitive "system key extracted" log block (see redaction.h — this is the
231 /// one deliberate, explicit exception to that file's masking, not a loosening of it).
232 void log_result_();
233
234 const uint8_t *node_id_;
235 RadioDriver **radio_;
236 const TuningConfig *tuning_;
237 /// Channel of the frame try_handle_frame() is dispatching, so the handler that answers it can
238 /// make that channel the session's (send_reply_()).
239 uint32_t rx_freq_hz_{FREQ_CH2};
240 /// millis() when try_handle_frame() received that frame.
241 uint32_t rx_ms_{0};
242 /// Channel of the session the responder last replied in; see session_freq().
243 uint32_t session_freq_{FREQ_CH2};
244 /// millis() when the frame the responder last answered was received; see in_session_hold().
245 uint32_t session_rx_ms_{0};
246 /// Session window for the Multi Information Byte the last discovery answer advertised.
247 uint32_t session_window_ms_{KEY_EXTRACTION_SESSION_WINDOW_MS};
248 /// The `0x29` waiting for its slot (schedule_discover_reply_()).
249 IoFrame pending_discover_resp_{};
250 /// True from scheduling that `0x29` until its timer fires; see discover_reply_pending().
251 bool discover_reply_pending_{false};
252 /// millis() at which that `0x29`'s slot comes.
253 uint32_t discover_reply_due_ms_{0};
254 DeviceRegistry &registry_;
255 TransmitFrameFn transmit_;
256 NamedTimeoutFn schedule_auto_off_;
257 std::function<void(bool)> armed_callback_;
258};
259
260namespace detail {
261
262/// @brief Build the ready-to-paste 2W system-key-extraction report: `node_id:`/`system_key:` as a
263/// `home_io_control:` YAML block.
264///
265/// Pure — takes already-decoded values, performs no I/O — so it is directly unit-testable without
266/// a live radio, mirroring build_oneway_adoption_report() (oneway_key_adoption.h); the two features
267/// end up sharing report *structure* as well as format_key_hex(). The caller
268/// (KeyExtractionResponder::log_result_()) logs the result through log_multiline_result() and
269/// nowhere else — this is the single intentional place the recovered `system_key` is formatted for
270/// display, a deliberate exception to redaction.h's masking.
271///
272/// The emitted keys must track the hub's own `CONFIG_SCHEMA` (`__init__.py`) by hand. `make
273/// yaml-emitter-sync` (scripts/check-yaml-emitters.py) catches drift between the two by
274/// cross-referencing this function's emitted key names against that schema statically.
275/// @param node_id Recovered hub node_id, 3 bytes.
276/// @param key Recovered system key, 16 bytes.
277/// @return Multi-line report text, ready to pass to log_multiline_result().
278std::string build_key_extraction_report(const uint8_t node_id[NODE_ID_SIZE], const uint8_t key[AES_KEY_SIZE]);
279
280} // namespace detail
281
282} // namespace home_io_control
283} // namespace esphome
Owns the per-hub device table, update callbacks, and linked-remote associations.
uint32_t session_freq() const
Channel of the session the responder last replied in: the channel the frame it answered came in on,...
KeyExtractionResponder & operator=(const KeyExtractionResponder &)=delete
uint32_t session_window_ms() const
Session window for the Multi Information Byte the last discovery answer advertised.
void generate_throwaway_id(uint8_t out[NODE_ID_SIZE])
Generate a random throwaway node ID for one key-extraction arm cycle, avoiding collisions with the br...
bool try_handle_frame(const IoFrame &frame, uint32_t rx_freq_hz=0)
Dispatch a frame to the responder if it's one of its 0x28/0x2C/0x31/0x32/0x36/0x3C frames and the res...
KeyExtractionResponder(const KeyExtractionResponder &)=delete
Non-copyable — holds injected callbacks and references into hub member addresses.
void set_armed(bool armed)
Arm or disarm the "Recover System Key" (key extraction) responder.
void arm_post_extraction_grace()
(Re)arm the post-extraction grace window that replaces the old immediate disarm-on-extraction: called...
pairing_responder::ResponderContext key_extraction_ctx_
State for the current "Accept Foreign Pairing" (key-extraction) arm cycle; DISARMED by default so a f...
bool discover_reply_pending() const
True while a discovery answer waits for its slot (schedule_discover_reply_()).
void set_armed_callback(std::function< void(bool)> cb)
Register a callback invoked whenever the key-extraction armed state changes — manual toggle,...
bool awaiting_reply() const
True whenever the responder has replied at least once, is waiting on the hub's next step,...
uint32_t ms_until_discover_reply() const
Milliseconds until the pending discovery answer's slot, 0 once it is due.
KeyExtractionResponder(const uint8_t *node_id, RadioDriver **radio, const TuningConfig *tuning, DeviceRegistry &registry, TransmitFrameFn transmit, NamedTimeoutFn schedule_auto_off)
uint32_t key_extraction_hold_deadline_ms_
Deadline (millis()) until which loop() listens for the key-extraction responder's peer,...
bool in_session_hold() const
True while awaiting_reply() and the frame the responder last answered was received less than the sess...
Abstract radio driver for IO-Homecontrol.
Injected-capability callback aliases shared by the hub's collaborator objects.
std::string build_key_extraction_report(const uint8_t node_id[NODE_ID_SIZE], const uint8_t key[AES_KEY_SIZE])
Build the ready-to-paste 2W system-key-extraction report: node_id:/system_key: as a home_io_control: ...
@ ARMED_IDLE
Armed, listening for a discovery request (0x28).
@ SENT_DISCOVER_RESP
Replied to discovery (0x29); waiting for discovery-confirm (0x2C) or key-init (0x31).
@ DISARMED
Not armed; 0x28/0x2C/0x31/0x32 traffic is ignored.
constexpr uint8_t discovery_att_class(uint8_t flags)
Extract the ATT class field from a discovery response's Multi Information Byte.
static constexpr uint32_t key_extraction_session_window_ms(uint8_t multi_info)
How long after receiving a frame it answers the responder stays on that frame's channel (SESSION_HOLD...
static constexpr uint32_t KEY_EXTRACTION_SESSION_WINDOW_MS
The session window for the default Multi Information Byte (KEY_EXTRACTION_DISCOVER_RESP_FLAGS).
std::function< bool(const IoFrame &frame, uint32_t freq_hz, uint16_t preamble)> TransmitFrameFn
Puts a frame on air on a given channel via the hub's protected transmit_frame_().
Definition hub_hooks.h:34
constexpr uint32_t att_class_ms(uint8_t att_class)
Turnaround time of an ATT class in milliseconds: 5, 10, 20 or 40 (each class doubles it).
std::function< void(const char *name, uint32_t delay_ms, std::function< void()> callback)> NamedTimeoutFn
Schedules a named, replace-on-same-name timeout on the hub's ESPHome scheduler.
Definition hub_hooks.h:27
Pure decision logic for the device-role "Accept Foreign Pairing" (system-key extraction) responder.
Command builders for the IO‑Homecontrol protocol.
IO-Homecontrol command IDs, result codes and protocol enumerations.
IO-Homecontrol 2W frame container: control bytes, IoFrame and (de)serialization.
Fundamental IO-Homecontrol frame and crypto size constants.
Physical-layer radio and timing parameters for the IO-Homecontrol protocol.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
All runtime tunable parameters for pairing and radio diagnostics.