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
25bool is_rx_kind(PairingTelemetryEventKind kind) {
27}
28
29/// A seeded pre-window sighting (PairingTelemetry::record_recent_one_way_sighting()) rather than a
30/// live in-window capture — see PairingTelemetryEvent::aux's doc comment. Excluded from
31/// channel_busy specifically: that advice is about *live* channel contention during this attempt,
32/// and a sighting from up to PAIRING_RECENT_ONE_WAY_SIGHTING_WINDOW_MS earlier carries no evidence
33/// about that. Still eligible for ONE_WAY_PAIRING_TRAFFIC, which is about the gesture itself.
34bool is_seeded_sighting(const PairingTelemetryEvent &event) {
35 return event.kind == PairingTelemetryEventKind::RX && event.aux == 1;
36}
37
38/// 1W pairing traffic (issue #27 case). Thin wrapper over the shared predicate (hub_decisions.h)
39/// so the hub's passive RX path can classify the same frame shape without duplicating it — see
40/// decisions::is_one_way_pairing_gesture()'s doc comment for the frame shape itself.
41bool is_oneway_pairing_frame(const PairingTelemetryEvent &event) {
42 return decisions::is_one_way_pairing_gesture(event.oneway, event.dst_node, event.cmd);
43}
44
45/// Count how many *live* RX/RX_REJECT events share `src_node` — excludes a seeded pre-window
46/// sighting, which is not evidence of live channel contention (see is_seeded_sighting()).
47uint8_t count_occurrences_of_source(const PairingTelemetryEvent *events, uint8_t event_count,
48 const uint8_t src_node[NODE_ID_SIZE]) {
49 uint8_t occurrences = 0;
50 for (uint8_t i = 0; i < event_count; i++) {
51 if (is_rx_kind(events[i].kind) && !is_seeded_sighting(events[i]) &&
52 memcmp(events[i].src_node, src_node, NODE_ID_SIZE) == 0)
53 occurrences++;
54 }
55 return occurrences;
56}
57
58void append_one_way_pairing_advice(const PairingTelemetry &telemetry, const PairingTelemetryEvent *events,
59 uint8_t event_count, PairingAdvice out[], uint8_t &count) {
60 // This advice's message tells the user no device answered their PROG gesture. On a successful
61 // pairing that is simply false, and a PROG press is the normal prelude to one — so the advice
62 // fired on most successes, warning about a failure that did not happen. The sibling advices are
63 // not gated: channel_busy stays true on a success, and foreign_controller matters *more* there
64 // (it is the case where the device that answered may not have been addressing us).
65 if (telemetry.outcome() == PairingOutcome::PAIRED)
66 return;
67 for (uint8_t i = 0; i < event_count && count < PAIRING_ADVICE_MAX; i++) {
68 const PairingTelemetryEvent &event = events[i];
69 if (!is_rx_kind(event.kind) || !is_oneway_pairing_frame(event))
70 continue;
71 PairingAdvice &advice = out[count++];
73 memcpy(advice.subject_node, event.src_node, NODE_ID_SIZE);
74 advice.rssi = event.rssi;
75 return;
76 }
77}
78
79void append_channel_busy_advice(const PairingTelemetry &telemetry, const PairingTelemetryEvent *events,
80 uint8_t event_count, PairingAdvice out[], uint8_t &count) {
81 if (count >= PAIRING_ADVICE_MAX || telemetry.lbt_retries() < LBT_MAX_RETRIES)
82 return;
83 for (uint8_t i = 0; i < event_count; i++) {
84 const PairingTelemetryEvent &event = events[i];
85 if (!is_rx_kind(event.kind) || is_seeded_sighting(event))
86 continue;
87 if (count_occurrences_of_source(events, event_count, event.src_node) < 2)
88 continue;
89 PairingAdvice &advice = out[count++];
91 memcpy(advice.subject_node, event.src_node, NODE_ID_SIZE);
92 advice.rssi = event.rssi;
93 return;
94 }
95}
96
97void append_foreign_controller_advice(const uint8_t own_node_id[NODE_ID_SIZE], const PairingTelemetryEvent *events,
98 uint8_t event_count, PairingAdvice out[], uint8_t &count) {
99 static const uint8_t ZERO_NODE_ID[NODE_ID_SIZE] = {0, 0, 0};
100 // An all-zero own_node_id means the controller's node ID isn't provisioned yet — every
101 // discovery response would look "foreign" by the memcmp below, so skip the check entirely
102 // rather than emit a false advisory during first-time setup.
103 if (memcmp(own_node_id, ZERO_NODE_ID, NODE_ID_SIZE) == 0)
104 return;
105 for (uint8_t i = 0; i < event_count && count < PAIRING_ADVICE_MAX; i++) {
106 const PairingTelemetryEvent &event = events[i];
107 if (!is_rx_kind(event.kind) || event.cmd != CMD_DISCOVER_RESP)
108 continue;
109 if (memcmp(event.dst_node, own_node_id, NODE_ID_SIZE) == 0)
110 continue;
111 PairingAdvice &advice = out[count++];
113 memcpy(advice.subject_node, event.src_node, NODE_ID_SIZE);
114 advice.rssi = event.rssi;
115 return;
116 }
117}
118
119} // namespace
120
121uint8_t analyze_pairing_telemetry(const PairingTelemetry &telemetry, const uint8_t own_node_id[NODE_ID_SIZE],
122 PairingAdvice out[PAIRING_ADVICE_MAX]) {
123 uint8_t count = 0;
124 const PairingTelemetryEvent *events = telemetry.events();
125 const uint8_t event_count = telemetry.event_count();
126
127 append_one_way_pairing_advice(telemetry, events, event_count, out, count);
128 append_channel_busy_advice(telemetry, events, event_count, out, count);
129 append_foreign_controller_advice(own_node_id, events, event_count, out, count);
130
131 if (count == 0 && telemetry.heard_count() == 0 && count < PAIRING_ADVICE_MAX) {
132 out[count++].code = PairingAdviceCode::RF_SILENT;
133 }
134
135 return count;
136}
137
139 switch (code) {
141 return "1w_traffic";
143 return "channel_busy";
145 return "foreign_controller";
147 return "rf_silent";
149 default:
150 return "none";
151 }
152}
153
154std::string pairing_advice_message(const PairingAdvice &advice) {
155 char buf[ADVICE_MESSAGE_BUFFER_SIZE];
156 switch (advice.code) {
158 // Deliberately no longer ends on "and retry", which reads as "press PROG again now": on a remote
159 // already registered to the device, a PROG press is also the add/remove toggle, so a second one
160 // can close the window the first opened. "Can", not "does" — this fits the field reports we have
161 // rather than being proven, and one press per attempt costs nothing either way.
162 snprintf(buf, sizeof(buf),
163 "A 1W remote (src %s) made a PROG gesture, but no device answered. If a 2W hub already "
164 "controls the device, use key extraction. Otherwise hold PROG ~2 s on a remote registered to "
165 "it, release at the first jog, and keep only that device in pairing mode. Use one PROG press "
166 "per attempt - a second press can close the window the first one opened. A reset alone is "
167 "not a pairing gesture.",
168 node_id_to_string(advice.subject_node).c_str());
169 return std::string(buf);
171 snprintf(buf, sizeof(buf),
172 "Channel busy (device %s beaconing at %d dBm); discovery TX was delayed by listen-before-talk.",
173 node_id_to_string(advice.subject_node).c_str(), advice.rssi);
174 return std::string(buf);
176 snprintf(buf, sizeof(buf), "Another controller is pairing device %s right now.",
177 node_id_to_string(advice.subject_node).c_str());
178 return std::string(buf);
180 return "Nothing heard on any channel during the whole discovery window. If this log shows other "
181 "IO-Homecontrol frames being received, the radio works and the device did not answer; otherwise "
182 "check the antenna/RF path.";
184 default:
185 return "";
186 }
187}
188
189} // namespace advisor
190} // namespace home_io_control
191} // 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.
@ RF_SILENT
Nothing at all was heard during the whole window.
@ FOREIGN_CONTROLLER_PAIRING
A discovery response was seen addressed to another controller.
@ ONE_WAY_PAIRING_TRAFFIC
A 1W remote's PROG gesture was overheard.
@ 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...
@ PAIRED
The key exchange completed and the device is registered.
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.
int16_t rssi
RSSI associated with the observation; zero if not applicable.