Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
pairing_advisor.cpp
Go to the documentation of this file.
1/// @file pairing_advisor.cpp
2/// @brief Read-only advisor that turns PairingTelemetry into actionable diagnostics.
3/// @ingroup hioc_hub
4
5#include "pairing_advisor.h"
6
7#include "hub_decisions.h"
8#include "proto_constants.h"
9#include "proto_timing.h"
10
11#include <cstdio>
12#include <cstring>
13
14namespace esphome {
15namespace home_io_control {
16namespace advisor {
17
18namespace {
19
20/// Buffer size for the rendered advice message. Sized for the longest message plus its node ID,
21/// and kept under ESPHome's 512-byte log line so the WARN line isn't truncated on hardware — the
22/// longest message plus the "Pairing advisor: " prefix comes to ~400 bytes on the wire.
23constexpr size_t ADVICE_MESSAGE_BUFFER_SIZE = 416;
24/// Buffer for the " answered our key with 0xNN NAME;" fragment: the longest result name is 32
25/// characters, plus 29 characters of fixed text and the terminator, with room to spare.
26constexpr size_t RESULT_CODE_TEXT_BUFFER_SIZE = 72;
27
28bool is_rx_kind(PairingTelemetryEventKind kind) {
30}
31
32/// A seeded pre-window sighting (PairingTelemetry::record_recent_one_way_sighting()) rather than a
33/// live in-window capture — see PairingTelemetryEvent::aux's doc comment. Excluded from
34/// channel_busy specifically: that advice is about *live* channel contention during this attempt,
35/// and a sighting from up to PAIRING_RECENT_ONE_WAY_SIGHTING_WINDOW_MS earlier carries no evidence
36/// about that. Still eligible for ONE_WAY_PAIRING_TRAFFIC, which is about the gesture itself.
37bool is_seeded_sighting(const PairingTelemetryEvent &event) {
38 return event.kind == PairingTelemetryEventKind::RX && event.aux == 1;
39}
40
41/// 1W pairing traffic (issue #27 case). Thin wrapper over the shared predicate (hub_decisions.h)
42/// so the hub's passive RX path can classify the same frame shape without duplicating it — see
43/// decisions::is_one_way_pairing_gesture()'s doc comment for the frame shape itself.
44bool is_oneway_pairing_frame(const PairingTelemetryEvent &event) {
45 return decisions::is_one_way_pairing_gesture(event.oneway, event.dst_node, event.cmd);
46}
47
48/// Count how many *live* RX/RX_REJECT events share `src_node` — excludes a seeded pre-window
49/// sighting, which is not evidence of live channel contention (see is_seeded_sighting()).
50uint8_t count_occurrences_of_source(const PairingTelemetryEvent *events, uint8_t event_count,
51 const uint8_t src_node[NODE_ID_SIZE]) {
52 uint8_t occurrences = 0;
53 for (uint8_t i = 0; i < event_count; i++) {
54 if (is_rx_kind(events[i].kind) && !is_seeded_sighting(events[i]) &&
55 memcmp(events[i].src_node, src_node, NODE_ID_SIZE) == 0)
56 occurrences++;
57 }
58 return occurrences;
59}
60
61void append_one_way_pairing_advice(const PairingTelemetry &telemetry, const PairingTelemetryEvent *events,
62 uint8_t event_count, PairingAdvice out[], uint8_t &count) {
63 // This advice's message tells the user no device answered their PROG gesture. On a successful
64 // pairing that is simply false, and a PROG press is the normal prelude to one — so the advice
65 // fired on most successes, warning about a failure that did not happen. The sibling advices are
66 // not gated: channel_busy stays true on a success, and foreign_controller matters *more* there
67 // (it is the case where the device that answered may not have been addressing us).
68 if (telemetry.outcome() == PairingOutcome::PAIRED)
69 return;
70 for (uint8_t i = 0; i < event_count && count < PAIRING_ADVICE_MAX; i++) {
71 const PairingTelemetryEvent &event = events[i];
72 if (!is_rx_kind(event.kind) || !is_oneway_pairing_frame(event))
73 continue;
74 PairingAdvice &advice = out[count++];
76 memcpy(advice.subject_node, event.src_node, NODE_ID_SIZE);
77 advice.rssi = event.rssi;
78 return;
79 }
80}
81
82void append_channel_busy_advice(const PairingTelemetry &telemetry, const PairingTelemetryEvent *events,
83 uint8_t event_count, PairingAdvice out[], uint8_t &count) {
84 if (count >= PAIRING_ADVICE_MAX || telemetry.lbt_retries() < LBT_MAX_RETRIES)
85 return;
86 for (uint8_t i = 0; i < event_count; i++) {
87 const PairingTelemetryEvent &event = events[i];
88 if (!is_rx_kind(event.kind) || is_seeded_sighting(event))
89 continue;
90 if (count_occurrences_of_source(events, event_count, event.src_node) < 2)
91 continue;
92 PairingAdvice &advice = out[count++];
94 memcpy(advice.subject_node, event.src_node, NODE_ID_SIZE);
95 advice.rssi = event.rssi;
96 return;
97 }
98}
99
100void append_foreign_controller_advice(const uint8_t own_node_id[NODE_ID_SIZE], const PairingTelemetryEvent *events,
101 uint8_t event_count, PairingAdvice out[], uint8_t &count) {
102 static const uint8_t ZERO_NODE_ID[NODE_ID_SIZE] = {0, 0, 0};
103 // An all-zero own_node_id means the controller's node ID isn't provisioned yet — every
104 // discovery response would look "foreign" by the memcmp below, so skip the check entirely
105 // rather than emit a false advisory during first-time setup.
106 if (memcmp(own_node_id, ZERO_NODE_ID, NODE_ID_SIZE) == 0)
107 return;
108 for (uint8_t i = 0; i < event_count && count < PAIRING_ADVICE_MAX; i++) {
109 const PairingTelemetryEvent &event = events[i];
110 if (!is_rx_kind(event.kind) || event.cmd != CMD_DISCOVER_RESP)
111 continue;
112 if (memcmp(event.dst_node, own_node_id, NODE_ID_SIZE) == 0)
113 continue;
114 PairingAdvice &advice = out[count++];
116 memcpy(advice.subject_node, event.src_node, NODE_ID_SIZE);
117 advice.rssi = event.rssi;
118 return;
119 }
120}
121
122/// Key-step advice: only for attempts that did not pair and got as far as discovering a device.
123/// A pull gets its own three codes: a proven key received (how to use it), refused (the device does
124/// not hand out its key), or answered with a key it did not prove. For a push, "command unknown", or a failed push to a
125/// device that answered discovery as a remote controller, points at the pull direction; any other refusal is reported
126/// with its code.
127void append_key_step_advice(const PairingTelemetry &telemetry, PairingAdvice out[], uint8_t &count) {
128 if (count >= PAIRING_ADVICE_MAX || !telemetry.has_discovered_device())
129 return;
130 // Every key-step advice is about the discovered device and carries its refusal code, if any.
131 auto add = [&](PairingAdviceCode code) -> PairingAdvice & {
132 PairingAdvice &advice = out[count++];
133 advice.code = code;
134 memcpy(advice.subject_node, telemetry.discovered_node_id(), NODE_ID_SIZE);
135 advice.has_result_code = telemetry.has_key_refusal();
136 advice.result_code = telemetry.key_refusal_code();
137 return advice;
138 };
139 const PairingOutcome outcome = telemetry.outcome();
140 if (outcome == PairingOutcome::KEY_RECEIVED) {
142 return;
143 }
144 if (outcome == PairingOutcome::HUB_KEY_RETURNED) {
146 return;
147 }
149 return;
150 if (telemetry.key_pull_attempted()) {
151 if (telemetry.has_key_refusal()) {
153 } else if (telemetry.key_pull_unproven()) {
155 }
156 return;
157 }
158 // This attempt pushed: "command unknown", or a failed push to a device that answered discovery as a
159 // remote controller, points at the pull direction.
160 const bool as_controller = telemetry.discovered_node_type() == DeviceType::REMOTE_CONTROLLER;
161 const bool command_unknown = telemetry.has_key_refusal() && telemetry.key_refusal_code() == RESULT_COMMAND_UNKNOWN;
162 if (command_unknown || as_controller) {
163 add(PairingAdviceCode::KEY_PUSH_NOT_ACCEPTED).answered_as_controller = as_controller;
164 } else if (telemetry.has_key_refusal()) {
166 }
167}
168
169} // namespace
170
171uint8_t analyze_pairing_telemetry(const PairingTelemetry &telemetry, const uint8_t own_node_id[NODE_ID_SIZE],
172 PairingAdvice out[PAIRING_ADVICE_MAX]) {
173 uint8_t count = 0;
174 const PairingTelemetryEvent *events = telemetry.events();
175 const uint8_t event_count = telemetry.event_count();
176
177 append_one_way_pairing_advice(telemetry, events, event_count, out, count);
178 append_channel_busy_advice(telemetry, events, event_count, out, count);
179 append_foreign_controller_advice(own_node_id, events, event_count, out, count);
180 append_key_step_advice(telemetry, out, count);
181
182 if (count == 0 && telemetry.heard_count() == 0 && count < PAIRING_ADVICE_MAX) {
183 out[count++].code = PairingAdviceCode::RF_SILENT;
184 }
185
186 return count;
187}
188
190 switch (code) {
192 return "1w_traffic";
194 return "channel_busy";
196 return "foreign_controller";
198 return "rf_silent";
200 return "key_push_not_accepted";
202 return "key_refused";
204 return "key_pull_not_offered";
206 return "key_pull_unproven";
208 return "pulled_key_received";
210 return "hub_key_no_responder";
212 default:
213 return "none";
214 }
215}
216
217std::string pairing_advice_message(const PairingAdvice &advice) {
218 char buf[ADVICE_MESSAGE_BUFFER_SIZE];
219 switch (advice.code) {
221 // Deliberately no longer ends on "and retry", which reads as "press PROG again now": on a remote
222 // already registered to the device, a PROG press is also the add/remove toggle, so a second one
223 // can close the window the first opened. "Can", not "does" — this fits the field reports we have
224 // rather than being proven, and one press per attempt costs nothing either way.
225 snprintf(buf, sizeof(buf),
226 "A 1W remote (src %s) made a PROG gesture, but no device answered. If a 2W hub already "
227 "controls the device, use key extraction. Otherwise hold PROG ~2 s on a remote registered to "
228 "it, release at the first jog, and keep only that device in pairing mode. Use one PROG press "
229 "per attempt - a second press can close the window the first one opened. A reset alone is "
230 "not a pairing gesture.",
231 node_id_to_string(advice.subject_node).c_str());
232 return std::string(buf);
234 snprintf(buf, sizeof(buf),
235 "Channel busy (device %s beaconing at %d dBm); discovery TX was delayed by listen-before-talk.",
236 node_id_to_string(advice.subject_node).c_str(), advice.rssi);
237 return std::string(buf);
239 snprintf(buf, sizeof(buf), "Another controller is pairing device %s right now.",
240 node_id_to_string(advice.subject_node).c_str());
241 return std::string(buf);
243 return "Nothing heard on any channel during the whole discovery window. If this log shows other "
244 "IO-Homecontrol frames being received, the radio works and the device did not answer; otherwise "
245 "check the antenna/RF path.";
247 char code_text[RESULT_CODE_TEXT_BUFFER_SIZE] = "";
248 if (advice.has_result_code) {
249 snprintf(code_text, sizeof(code_text), " answered our key with 0x%02X %s;", advice.result_code,
251 }
252 snprintf(buf, sizeof(buf),
253 "Device %s does not take a pushed key:%s%s It may hand over its own key instead: set "
254 "`pairing_key_transfer: pull` (or `auto`, the default, which pulls from a device that answers as a "
255 "remote controller) under `tuning:`, put the device back into pairing mode and pair again.",
256 node_id_to_string(advice.subject_node).c_str(), code_text,
257 advice.answered_as_controller ? " it answered discovery as a remote controller (node type 1023)." : "");
258 return std::string(buf);
259 }
261 snprintf(buf, sizeof(buf),
262 "Device %s refused the key transfer with 0x%02X %s (%s). If it was paired before, run Scan Paired "
263 "Devices before retrying: it may already hold a key.",
264 node_id_to_string(advice.subject_node).c_str(), advice.result_code,
266 return std::string(buf);
268 snprintf(buf, sizeof(buf),
269 "Device %s does not hand out its key: it answered the key request with 0x%02X %s. Set "
270 "`pairing_key_transfer: push` to force the usual pairing, put the device back into pairing mode and "
271 "pair again.",
272 node_id_to_string(advice.subject_node).c_str(), advice.result_code,
274 return std::string(buf);
276 snprintf(buf, sizeof(buf),
277 "Device %s sent a key, but did not prove it holds it, so it is not printed. The 'Key pull "
278 "check' line above contains no key and is safe to share.",
279 node_id_to_string(advice.subject_node).c_str());
280 return std::string(buf);
282 snprintf(buf, sizeof(buf),
283 "Device %s handed over its key (printed above), but a roll-call under it found no device that holds "
284 "it. Put the key into the `system_key:` of the entity that controls the device, flash, then run Scan "
285 "Paired Devices: the log prints its config block. Each device of this kind keeps its own key, so "
286 "pull each one and compare the key check values.",
287 node_id_to_string(advice.subject_node).c_str());
288 return std::string(buf);
290 snprintf(buf, sizeof(buf),
291 "Device %s already holds this hub's key, but no device that holds it answered. The node that "
292 "pairs is not always the one to control (an Atlantic radiator's heater sits at the next address). "
293 "Press Scan Paired Devices, and again if nothing answers: it prints the YAML block of each device "
294 "that holds the key.",
295 node_id_to_string(advice.subject_node).c_str());
296 return std::string(buf);
298 default:
299 return "";
300 }
301}
302
303} // namespace advisor
304} // namespace home_io_control
305} // namespace esphome
Fixed-size per-attempt telemetry recorder for the pairing flow.
const PairingTelemetryEvent * events() const
Pure transition helpers for hub-owned exchange and pairing frame decisions.
std::string pairing_advice_message(const PairingAdvice &advice)
const char * pairing_advice_code_name(PairingAdviceCode code)
PairingAdviceCode
Actionable diagnosis derived from a completed pairing attempt's telemetry.
@ PULLED_KEY_RECEIVED
The pull got a different key that the device proved, and a roll-call under it found no registrable de...
@ RF_SILENT
Nothing at all was heard during the whole window.
@ KEY_PULL_NOT_OFFERED
pairing_key_transfer: pull was refused with CMD_ERROR_RESP: the device does not hand out its key.
@ KEY_PULL_UNPROVEN
The device answered the pull with a key, but sent no 0x3D that verifies under it; the key is not prin...
@ KEY_PUSH_NOT_ACCEPTED
The device does not take a pushed key: it answered 0x32 with RESULT_COMMAND_UNKNOWN,...
@ KEY_TRANSFER_REFUSED
The device answered the key transfer with any other CMD_ERROR_RESP.
@ FOREIGN_CONTROLLER_PAIRING
A discovery response was seen addressed to another controller.
@ ONE_WAY_PAIRING_TRAFFIC
A 1W remote's PROG gesture was overheard.
@ HUB_KEY_NO_RESPONDER
The pull returned the hub's own key from a node no platform drives, and no device that holds it answe...
@ CHANNEL_BUSY_LBT_DELAYED
LBT retries were consumed while a source kept repeating; the reported subject_node is a best-effort c...
uint8_t analyze_pairing_telemetry(const PairingTelemetry &telemetry, const uint8_t own_node_id[NODE_ID_SIZE], PairingAdvice out[PAIRING_ADVICE_MAX])
Inspect a completed pairing attempt's telemetry and produce actionable advice.
bool is_one_way_pairing_gesture(bool oneway, const uint8_t dst[NODE_ID_SIZE], uint8_t cmd)
True if a frame's shape matches a 1W remote's pairing gesture (issue #27/#65): CTRL0 1W bit set,...
PairingTelemetryEventKind
Kind of a recorded telemetry event.
@ RX_REJECT
We received a frame that parsed but was rejected (wrong source, wrong command, etc....
@ RX
We received and accepted a frame for the current wait (including a seeded pre-window sighting — see P...
@ REMOTE_CONTROLLER
Remote controller (all ten type bits set, packed bytes FF C0).
PairingOutcome
Final disposition of a pairing attempt, used by the result sensor string.
@ HUB_KEY_RETURNED
A pull returned the hub's own key from a node no platform drives (an Atlantic radiator's pairing node...
@ KEY_EXCHANGE_FAILED
Discovery succeeded but the key exchange did not complete.
@ PAIRED
The key exchange completed and the device is registered.
@ KEY_TRANSFER_REFUSED
The device answered our key transfer with CMD_ERROR_RESP; the code is in PairingTelemetry::key_refusa...
@ KEY_RECEIVED
pairing_key_transfer: pull received a key that differs from ours and the device
const char * command_result_description(uint8_t result)
Return a human-readable explanation for a CMD_ERROR_RESP result code.
const char * command_result_name(uint8_t result)
Return a stable symbolic name for a CMD_ERROR_RESP result code.
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.
Read-only advisor that turns PairingTelemetry into actionable diagnostics.
IO-Homecontrol command IDs, result codes and protocol enumerations.
Physical-layer radio and timing parameters for the IO-Homecontrol protocol.
uint8_t cmd
Frame command byte (TX/RX/RX_REJECT); unused otherwise.
PairingTelemetryEventKind kind
What happened.
bool oneway
CTRL0 1W-protocol bit (RX/RX_REJECT only); false otherwise.
uint8_t dst_node[NODE_ID_SIZE]
Destination node ID (RX/RX_REJECT only); zero otherwise.
uint8_t src_node[NODE_ID_SIZE]
Sender node ID (RX/RX_REJECT only); zero otherwise.
One piece of advice, with the node/RSSI it pertains to (if any).
uint8_t subject_node[NODE_ID_SIZE]
Node the advice is about; zero if not applicable.
bool has_result_code
True when result_code carries the device's CMD_ERROR_RESP byte.
bool answered_as_controller
True when the device answered discovery as a remote controller.
uint8_t result_code
CMD_ERROR_RESP byte the advice is about.
int16_t rssi
RSSI associated with the observation; zero if not applicable.