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};
87
88/// @brief Fixed-size per-attempt telemetry recorder for the pairing flow.
89/// @ingroup hioc_hub
90///
91/// Owned by the hub, reset at the start of every `discover_and_pair()` call. Not thread-safe —
92/// pairing is a single blocking call on the main loop, matching the rest of this component.
94 public:
95 /// TransmitObserver: records a TX event for the frame's command byte (see record_tx()).
96 void on_transmit(const IoFrame &frame, const RadioTxConfig &config, uint8_t wire_len) override {
97 this->record_tx(frame.cmd);
98 }
99 /// TransmitObserver: records an LBT_DEFER event (see record_lbt_defer()).
100 void on_lbt_defer(int16_t rssi_dbm) override { this->record_lbt_defer(rssi_dbm); }
101
102 /// Reset all state and start a new attempt. Call once at `discover_and_pair()` entry.
103 void begin();
104
105 /// Record that we transmitted a frame.
106 /// @param cmd Frame command byte.
107 void record_tx(uint8_t cmd);
108 /// Record that we received and accepted a frame.
109 /// @param frame Parsed frame (cmd, src, dst, and the 1W protocol bit are recorded — `dst` and
110 /// the 1W bit feed PairingAdvisor's ONE_WAY_PAIRING_TRAFFIC and
111 /// FOREIGN_CONTROLLER_PAIRING detection, see pairing_advisor.h).
112 /// @param rssi RSSI in dBm.
113 void record_rx(const IoFrame &frame, int16_t rssi);
114 /// Record that we received a frame that parsed but was rejected by a classifier.
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_reject(const IoFrame &frame, int16_t rssi);
120 /// Record a listen-before-talk defer (channel busy).
121 /// @param rssi RSSI in dBm that triggered the defer.
122 void record_lbt_defer(int16_t rssi);
123 /// Record a frequency hop while waiting. The exchange engine does not expose which channel
124 /// index a hop landed on, so this only marks that a hop happened, not where to.
125 ///
126 /// Counted via hop_count() only, deliberately **not** stored in the fixed event array: at the
127 /// current 5-7 ms hop slice, a multi-second discovery window produces far more hops than
128 /// PAIRING_TELEMETRY_MAX_EVENTS, which used to exhaust the buffer with hop events alone within
129 /// about a second of essentially every real attempt (issue #27). This did **not** produce a false
130 /// `rf_silent` — heard_count() is incremented in record_() before the capacity check, so it still
131 /// counts an RX/RX_REJECT that arrives after the array is full. What it did do: once the array
132 /// was full, PairingAdvisor's `1w_traffic`/`channel_busy`/`foreign_controller` passes — which all
133 /// scan events(), not heard_count() — could only ever see whatever RX/RX_REJECT evidence had
134 /// landed in roughly the first ~200 ms of the attempt, and truncated() was true on nearly every
135 /// real pairing attempt. A hop carries no per-event information worth itemizing (no channel
136 /// index, no RSSI) anyway; the total count is enough.
137 void record_hop();
138 /// Record a pairing state-machine phase transition.
139 /// @param phase New phase.
141 /// Record the final outcome of the attempt.
142 /// @param outcome Final disposition.
144 /// Record the successfully paired device, if any.
145 /// @param node_id Paired device's node ID.
146 /// @param type Paired device's type.
147 void set_paired_device(const uint8_t node_id[NODE_ID_SIZE], DeviceType type);
148 /// Increment the discovery attempt counter (one call per discovery command retry).
150 /// Record the advisor's short advice codes for the `;advice=` result-sensor field.
151 /// @param codes Comma-separated short codes (e.g. "1w_traffic,channel_busy"), or empty if none.
152 void set_advice_codes(const std::string &codes) { this->advice_codes_ = codes; }
153
154 /// Seed telemetry with a 1W pairing-gesture frame observed shortly before this attempt began.
155 /// Recorded as an RX event with its real RSSI, marked seeded (PairingTelemetryEvent::aux = 1) so
156 /// log_summary() can label it honestly instead of implying a live in-window capture, and so
157 /// PairingAdvisor's channel_busy pass — which is specifically about *live* contention during
158 /// this attempt — can exclude it while ONE_WAY_PAIRING_TRAFFIC still picks it up like any other
159 /// RX. It counts toward heard_count(). Timestamped at the start of the attempt (millis_offset
160 /// ≈ 0), since its real arrival time — before begin() — isn't preserved; the seeded marker is
161 /// what tells a reader not to read that as "heard 0 ms into this attempt". The caller (
162 /// PairingEngine::discover_and_pair()) is responsible for deciding whether `sighting` is recent
163 /// enough to seed with — this method always records what it's given.
164 /// @param sighting The overheard frame to seed telemetry with.
166
167 /// @return Recorded events (up to PAIRING_TELEMETRY_MAX_EVENTS), oldest first.
168 [[nodiscard]] const PairingTelemetryEvent *events() const { return this->events_; }
169 /// @return Number of events actually stored (<= PAIRING_TELEMETRY_MAX_EVENTS).
170 [[nodiscard]] uint8_t event_count() const { return this->event_count_; }
171 /// @return Total RX events seen, including ones beyond the storage capacity.
172 [[nodiscard]] uint16_t heard_count() const { return this->heard_count_; }
173 /// @return Total number of channel hops during the attempt. Not itemized in events() — see
174 /// record_hop()'s doc comment for why.
175 [[nodiscard]] uint32_t hop_count() const { return this->hop_count_; }
176 /// @return true if any event (of any kind — TX/RX/RX_REJECT/LBT_DEFER/HOP/PHASE) was dropped
177 /// because the fixed event array was full. Deliberately not derived from
178 /// `heard_count() > event_count()` — the two counters measure different populations
179 /// (`heard_count()` is RX/RX_REJECT only; `event_count()` is stored events of every kind), so
180 /// that comparison misses truncation whenever most stored events aren't RX/RX_REJECT.
181 [[nodiscard]] bool truncated() const { return this->truncated_; }
182 /// @return Highest phase reached during the attempt.
183 [[nodiscard]] pairing::PairingState phase() const { return this->phase_; }
184 /// @return Final outcome, or PairingOutcome::NONE if no attempt has completed yet.
185 [[nodiscard]] PairingOutcome outcome() const { return this->outcome_; }
186 /// @return Number of discovery command attempts made.
187 [[nodiscard]] uint8_t discovery_attempts() const { return this->discovery_attempts_; }
188 /// @return Total listen-before-talk retries consumed across the whole attempt.
189 [[nodiscard]] uint8_t lbt_retries() const { return this->lbt_retries_; }
190 /// @return true if a device was successfully paired this attempt.
191 [[nodiscard]] bool has_paired_device() const { return this->has_paired_device_; }
192 /// @return Paired device's node ID; only meaningful if has_paired_device() is true.
193 [[nodiscard]] const uint8_t *paired_node_id() const { return this->paired_node_id_; }
194 /// @return Paired device's type; only meaningful if has_paired_device() is true.
195 [[nodiscard]] DeviceType paired_device_type() const { return this->paired_device_type_; }
196 /// @return Duration of the attempt so far (or total, once complete) in milliseconds.
197 [[nodiscard]] uint32_t duration_ms() const;
198
199 /// Emit a multi-line human-readable summary via ESP_LOGI. Call once, at the end of the attempt.
200 void log_summary() const;
201
202 /// @brief Render the frozen `v1;` machine-readable result string.
203 ///
204 /// Format (never change without bumping the version tag — the Phase 2 rig's read-back
205 /// contract parses this exactly; the space after each `;` is intentional — Home Assistant's
206 /// UI only line-wraps this sensor's long value at whitespace, so a fields-only-separated-by-`;`
207 /// string renders as one unbroken, unreadable line):
208 /// `v1; outcome=<...>; phase=<...>; node=<XXXXXX|->; type=<...|->; attempts=<n>; lbt=<n>; dur_ms=<n>; heard=<n>; `
209 /// `advice=<comma-separated codes|none>`
210 /// @return The formatted result string.
211 [[nodiscard]] std::string result_sensor_string() const;
212
213 private:
214 /// Append an event to the fixed array (dropped silently past capacity; heard_count_ still counts it).
215 void record_(PairingTelemetryEventKind kind, uint8_t cmd, const uint8_t *src, const uint8_t *dst, int16_t rssi,
216 uint8_t aux, bool oneway);
217
219 uint8_t event_count_{0};
220 uint16_t heard_count_{0};
221 uint32_t hop_count_{0};
222 bool truncated_{false};
223 uint32_t start_ms_{0};
224 uint32_t end_ms_{0};
225 bool ended_{false};
228 uint8_t discovery_attempts_{0};
229 uint8_t lbt_retries_{0};
230 bool has_paired_device_{false};
231 uint8_t paired_node_id_[NODE_ID_SIZE]{};
232 DeviceType paired_device_type_{DeviceType::UNKNOWN};
233 std::string advice_codes_;
234};
235
236} // namespace home_io_control
237} // 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_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).
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_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.
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
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
The key exchange completed and the device is registered.
@ 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: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 and preamble length.
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.