18#include "esphome/core/application.h"
19#include "esphome/core/log.h"
30const char *
const TAG =
"home_io_control";
39 ESP_LOGW(
TAG,
"No device responded to discovery");
42 ESP_LOGW(
TAG,
"No valid discovery response received");
56 : radio_ptr_(radio_ptr),
58 system_key_(system_key),
62 telemetry_(telemetry) {}
79 const uint32_t hop_slice_ms = radio_()->discovery_hop_slice_ms(*tuning_);
80 static constexpr uint32_t PREAMBLE_DWELL_MS = 15;
82 auto try_accept = [&]() {
87 this->record_discovery_rx_telemetry_(response_frame, accepted, radio_()->get_last_capture().rssi_dbm);
91 bool saw_traffic =
false;
92 const uint32_t deadline = millis() + timeout_ms;
93 while ((int32_t) (deadline - millis()) > 0) {
94 const uint32_t slice = std::min((uint32_t) (deadline - millis()), hop_slice_ms);
95 if (radio_()->wait_for_packet(packet, slice)) {
99 if ((int32_t) (deadline - millis()) > 0 && !radio_()->is_preamble_detected() && !radio_()->is_sync_detected()) {
100 engine_.hop_frequency();
101 this->telemetry_.record_hop();
105 if ((int32_t) (deadline - millis()) <= 0)
107 if (!radio_()->is_preamble_detected() && !radio_()->is_sync_detected()) {
108 engine_.hop_frequency();
109 this->telemetry_.record_hop();
112 const uint32_t ext = std::min((uint32_t) (deadline - millis()), PREAMBLE_DWELL_MS);
113 if (radio_()->wait_for_packet(packet, ext)) {
131 bool saw_traffic =
false;
132 const uint32_t deadline = millis() + timeout_ms;
133 while ((int32_t) (deadline - millis()) > 0) {
134 const uint32_t remaining_ms = deadline - millis();
136 if (!radio_()->wait_for_packet(packet, slice))
141 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
144 this->telemetry_.record_rx(challenge_frame, rssi);
149 this->telemetry_.record_rx_reject(challenge_frame, rssi);
152 this->telemetry_.record_rx(challenge_frame, rssi);
155 ESP_LOGW(
TAG, saw_traffic ?
"Key exchange: no valid challenge received" :
"Key exchange: no challenge received");
171 if (!engine_.transmit_frame(context.
req,
FREQ_CH2, radio_()->response_preamble()))
175 bool saw_any =
false;
176 while ((int32_t) (deadline - millis()) > 0) {
177 const uint32_t remaining = deadline - millis();
179 if (!radio_()->wait_for_packet(context.
packet, slice)) {
180 if ((int32_t) (deadline - millis()) > 0) {
181 engine_.hop_frequency();
182 this->telemetry_.record_hop();
189 ESP_LOGD(
TAG,
"Key confirm wait: parse failed");
192 ESP_LOGD(
TAG,
"Key confirm wait: parsed cmd=0x%02X src=%02X%02X%02X dst=%02X%02X%02X", context.
resp.
cmd,
197 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
198 if (frame_is_key_confirm(context.
resp)) {
199 this->telemetry_.record_rx(context.
resp, rssi);
202 this->telemetry_.record_rx_reject(context.
resp, rssi);
203 ESP_LOGW(
TAG,
"Key transfer: device responded with cmd=%s(0x%02X) (expected KEY_CONFIRM 0x33)",
206 ESP_LOGW(
TAG,
"Key transfer: error code=0x%02X", context.
resp.
data[0]);
209 ESP_LOGI(
TAG,
"Try %d ended: no response for key transfer (0x32) within %" PRIu32
" ms (saw_any=%d)", tries + 1,
228 ESP_LOGI(
TAG,
"Discovery: device %s manufacturer=%u (%s)", device_id.c_str(), info.
manufacturer, mfr_name);
231 "Unknown manufacturer ID %u reported by device %s. "
232 "Please file a GitHub issue with this ID and your device model so support can be added.",
242 ESP_LOGI(
TAG,
"Discovery: device %s turnaround=%s power_save=%s flags=0x%02X", device_id.c_str(),
246 "Device %s reports low-power mode. "
247 "Consider adding 'low_power: true' to YAML if commands are unreliable.",
261 if (tuning_->pairing_discovery_initial_dwell_ms > 0) {
262 ESP_LOGD(
TAG,
"Discovery: initial dwell %u ms", tuning_->pairing_discovery_initial_dwell_ms);
263 delay(tuning_->pairing_discovery_initial_dwell_ms);
270 bool saw_invalid =
false;
272 for (
size_t command_index = 0; command_index < tuning_->pairing_discovery_commands.size(); ++command_index) {
273 auto command =
static_cast<uint8_t
>(tuning_->pairing_discovery_commands[command_index]);
275 tuning_->pairing_discovery_destination.data());
276 ESP_LOGD(
TAG,
"Discovery command %zu/%zu: cmd=0x%02X dst=%02X%02X%02X", command_index + 1,
277 tuning_->pairing_discovery_commands.size(), command, destination[0], destination[1], destination[2]);
280 this->telemetry_.increment_discovery_attempt();
283 this->telemetry_.set_phase(context.
state);
285 tuning_->pairing_discovery_payload_enabled, tuning_->pairing_discovery_payload,
293 this->telemetry_.set_phase(context.
state);
305 ESP_LOGI(
TAG,
"Discovery attempt %u/%u for cmd=0x%02X: no response, retrying...", attempt,
330 this->telemetry_.set_phase(context.
state);
338 this->telemetry_.set_phase(context.
state);
345 ESP_LOGI(
TAG,
"Device accepted key immediately (0x33 without 0x32 exchange)");
346 context.
resp = context.
rx;
353 ESP_LOGI(
TAG,
"Challenge (0x3C) received: data_len=%u freq=%" PRIu32
" rssi=%d", context.
rx.
data_len,
354 context.
packet.
freq_hz, radio_()->get_last_capture().rssi_dbm);
358 this->telemetry_.set_phase(context.
state);
366 this->telemetry_.set_phase(context.
state);
371 if (radio_()->has_fast_tx_rx_turnaround()) {
372 key_ok = engine_.send_and_receive(context.
req, context.
resp,
FREQ_CH2) && frame_is_key_confirm(context.
resp);
375 for (
int re = 0; !key_ok && re < 2; re++) {
376 ESP_LOGI(
TAG,
"Key confirm missed, re-sending key-init to trigger auto-confirm (attempt %d/2)", re + 1);
389 ESP_LOGW(
TAG,
"Key exchange failed");
413 this->telemetry_.begin();
414 this->engine_.set_pairing_telemetry(&this->telemetry_);
416 ESP_LOGI(
TAG,
"Starting device discovery...");
424 log_discovery_diagnostic(disc_disp);
432 bool key_exchanged =
false;
433 for (uint8_t ke_attempt = 0; ke_attempt < tuning_->pairing_key_exchange_retries; ke_attempt++) {
434 if (ke_attempt > 0) {
435 ESP_LOGI(
TAG,
"Retrying key exchange (attempt %d/%u)...", ke_attempt + 1, tuning_->pairing_key_exchange_retries);
440 key_exchanged =
true;
444 if (!key_exchanged) {
456 this->telemetry_.set_phase(context.
state);
466 if (snippet.empty()) {
471 "Device %s paired successfully, but this repo does not yet expose an ESPHome platform for type=%s "
472 "class=%s subtype=%u.",
476 "No ready-to-paste YAML was generated. If you want to experiment manually, choose the most likely "
477 "platform and set io_device_type: %s.",
479 ESP_LOGW(
TAG,
"Please file a GitHub issue with this device type, subtype, model, and the pairing log so support "
484 this->telemetry_.set_phase(context.
state);
485 this->finish_pairing_attempt_(outcome);
491 "Device %s paired successfully, but the discovery response did not include type/subtype "
492 "metadata, so the platform (cover/light/switch/lock) can't be determined automatically.",
494 ESP_LOGI(
TAG,
"Add this to your YAML once you know what kind of device it is:\n%s", snippet.c_str());
495 ESP_LOGW(
TAG,
"Please file a GitHub issue with the pairing log and device model so this discovery edge case can "
500 this->telemetry_.set_phase(context.
state);
501 this->finish_pairing_attempt_(outcome);
505 ESP_LOGI(
TAG,
"Device %s paired successfully! Add this to your YAML:\n%s", context.
device_id.c_str(),
510 "This snippet uses the raw device type %s because the project does not yet expose a named YAML alias "
512 type_yaml.c_str(), type_diag.c_str());
513 ESP_LOGW(
TAG,
"Please file a GitHub issue with this type, subtype, device model, and the pairing log so support "
519 this->telemetry_.set_phase(context.
state);
520 this->finish_pairing_attempt_(outcome);
524void PairingEngine::finish_pairing_attempt_(
PairingOutcome outcome) {
531 std::string advice_codes;
532 for (uint8_t i = 0; i < advice_count; i++) {
534 if (!advice_codes.empty())
541void PairingEngine::record_discovery_rx_telemetry_(
const IoFrame &frame,
bool accepted, int16_t rssi) {
545 this->telemetry_.record_rx_reject(frame, rssi);
Owns the per-hub device table, update callbacks, and linked-remote associations.
Authenticated exchange engine — outbound and inbound protocol flows.
void set_pairing_telemetry(PairingTelemetry *telemetry)
Attach a telemetry recorder so transmit_frame()'s LBT loop records defer events.
decisions::PairingDiscoveryDisposition run_discovery_phase_(pairing::PairingContext &context)
Phase 1: broadcast discovery command(s) and wait for a device response (0x29).
PairingEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning, ExchangeEngine &engine, DeviceRegistry ®istry, PairingTelemetry &telemetry)
Construct the engine with all required collaborators.
static void parse_device_from_discovery(const IoFrame &frame, IoDevice &device, std::string &device_id)
Extract node ID, device type, and subtype from a CMD_DISCOVER_RESP frame.
bool run_key_exchange_phase_(pairing::PairingContext &context)
Phase 2: authenticated key exchange (0x31 → 0x3C → 0x32 → 0x33).
bool wait_for_key_confirm_(pairing::PairingContext &context)
Transmit the 0x32 key transfer and wait for the 0x33 key confirm with retry.
bool wait_for_key_challenge_(uint32_t timeout_ms, RadioRxPacket &packet, IoFrame &challenge_frame, const uint8_t device_node_id[NODE_ID_SIZE])
Wait for a key-challenge (0x3C) or direct key-confirm (0x33) from the target device.
bool discover_and_pair()
Discover and pair a device currently in pairing mode (three-phase orchestrator).
decisions::PairingDiscoveryDisposition wait_for_discovery_response_(uint32_t timeout_ms, RadioRxPacket &packet, IoFrame &response_frame)
Wait for a discovery response (0x29) within timeout_ms with per-chip frequency hopping.
bool finalize_pairing_configuration_(pairing::PairingContext &context)
Phase 3: send SetConfig1 (0x6F) to enable automatic status updates; best-effort.
Fixed-size per-attempt telemetry recorder for the pairing flow.
void set_advice_codes(const std::string &codes)
Record the advisor's short advice codes for the ;advice= result-sensor field.
void log_summary() const
Emit a multi-line human-readable summary via ESP_LOGI. Call once, at the end of the attempt.
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.
Abstract radio driver for IO-Homecontrol.
Pure transition helpers for hub-owned exchange and pairing frame decisions.
std::string pairing_advice_message(const PairingAdvice &advice)
static constexpr uint8_t PAIRING_ADVICE_MAX
Maximum number of advice entries a single attempt can produce (one slot per non-NONE code).
const char * pairing_advice_code_name(PairingAdviceCode code)
uint8_t analyze_pairing_telemetry(const PairingTelemetry &telemetry, const uint8_t own_node_id[NODE_ID_SIZE], PairingAdvice out[PAIRING_ADVICE_MAX])
Inspect a completed pairing attempt's telemetry and produce actionable advice.
PairingDiscoveryDisposition
Disposition during pairing discovery phase.
@ ACCEPT
Valid discovery response received.
@ NO_RESPONSE
No packets received on the channel within timeout.
@ INVALID
Packets seen but none were valid discovery (0x29) frames.
PairingKeyChallengeDisposition classify_pairing_key_challenge(const IoFrame &candidate, const uint8_t device_id[NODE_ID_SIZE], const uint8_t controller_id[NODE_ID_SIZE])
Decide if a frame is a valid key-challenge (0x3C) during pairing key exchange.
@ ACCEPT
Valid 0x3C challenge from target device.
PairingDiscoveryDisposition classify_pairing_discovery_response(const IoFrame &candidate, const uint8_t controller_id[NODE_ID_SIZE])
Decide if a frame is a valid discovery response (0x29) during pairing.
uint32_t response_wait_slice_ms(uint32_t remaining_ms)
Slice remaining wait time into bounded intervals to allow frequency hopping.
bool frame_matches_exchange_endpoints(const IoFrame &request, const IoFrame &candidate)
Check if candidate frame endpoints are the reverse of the request (dst==request.src,...
@ REGISTER_DEVICE
Registering device in the runtime registry for the current boot.
@ TX_DISCOVER
Discovery broadcast (0x28) sent; awaiting device response.
@ WAIT_DISCOVER_RESPONSE
Listening for discovery response (0x29) from a device in pairing mode.
@ COMPLETE
Pairing completed successfully; device ready for use.
@ WAIT_KEY_CHALLENGE
Waiting for challenge (0x3C) from device as part of key transfer.
@ WAIT_KEY_CONFIRM
Waiting for key‑confirm (0x33) from device (key receipt acknowledgement).
@ TX_KEY_INIT
Key‑init (0x31) sent to the discovered device.
@ TX_KEY_TRANSFER
Key‑transfer (0x32) sent with encrypted system key.
const char * manufacturer_name(uint8_t id)
Get a human-readable manufacturer name from the protocol manufacturer byte.
static constexpr uint8_t DEVICE_METADATA_SIZE
Packed device metadata uses two bytes where the high 8 bits carry the upper type bits and the low byt...
uint8_t discovery_power_save_mode(uint8_t flags)
Extract the power save mode field from a discovery response's Multi Information Byte.
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
std::string format_device_type_for_yaml(DeviceType type)
Build the YAML value for a device's io_device_type key.
static constexpr uint8_t CMD_ERROR_RESP
Error response to any command.
const char * att_class_name(uint8_t att_class)
Get a human-readable turnaround time string for an ATT class value.
static constexpr uint8_t DISCOVERY_RESP_MANUFACTURER_OFFSET
Manufacturer ID at data[5].
constexpr uint8_t PAIRING_DISCOVERY_MAX_ATTEMPTS
Retry discovery TX up to this many times.
std::string build_device_yaml_snippet(DeviceType type, uint8_t subtype, const std::string &device_id, bool metadata_complete, bool inverted)
Build the ready-to-paste YAML block describing a device, for both a fully-decoded device and one whos...
static constexpr const char * TAG
const char * power_save_mode_name(uint8_t mode)
Get a human-readable power save mode name.
constexpr uint32_t PAIRING_KEY_CONFIRM_TIMEOUT_MS
Wait for 0x33 key confirm after sending 0x32.
bool create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power, bool payload_enabled, uint8_t payload, const uint8_t *system_key)
Build a configurable discovery request command (0x28, 0x2A, or 0x2E).
const char * command_name(uint8_t cmd)
Get a human-readable name for any IO-Homecontrol command ID.
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.
static constexpr uint8_t DISCOVERY_RESP_BACKBONE_OFFSET
Byte offsets within CMD_DISCOVER_RESP (0x29) payload data.
static constexpr uint8_t CMD_KEY_CONFIRM
Device confirms key was received.
static constexpr int32_t EXCHANGE_RETRY_DELAY_MS
Gap between retries within one HA command.
bool create_set_config1(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a set-config command (0x6F) to tell the device to automatically send status updates when contro...
constexpr uint32_t PAIRING_KEY_CONFIRM_SLICE_MS
RX slice during key confirm wait (hop each slice).
bool create_key_init(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a key-init request (0x31) to start the pairing key exchange with a discovered device.
const char * pairing_stage_name(pairing::PairingState state)
Get a short, log/telemetry-friendly name for a pairing state.
static constexpr uint8_t DISCOVERY_RESP_FLAGS_OFFSET
Flags byte at data[6].
std::string tuning_config_full_snapshot(const TuningConfig &cfg)
Format the current tuning configuration as a full one-line snapshot.
bool parse(const uint8_t *buf, uint8_t buf_len, IoFrame &f)
Parse a wire buffer into a parsed IoFrame (validates length and CTRL0).
static constexpr uint8_t MANUFACTURER_ID_MAX
Maximum manufacturer ID with a known name in the lookup table.
static constexpr uint8_t EXCHANGE_RETRY_COUNT
Attempts per command before reporting failure.
const char * device_capability_class_name(DeviceType type)
Get a human‑readable name for a capability class.
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
uint8_t discovery_att_class(uint8_t flags)
Extract the ATT class field from a discovery response's Multi Information Byte.
static constexpr uint8_t POWER_SAVE_LOW_POWER
Device sleeps — needs long preamble to wake.
constexpr uint32_t PAIRING_KEY_CHALLENGE_TIMEOUT_MS
Wait window for the device's 0x3C challenge.
const char * yaml_device_type_name(DeviceType type)
Return the YAML-friendly device-type name for types exposed in the Python schema.
static constexpr uint16_t LONG_PREAMBLE
Preamble is a sequence of 0xAA bytes that precedes every frame.
std::string format_device_type_diagnostic(DeviceType type)
Human-readable device type string for diagnostics, including the raw numeric value.
const uint8_t * resolve_discovery_destination(uint8_t command, bool destination_auto, const uint8_t destination[NODE_ID_SIZE])
Resolve the destination address for a discovery command.
DiscoveryResponseInfo decode_discovery_response(const IoFrame &frame, IoDevice &device, std::string &device_id)
Decode a discovery-response payload (CMD_DISCOVER_RESP 0x29 or CMD_DISCOVER_SPE_RESP 0x2B — both carr...
bool create_key_transfer(IoFrame &f, IoFrame &old_frame, const uint8_t *dst, const uint8_t *src, const uint8_t key[AES_KEY_SIZE], const uint8_t challenge[HMAC_SIZE])
Build a key-transfer frame (0x32) containing the system key encrypted with the transfer key.
Device discovery and key-exchange engine for IO-Homecontrol pairing.
Command builders for the IO‑Homecontrol protocol.
Extended discovery-response fields (manufacturer, Multi Information Byte, backbone address,...
uint8_t backbone[NODE_ID_SIZE]
Backbone address as reported by the device.
uint8_t manufacturer
Raw manufacturer ID; name via manufacturer_name().
uint8_t flags
Multi Information Byte; decode with DISCOVERY_FLAGS_* masks.
Runtime state of a paired IO‑Homecontrol device.
bool inverted
True if open/close positions are swapped (e.g., horizontal awning).
uint8_t subtype
Device subtype (manufacturer‑specific).
uint8_t node_id[NODE_ID_SIZE]
Device's 3‑byte radio address.
DeviceType type
Device type (shutter, awning, etc.).
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes).
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
uint8_t data_len
Actual length of data.
Raw packet received from the radio.
uint8_t len
Length of packet in bytes.
uint32_t freq_hz
Frequency the packet was received on (Hz).
uint8_t data[RADIO_PACKET_BUFFER_SIZE]
Raw packet data buffer.
All runtime tunable parameters for pairing and radio diagnostics.
One piece of advice, with the node/RSSI it pertains to (if any).
Context object that lives for the duration of a single pairing attempt.
IoFrame req
Outbound frame buffer (reused across all phases).
PairingState state
Current state machine state.
IoFrame resp
Inbound frame buffer (holds key‑confirm response).
RadioRxPacket packet
Raw radio capture for the current phase.
IoDevice device
Resolved device metadata after discovery (node_id, type, subtype, etc.).
IoFrame key_init
Key‑init frame retained for key‑transfer IV derivation.
std::string device_id
Hex string representation of the paired node ID.
bool discovery_metadata_complete
True when discovery carried type/subtype bytes.
IoFrame rx
Raw RX frame during waiting phases (discovery, challenge, confirm).
Runtime tuning configuration for pairing and radio diagnostics.