18#include "esphome/core/application.h"
19#include "esphome/core/log.h"
29const char *
const TAG =
"home_io_control";
38 ESP_LOGW(
TAG,
"No device responded to discovery");
41 ESP_LOGW(
TAG,
"No valid discovery response received");
55 : radio_ptr_(radio_ptr),
57 system_key_(system_key),
61 telemetry_(telemetry),
62 recent_oneway_sighting_(recent_oneway_sighting) {}
91 spec.
on_hop = [
this]() { this->telemetry_.record_hop(); };
93 bool saw_traffic =
false;
95 engine_.listen(spec, packet, response_frame, [&](
const IoFrame *parsed,
const RadioRxPacket & ) {
97 if (parsed ==
nullptr)
101 this->record_discovery_rx_telemetry_(*parsed, accepted, radio_()->get_last_capture().rssi_dbm);
130 bool saw_traffic =
false;
132 engine_.listen(spec, packet, challenge_frame, [&](
const IoFrame *parsed,
const RadioRxPacket & ) {
134 if (parsed ==
nullptr)
136 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
139 this->telemetry_.record_rx(*parsed, rssi);
144 this->telemetry_.record_rx_reject(*parsed, rssi);
147 this->telemetry_.record_rx(*parsed, rssi);
153 ESP_LOGW(
TAG, saw_traffic ?
"Key exchange: no valid challenge received" :
"Key exchange: no challenge received");
172 if (!engine_.transmit_frame(context.
req,
FREQ_CH2, radio_()->response_preamble()))
197 bool saw_any =
false;
201 ESP_LOGD(TAG,
"Key confirm wait: got %u bytes on freq=%" PRIu32, packet.len, packet.freq_hz);
202 if (parsed == nullptr) {
203 ESP_LOGD(TAG,
"Key confirm wait: parse failed");
204 return ReplyDisposition::IGNORE;
206 ESP_LOGD(
TAG,
"Key confirm wait: parsed cmd=0x%02X src=%02X%02X%02X dst=%02X%02X%02X", parsed->
cmd,
207 parsed->
src[0], parsed->
src[1], parsed->
src[2], parsed->
dst[0], parsed->
dst[1], parsed->
dst[2]);
210 const int16_t rssi = radio_()->get_last_capture().rssi_dbm;
211 if (frame_is_key_confirm(*parsed)) {
212 this->telemetry_.record_rx(*parsed, rssi);
215 this->telemetry_.record_rx_reject(*parsed, rssi);
216 ESP_LOGW(
TAG,
"Key transfer: device responded with cmd=%s(0x%02X) (expected KEY_CONFIRM 0x33)",
219 ESP_LOGW(
TAG,
"Key transfer: error code=0x%02X", parsed->
data[0]);
228 ESP_LOGI(
TAG,
"Try %d ended: no response for key transfer (0x32) within %" PRIu32
" ms (saw_any=%d)", tries + 1,
242 this->telemetry_.set_phase(context.
state);
250 this->telemetry_.set_phase(context.
state);
254 if (radio_()->has_fast_tx_rx_turnaround()) {
258 frame_is_key_confirm(context.
resp);
272 std::string &device_id) {
277 ESP_LOGI(
TAG,
"Discovery: device %s manufacturer=%u (%s)", device_id.c_str(), info.
manufacturer, mfr_name);
280 "Unknown manufacturer ID %u reported by device %s. "
281 "Please file a GitHub issue with this ID and your device model so support can be added.",
291 ESP_LOGI(
TAG,
"Discovery: device %s turnaround=%s power_save=%s flags=0x%02X", device_id.c_str(),
295 "Device %s reports low-power mode: add 'low_power: true' to its YAML entry. "
296 "The pairing snippet below pre-fills it when one is printed.",
311 if (tuning_->pairing_discovery_initial_dwell_ms > 0) {
312 ESP_LOGD(
TAG,
"Discovery: initial dwell %u ms", tuning_->pairing_discovery_initial_dwell_ms);
313 delay(tuning_->pairing_discovery_initial_dwell_ms);
320 bool saw_invalid =
false;
322 for (
size_t command_index = 0; command_index < tuning_->pairing_discovery_commands.size(); ++command_index) {
323 auto command =
static_cast<uint8_t
>(tuning_->pairing_discovery_commands[command_index]);
325 tuning_->pairing_discovery_destination.data());
326 ESP_LOGD(
TAG,
"Discovery command %zu/%zu: cmd=0x%02X dst=%02X%02X%02X", command_index + 1,
327 tuning_->pairing_discovery_commands.size(), command, destination[0], destination[1], destination[2]);
330 this->telemetry_.increment_discovery_attempt();
333 this->telemetry_.set_phase(context.
state);
335 tuning_->pairing_discovery_payload_enabled, tuning_->pairing_discovery_payload,
337 !engine_.transmit_frame(context.
req,
FREQ_CH2, tuning_->pairing_discovery_preamble)) {
343 this->telemetry_.set_phase(context.
state);
357 ESP_LOGI(
TAG,
"Discovery attempt %u/%u for cmd=0x%02X: no response, retrying...", attempt,
401 this->telemetry_.set_phase(context.
state);
409 this->telemetry_.set_phase(context.
state);
416 ESP_LOGI(
TAG,
"Device accepted key immediately (0x33 without 0x32 exchange)");
417 context.
resp = context.
rx;
424 ESP_LOGI(
TAG,
"Challenge (0x3C) received: data_len=%u freq=%" PRIu32
" rssi=%d", context.
rx.
data_len,
425 context.
packet.
freq_hz, radio_()->get_last_capture().rssi_dbm);
431 if (!radio_()->has_fast_tx_rx_turnaround()) {
432 for (
int re = 0; !key_ok && re < 2; re++) {
433 ESP_LOGI(
TAG,
"Key confirm missed, re-sending key-init to trigger auto-confirm (attempt %d/2)", re + 1);
453 ESP_LOGW(
TAG,
"Key exchange failed");
478 this->telemetry_.begin();
479 this->engine_.set_pairing_telemetry(&this->telemetry_);
486 if (this->recent_oneway_sighting_.seen_ms != 0 &&
488 this->telemetry_.record_recent_one_way_sighting(this->recent_oneway_sighting_);
491 ESP_LOGI(
TAG,
"Starting device discovery...");
499 log_discovery_diagnostic(disc_disp);
507 bool key_exchanged =
false;
508 for (uint8_t ke_attempt = 0; ke_attempt < tuning_->pairing_key_exchange_retries; ke_attempt++) {
509 if (ke_attempt > 0) {
510 ESP_LOGI(
TAG,
"Retrying key exchange (attempt %d/%u)...", ke_attempt + 1, tuning_->pairing_key_exchange_retries);
515 key_exchanged =
true;
519 if (!key_exchanged) {
531 this->telemetry_.set_phase(context.
state);
542 if (snippet.empty()) {
547 "Device %s paired successfully, but this repo does not yet expose an ESPHome platform for type=%s "
548 "class=%s subtype=%u.",
552 "No ready-to-paste YAML was generated. If you want to experiment manually, choose the most likely "
553 "platform and set io_device_type: %s.",
555 ESP_LOGW(
TAG,
"Please file a GitHub issue with this device type, subtype, model, and the pairing log so support "
560 this->telemetry_.set_phase(context.
state);
561 this->finish_pairing_attempt_(outcome);
567 "Device %s paired successfully, but the discovery response did not include type/subtype "
568 "metadata, so the platform (cover/light/switch/lock) can't be determined automatically.",
570 ESP_LOGI(
TAG,
"Add this to your YAML once you know what kind of device it is:\n%s", snippet.c_str());
571 ESP_LOGW(
TAG,
"Please file a GitHub issue with the pairing log and device model so this discovery edge case can "
576 this->telemetry_.set_phase(context.
state);
577 this->finish_pairing_attempt_(outcome);
581 ESP_LOGI(
TAG,
"Device %s paired successfully! Add this to your YAML:\n%s", context.
device_id.c_str(),
586 "This snippet uses the raw device type %s because the project does not yet expose a named YAML alias "
588 type_yaml.c_str(), type_diag.c_str());
589 ESP_LOGW(
TAG,
"Please file a GitHub issue with this type, subtype, device model, and the pairing log so support "
595 this->telemetry_.set_phase(context.
state);
596 this->finish_pairing_attempt_(outcome);
600void PairingEngine::finish_pairing_attempt_(
PairingOutcome outcome) {
601 this->telemetry_.set_outcome(outcome);
602 this->engine_.set_pairing_telemetry(
nullptr);
603 this->telemetry_.log_summary();
607 std::string advice_codes;
608 for (uint8_t i = 0; i < advice_count; i++) {
610 if (!advice_codes.empty())
614 this->telemetry_.set_advice_codes(advice_codes);
617void PairingEngine::record_discovery_rx_telemetry_(
const IoFrame &frame,
bool accepted, int16_t rssi) {
619 this->telemetry_.record_rx(frame, rssi);
621 this->telemetry_.record_rx_reject(frame, rssi);
Owns the per-hub device table, update callbacks, and linked-remote associations.
decisions::PairingDiscoveryDisposition run_discovery_phase_(pairing::PairingContext &context)
Phase 1: broadcast discovery command(s) and wait for a device response (0x29).
static DiscoveryResponseInfo 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).
bool transfer_key_and_wait_confirm_(pairing::PairingContext &context)
Build CMD_KEY_TRANSFER against the current challenge and wait for the 0x33 confirm; see run_key_excha...
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.
PairingEngine(RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning, ExchangeEngine &engine, DeviceRegistry ®istry, PairingTelemetry &telemetry, const RecentOneWayPairingSighting &recent_oneway_sighting)
Construct the engine with all required collaborators.
Fixed-size per-attempt telemetry recorder for the pairing flow.
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.
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.
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.
@ ACCEPT
This is the frame the caller was waiting for — stop listening, return ACCEPTED.
@ ABORT
An explicit refusal (e.g. CMD_ERROR_RESP) — stop listening, return ABORTED.
@ IGNORE
Unparsable / not ours / wrong exchange — keep listening.
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...
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.
@ SUCCESS_WITH_RESPONSE
Device replied; the caller's response frame is populated.
@ FAILED
No usable reply; the device may never have heard the request.
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.
@ ROTATE_SKIPPING_REQUEST
The two channels that are not the request channel. Roll-call.
@ HOLD_REQUEST_CHANNEL
Never retunes, never slices. Unicast replies.
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.
std::string build_device_yaml_snippet(DeviceType type, uint8_t subtype, const std::string &device_id, bool metadata_complete, bool inverted, bool low_power)
Build the ready-to-paste YAML block describing a device, for both a fully-decoded device and one whos...
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
constexpr uint32_t PAIRING_RECENT_ONE_WAY_SIGHTING_WINDOW_MS
How recent a RecentOneWayPairingSighting has to be, relative to discover_and_pair() starting,...
@ ABORTED
The handler returned ReplyDisposition::ABORT for some received frame.
@ ACCEPTED
The handler returned ReplyDisposition::ACCEPT for some received frame.
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 uint32_t PREAMBLE_LINGER_DWELL_MS
Preamble/sync linger extension for a rotating listen (ListenSpec::linger_dwell_ms): how much longer t...
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,...
bool has_extended
data_len >= DISCOVERY_RESP_FULL_SIZE (mfr/flags/timestamp present).
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). Never includes mac.
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.
How one listen window is to be spent — everything ExchangeEngine::listen() needs; everything else is ...
uint32_t request_freq
Channel the request went out on (Hz).
uint32_t window_ms
Total time budget for this listen, in milliseconds.
uint32_t linger_dwell_ms
Length of the preamble/sync extension, in milliseconds.
ListenPolicy policy
Which channels this listen covers.
bool linger_on_preamble
Extend the listen instead of hopping while the chip reports a preamble or sync word,...
std::function< void()> on_hop
Called on every hop, for callers that count hops in their own telemetry (pairing does; the exchange l...
Raw packet received from the radio.
uint32_t freq_hz
Frequency the packet was received on (Hz).
A 1W pairing-gesture frame observed on the hub's normal passive RX path, remembered so a fresh discov...
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).
bool discovery_low_power
True when discovery reported POWER_SAVE_LOW_POWER.
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.