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.
31 RX_REJECT, ///< We received a frame that parsed but was rejected (wrong source, wrong command, etc.).
32 LBT_DEFER, ///< A listen-before-talk check deferred a transmit because the channel was busy.
33 HOP, ///< The radio hopped to a different channel while waiting.
34 PHASE, ///< The pairing state machine advanced to a new phase.
35};
36
37/// @brief Maximum number of events recorded per pairing attempt.
38///
39/// Events beyond this bound are still counted in PairingTelemetry::heard_count() but not
40/// stored — the array is a fixed-size ring-free buffer (first N events), not a true ring.
41static constexpr uint8_t PAIRING_TELEMETRY_MAX_EVENTS = 32;
42
43/// @brief One recorded telemetry event.
45 uint32_t millis_offset{0}; ///< millis() at record time, relative to PairingTelemetry::begin().
47 uint8_t cmd{0}; ///< Frame command byte (TX/RX/RX_REJECT); unused otherwise.
48 uint8_t src_node[NODE_ID_SIZE]{}; ///< Sender node ID (RX/RX_REJECT only); zero otherwise.
49 uint8_t dst_node[NODE_ID_SIZE]{}; ///< Destination node ID (RX/RX_REJECT only); zero otherwise.
50 int16_t rssi{0}; ///< RSSI in dBm (RX/RX_REJECT/LBT_DEFER); zero otherwise.
51 uint8_t aux{0}; ///< Kind-specific extra byte: PHASE -> pairing::PairingState value;
52 ///< LBT_DEFER -> retry number reached; unused (0) for HOP.
53 bool oneway{false}; ///< CTRL0 1W-protocol bit (RX/RX_REJECT only); false otherwise.
54};
55
56/// @brief Final disposition of a pairing attempt, used by the result sensor string.
57enum class PairingOutcome : uint8_t {
58 NONE, ///< No attempt has completed yet (initial state).
59 PAIRED, ///< All three phases completed successfully.
60 NO_RESPONSE, ///< No device responded to discovery.
61 INVALID_RESPONSE, ///< Discovery saw traffic but nothing valid.
62 KEY_EXCHANGE_FAILED, ///< Discovery succeeded but the key exchange did not complete.
63 CONFIG_FAILED, ///< Key exchange succeeded but SetConfig1 failed (still counted as paired).
64};
65
66/// @brief Fixed-size per-attempt telemetry recorder for the pairing flow.
67/// @ingroup hioc_hub
68///
69/// Owned by the hub, reset at the start of every `discover_and_pair()` call. Not thread-safe —
70/// pairing is a single blocking call on the main loop, matching the rest of this component.
72 public:
73 /// Reset all state and start a new attempt. Call once at `discover_and_pair()` entry.
74 void begin();
75
76 /// Record that we transmitted a frame.
77 /// @param cmd Frame command byte.
78 void record_tx(uint8_t cmd);
79 /// Record that we received and accepted a frame.
80 /// @param frame Parsed frame (cmd, src, dst, and the 1W protocol bit are recorded — `dst` and
81 /// the 1W bit feed PairingAdvisor's ONE_WAY_PAIRING_TRAFFIC and
82 /// FOREIGN_CONTROLLER_PAIRING detection, see pairing_advisor.h).
83 /// @param rssi RSSI in dBm.
84 void record_rx(const IoFrame &frame, int16_t rssi);
85 /// Record that we received a frame that parsed but was rejected by a classifier.
86 /// @param frame Parsed frame (cmd, src, dst, and the 1W protocol bit are recorded — `dst` and
87 /// the 1W bit feed PairingAdvisor's ONE_WAY_PAIRING_TRAFFIC and
88 /// FOREIGN_CONTROLLER_PAIRING detection, see pairing_advisor.h).
89 /// @param rssi RSSI in dBm.
90 void record_rx_reject(const IoFrame &frame, int16_t rssi);
91 /// Record a listen-before-talk defer (channel busy).
92 /// @param rssi RSSI in dBm that triggered the defer.
93 void record_lbt_defer(int16_t rssi);
94 /// Record a frequency hop while waiting. The exchange engine does not expose which channel
95 /// index a hop landed on, so this only marks that a hop happened, not where to.
96 void record_hop();
97 /// Record a pairing state-machine phase transition.
98 /// @param phase New phase.
100 /// Record the final outcome of the attempt.
101 /// @param outcome Final disposition.
103 /// Record the successfully paired device, if any.
104 /// @param node_id Paired device's node ID.
105 /// @param type Paired device's type.
106 void set_paired_device(const uint8_t node_id[NODE_ID_SIZE], DeviceType type);
107 /// Increment the discovery attempt counter (one call per discovery command retry).
109 /// Record the advisor's short advice codes for the `;advice=` result-sensor field.
110 /// @param codes Comma-separated short codes (e.g. "1w_traffic,channel_busy"), or empty if none.
111 void set_advice_codes(const std::string &codes) { this->advice_codes_ = codes; }
112
113 /// @return Recorded events (up to PAIRING_TELEMETRY_MAX_EVENTS), oldest first.
114 [[nodiscard]] const PairingTelemetryEvent *events() const { return this->events_; }
115 /// @return Number of events actually stored (<= PAIRING_TELEMETRY_MAX_EVENTS).
116 [[nodiscard]] uint8_t event_count() const { return this->event_count_; }
117 /// @return Total RX events seen, including ones beyond the storage capacity.
118 [[nodiscard]] uint16_t heard_count() const { return this->heard_count_; }
119 /// @return true if any event (of any kind — TX/RX/RX_REJECT/LBT_DEFER/HOP/PHASE) was dropped
120 /// because the fixed event array was full. Deliberately not derived from
121 /// `heard_count() > event_count()` — the two counters measure different populations
122 /// (`heard_count()` is RX/RX_REJECT only; `event_count()` is stored events of every kind), so
123 /// that comparison misses truncation whenever most stored events aren't RX/RX_REJECT.
124 [[nodiscard]] bool truncated() const { return this->truncated_; }
125 /// @return Highest phase reached during the attempt.
126 [[nodiscard]] pairing::PairingState phase() const { return this->phase_; }
127 /// @return Final outcome, or PairingOutcome::NONE if no attempt has completed yet.
128 [[nodiscard]] PairingOutcome outcome() const { return this->outcome_; }
129 /// @return Number of discovery command attempts made.
130 [[nodiscard]] uint8_t discovery_attempts() const { return this->discovery_attempts_; }
131 /// @return Total listen-before-talk retries consumed across the whole attempt.
132 [[nodiscard]] uint8_t lbt_retries() const { return this->lbt_retries_; }
133 /// @return true if a device was successfully paired this attempt.
134 [[nodiscard]] bool has_paired_device() const { return this->has_paired_device_; }
135 /// @return Paired device's node ID; only meaningful if has_paired_device() is true.
136 [[nodiscard]] const uint8_t *paired_node_id() const { return this->paired_node_id_; }
137 /// @return Paired device's type; only meaningful if has_paired_device() is true.
138 [[nodiscard]] DeviceType paired_device_type() const { return this->paired_device_type_; }
139 /// @return Duration of the attempt so far (or total, once complete) in milliseconds.
140 [[nodiscard]] uint32_t duration_ms() const;
141
142 /// Emit a multi-line human-readable summary via ESP_LOGI. Call once, at the end of the attempt.
143 void log_summary() const;
144
145 /// @brief Render the frozen `v1;` machine-readable result string.
146 ///
147 /// Format (never change without bumping the version tag — the Phase 2 rig's read-back
148 /// contract parses this exactly; the space after each `;` is intentional — Home Assistant's
149 /// UI only line-wraps this sensor's long value at whitespace, so a fields-only-separated-by-`;`
150 /// string renders as one unbroken, unreadable line):
151 /// `v1; outcome=<...>; phase=<...>; node=<XXXXXX|->; type=<...|->; attempts=<n>; lbt=<n>; dur_ms=<n>; heard=<n>; `
152 /// `advice=<comma-separated codes|none>`
153 /// @return The formatted result string.
154 [[nodiscard]] std::string result_sensor_string() const;
155
156 private:
157 /// Append an event to the fixed array (dropped silently past capacity; heard_count_ still counts it).
158 void record_(PairingTelemetryEventKind kind, uint8_t cmd, const uint8_t *src, const uint8_t *dst, int16_t rssi,
159 uint8_t aux, bool oneway);
160
162 uint8_t event_count_{0};
163 uint16_t heard_count_{0};
164 bool truncated_{false};
165 uint32_t start_ms_{0};
166 uint32_t end_ms_{0};
167 bool ended_{false};
170 uint8_t discovery_attempts_{0};
171 uint8_t lbt_retries_{0};
172 bool has_paired_device_{false};
173 uint8_t paired_node_id_[NODE_ID_SIZE]{};
174 DeviceType paired_device_type_{DeviceType::UNKNOWN};
175 std::string advice_codes_;
176};
177
178} // namespace home_io_control
179} // 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_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....
@ HOP
The radio hopped to a different channel while waiting.
@ 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.
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:71
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); zero otherwise.
uint8_t aux
Kind-specific extra byte: PHASE -> pairing::PairingState value; LBT_DEFER -> retry number reached; un...
uint8_t src_node[NODE_ID_SIZE]
Sender node ID (RX/RX_REJECT only); zero otherwise.