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 "proto_constants.h"
8#include "proto_timing.h"
9
10#include <cstdio>
11#include <cstring>
12
13namespace esphome {
14namespace home_io_control {
15namespace advisor {
16
17namespace {
18
19/// Buffer size for the rendered advice message.
20constexpr size_t ADVICE_MESSAGE_BUFFER_SIZE = 224;
21
22bool is_rx_kind(PairingTelemetryEventKind kind) {
24}
25
26/// 1W pairing traffic (issue #27 case): CTRL0 1W bit set, addressed to the 1W broadcast address
27/// 0x00003F, with one of the three command bytes observed in the field capture — 0x20
28/// (WRITE_PRIVATE), 0x39 (1W remove), or 0x2E (alternate discovery, 1W-flagged).
29bool is_oneway_pairing_frame(const PairingTelemetryEvent &event) {
30 if (!event.oneway)
31 return false;
32 if (memcmp(event.dst_node, BROADCAST_DISCOVER_ALT, NODE_ID_SIZE) != 0)
33 return false;
34 return event.cmd == CMD_WRITE_PRIVATE || event.cmd == CMD_ONEWAY_REMOVE || event.cmd == CMD_DISCOVER_ALT_REQ;
35}
36
37/// Count how many RX/RX_REJECT events share `src_node`.
38uint8_t count_occurrences_of_source(const PairingTelemetryEvent *events, uint8_t event_count,
39 const uint8_t src_node[NODE_ID_SIZE]) {
40 uint8_t occurrences = 0;
41 for (uint8_t i = 0; i < event_count; i++) {
42 if (is_rx_kind(events[i].kind) && memcmp(events[i].src_node, src_node, NODE_ID_SIZE) == 0)
43 occurrences++;
44 }
45 return occurrences;
46}
47
48void append_one_way_pairing_advice(const PairingTelemetryEvent *events, uint8_t event_count, PairingAdvice out[],
49 uint8_t &count) {
50 for (uint8_t i = 0; i < event_count && count < PAIRING_ADVICE_MAX; i++) {
51 const PairingTelemetryEvent &event = events[i];
52 if (!is_rx_kind(event.kind) || !is_oneway_pairing_frame(event))
53 continue;
54 PairingAdvice &advice = out[count++];
56 memcpy(advice.subject_node, event.src_node, NODE_ID_SIZE);
57 advice.rssi = event.rssi;
58 return;
59 }
60}
61
62void append_channel_busy_advice(const PairingTelemetry &telemetry, const PairingTelemetryEvent *events,
63 uint8_t event_count, PairingAdvice out[], uint8_t &count) {
64 if (count >= PAIRING_ADVICE_MAX || telemetry.lbt_retries() < LBT_MAX_RETRIES)
65 return;
66 for (uint8_t i = 0; i < event_count; i++) {
67 const PairingTelemetryEvent &event = events[i];
68 if (!is_rx_kind(event.kind))
69 continue;
70 if (count_occurrences_of_source(events, event_count, event.src_node) < 2)
71 continue;
72 PairingAdvice &advice = out[count++];
74 memcpy(advice.subject_node, event.src_node, NODE_ID_SIZE);
75 advice.rssi = event.rssi;
76 return;
77 }
78}
79
80void append_foreign_controller_advice(const uint8_t own_node_id[NODE_ID_SIZE], const PairingTelemetryEvent *events,
81 uint8_t event_count, PairingAdvice out[], uint8_t &count) {
82 static const uint8_t ZERO_NODE_ID[NODE_ID_SIZE] = {0, 0, 0};
83 // An all-zero own_node_id means the controller's node ID isn't provisioned yet — every
84 // discovery response would look "foreign" by the memcmp below, so skip the check entirely
85 // rather than emit a false advisory during first-time setup.
86 if (memcmp(own_node_id, ZERO_NODE_ID, NODE_ID_SIZE) == 0)
87 return;
88 for (uint8_t i = 0; i < event_count && count < PAIRING_ADVICE_MAX; i++) {
89 const PairingTelemetryEvent &event = events[i];
90 if (!is_rx_kind(event.kind) || event.cmd != CMD_DISCOVER_RESP)
91 continue;
92 if (memcmp(event.dst_node, own_node_id, NODE_ID_SIZE) == 0)
93 continue;
94 PairingAdvice &advice = out[count++];
96 memcpy(advice.subject_node, event.src_node, NODE_ID_SIZE);
97 advice.rssi = event.rssi;
98 return;
99 }
100}
101
102} // namespace
103
104uint8_t analyze_pairing_telemetry(const PairingTelemetry &telemetry, const uint8_t own_node_id[NODE_ID_SIZE],
106 uint8_t count = 0;
107 const PairingTelemetryEvent *events = telemetry.events();
108 const uint8_t event_count = telemetry.event_count();
109
110 append_one_way_pairing_advice(events, event_count, out, count);
111 append_channel_busy_advice(telemetry, events, event_count, out, count);
112 append_foreign_controller_advice(own_node_id, events, event_count, out, count);
113
114 if (count == 0 && telemetry.heard_count() == 0 && count < PAIRING_ADVICE_MAX) {
115 out[count++].code = PairingAdviceCode::RF_SILENT;
116 }
117
118 return count;
119}
120
122 switch (code) {
124 return "1w_traffic";
126 return "channel_busy";
128 return "foreign_controller";
130 return "rf_silent";
132 default:
133 return "none";
134 }
135}
136
137std::string pairing_advice_message(const PairingAdvice &advice) {
138 char buf[ADVICE_MESSAGE_BUFFER_SIZE];
139 switch (advice.code) {
141 snprintf(buf, sizeof(buf),
142 "A 1W remote (src %s) is performing 1W pairing. The motor is NOT in 2W learning mode - a PROG "
143 "press on a 1W remote does not enable 2W discovery. Use the Double Power Cut procedure to force "
144 "2W learning mode.",
145 node_id_to_string(advice.subject_node).c_str());
146 return std::string(buf);
148 snprintf(buf, sizeof(buf),
149 "Channel busy (device %s beaconing at %d dBm); discovery TX was delayed by listen-before-talk.",
150 node_id_to_string(advice.subject_node).c_str(), advice.rssi);
151 return std::string(buf);
153 snprintf(buf, sizeof(buf), "Another controller is pairing device %s right now.",
154 node_id_to_string(advice.subject_node).c_str());
155 return std::string(buf);
157 return "Nothing heard on any channel during the whole discovery window - check the antenna/RF path before "
158 "assuming the device isn't in pairing mode.";
160 default:
161 return "";
162 }
163}
164
165} // namespace advisor
166} // namespace home_io_control
167} // namespace esphome
Fixed-size per-attempt telemetry recorder for the pairing flow.
const PairingTelemetryEvent * events() const
std::string pairing_advice_message(const PairingAdvice &advice)
static constexpr uint8_t PAIRING_ADVICE_MAX
Maximum number of advice entries a single attempt can produce (one slot per non-NONE code).
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 is pairing to the target — not 2W learning mode.
@ 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.
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
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.
static constexpr uint8_t LBT_MAX_RETRIES
Max carrier-sense attempts before TX anyway.
static constexpr uint8_t CMD_DISCOVER_ALT_REQ
Alternate discovery.
static constexpr uint8_t CMD_WRITE_PRIVATE
Write private register (climate/heating devices).
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.
static constexpr uint8_t CMD_ONEWAY_REMOVE
1W "remove controller" (un-pair a 1W remote from a device); same payload shape as 0x2E.
static constexpr uint8_t CMD_DISCOVER_RESP
Device responds with its ID and type.
static constexpr uint8_t BROADCAST_DISCOVER_ALT[NODE_ID_SIZE]
Alternate discovery / 1W broadcast address (0x00003F).
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.