Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
oneway_key_adoption.cpp
Go to the documentation of this file.
2
3#include "hub_internal.h"
4#include "proto_codecs.h"
5
6/// @file oneway_key_adoption.cpp
7/// @brief Opt-in, receive-only adoption of a 1W installation's controller key.
8/// @ingroup hioc_hub
9///
10/// A 1W device broadcasts CMD_ONEWAY_ADD_CONTROLLER (0x30) while its key-copy gesture is active,
11/// handing its network's system key to whichever controller is listening. The payload is wrapped
12/// with the public TRANSFER_KEY under an IV derived only from the sender's own node address, both
13/// of which are available to anyone in radio range — so overhearing that one frame is enough to
14/// recover the key. This file turns that into a deliberate, time-boxed, user-armed action.
15///
16/// **This is a property of io-homecontrol, not something this project introduces.** The same
17/// framing applies as to 2W key extraction (key_extraction_responder.cpp): the protocol offers no
18/// confidentiality for the key-copy gesture, so the honest response is to make the capability
19/// explicit, opt-in and loud rather than to pretend it is unavailable.
20///
21/// Differences from its 2W sibling, both deliberate:
22/// - **Receive-only.** Nothing here transmits. 2W extraction impersonates an unpaired device to
23/// make a foreign hub pair *to* it; this only listens for a frame a device sends of its own
24/// accord, so it cannot disturb an existing installation at all.
25/// - **One adoption per arm.** The window closes as soon as a key is recovered, bounding the
26/// period in which a key-bearing frame is captured and matching the single physical gesture
27/// the user performs.
28///
29/// The recovered key is never persisted — not to NVS, not to a Home Assistant event. It is
30/// reported once for the user to paste into their own YAML/secrets, which is ADR 0018's
31/// paste-and-reflash shape, exactly as the 2W feature does.
32
33namespace esphome {
34namespace home_io_control {
35
36namespace {
37
38/// Arm window. Deliberately the same 10 minutes as the 2W responder's KEY_EXTRACTION_AUTO_OFF_MS
39/// (key_extraction_responder.cpp): both windows exist for the same reason — long enough to walk to the
40/// device and perform a physical gesture, short enough that forgetting to disarm is not a
41/// standing exposure — and a user arming both should not have to reason about two numbers.
42constexpr uint32_t ONEWAY_KEY_ADOPTION_AUTO_OFF_MS = 10 * 60 * 1000;
43constexpr const char *ONEWAY_KEY_ADOPTION_TIMEOUT_NAME = "oneway_key_adoption_auto_off";
44
45} // namespace
46
48 if (!armed) {
49 if (!this->armed_)
50 return;
51 // No cancel_timeout() here: a pending auto-off callback is harmless because it re-checks the
52 // armed flag before acting (see the guard below), and set_timeout() replaces a callback of
53 // the same name on re-arm. Same idiom as the 2W responder in key_extraction_responder.cpp.
54 this->armed_ = false;
55 ESP_LOGI(detail::TAG, "1W key adoption: disarmed");
56 if (this->armed_callback_)
57 this->armed_callback_(false);
58 return;
59 }
60
61 this->armed_ = true;
62 // Drop any class observed during an earlier window so a stale sender's type can never prefill
63 // this one's report.
64 this->observed_class_ = ObservedClass{};
65 ESP_LOGW(detail::TAG,
66 "1W key adoption: ARMED for 10 minutes. Trigger the key-copy gesture on your existing 1W remote now "
67 "(the remote-to-remote copy mode described in its manual). Receive-only — nothing is transmitted.");
68
69 this->schedule_auto_off_(ONEWAY_KEY_ADOPTION_TIMEOUT_NAME, ONEWAY_KEY_ADOPTION_AUTO_OFF_MS, [this]() {
70 // Guards against a stale timeout firing after a manual disarm/re-arm already ran; set_timeout()
71 // replaces a pending callback of the same name, but the check documents the intent either way.
72 if (!this->armed_)
73 return;
74 ESP_LOGW(detail::TAG, "1W key adoption: window expired, no add-controller frame seen. Disarming.");
75 this->set_armed(false);
76 });
77
78 if (this->armed_callback_)
79 this->armed_callback_(true);
80}
81
83 if (!this->armed_)
84 return;
85 // A typed broadcast names a device class; the add-controller frame itself targets "all" and
86 // decodes to UNKNOWN, so skipping UNKNOWN keeps the 0x30 from clobbering a genuine earlier
87 // observation from the same sender.
89 return;
90 memcpy(this->observed_class_.node, info.src, NODE_ID_SIZE);
91 this->observed_class_.type = info.target_type;
92 this->observed_class_.valid = true;
93}
94
96 if (!this->armed_)
97 return;
98 if (frame.cmd != CMD_ONEWAY_ADD_CONTROLLER)
99 return;
100
101 OneWayAdoptedKey adopted{};
102 const OneWayAddControllerDecodeError error = decode_1w_add_controller(frame, adopted);
104 // Stay armed: a malformed 0x30 is far more likely to be a corrupted capture than the user's
105 // real gesture, and disarming here would make them re-arm and repeat the gesture for nothing.
106 ESP_LOGW(detail::TAG, "1W key adoption: heard an add-controller frame from %s but could not decode it (error %u)",
107 node_id_to_string(frame.src).c_str(), static_cast<unsigned>(error));
108 return;
109 }
110
111 // Prefill io_device_type from this sender's other 1W traffic, if any was overheard during this
112 // window. The 0x30 itself broadcasts to "all" and carries no class, so without this the user
113 // would have to guess which class their device answers to.
114 const bool observed_known =
115 this->observed_class_.valid && memcmp(this->observed_class_.node, adopted.sender_node, NODE_ID_SIZE) == 0;
116 const DeviceType observed_type = observed_known ? this->observed_class_.type : DeviceType::UNKNOWN;
117
118 // The single intentional emission of the recovered key — a deliberate, narrow exception to
119 // redaction.h's masking, exactly as KeyExtractionResponder::log_result_() is for the 2W path. The key
120 // must not reach any other log path, and the generic frame-log helpers keep masking 0x30.
121 //
122 // The report is logged line-by-line via log_multiline_result(), not as one ESP_LOGW("%s", ...)
123 // call: a single call silently truncates at ESPHome's 512-byte log buffer, and this report is
124 // long enough to do exactly that — cutting off before the recovered key ever appears, which
125 // defeats the entire feature with no error and no indication anything was lost. See
126 // log_multiline_result()'s doxygen (hub_internal.h) for the root cause.
127 ESP_LOGW(detail::TAG, "========================================");
128 ESP_LOGW(detail::TAG, "1W CONTROLLER KEY ADOPTED FROM %s -- DO NOT SHARE THIS KEY",
129 node_id_to_string(adopted.sender_node).c_str());
130 detail::log_multiline_result(detail::TAG, /*is_warning=*/true, /*prefix=*/"",
131 detail::build_oneway_adoption_report(adopted, observed_known, observed_type));
132 ESP_LOGW(detail::TAG, "========================================");
133
134 // One adoption per arm.
135 this->set_armed(false);
136}
137
138} // namespace home_io_control
139} // namespace esphome
void try_adopt(const IoFrame &frame)
Decode an inbound CMD_ONEWAY_ADD_CONTROLLER (0x30) while armed, report the result,...
bool armed() const
Whether the listener is currently armed.
void set_armed(bool armed)
Arm or disarm the 1W controller-key adoption listener.
void record_observed_class(const OneWayFrameInfo &info)
Remember the most recent 1W target device class observed from info.src, for the adoption report's io_...
Internal helpers shared by the hub implementation .cpp files.
constexpr const char * TAG
Shared log tag for hub-level messages.
void log_multiline_result(const char *tag, bool is_warning, const std::string &prefix, const std::string &message)
Log prefix followed by message, one line per log call rather than one call for the whole (possibly mu...
std::string build_oneway_adoption_report(const OneWayAdoptedKey &adopted, bool observed_type_known, DeviceType observed_type)
Build the full 1W controller-key-adoption report: MAC-verification status, the own-address transmissi...
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
DeviceType
Device type identifiers reported by IO‑Homecontrol products.
@ UNKNOWN
Unknown/unspecified device.
OneWayAddControllerDecodeError decode_1w_add_controller(const IoFrame &frame, OneWayAdoptedKey &out)
Decode a CMD_ONEWAY_ADD_CONTROLLER (0x30) frame into a recovered controller identity.
OneWayAddControllerDecodeError
Decoding outcome for decode_1w_add_controller().
static constexpr uint8_t CMD_ONEWAY_ADD_CONTROLLER
1W "add controller" — a 1W device broadcasts this while its key-copy gesture is active,...
std::string node_id_to_string(const uint8_t id[NODE_ID_SIZE])
Format a 3‑byte node ID as a 6‑character uppercase hex string.
Opt-in, receive-only adoption of a 1W installation's controller key.
Device-name, address-classification and 1W-frame codecs.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:88
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:92
Recovered controller identity from a decoded CMD_ONEWAY_ADD_CONTROLLER (0x30) frame.
uint8_t sender_node[NODE_ID_SIZE]
Sender's node address (frame.src) — the new identity's node.
Decoded representation of a 1W remote frame.
DeviceType target_type
Target device class from broadcast address.
uint8_t src[NODE_ID_SIZE]
Remote source node ID (3 bytes).
Most recent 1W target device class observed while armed.