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 "log_helpers.h"
4#include "proto_codecs.h"
5
6#include <algorithm>
7#include <cctype>
8#include <cstdio>
9#include <cstring>
10#include <string>
11
12/// @file oneway_key_adoption.cpp
13/// @brief Opt-in, receive-only adoption of a 1W installation's controller key.
14/// @ingroup hioc_hub
15///
16/// A 1W device broadcasts CMD_ONEWAY_ADD_CONTROLLER (0x30) while its key-copy gesture is active,
17/// handing its network's system key to whichever controller is listening. The payload is wrapped
18/// with the public TRANSFER_KEY under an IV derived only from the sender's own node address, both
19/// of which are available to anyone in radio range — so overhearing that one frame is enough to
20/// recover the key. This file turns that into a deliberate, time-boxed, user-armed action.
21///
22/// **This is a property of io-homecontrol, not something this project introduces.** The same
23/// framing applies as to 2W key extraction (key_extraction_responder.cpp): the protocol offers no
24/// confidentiality for the key-copy gesture, so the honest response is to make the capability
25/// explicit, opt-in and loud rather than to pretend it is unavailable.
26///
27/// Differences from its 2W sibling, both deliberate:
28/// - **Receive-only.** Nothing here transmits. 2W extraction impersonates an unpaired device to
29/// make a foreign hub pair *to* it; this only listens for a frame a device sends of its own
30/// accord, so it cannot disturb an existing installation at all.
31/// - **One adoption per arm.** The window closes as soon as a key is recovered, bounding the
32/// period in which a key-bearing frame is captured and matching the single physical gesture
33/// the user performs.
34///
35/// The recovered key is never persisted — not to NVS, not to a Home Assistant event. It is
36/// reported once for the user to paste into their own YAML/secrets, which is ADR 0018's
37/// paste-and-reflash shape, exactly as the 2W feature does.
38
39namespace esphome {
40namespace home_io_control {
41
42namespace {
43
44/// Arm window. Deliberately the same 10 minutes as the 2W responder's KEY_EXTRACTION_AUTO_OFF_MS
45/// (key_extraction_responder.cpp): both windows exist for the same reason — long enough to walk to the
46/// device and perform a physical gesture, short enough that forgetting to disarm is not a
47/// standing exposure — and a user arming both should not have to reason about two numbers.
48constexpr uint32_t ONEWAY_KEY_ADOPTION_AUTO_OFF_MS = 10 * 60 * 1000;
49constexpr const char *ONEWAY_KEY_ADOPTION_TIMEOUT_NAME = "oneway_key_adoption_auto_off";
50
51} // namespace
52
53namespace detail {
54
55std::string build_oneway_adoption_report(const OneWayAdoptedKey &adopted, bool observed_type_known,
56 DeviceType observed_type) {
57 std::string sender_hex_lower = node_id_to_string(adopted.sender_node);
58 std::transform(sender_hex_lower.begin(), sender_hex_lower.end(), sender_hex_lower.begin(),
59 [](unsigned char c) { return std::tolower(c); });
60 const std::string key_hex = format_key_hex(adopted.system_key);
61
62 std::string mac_line;
63 switch (adopted.mac_status) {
65 mac_line = "MAC VERIFIED: this frame's MAC checked out under the recovered key -- the strongest evidence "
66 "available on the spot that it is correct.";
67 break;
69 mac_line = "MAC FAILED: this frame's MAC did NOT check out under the recovered key -- it is probably wrong. "
70 "Re-arm and repeat the key-copy gesture closer to the hub.";
71 break;
73 default:
74 mac_line = "MAC not present: this frame carried no MAC trailer to verify against -- treat this key as "
75 "unconfirmed until tested.";
76 break;
77 }
78
79 // Fits " manufacturer: 0xNN" plus its terminator with room to spare.
80 constexpr size_t manufacturer_line_size = 32;
81 char manufacturer_line[manufacturer_line_size];
82 snprintf(manufacturer_line, sizeof(manufacturer_line), " manufacturer: 0x%02X",
83 static_cast<unsigned>(adopted.manufacturer));
84
85 std::string type_lines;
86 if (observed_type_known) {
87 type_lines = " io_device_type: " + format_device_type_for_yaml(observed_type) +
88 " # observed from this sender's traffic; verify\n";
89 } else {
90 type_lines = " # io_device_type: unknown -- no other 1W traffic was observed from this sender while armed;\n"
91 " # check the DEBUG \"rx 1W remote ...\" log line once you see this sender transmit again.\n";
92 }
93
94 return mac_line +
95 "\nThe hub always transmits under its own node_id, never the sender's -- copying the sender's address "
96 "would hijack its rolling sequence counter and break its existing remote.\n"
97 "Copy the block below into your hub's YAML.\n"
98 "oneway_controllers:\n"
99 " # node_id omitted -> derived from your hub node_id; see the boot log\n"
100 " - id: adopted_" +
101 sender_hex_lower + "\n" + " system_key: \"" + key_hex + "\"\n" + manufacturer_line + "\n" + type_lines +
102 " commands: [open, close, stop]";
103}
104
105} // namespace detail
106
108 if (!armed) {
109 if (!this->armed_)
110 return;
111 // No cancel_timeout() here: a pending auto-off callback is harmless because it re-checks the
112 // armed flag before acting (see the guard below), and set_timeout() replaces a callback of
113 // the same name on re-arm. Same idiom as the 2W responder in key_extraction_responder.cpp.
114 this->armed_ = false;
115 ESP_LOGI(detail::TAG, "1W key adoption: disarmed");
116 if (this->armed_callback_)
117 this->armed_callback_(false);
118 return;
119 }
120
121 this->armed_ = true;
122 // Drop any class observed during an earlier window so a stale sender's type can never prefill
123 // this one's report.
124 this->observed_class_ = ObservedClass{};
125 ESP_LOGW(detail::TAG,
126 "1W key adoption: ARMED for 10 minutes. Trigger the key-copy gesture on your existing 1W remote now "
127 "(the remote-to-remote copy mode described in its manual). Receive-only — nothing is transmitted.");
128
129 this->schedule_auto_off_(ONEWAY_KEY_ADOPTION_TIMEOUT_NAME, ONEWAY_KEY_ADOPTION_AUTO_OFF_MS, [this]() {
130 // Guards against a stale timeout firing after a manual disarm/re-arm already ran; set_timeout()
131 // replaces a pending callback of the same name, but the check documents the intent either way.
132 if (!this->armed_)
133 return;
134 ESP_LOGW(detail::TAG, "1W key adoption: window expired, no add-controller frame seen. Disarming.");
135 this->set_armed(false);
136 });
137
138 if (this->armed_callback_)
139 this->armed_callback_(true);
140}
141
143 if (!this->armed_)
144 return;
145 // A typed broadcast names a device class; the add-controller frame itself targets "all" and
146 // decodes to UNKNOWN, so skipping UNKNOWN keeps the 0x30 from clobbering a genuine earlier
147 // observation from the same sender.
149 return;
150 memcpy(this->observed_class_.node, info.src, NODE_ID_SIZE);
151 this->observed_class_.type = info.target_type;
152 this->observed_class_.valid = true;
153}
154
156 if (!this->armed_)
157 return;
158 if (frame.cmd != CMD_ONEWAY_ADD_CONTROLLER)
159 return;
160
161 OneWayAdoptedKey adopted{};
162 const OneWayAddControllerDecodeError error = decode_1w_add_controller(frame, adopted);
164 // Stay armed: a malformed 0x30 is far more likely to be a corrupted capture than the user's
165 // real gesture, and disarming here would make them re-arm and repeat the gesture for nothing.
166 ESP_LOGW(detail::TAG, "1W key adoption: heard an add-controller frame from %s but could not decode it (error %u)",
167 node_id_to_string(frame.src).c_str(), static_cast<unsigned>(error));
168 return;
169 }
170
171 // Prefill io_device_type from this sender's other 1W traffic, if any was overheard during this
172 // window. The 0x30 itself broadcasts to "all" and carries no class, so without this the user
173 // would have to guess which class their device answers to.
174 const bool observed_known =
175 this->observed_class_.valid && memcmp(this->observed_class_.node, adopted.sender_node, NODE_ID_SIZE) == 0;
176 const DeviceType observed_type = observed_known ? this->observed_class_.type : DeviceType::UNKNOWN;
177
178 // The single intentional emission of the recovered key — a deliberate, narrow exception to
179 // redaction.h's masking, exactly as KeyExtractionResponder::log_result_() is for the 2W path. The key
180 // must not reach any other log path, and the generic frame-log helpers keep masking 0x30.
181 //
182 // The report is logged line-by-line via log_multiline_result(), not as one ESP_LOGW("%s", ...)
183 // call: a single call silently truncates at ESPHome's 512-byte log buffer, and this report is
184 // long enough to do exactly that — cutting off before the recovered key ever appears, which
185 // defeats the entire feature with no error and no indication anything was lost. See
186 // log_multiline_result()'s doxygen (log_helpers.h) for the root cause.
187 ESP_LOGW(detail::TAG, "========================================");
188 ESP_LOGW(detail::TAG, "1W CONTROLLER KEY ADOPTED FROM %s -- DO NOT SHARE THIS KEY",
189 node_id_to_string(adopted.sender_node).c_str());
190 detail::log_multiline_result(detail::TAG, /*is_warning=*/true, /*prefix=*/"",
191 detail::build_oneway_adoption_report(adopted, observed_known, observed_type));
192 ESP_LOGW(detail::TAG, "========================================");
193
194 // One adoption per arm.
195 this->set_armed(false);
196}
197
198} // namespace home_io_control
199} // 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_...
Hub-layer log tag and log/format helpers shared by the hub and its collaborators.
constexpr const char * TAG
Shared log tag for hub-level messages.
Definition log_helpers.h:31
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 format_key_hex(const uint8_t key[AES_KEY_SIZE])
Format a 16-byte key as an uppercase, unseparated hex string for display.
Definition log_helpers.h:61
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...
std::string format_device_type_for_yaml(DeviceType type)
Build the YAML value for a device's io_device_type key.
@ VERIFIED
frame.has_mac was true and the MAC verified under the recovered key.
@ NOT_PRESENT
frame.has_mac was false; nothing to verify.
@ FAILED
frame.has_mac was true and the MAC did NOT verify under the recovered key.
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().
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:93
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:97
Recovered controller identity from a decoded CMD_ONEWAY_ADD_CONTROLLER (0x30) frame.
uint8_t manufacturer
Manufacturer ID byte from the payload (man_id).
OneWayMacStatus mac_status
MAC-verification outcome; see OneWayMacStatus.
uint8_t system_key[AES_KEY_SIZE]
Recovered network system key (crypto::crypt_1w_key() output).
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.