Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
pairing_telemetry.h
Go to the documentation of this file.
1#pragma once
2
3/// @file pairing_telemetry.h
4/// @brief Structured per-attempt telemetry recorder for the pairing flow.
5/// @ingroup hioc_hub
6///
7/// PairingTelemetry is a fixed-size recorder owned by the hub and shared by reference with
8/// PairingEngine, which also attaches it to ExchangeEngine as its TransmitObserver for the length
9/// of an attempt (that is how TX and LBT events reach it). It records every radio-visible event
10/// during a `discover_and_pair()` attempt — TX, RX (accepted and rejected), LBT defers, and
11/// hop/phase transitions — so a single attempt can be summarized as a human-readable log block and
12/// a frozen, machine-readable "Last Pairing Result" string.
13///
14/// Telemetry events store only cmd/src/rssi/phase metadata, never frame payload bytes, so key
15/// material cannot appear here by construction — there is no redaction to apply because there
16/// is nothing to redact.
17
18#include "hub_pairing.h"
19#include "proto_device_model.h"
20#include "proto_frame.h"
21#include "transmit_observer.h"
22
23#include <cstdint>
24#include <string>
25
26namespace esphome {
27namespace home_io_control {
28
29/// @brief Kind of a recorded telemetry event.
30enum class PairingTelemetryEventKind : uint8_t {
31 TX, ///< We transmitted a frame.
32 RX, ///< We received and accepted a frame for the current wait (including a seeded
33 ///< pre-window sighting — see PairingTelemetryEvent::aux).
34 RX_REJECT, ///< We received a frame that parsed but was rejected (wrong source, wrong command, etc.).
35 LBT_DEFER, ///< A listen-before-talk check deferred a transmit because the channel was busy.
36 PHASE, ///< The pairing state machine advanced to a new phase.
37 // Deliberately no HOP kind: a channel hop is counted via PairingTelemetry::hop_count() only,
38 // never stored as an event — see record_hop()'s doc comment for why.
39};
40
41/// @brief Maximum number of events recorded per pairing attempt.
42///
43/// Events beyond this bound are still counted in PairingTelemetry::heard_count() but not
44/// stored — the array is a fixed-size ring-free buffer (first N events), not a true ring.
45static constexpr uint8_t PAIRING_TELEMETRY_MAX_EVENTS = 32;
46
47/// @brief One recorded telemetry event.
49 uint32_t millis_offset{0}; ///< millis() at record time, relative to PairingTelemetry::begin().
51 uint8_t cmd{0}; ///< Frame command byte (TX/RX/RX_REJECT); unused otherwise.
52 uint8_t src_node[NODE_ID_SIZE]{}; ///< Sender node ID (RX/RX_REJECT only); zero otherwise.
53 uint8_t dst_node[NODE_ID_SIZE]{}; ///< Destination node ID (RX/RX_REJECT only); zero otherwise.
54 int16_t rssi{0}; ///< RSSI in dBm (RX/RX_REJECT/LBT_DEFER, including a seeded
55 ///< pre-window RX — see PairingTelemetry::record_recent_one_way_sighting());
56 ///< zero otherwise.
57 uint8_t aux{0}; ///< Kind-specific extra byte: PHASE -> pairing::PairingState value; LBT_DEFER ->
58 ///< retry number reached; RX -> 1 if this is a seeded pre-window sighting rather
59 ///< than a live in-window capture (0 for both a live RX and every RX_REJECT);
60 ///< unused (0) for TX.
61 bool oneway{false}; ///< CTRL0 1W-protocol bit (RX/RX_REJECT only); false otherwise.
62};
63
64/// @brief A 1W pairing-gesture frame observed on the hub's normal passive RX path, remembered so
65/// a fresh `discover_and_pair()` attempt can seed its telemetry with it (issue #27/#65: the
66/// discovery telemetry window only starts recording at `PairingTelemetry::begin()`, so a PROG
67/// press completed just before "Discover & Pair" is pressed could otherwise be invisible to the
68/// pairing advisor even though the radio heard it just fine). `seen_ms == 0` means none has been
69/// observed since boot. Owned by the hub, written from hub_status.cpp's passive RX path, read by
70/// PairingEngine at the start of every attempt.
72 uint8_t src[NODE_ID_SIZE]{}; ///< Sender node ID.
73 uint8_t dst[NODE_ID_SIZE]{}; ///< Destination node ID (the 1W broadcast address, in practice).
74 uint8_t cmd{0}; ///< Frame command byte.
75 int16_t rssi{0}; ///< RSSI in dBm at the time it was overheard.
76 uint32_t seen_ms{0}; ///< millis() the frame was seen; 0 if none seen since boot.
77};
78
79/// @brief Final disposition of a pairing attempt, used by the result sensor string.
80enum class PairingOutcome : uint8_t {
81 NONE, ///< No attempt has completed yet (initial state).
82 PAIRED, ///< The key exchange completed and the device is registered.
83 NO_RESPONSE, ///< No device responded to discovery.
84 INVALID_RESPONSE, ///< Discovery saw traffic but nothing valid.
85 KEY_EXCHANGE_FAILED, ///< Discovery succeeded but the key exchange did not complete.
86 KEY_TRANSFER_REFUSED, ///< The device answered our key transfer with CMD_ERROR_RESP; the code is in
87 ///< PairingTelemetry::key_refusal_code().
88 KEY_RECEIVED, ///< `pairing_key_transfer: pull` received a key that differs from ours and the device
89 ///< proved it holds it; the device is not registered (see PairingEngine::run_key_pull_phase_()).
90 HUB_KEY_RETURNED, ///< A pull returned the hub's own key from a node no platform drives (an Atlantic radiator's
91 ///< pairing node), and no device that holds the key answered; nothing is registered.
92};
93
94/// @brief Fixed-size per-attempt telemetry recorder for the pairing flow.
95/// @ingroup hioc_hub
96///
97/// Owned by the hub, reset at the start of every `discover_and_pair()` call. Not thread-safe —
98/// pairing is a single blocking call on the main loop, matching the rest of this component.
100 public:
101 /// TransmitObserver: records a TX event for the frame's command byte (see record_tx()).
102 void on_transmit(const IoFrame &frame, const RadioTxConfig &config, uint8_t wire_len) override {
103 this->record_tx(frame.cmd);
104 }
105 /// TransmitObserver: records an LBT_DEFER event (see record_lbt_defer()).
106 void on_lbt_defer(int16_t rssi_dbm) override { this->record_lbt_defer(rssi_dbm); }
107
108 /// Reset all state and start a new attempt. Call once at `discover_and_pair()` entry.
109 void begin();
110
111 /// Record that we transmitted a frame.
112 /// @param cmd Frame command byte.
113 void record_tx(uint8_t cmd);
114 /// Record that we received and accepted a frame.
115 /// @param frame Parsed frame (cmd, src, dst, and the 1W protocol bit are recorded — `dst` and
116 /// the 1W bit feed PairingAdvisor's ONE_WAY_PAIRING_TRAFFIC and
117 /// FOREIGN_CONTROLLER_PAIRING detection, see pairing_advisor.h).
118 /// @param rssi RSSI in dBm.
119 void record_rx(const IoFrame &frame, int16_t rssi);
120 /// Record that we received a frame that parsed but was rejected by a classifier.
121 /// @param frame Parsed frame (cmd, src, dst, and the 1W protocol bit are recorded — `dst` and
122 /// the 1W bit feed PairingAdvisor's ONE_WAY_PAIRING_TRAFFIC and
123 /// FOREIGN_CONTROLLER_PAIRING detection, see pairing_advisor.h).
124 /// @param rssi RSSI in dBm.
125 void record_rx_reject(const IoFrame &frame, int16_t rssi);
126 /// Record a listen-before-talk defer (channel busy).
127 /// @param rssi RSSI in dBm that triggered the defer.
128 void record_lbt_defer(int16_t rssi);
129 /// Record a frequency hop while waiting. The exchange engine does not expose which channel
130 /// index a hop landed on, so this only marks that a hop happened, not where to.
131 ///
132 /// Counted via hop_count() only, deliberately **not** stored in the fixed event array: at the
133 /// current 5-7 ms hop slice, a multi-second discovery window produces far more hops than
134 /// PAIRING_TELEMETRY_MAX_EVENTS, which used to exhaust the buffer with hop events alone within
135 /// about a second of essentially every real attempt (issue #27). This did **not** produce a false
136 /// `rf_silent` — heard_count() is incremented in record_() before the capacity check, so it still
137 /// counts an RX/RX_REJECT that arrives after the array is full. What it did do: once the array
138 /// was full, PairingAdvisor's `1w_traffic`/`channel_busy`/`foreign_controller` passes — which all
139 /// scan events(), not heard_count() — could only ever see whatever RX/RX_REJECT evidence had
140 /// landed in roughly the first ~200 ms of the attempt, and truncated() was true on nearly every
141 /// real pairing attempt. A hop carries no per-event information worth itemizing (no channel
142 /// index, no RSSI) anyway; the total count is enough.
143 void record_hop();
144 /// Record a pairing state-machine phase transition.
145 /// @param phase New phase.
147 /// Record the final outcome of the attempt.
148 /// @param outcome Final disposition.
150 /// Record the successfully paired device, if any.
151 /// @param node_id Paired device's node ID.
152 /// @param type Paired device's type.
153 void set_paired_device(const uint8_t node_id[NODE_ID_SIZE], DeviceType type);
154 /// Increment the discovery attempt counter (one call per discovery command retry).
156 /// Record the advisor's short advice codes for the `;advice=` result-sensor field.
157 /// @param codes Comma-separated short codes (e.g. "1w_traffic,channel_busy"), or empty if none.
158 void set_advice_codes(const std::string &codes) { this->advice_codes_ = codes; }
159 /// Record the CMD_ERROR_RESP byte the device answered a key transfer (or key pull) with.
160 /// @param code Result byte (decode with command_result_name()).
161 void set_key_refusal(uint8_t code) {
162 this->has_key_refusal_ = true;
163 this->key_refusal_code_ = code;
164 }
165 /// Record the device discovery accepted: its node ID and node type.
166 /// @param node_id Discovered device's node ID.
167 /// @param node_type Node type decoded from the discovery response.
168 void set_discovered_device(const uint8_t node_id[NODE_ID_SIZE], DeviceType node_type);
169 /// Record that this attempt pulled the key (0x38) instead of pushing it (0x31/0x32).
170 void set_key_pull_attempted() { this->key_pull_attempted_ = true; }
171 /// Record that a pulled key could not be verified: the device sent a 0x32, but no 0x3D that
172 /// verifies under the key it carries.
173 void set_key_pull_unproven() { this->key_pull_unproven_ = true; }
174
175 /// Seed telemetry with a 1W pairing-gesture frame observed shortly before this attempt began.
176 /// Recorded as an RX event with its real RSSI, marked seeded (PairingTelemetryEvent::aux = 1) so
177 /// log_summary() can label it honestly instead of implying a live in-window capture, and so
178 /// PairingAdvisor's channel_busy pass — which is specifically about *live* contention during
179 /// this attempt — can exclude it while ONE_WAY_PAIRING_TRAFFIC still picks it up like any other
180 /// RX. It counts toward heard_count(). Timestamped at the start of the attempt (millis_offset
181 /// ≈ 0), since its real arrival time — before begin() — isn't preserved; the seeded marker is
182 /// what tells a reader not to read that as "heard 0 ms into this attempt". The caller (
183 /// PairingEngine::discover_and_pair()) is responsible for deciding whether `sighting` is recent
184 /// enough to seed with — this method always records what it's given.
185 /// @param sighting The overheard frame to seed telemetry with.
187
188 /// @return Recorded events (up to PAIRING_TELEMETRY_MAX_EVENTS), oldest first.
189 [[nodiscard]] const PairingTelemetryEvent *events() const { return this->events_; }
190 /// @return Number of events actually stored (<= PAIRING_TELEMETRY_MAX_EVENTS).
191 [[nodiscard]] uint8_t event_count() const { return this->event_count_; }
192 /// @return Total RX events seen, including ones beyond the storage capacity.
193 [[nodiscard]] uint16_t heard_count() const { return this->heard_count_; }
194 /// @return Total number of channel hops during the attempt. Not itemized in events() — see
195 /// record_hop()'s doc comment for why.
196 [[nodiscard]] uint32_t hop_count() const { return this->hop_count_; }
197 /// @return true if any event (of any kind — TX/RX/RX_REJECT/LBT_DEFER/HOP/PHASE) was dropped
198 /// because the fixed event array was full. Deliberately not derived from
199 /// `heard_count() > event_count()` — the two counters measure different populations
200 /// (`heard_count()` is RX/RX_REJECT only; `event_count()` is stored events of every kind), so
201 /// that comparison misses truncation whenever most stored events aren't RX/RX_REJECT.
202 [[nodiscard]] bool truncated() const { return this->truncated_; }
203 /// @return Highest phase reached during the attempt.
204 [[nodiscard]] pairing::PairingState phase() const { return this->phase_; }
205 /// @return Final outcome, or PairingOutcome::NONE if no attempt has completed yet.
206 [[nodiscard]] PairingOutcome outcome() const { return this->outcome_; }
207 /// @return Number of discovery command attempts made.
208 [[nodiscard]] uint8_t discovery_attempts() const { return this->discovery_attempts_; }
209 /// @return Total listen-before-talk retries consumed across the whole attempt.
210 [[nodiscard]] uint8_t lbt_retries() const { return this->lbt_retries_; }
211 /// @return true if a device was successfully paired this attempt.
212 [[nodiscard]] bool has_paired_device() const { return this->has_paired_device_; }
213 /// @return Paired device's node ID; only meaningful if has_paired_device() is true.
214 [[nodiscard]] const uint8_t *paired_node_id() const { return this->paired_node_id_; }
215 /// @return Paired device's type; only meaningful if has_paired_device() is true.
216 [[nodiscard]] DeviceType paired_device_type() const { return this->paired_device_type_; }
217 /// @return true if the device answered a key transfer or key pull with CMD_ERROR_RESP.
218 [[nodiscard]] bool has_key_refusal() const { return this->has_key_refusal_; }
219 /// @return The CMD_ERROR_RESP byte of that refusal; only meaningful if has_key_refusal() is true.
220 [[nodiscard]] uint8_t key_refusal_code() const { return this->key_refusal_code_; }
221 /// @return true if this attempt pulled the key instead of pushing it.
222 [[nodiscard]] bool key_pull_attempted() const { return this->key_pull_attempted_; }
223 /// @return true if this attempt's pull received a key that the device did not prove.
224 [[nodiscard]] bool key_pull_unproven() const { return this->key_pull_unproven_; }
225 /// @return true if discovery accepted a device this attempt.
226 [[nodiscard]] bool has_discovered_device() const { return this->has_discovered_device_; }
227 /// @return Discovered device's node ID; only meaningful if has_discovered_device() is true.
228 [[nodiscard]] const uint8_t *discovered_node_id() const { return this->discovered_node_id_; }
229 /// @return Node type of the discovered device, DeviceType::UNKNOWN if discovery accepted none.
230 [[nodiscard]] DeviceType discovered_node_type() const { return this->discovered_node_type_; }
231 /// @return Duration of the attempt so far (or total, once complete) in milliseconds.
232 [[nodiscard]] uint32_t duration_ms() const;
233
234 /// Emit a multi-line human-readable summary via ESP_LOGI. Call once, at the end of the attempt.
235 void log_summary() const;
236
237 /// @brief Render the frozen `v1;` machine-readable result string.
238 ///
239 /// Format (never change without bumping the version tag — the Phase 2 rig's read-back
240 /// contract parses this exactly; the space after each `;` is intentional — Home Assistant's
241 /// UI only line-wraps this sensor's long value at whitespace, so a fields-only-separated-by-`;`
242 /// string renders as one unbroken, unreadable line):
243 /// `v1; outcome=<...>; phase=<...>; node=<XXXXXX|->; type=<...|->; attempts=<n>; lbt=<n>; dur_ms=<n>; heard=<n>; `
244 /// `advice=<comma-separated codes|none>`
245 /// @return The formatted result string.
246 [[nodiscard]] std::string result_sensor_string() const;
247
248 private:
249 /// Append an event to the fixed array (dropped silently past capacity; heard_count_ still counts it).
250 void record_(PairingTelemetryEventKind kind, uint8_t cmd, const uint8_t *src, const uint8_t *dst, int16_t rssi,
251 uint8_t aux, bool oneway);
252
254 uint8_t event_count_{0};
255 uint16_t heard_count_{0};
256 uint32_t hop_count_{0};
257 bool truncated_{false};
258 uint32_t start_ms_{0};
259 uint32_t end_ms_{0};
260 bool ended_{false};
263 uint8_t discovery_attempts_{0};
264 uint8_t lbt_retries_{0};
265 bool has_paired_device_{false};
266 uint8_t paired_node_id_[NODE_ID_SIZE]{};
267 DeviceType paired_device_type_{DeviceType::UNKNOWN};
268 bool has_key_refusal_{false};
269 uint8_t key_refusal_code_{0};
270 bool key_pull_attempted_{false};
271 bool key_pull_unproven_{false};
272 bool has_discovered_device_{false};
273 uint8_t discovered_node_id_[NODE_ID_SIZE]{};
274 DeviceType discovered_node_type_{DeviceType::UNKNOWN};
275 std::string advice_codes_;
276};
277
278} // namespace home_io_control
279} // namespace esphome
Fixed-size per-attempt telemetry recorder for the pairing flow.
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 on_lbt_defer(int16_t rssi_dbm) override
TransmitObserver: records an LBT_DEFER event (see record_lbt_defer()).
void set_key_pull_unproven()
Record that a pulled key could not be verified: the device sent a 0x32, but no 0x3D that verifies und...
void set_advice_codes(const std::string &codes)
Record the advisor's short advice codes for the ;advice= result-sensor field.
void set_key_pull_attempted()
Record that this attempt pulled the key (0x38) instead of pushing it (0x31/0x32).
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 on_transmit(const IoFrame &frame, const RadioTxConfig &config, uint8_t wire_len) override
TransmitObserver: records a TX event for the frame's command byte (see record_tx()).
const PairingTelemetryEvent * events() const
void begin()
Reset all state and start a new attempt. Call once at discover_and_pair() entry.
void set_key_refusal(uint8_t code)
Record the CMD_ERROR_RESP byte the device answered a key transfer (or key pull) with.
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.
void set_discovered_device(const uint8_t node_id[NODE_ID_SIZE], DeviceType node_type)
Record the device discovery accepted: its node ID and node type.
Receives transmit events from ExchangeEngine::transmit_frame().
Internal pairing-state model for hub‑owned discovery and key‑exchange flows.
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
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...
DeviceType
IO-Homecontrol node types.
@ UNKNOWN
Unknown/unspecified device.
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.
@ HUB_KEY_RETURNED
A pull returned the hub's own key from a node no platform drives (an Atlantic radiator's pairing node...
@ 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.
@ NONE
No attempt has completed yet (initial state).
@ KEY_RECEIVED
pairing_key_transfer: pull received a key that differs from ours and the device
IO-Homecontrol device-type model, capabilities and runtime device state.
IO-Homecontrol 2W frame container: control bytes, IoFrame and (de)serialization.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
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.
uint32_t millis_offset
millis() at record time, relative to PairingTelemetry::begin().
uint8_t dst_node[NODE_ID_SIZE]
Destination node ID (RX/RX_REJECT only); zero otherwise.
int16_t rssi
RSSI in dBm (RX/RX_REJECT/LBT_DEFER, including a seeded pre-window RX — see PairingTelemetry::record_...
uint8_t aux
Kind-specific extra byte: PHASE -> pairing::PairingState value; LBT_DEFER -> retry number reached; RX...
uint8_t src_node[NODE_ID_SIZE]
Sender node ID (RX/RX_REJECT only); zero otherwise.
Configuration for transmitting a packet: carrier frequency, preamble length, wake-up level and clear-...
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).
uint32_t seen_ms
millis() the frame was seen; 0 if none seen since boot.
int16_t rssi
RSSI in dBm at the time it was overheard.
Observer interface for every frame the exchange engine puts on air.