Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
pairing_telemetry.cpp
Go to the documentation of this file.
1/// @file pairing_telemetry.cpp
2/// @brief Structured per-attempt telemetry recorder implementation.
3/// @ingroup hioc_hub
4
5#include "pairing_telemetry.h"
6
7#include "esphome/core/hal.h"
8#include "esphome/core/log.h"
9
10#include <cinttypes>
11#include <cstdint>
12#include <cstdio>
13#include <cstring>
14
15namespace esphome {
16namespace home_io_control {
17
18namespace {
19
20const char *const TAG = "home_io_control.pairing";
21
22/// Buffer size for the frozen `v1;` result sensor string.
23/// Sized for the worst case, not the common case: outcome=invalid_response (17) +
24/// phase=wait_discover_response (25) + type=heating_temperature_interface (29) + all three
25/// advice codes ("1w_traffic,channel_busy,foreign_controller", 44) + saturated counters
26/// (attempts=255, lbt=255, dur_ms=4294967295, heard=65535) measures ~189 chars; 256 leaves
27/// headroom for future advice codes without silently truncating this frozen contract.
28constexpr size_t RESULT_SENSOR_STRING_BUFFER_SIZE = 256;
29
30/// Saturation ceiling for the uint8_t retry/attempt counters — a real pairing attempt never
31/// gets close to 255 of either, this just guards against wraparound.
32constexpr uint8_t COUNTER_SATURATION_MAX = 0xFF;
33
34const char *outcome_name(PairingOutcome outcome) {
35 switch (outcome) {
37 return "paired";
39 return "no_response";
41 return "invalid_response";
43 return "key_exchange_failed";
45 default:
46 return "none";
47 }
48}
49
50// Only referenced from ESP_LOGI() calls in log_summary(); ESP_LOGI is a no-op in host unit
51// test builds, so those call sites (and this function) vanish entirely under -Wunused-function
52// there even though it's genuinely used in firmware builds.
53[[maybe_unused]] const char *event_kind_name(PairingTelemetryEventKind kind) {
54 switch (kind) {
56 return "tx";
58 return "rx";
60 return "rx_reject";
62 return "lbt_defer";
64 default:
65 return "phase";
66 }
67}
68
69} // namespace
70
72 this->event_count_ = 0;
73 this->heard_count_ = 0;
74 this->hop_count_ = 0;
75 this->truncated_ = false;
76 this->start_ms_ = millis();
77 this->end_ms_ = this->start_ms_;
78 this->ended_ = false;
79 this->phase_ = pairing::PairingState::IDLE;
80 this->outcome_ = PairingOutcome::NONE;
81 this->discovery_attempts_ = 0;
82 this->lbt_retries_ = 0;
83 this->has_paired_device_ = false;
84 memset(this->paired_node_id_, 0, sizeof(this->paired_node_id_));
85 this->paired_device_type_ = DeviceType::UNKNOWN;
86 this->advice_codes_.clear();
87}
88
89void PairingTelemetry::record_(PairingTelemetryEventKind kind, uint8_t cmd, const uint8_t *src, const uint8_t *dst,
90 int16_t rssi, uint8_t aux, bool oneway) {
92 if (this->heard_count_ < UINT16_MAX)
93 this->heard_count_++;
94 }
95 if (this->event_count_ >= PAIRING_TELEMETRY_MAX_EVENTS) {
96 this->truncated_ = true;
97 return;
98 }
99 PairingTelemetryEvent &event = this->events_[this->event_count_++];
100 event.millis_offset = millis() - this->start_ms_;
101 event.kind = kind;
102 event.cmd = cmd;
103 if (src != nullptr) {
104 memcpy(event.src_node, src, NODE_ID_SIZE);
105 } else {
106 memset(event.src_node, 0, NODE_ID_SIZE);
107 }
108 if (dst != nullptr) {
109 memcpy(event.dst_node, dst, NODE_ID_SIZE);
110 } else {
111 memset(event.dst_node, 0, NODE_ID_SIZE);
112 }
113 event.rssi = rssi;
114 event.aux = aux;
115 event.oneway = oneway;
116}
117
119 this->record_(PairingTelemetryEventKind::TX, cmd, nullptr, nullptr, 0, 0, false);
120}
121
122void PairingTelemetry::record_rx(const IoFrame &frame, int16_t rssi) {
123 this->record_(PairingTelemetryEventKind::RX, frame.cmd, frame.src, frame.dst, rssi, 0,
124 (frame.ctrl0 & CTRL0_PROTOCOL_1W) != 0);
125}
126
127void PairingTelemetry::record_rx_reject(const IoFrame &frame, int16_t rssi) {
128 this->record_(PairingTelemetryEventKind::RX_REJECT, frame.cmd, frame.src, frame.dst, rssi, 0,
129 (frame.ctrl0 & CTRL0_PROTOCOL_1W) != 0);
130}
131
133 if (this->lbt_retries_ < COUNTER_SATURATION_MAX)
134 this->lbt_retries_++;
135 this->record_(PairingTelemetryEventKind::LBT_DEFER, 0, nullptr, nullptr, rssi, this->lbt_retries_, false);
136}
137
139 if (this->hop_count_ < UINT32_MAX)
140 this->hop_count_++;
141}
142
144 this->record_(PairingTelemetryEventKind::RX, sighting.cmd, sighting.src, sighting.dst, sighting.rssi, /*aux=*/1,
145 /*oneway=*/true);
146}
147
149 this->phase_ = phase;
150 this->record_(PairingTelemetryEventKind::PHASE, 0, nullptr, nullptr, 0, static_cast<uint8_t>(phase), false);
151}
152
154 this->outcome_ = outcome;
155 this->end_ms_ = millis();
156 this->ended_ = true;
157}
158
159void PairingTelemetry::set_paired_device(const uint8_t node_id[NODE_ID_SIZE], DeviceType type) {
160 this->has_paired_device_ = true;
161 memcpy(this->paired_node_id_, node_id, NODE_ID_SIZE);
162 this->paired_device_type_ = type;
163}
164
166 if (this->discovery_attempts_ < COUNTER_SATURATION_MAX)
167 this->discovery_attempts_++;
168}
169
170uint32_t PairingTelemetry::duration_ms() const { return (this->ended_ ? this->end_ms_ : millis()) - this->start_ms_; }
171
173 ESP_LOGI(TAG,
174 "Pairing attempt summary: outcome=%s phase=%s attempts=%u lbt=%u dur_ms=%" PRIu32 " heard=%u hops=%" PRIu32
175 " events=%u%s",
176 outcome_name(this->outcome_), pairing_stage_name(this->phase_), this->discovery_attempts_,
177 this->lbt_retries_, this->duration_ms(), this->heard_count_, this->hop_count_, this->event_count_,
178 this->truncated_ ? " (truncated)" : "");
179 for (uint8_t i = 0; i < this->event_count_; i++) {
180 const PairingTelemetryEvent &event = this->events_[i];
181 if (event.kind == PairingTelemetryEventKind::RX && event.aux == 1) {
182 // A seeded pre-window sighting (record_recent_one_way_sighting()): millis_offset is a
183 // placeholder (recorded at the start of this attempt, not when the frame actually arrived),
184 // so label it instead of implying a live in-window capture.
185 ESP_LOGI(TAG, " [pre-window] %s cmd=0x%02X src=%s rssi=%d", event_kind_name(event.kind), event.cmd,
186 node_id_to_string(event.src_node).c_str(), event.rssi);
187 } else if (event.kind == PairingTelemetryEventKind::RX || event.kind == PairingTelemetryEventKind::RX_REJECT) {
188 ESP_LOGI(TAG, " [%4" PRIu32 " ms] %s cmd=0x%02X src=%s rssi=%d", event.millis_offset,
189 event_kind_name(event.kind), event.cmd, node_id_to_string(event.src_node).c_str(), event.rssi);
190 } else if (event.kind == PairingTelemetryEventKind::TX) {
191 ESP_LOGI(TAG, " [%4" PRIu32 " ms] %s cmd=0x%02X", event.millis_offset, event_kind_name(event.kind), event.cmd);
192 } else if (event.kind == PairingTelemetryEventKind::PHASE) {
193 ESP_LOGI(TAG, " [%4" PRIu32 " ms] %s -> %s", event.millis_offset, event_kind_name(event.kind),
194 pairing_stage_name(static_cast<pairing::PairingState>(event.aux)));
195 } else {
196 ESP_LOGI(TAG, " [%4" PRIu32 " ms] %s aux=%u rssi=%d", event.millis_offset, event_kind_name(event.kind),
197 event.aux, event.rssi);
198 }
199 }
200 if (this->has_paired_device_) {
201 ESP_LOGI(TAG, " paired node=%s type=%s", node_id_to_string(this->paired_node_id_).c_str(),
202 device_type_name(this->paired_device_type_));
203 }
204}
205
207 char buf[RESULT_SENSOR_STRING_BUFFER_SIZE];
208 const std::string node = this->has_paired_device_ ? node_id_to_string(this->paired_node_id_) : "-";
209 const std::string type = this->has_paired_device_ ? device_type_name(this->paired_device_type_) : "-";
210 const std::string advice = this->advice_codes_.empty() ? "none" : this->advice_codes_;
211 snprintf(buf, sizeof(buf),
212 "v1; outcome=%s; phase=%s; node=%s; type=%s; attempts=%u; lbt=%u; dur_ms=%" PRIu32 "; heard=%u; advice=%s",
213 outcome_name(this->outcome_), pairing_stage_name(this->phase_), node.c_str(), type.c_str(),
214 this->discovery_attempts_, this->lbt_retries_, this->duration_ms(), this->heard_count_, advice.c_str());
215 return std::string(buf);
216}
217
218} // namespace home_io_control
219} // namespace esphome
void record_rx_reject(const IoFrame &frame, int16_t rssi)
Record that we received a frame that parsed but was rejected by a classifier.
void increment_discovery_attempt()
Increment the discovery attempt counter (one call per discovery command retry).
void set_paired_device(const uint8_t node_id[NODE_ID_SIZE], DeviceType type)
Record the successfully paired device, if any.
void set_phase(pairing::PairingState phase)
Record a pairing state-machine phase transition.
std::string result_sensor_string() const
Render the frozen v1; machine-readable result string.
void record_recent_one_way_sighting(const RecentOneWayPairingSighting &sighting)
Seed telemetry with a 1W pairing-gesture frame observed shortly before this attempt began.
void record_tx(uint8_t cmd)
Record that we transmitted a frame.
void record_hop()
Record a frequency hop while waiting.
void log_summary() const
Emit a multi-line human-readable summary via ESP_LOGI. Call once, at the end of the attempt.
void record_lbt_defer(int16_t rssi)
Record a listen-before-talk defer (channel busy).
void begin()
Reset all state and start a new attempt. Call once at discover_and_pair() entry.
void set_outcome(PairingOutcome outcome)
Record the final outcome of the attempt.
void record_rx(const IoFrame &frame, int16_t rssi)
Record that we received and accepted a frame.
PairingState
State machine for the three‑phase pairing flow.
Definition hub_pairing.h:48
@ IDLE
No pairing in progress; idle state.
Definition hub_pairing.h:49
DeviceType
Device type identifiers reported by IO‑Homecontrol products.
@ UNKNOWN
Unknown/unspecified device.
PairingTelemetryEventKind
Kind of a recorded telemetry event.
@ PHASE
The pairing state machine advanced to a new phase.
@ RX_REJECT
We received a frame that parsed but was rejected (wrong source, wrong command, etc....
@ LBT_DEFER
A listen-before-talk check deferred a transmit because the channel was busy.
@ RX
We received and accepted a frame for the current wait (including a seeded pre-window sighting — see P...
const char * device_type_name(DeviceType type)
Convert a DeviceType to a lowercase string identifier.
PairingOutcome
Final disposition of a pairing attempt, used by the result sensor string.
@ INVALID_RESPONSE
Discovery saw traffic but nothing valid.
@ KEY_EXCHANGE_FAILED
Discovery succeeded but the key exchange did not complete.
@ PAIRED
The key exchange completed and the device is registered.
@ NO_RESPONSE
No device responded to discovery.
@ NONE
No attempt has completed yet (initial state).
const char * pairing_stage_name(pairing::PairingState state)
Get a short, log/telemetry-friendly name for a pairing state.
Definition hub_pairing.h:94
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 const char *const TAG
Structured per-attempt telemetry recorder for the pairing flow.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
uint8_t ctrl0
Control byte 0: flags + length.
Definition proto_frame.h:94
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:97
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:96
uint32_t millis_offset
millis() at record time, relative to PairingTelemetry::begin().
A 1W pairing-gesture frame observed on the hub's normal passive RX path, remembered so a fresh discov...
uint8_t dst[NODE_ID_SIZE]
Destination node ID (the 1W broadcast address, in practice).
int16_t rssi
RSSI in dBm at the time it was overheard.