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 pointer) with
8/// ExchangeEngine and PairingEngine, mirroring how TuningConfig is injected into both. It
9/// records every radio-visible event during a `discover_and_pair()` attempt — TX, RX (accepted
10/// and rejected), LBT defers, and hop/phase transitions — so a single attempt can be summarized
11/// as a human-readable log block and a frozen, machine-readable "Last Pairing Result" string.
12///
13/// Telemetry events store only cmd/src/rssi/phase metadata, never frame payload bytes, so key
14/// material cannot appear here by construction — there is no redaction to apply because there
15/// is nothing to redact.
16
17#include "hub_pairing.h"
18#include "proto_device_model.h"
19#include "proto_frame.h"
20
21#include <cstdint>
22#include <string>
23
24namespace esphome {
25namespace home_io_control {
26
27/// @brief Kind of a recorded telemetry event.
28enum class PairingTelemetryEventKind : uint8_t {
29 TX, ///< We transmitted a frame.
30 RX, ///< We received and accepted a frame for the current wait (including a seeded
31 ///< pre-window sighting — see PairingTelemetryEvent::aux).
32 RX_REJECT, ///< We received a frame that parsed but was rejected (wrong source, wrong command, etc.).
33 LBT_DEFER, ///< A listen-before-talk check deferred a transmit because the channel was busy.
34 PHASE, ///< The pairing state machine advanced to a new phase.
35 // Deliberately no HOP kind: a channel hop is counted via PairingTelemetry::hop_count() only,
36 // never stored as an event — see record_hop()'s doc comment for why.
37};
38
39/// @brief Maximum number of events recorded per pairing attempt.
40///
41/// Events beyond this bound are still counted in PairingTelemetry::heard_count() but not
42/// stored — the array is a fixed-size ring-free buffer (first N events), not a true ring.
43static constexpr uint8_t PAIRING_TELEMETRY_MAX_EVENTS = 32;
44
45/// @brief One recorded telemetry event.
47 uint32_t millis_offset{0}; ///< millis() at record time, relative to PairingTelemetry::begin().
49 uint8_t cmd{0}; ///< Frame command byte (TX/RX/RX_REJECT); unused otherwise.
50 uint8_t src_node[NODE_ID_SIZE]{}; ///< Sender node ID (RX/RX_REJECT only); zero otherwise.
51 uint8_t dst_node[NODE_ID_SIZE]{}; ///< Destination node ID (RX/RX_REJECT only); zero otherwise.
52 int16_t rssi{0}; ///< RSSI in dBm (RX/RX_REJECT/LBT_DEFER, including a seeded
53 ///< pre-window RX — see PairingTelemetry::record_recent_one_way_sighting());
54 ///< zero otherwise.
55 uint8_t aux{0}; ///< Kind-specific extra byte: PHASE -> pairing::PairingState value; LBT_DEFER ->
56 ///< retry number reached; RX -> 1 if this is a seeded pre-window sighting rather
57 ///< than a live in-window capture (0 for both a live RX and every RX_REJECT);
58 ///< unused (0) for TX.
59 bool oneway{false}; ///< CTRL0 1W-protocol bit (RX/RX_REJECT only); false otherwise.
60};
61
62/// @brief A 1W pairing-gesture frame observed on the hub's normal passive RX path, remembered so
63/// a fresh `discover_and_pair()` attempt can seed its telemetry with it (issue #27/#65: the
64/// discovery telemetry window only starts recording at `PairingTelemetry::begin()`, so a PROG
65/// press completed just before "Discover & Pair" is pressed could otherwise be invisible to the
66/// pairing advisor even though the radio heard it just fine). `seen_ms == 0` means none has been
67/// observed since boot. Owned by the hub, written from hub_status.cpp's passive RX path, read by
68/// PairingEngine at the start of every attempt.
70 uint8_t src[NODE_ID_SIZE]{}; ///< Sender node ID.
71 uint8_t dst[NODE_ID_SIZE]{}; ///< Destination node ID (the 1W broadcast address, in practice).
72 uint8_t cmd{0}; ///< Frame command byte.
73 int16_t rssi{0}; ///< RSSI in dBm at the time it was overheard.
74 uint32_t seen_ms{0}; ///< millis() the frame was seen; 0 if none seen since boot.
75};
76
77/// @brief Final disposition of a pairing attempt, used by the result sensor string.
78enum class PairingOutcome : uint8_t {
79 NONE, ///< No attempt has completed yet (initial state).
80 PAIRED, ///< All three phases completed successfully.
81 NO_RESPONSE, ///< No device responded to discovery.
82 INVALID_RESPONSE, ///< Discovery saw traffic but nothing valid.
83 KEY_EXCHANGE_FAILED, ///< Discovery succeeded but the key exchange did not complete.
84 CONFIG_FAILED, ///< Key exchange succeeded but SetConfig1 failed (still counted as paired).
85};
86
87/// @brief Fixed-size per-attempt telemetry recorder for the pairing flow.
88/// @ingroup hioc_hub
89///
90/// Owned by the hub, reset at the start of every `discover_and_pair()` call. Not thread-safe —
91/// pairing is a single blocking call on the main loop, matching the rest of this component.
93 public:
94 /// Reset all state and start a new attempt. Call once at `discover_and_pair()` entry.
95 void begin();
96
97 /// Record that we transmitted a frame.
98 /// @param cmd Frame command byte.
99 void record_tx(uint8_t cmd);
100 /// Record that we received and accepted a frame.
101 /// @param frame Parsed frame (cmd, src, dst, and the 1W protocol bit are recorded — `dst` and
102 /// the 1W bit feed PairingAdvisor's ONE_WAY_PAIRING_TRAFFIC and
103 /// FOREIGN_CONTROLLER_PAIRING detection, see pairing_advisor.h).
104 /// @param rssi RSSI in dBm.
105 void record_rx(const IoFrame &frame, int16_t rssi);
106 /// Record that we received a frame that parsed but was rejected by a classifier.
107 /// @param frame Parsed frame (cmd, src, dst, and the 1W protocol bit are recorded — `dst` and
108 /// the 1W bit feed PairingAdvisor's ONE_WAY_PAIRING_TRAFFIC and
109 /// FOREIGN_CONTROLLER_PAIRING detection, see pairing_advisor.h).
110 /// @param rssi RSSI in dBm.
111 void record_rx_reject(const IoFrame &frame, int16_t rssi);
112 /// Record a listen-before-talk defer (channel busy).
113 /// @param rssi RSSI in dBm that triggered the defer.
114 void record_lbt_defer(int16_t rssi);
115 /// Record a frequency hop while waiting. The exchange engine does not expose which channel
116 /// index a hop landed on, so this only marks that a hop happened, not where to.
117 ///
118 /// Counted via hop_count() only, deliberately **not** stored in the fixed event array: at the
119 /// current 5-7 ms hop slice, a multi-second discovery window produces far more hops than
120 /// PAIRING_TELEMETRY_MAX_EVENTS, which used to exhaust the buffer with hop events alone within
121 /// about a second of essentially every real attempt (issue #27). This did **not** produce a false
122 /// `rf_silent` — heard_count() is incremented in record_() before the capacity check, so it still
123 /// counts an RX/RX_REJECT that arrives after the array is full. What it did do: once the array
124 /// was full, PairingAdvisor's `1w_traffic`/`channel_busy`/`foreign_controller` passes — which all
125 /// scan events(), not heard_count() — could only ever see whatever RX/RX_REJECT evidence had
126 /// landed in roughly the first ~200 ms of the attempt, and truncated() was true on nearly every
127 /// real pairing attempt. A hop carries no per-event information worth itemizing (no channel
128 /// index, no RSSI) anyway; the total count is enough.
129 void record_hop();
130 /// Record a pairing state-machine phase transition.
131 /// @param phase New phase.
133 /// Record the final outcome of the attempt.
134 /// @param outcome Final disposition.
136 /// Record the successfully paired device, if any.
137 /// @param node_id Paired device's node ID.
138 /// @param type Paired device's type.
139 void set_paired_device(const uint8_t node_id[NODE_ID_SIZE], DeviceType type);
140 /// Increment the discovery attempt counter (one call per discovery command retry).
142 /// Record the advisor's short advice codes for the `;advice=` result-sensor field.
143 /// @param codes Comma-separated short codes (e.g. "1w_traffic,channel_busy"), or empty if none.
144 void set_advice_codes(const std::string &codes) { this->advice_codes_ = codes; }
145
146 /// Seed telemetry with a 1W pairing-gesture frame observed shortly before this attempt began.
147 /// Recorded as an RX event with its real RSSI, marked seeded (PairingTelemetryEvent::aux = 1) so
148 /// log_summary() can label it honestly instead of implying a live in-window capture, and so
149 /// PairingAdvisor's channel_busy pass — which is specifically about *live* contention during
150 /// this attempt — can exclude it while ONE_WAY_PAIRING_TRAFFIC still picks it up like any other
151 /// RX. It counts toward heard_count(). Timestamped at the start of the attempt (millis_offset
152 /// ≈ 0), since its real arrival time — before begin() — isn't preserved; the seeded marker is
153 /// what tells a reader not to read that as "heard 0 ms into this attempt". The caller (
154 /// PairingEngine::discover_and_pair()) is responsible for deciding whether `sighting` is recent
155 /// enough to seed with — this method always records what it's given.
156 /// @param sighting The overheard frame to seed telemetry with.
158
159 /// @return Recorded events (up to PAIRING_TELEMETRY_MAX_EVENTS), oldest first.
160 [[nodiscard]] const PairingTelemetryEvent *events() const { return this->events_; }
161 /// @return Number of events actually stored (<= PAIRING_TELEMETRY_MAX_EVENTS).
162 [[nodiscard]] uint8_t event_count() const { return this->event_count_; }
163 /// @return Total RX events seen, including ones beyond the storage capacity.
164 [[nodiscard]] uint16_t heard_count() const { return this->heard_count_; }
165 /// @return Total number of channel hops during the attempt. Not itemized in events() — see
166 /// record_hop()'s doc comment for why.
167 [[nodiscard]] uint32_t hop_count() const { return this->hop_count_; }
168 /// @return true if any event (of any kind — TX/RX/RX_REJECT/LBT_DEFER/HOP/PHASE) was dropped
169 /// because the fixed event array was full. Deliberately not derived from
170 /// `heard_count() > event_count()` — the two counters measure different populations
171 /// (`heard_count()` is RX/RX_REJECT only; `event_count()` is stored events of every kind), so
172 /// that comparison misses truncation whenever most stored events aren't RX/RX_REJECT.
173 [[nodiscard]] bool truncated() const { return this->truncated_; }
174 /// @return Highest phase reached during the attempt.
175 [[nodiscard]] pairing::PairingState phase() const { return this->phase_; }
176 /// @return Final outcome, or PairingOutcome::NONE if no attempt has completed yet.
177 [[nodiscard]] PairingOutcome outcome() const { return this->outcome_; }
178 /// @return Number of discovery command attempts made.
179 [[nodiscard]] uint8_t discovery_attempts() const { return this->discovery_attempts_; }
180 /// @return Total listen-before-talk retries consumed across the whole attempt.
181 [[nodiscard]] uint8_t lbt_retries() const { return this->lbt_retries_; }
182 /// @return true if a device was successfully paired this attempt.
183 [[nodiscard]] bool has_paired_device() const { return this->has_paired_device_; }
184 /// @return Paired device's node ID; only meaningful if has_paired_device() is true.
185 [[nodiscard]] const uint8_t *paired_node_id() const { return this->paired_node_id_; }
186 /// @return Paired device's type; only meaningful if has_paired_device() is true.
187 [[nodiscard]] DeviceType paired_device_type() const { return this->paired_device_type_; }
188 /// @return Duration of the attempt so far (or total, once complete) in milliseconds.
189 [[nodiscard]] uint32_t duration_ms() const;
190
191 /// Emit a multi-line human-readable summary via ESP_LOGI. Call once, at the end of the attempt.
192 void log_summary() const;
193
194 /// @brief Render the frozen `v1;` machine-readable result string.
195 ///
196 /// Format (never change without bumping the version tag — the Phase 2 rig's read-back
197 /// contract parses this exactly; the space after each `;` is intentional — Home Assistant's
198 /// UI only line-wraps this sensor's long value at whitespace, so a fields-only-separated-by-`;`
199 /// string renders as one unbroken, unreadable line):
200 /// `v1; outcome=<...>; phase=<...>; node=<XXXXXX|->; type=<...|->; attempts=<n>; lbt=<n>; dur_ms=<n>; heard=<n>; `
201 /// `advice=<comma-separated codes|none>`
202 /// @return The formatted result string.
203 [[nodiscard]] std::string result_sensor_string() const;
204
205 private:
206 /// Append an event to the fixed array (dropped silently past capacity; heard_count_ still counts it).
207 void record_(PairingTelemetryEventKind kind, uint8_t cmd, const uint8_t *src, const uint8_t *dst, int16_t rssi,
208 uint8_t aux, bool oneway);
209
211 uint8_t event_count_{0};
212 uint16_t heard_count_{0};
213 uint32_t hop_count_{0};
214 bool truncated_{false};
215 uint32_t start_ms_{0};
216 uint32_t end_ms_{0};
217 bool ended_{false};
220 uint8_t discovery_attempts_{0};
221 uint8_t lbt_retries_{0};
222 bool has_paired_device_{false};
223 uint8_t paired_node_id_[NODE_ID_SIZE]{};
224 DeviceType paired_device_type_{DeviceType::UNKNOWN};
225 std::string advice_codes_;
226};
227
228} // namespace home_io_control
229} // 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 set_advice_codes(const std::string &codes)
Record the advisor's short advice codes for the ;advice= result-sensor field.
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).
const PairingTelemetryEvent * events() const
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.
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: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 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).
@ NONE
target_fw is unknown, or its required bootloader matches bootloader_version.
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:88
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.
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.