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 return "config_failed";
47 default:
48 return "none";
49 }
50}
51
52// Only referenced from ESP_LOGI() calls in log_summary(); ESP_LOGI is a no-op in host unit
53// test builds, so those call sites (and this function) vanish entirely under -Wunused-function
54// there even though it's genuinely used in firmware builds.
55[[maybe_unused]] const char *event_kind_name(PairingTelemetryEventKind kind) {
56 switch (kind) {
58 return "tx";
60 return "rx";
62 return "rx_reject";
64 return "lbt_defer";
66 default:
67 return "phase";
68 }
69}
70
71} // namespace
72
74 this->event_count_ = 0;
75 this->heard_count_ = 0;
76 this->hop_count_ = 0;
77 this->truncated_ = false;
78 this->start_ms_ = millis();
79 this->end_ms_ = this->start_ms_;
80 this->ended_ = false;
81 this->phase_ = pairing::PairingState::IDLE;
82 this->outcome_ = PairingOutcome::NONE;
83 this->discovery_attempts_ = 0;
84 this->lbt_retries_ = 0;
85 this->has_paired_device_ = false;
86 memset(this->paired_node_id_, 0, sizeof(this->paired_node_id_));
87 this->paired_device_type_ = DeviceType::UNKNOWN;
88 this->advice_codes_.clear();
89}
90
91void PairingTelemetry::record_(PairingTelemetryEventKind kind, uint8_t cmd, const uint8_t *src, const uint8_t *dst,
92 int16_t rssi, uint8_t aux, bool oneway) {
94 if (this->heard_count_ < UINT16_MAX)
95 this->heard_count_++;
96 }
97 if (this->event_count_ >= PAIRING_TELEMETRY_MAX_EVENTS) {
98 this->truncated_ = true;
99 return;
100 }
101 PairingTelemetryEvent &event = this->events_[this->event_count_++];
102 event.millis_offset = millis() - this->start_ms_;
103 event.kind = kind;
104 event.cmd = cmd;
105 if (src != nullptr) {
106 memcpy(event.src_node, src, NODE_ID_SIZE);
107 } else {
108 memset(event.src_node, 0, NODE_ID_SIZE);
109 }
110 if (dst != nullptr) {
111 memcpy(event.dst_node, dst, NODE_ID_SIZE);
112 } else {
113 memset(event.dst_node, 0, NODE_ID_SIZE);
114 }
115 event.rssi = rssi;
116 event.aux = aux;
117 event.oneway = oneway;
118}
119
121 this->record_(PairingTelemetryEventKind::TX, cmd, nullptr, nullptr, 0, 0, false);
122}
123
124void PairingTelemetry::record_rx(const IoFrame &frame, int16_t rssi) {
125 this->record_(PairingTelemetryEventKind::RX, frame.cmd, frame.src, frame.dst, rssi, 0,
126 (frame.ctrl0 & CTRL0_PROTOCOL_1W) != 0);
127}
128
129void PairingTelemetry::record_rx_reject(const IoFrame &frame, int16_t rssi) {
130 this->record_(PairingTelemetryEventKind::RX_REJECT, frame.cmd, frame.src, frame.dst, rssi, 0,
131 (frame.ctrl0 & CTRL0_PROTOCOL_1W) != 0);
132}
133
135 if (this->lbt_retries_ < COUNTER_SATURATION_MAX)
136 this->lbt_retries_++;
137 this->record_(PairingTelemetryEventKind::LBT_DEFER, 0, nullptr, nullptr, rssi, this->lbt_retries_, false);
138}
139
141 if (this->hop_count_ < UINT32_MAX)
142 this->hop_count_++;
143}
144
146 this->record_(PairingTelemetryEventKind::RX, sighting.cmd, sighting.src, sighting.dst, sighting.rssi, /*aux=*/1,
147 /*oneway=*/true);
148}
149
151 this->phase_ = phase;
152 this->record_(PairingTelemetryEventKind::PHASE, 0, nullptr, nullptr, 0, static_cast<uint8_t>(phase), false);
153}
154
156 this->outcome_ = outcome;
157 this->end_ms_ = millis();
158 this->ended_ = true;
159}
160
162 this->has_paired_device_ = true;
163 memcpy(this->paired_node_id_, node_id, NODE_ID_SIZE);
164 this->paired_device_type_ = type;
165}
166
168 if (this->discovery_attempts_ < COUNTER_SATURATION_MAX)
169 this->discovery_attempts_++;
170}
171
172uint32_t PairingTelemetry::duration_ms() const { return (this->ended_ ? this->end_ms_ : millis()) - this->start_ms_; }
173
175 ESP_LOGI(TAG,
176 "Pairing attempt summary: outcome=%s phase=%s attempts=%u lbt=%u dur_ms=%" PRIu32 " heard=%u hops=%" PRIu32
177 " events=%u%s",
178 outcome_name(this->outcome_), pairing_stage_name(this->phase_), this->discovery_attempts_,
179 this->lbt_retries_, this->duration_ms(), this->heard_count_, this->hop_count_, this->event_count_,
180 this->truncated_ ? " (truncated)" : "");
181 for (uint8_t i = 0; i < this->event_count_; i++) {
182 const PairingTelemetryEvent &event = this->events_[i];
183 if (event.kind == PairingTelemetryEventKind::RX && event.aux == 1) {
184 // A seeded pre-window sighting (record_recent_one_way_sighting()): millis_offset is a
185 // placeholder (recorded at the start of this attempt, not when the frame actually arrived),
186 // so label it instead of implying a live in-window capture.
187 ESP_LOGI(TAG, " [pre-window] %s cmd=0x%02X src=%s rssi=%d", event_kind_name(event.kind), event.cmd,
188 node_id_to_string(event.src_node).c_str(), event.rssi);
189 } else if (event.kind == PairingTelemetryEventKind::RX || event.kind == PairingTelemetryEventKind::RX_REJECT) {
190 ESP_LOGI(TAG, " [%4" PRIu32 " ms] %s cmd=0x%02X src=%s rssi=%d", event.millis_offset,
191 event_kind_name(event.kind), event.cmd, node_id_to_string(event.src_node).c_str(), event.rssi);
192 } else if (event.kind == PairingTelemetryEventKind::TX) {
193 ESP_LOGI(TAG, " [%4" PRIu32 " ms] %s cmd=0x%02X", event.millis_offset, event_kind_name(event.kind), event.cmd);
194 } else if (event.kind == PairingTelemetryEventKind::PHASE) {
195 ESP_LOGI(TAG, " [%4" PRIu32 " ms] %s -> %s", event.millis_offset, event_kind_name(event.kind),
196 pairing_stage_name(static_cast<pairing::PairingState>(event.aux)));
197 } else {
198 ESP_LOGI(TAG, " [%4" PRIu32 " ms] %s aux=%u rssi=%d", event.millis_offset, event_kind_name(event.kind),
199 event.aux, event.rssi);
200 }
201 }
202 if (this->has_paired_device_) {
203 ESP_LOGI(TAG, " paired node=%s type=%s", node_id_to_string(this->paired_node_id_).c_str(),
204 device_type_name(this->paired_device_type_));
205 }
206}
207
209 char buf[RESULT_SENSOR_STRING_BUFFER_SIZE];
210 const std::string node = this->has_paired_device_ ? node_id_to_string(this->paired_node_id_) : "-";
211 const std::string type = this->has_paired_device_ ? device_type_name(this->paired_device_type_) : "-";
212 const std::string advice = this->advice_codes_.empty() ? "none" : this->advice_codes_;
213 snprintf(buf, sizeof(buf),
214 "v1; outcome=%s; phase=%s; node=%s; type=%s; attempts=%u; lbt=%u; dur_ms=%" PRIu32 "; heard=%u; advice=%s",
215 outcome_name(this->outcome_), pairing_stage_name(this->phase_), node.c_str(), type.c_str(),
216 this->discovery_attempts_, this->lbt_retries_, this->duration_ms(), this->heard_count_, advice.c_str());
217 return std::string(buf);
218}
219
220} // namespace home_io_control
221} // 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:45
@ IDLE
No pairing in progress; idle state.
Definition hub_pairing.h:46
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
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...
static constexpr const char * TAG
static constexpr uint8_t CTRL0_PROTOCOL_1W
Bit 5: 1=OneWay protocol, 0=TwoWay protocol.
Definition proto_frame.h:43
const char * device_type_name(DeviceType type)
Convert a DeviceType to a lowercase string identifier.
static constexpr uint8_t PAIRING_TELEMETRY_MAX_EVENTS
Maximum number of events recorded per pairing attempt.
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
All three phases completed successfully.
@ CONFIG_FAILED
Key exchange succeeded but SetConfig1 failed (still counted as paired).
@ 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:77
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.
Structured per-attempt telemetry recorder for the pairing flow.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:88
uint8_t ctrl0
Control byte 0: flags + length.
Definition proto_frame.h:89
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:92
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:91
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.