Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
hub_core.cpp
Go to the documentation of this file.
1/// @file hub_core.cpp
2/// @brief Component lifecycle and main-loop scheduling.
3/// @ingroup hioc_hub
4///
5/// The core file owns the parts of IOHomeControlComponent that are primarily about
6/// runtime orchestration rather than protocol interpretation:
7/// - hardware/radio setup,
8/// - main loop scheduling,
9/// - device registry and callback fan-out.
10///
11/// Protocol exchange, pairing, inbound status handling, and outbound operations live
12/// in dedicated translation units so this file remains the place to understand how the
13/// component is brought up and driven over time.
14
15#include "hub_internal.h"
16
17#include "radio_sx1276.h"
18#include "radio_sx1262.h"
19
20#include <new>
21
22namespace esphome {
23namespace home_io_control {
24
25namespace {
26
27constexpr uint32_t BLOCKING_WARNING_THRESHOLD_MS =
28 250; ///< setup() and exchanges can legitimately block longer than generic ESPHome components.
29constexpr uint8_t SX1276_VERSION_REGISTER = 0x42; ///< SX1276 version register used for auto-detection.
30constexpr uint8_t SX1276_VERSION_REGISTER_READ_MASK = 0x7F; ///< SX1276 SPI read transactions clear bit 7.
31constexpr uint8_t SX1276_EXPECTED_VERSION = 0x12; ///< Known chip ID returned by SX1276 silicon.
32
33} // namespace
34
35static const char *const TAG = detail::TAG;
36
39 this->last_exchange_debug_.request_cmd = request_cmd;
40}
41
42void IOHomeControlComponent::record_exchange_debug_(const char *stage, uint8_t tries, bool saw_challenge) {
43 this->last_exchange_debug_.stage = stage;
44 this->last_exchange_debug_.tries = tries;
45 this->last_exchange_debug_.saw_challenge = this->last_exchange_debug_.saw_challenge || saw_challenge;
46
47 const RadioCaptureInfo &capture = this->radio_->get_last_capture();
48 this->last_exchange_debug_.capture_valid = capture.valid;
49 this->last_exchange_debug_.capture_rx_done = capture.rx_done;
50 this->last_exchange_debug_.capture_crc_error = capture.crc_error;
51 this->last_exchange_debug_.capture_freq_hz = capture.freq_hz;
52 this->last_exchange_debug_.capture_irq_status = capture.irq_status;
53 this->last_exchange_debug_.capture_packet_status = capture.packet_status;
54 this->last_exchange_debug_.capture_reported_len = capture.reported_len;
55 this->last_exchange_debug_.capture_frame_len = capture.frame_len;
56 this->last_exchange_debug_.capture_rssi_dbm = capture.rssi_dbm;
57}
58
59void IOHomeControlComponent::log_exchange_debug_(const char *device_id) const {
60 const auto &debug = this->last_exchange_debug_;
61 ESP_LOGW(detail::TAG,
62 "Exchange failed: device=%s cmd=0x%02X stage=%s tries=%u saw_challenge=%u cap_valid=%u cap_rx_done=%u "
63 "cap_crc_err=%u cap_freq=%u cap_irq=0x%04X cap_pkt=0x%02X cap_reported_len=%u cap_frame_len=%u cap_rssi=%d",
64 device_id, debug.request_cmd, debug.stage, debug.tries, debug.saw_challenge, debug.capture_valid,
65 debug.capture_rx_done, debug.capture_crc_error, debug.capture_freq_hz, debug.capture_irq_status,
66 debug.capture_packet_status, debug.capture_reported_len, debug.capture_frame_len, debug.capture_rssi_dbm);
67}
68
69// === Setup ===
70
71/// Initialize the IO‑Homecontrol component and radio hardware.
72///
73/// This is the main setup entry point called by ESPHome during startup.
74/// The sequence:
75/// 1. Parse node_id and system_key from hex strings (fails early if malformed).
76/// 2. Initialize the SPI bus via spi_setup().
77/// 3. Auto‑detect or explicitly select the radio chip:
78/// - If radio_type is "sx1276" or "sx1262", select that driver.
79/// - If empty (default), attempt to read SX1276 version register (0x42 = 0x12).
80/// If the read fails or returns wrong version, fall back to SX1262.
81/// 4. Allocate the appropriate RadioDriver (SX1276 needs DIO0; SX1262 needs BUSY+DIO1).
82/// 5. Call radio_->init() which performs chip reset, calibration, and register configuration.
83/// 6. Enter normal loop() operation with radio in RX mode.
84///
85/// @note Blocking operations in setup() temporarily raise the ESPHome WDT threshold
86/// to 250 ms (warn_if_blocking_over_) because radio init can
87/// exceed the default 30–50 ms budget.
89 // IO-homecontrol exchanges are intentionally blocking and often take a few hundred
90 // milliseconds, so use a higher warning threshold than ESPHome's generic 30-50 ms.
91 this->warn_if_blocking_over_ = BLOCKING_WARNING_THRESHOLD_MS;
92 ESP_LOGI(detail::TAG, "Initializing...");
93 if (!hex_to_bytes(this->node_id_str_, this->node_id_, NODE_ID_SIZE) ||
95 ESP_LOGE(detail::TAG, "Invalid node_id or system_key configuration");
96 this->mark_failed();
97 return;
98 }
99
100 this->spi_setup();
101
102 // --- Radio driver selection ---
103 bool use_sx1262 = false;
104
105 if (this->radio_type_ == "sx1262") {
106 use_sx1262 = true;
107 } else if (this->radio_type_ == "sx1276") {
108 use_sx1262 = false;
109 } else {
110 // Auto-detect: read SX1276 version register and compare against the known chip ID.
111 // Falls back to SX1262 if the version does not match.
112 this->enable();
113 this->write_byte(SX1276_VERSION_REGISTER & SX1276_VERSION_REGISTER_READ_MASK);
114 uint8_t const version = this->read_byte();
115 this->disable();
116 if (version == SX1276_EXPECTED_VERSION) {
117 ESP_LOGI(detail::TAG, "Auto-detected SX1276 (version=0x%02X)", version);
118 use_sx1262 = false;
119 } else {
120 ESP_LOGI(detail::TAG, "SX1276 not detected (version=0x%02X), trying SX1262", version);
121 use_sx1262 = true;
122 }
123 }
124
125 if (use_sx1262) {
126 if (this->busy_pin_ == nullptr || this->dio1_pin_ == nullptr) {
127 ESP_LOGE(detail::TAG, "SX1262 requires busy_pin and dio1_pin");
128 this->mark_failed();
129 return;
130 }
131 this->radio_ =
132 new (std::nothrow) RadioSX1262(this, this->rst_pin_, this->dio1_pin_, this->busy_pin_, this->tx_power_,
133 this->tcxo_voltage_, this->fem_en_pin_, this->vfem_pin_, this->fem_pa_pin_);
134 } else {
135 if (this->dio0_pin_ == nullptr) {
136 ESP_LOGE(detail::TAG, "SX1276 requires dio0_pin");
137 this->mark_failed();
138 return;
139 }
140 this->radio_ = new (std::nothrow)
141 RadioSX1276(this, this->rst_pin_, this->dio0_pin_, this->dio4_pin_, this->tx_power_, this->pa_pin_);
142 }
143
144 if (this->radio_ == nullptr) {
145 ESP_LOGE(detail::TAG, "Failed to allocate %s radio driver", use_sx1262 ? "SX1262" : "SX1276");
146 this->mark_failed();
147 return;
148 }
149
150 if (!this->radio_->init()) {
151 delete this->radio_;
152 this->radio_ = nullptr;
153 this->mark_failed();
154 return;
155 }
156
157 this->initialized_ = true;
159 this->last_hop_us_ = micros();
160 ESP_LOGI(detail::TAG, "Radio initialized (%s), Node ID: %s", use_sx1262 ? "SX1262" : "SX1276",
161 this->node_id_str_.c_str());
162}
163
164// === Frequency hopping ===
165
166/// Hop to the next channel in the 3‑channel sequence: CH1 → CH2 → CH3 → CH1.
167/// Called periodically by the main loop when idle to maintain synchronization
168/// with the protocol's ~2.7 ms hopping schedule. Devices also hop, so the controller
169/// must hop even while waiting for a response; the next transmit will use whatever
170/// channel is current.
172 uint32_t const cur = this->radio_->get_current_freq();
173 uint32_t next;
174 switch (cur) {
175 case FREQ_CH1:
176 next = FREQ_CH2;
177 break;
178 case FREQ_CH3:
179 next = FREQ_CH1;
180 break;
181 default:
182 next = FREQ_CH3;
183 break;
184 }
185 this->radio_->change_frequency(next);
186 this->last_hop_us_ = micros();
187}
188
189// === Protocol send/receive ===
190
191// Listen-before-talk (LBT) for ETSI EN 300 220 compliance on 868 MHz SRD band.
192// Before transmitting, read instantaneous RSSI to check channel occupancy. If
193// above threshold, back off and retry up to LBT_MAX_RETRIES times. If the channel
194// remains busy after all retries, transmit anyway — our duty cycle is very low and
195// failing silently would be worse than a potential collision.
196bool IOHomeControlComponent::transmit_frame_(const IoFrame &frame, uint32_t freq, uint16_t preamble) {
197 uint8_t buf[FRAME_MAX_SIZE];
198 uint8_t const len = serialize(frame, buf, sizeof(buf));
199 if (len == 0) {
200 detail::log_frame_issue(this, "tx", "serialize_failed", frame, 0);
201 return false;
202 }
203 // LBT: check channel is clear before transmitting
204 for (uint8_t lbt = 0; lbt < LBT_MAX_RETRIES; lbt++) {
205 int16_t const rssi = this->radio_->read_rssi();
206 if (rssi < LBT_RSSI_THRESHOLD_DBM)
207 break;
208 ESP_LOGD(detail::TAG, "LBT: channel busy (RSSI %d dBm), retry %u/%u", rssi, lbt + 1, LBT_MAX_RETRIES);
209 delay(LBT_RETRY_DELAY_MS);
210 }
211 detail::log_component_capture(this->radio_, "tx_frame", buf, len, &frame);
212 RadioTxConfig tx_config{};
213 tx_config.freq_hz = freq;
214 tx_config.preamble_len = preamble;
215 if (!this->radio_->send_packet(buf, len, tx_config)) {
216 detail::log_frame_issue(this, "tx", "send_failed", frame, len);
217 return false;
218 }
219 return true;
220}
221
223 auto it = this->devices_.find(id);
224 if (it == this->devices_.end())
225 return;
226 for (auto &cb : this->callbacks_)
227 cb(id, it->second);
228}
229
230// === Device management ===
231
232void IOHomeControlComponent::set_device_status_poll_interval(const std::string &device_id, uint32_t poll_interval_ms) {
233 auto *dev = this->get_device(device_id);
234 if (dev == nullptr)
235 return;
236 dev->status_poll_interval_ms = poll_interval_ms;
237}
238
239void IOHomeControlComponent::add_device(const std::string &device_id) {
240 this->add_device(device_id, DeviceType::UNKNOWN, 0, false);
241}
242
243void IOHomeControlComponent::add_device(const std::string &device_id, DeviceType type, uint8_t subtype, bool inverted) {
244 if (this->devices_.count(device_id) != 0)
245 return;
246 IoDevice dev{};
247 if (!hex_to_bytes(device_id, dev.node_id, NODE_ID_SIZE)) {
248 ESP_LOGW(detail::TAG, "Ignoring invalid device ID %s", device_id.c_str());
249 return;
250 }
251 dev.type = type;
252 dev.subtype = subtype;
253 if (inverted)
254 dev.inverted = true;
255 this->devices_[device_id] = dev;
256}
257
258IoDevice *IOHomeControlComponent::get_device(const std::string &device_id) {
259 auto it = this->devices_.find(device_id);
260 return (it != this->devices_.end()) ? &it->second : nullptr;
261}
262
263// === Main loop ===
264
266 if (!this->initialized_)
267 return;
268
269 // Check for received packets (non-blocking)
270 if (!this->busy_) {
271 RadioRxPacket packet{};
272 if (this->radio_->check_for_packet(packet))
273 this->process_received_packet_(packet);
274 }
275
276 if (!this->busy_)
278
279 // Frequency hopping — protocol specifies 2.7ms per channel, but ESPHome calls
280 // loop() every ~16-30ms. This is acceptable for a controller: we initiate all
281 // exchanges with a long preamble (1024 bytes ≈ 330ms airtime) so the device has
282 // time to detect us regardless of channel alignment. Precise hopping would only
283 // matter for a passive receiver scanning for unsolicited frames.
284 if (!this->busy_ && (micros() - this->last_hop_us_) > HOP_TIME_US)
285 this->hop_frequency_();
286
287 // Periodic status polling
288 if (!this->busy_) {
289 uint32_t const now = millis();
290 for (auto &pair : this->devices_) {
291 if (pair.second.next_update != 0 && now > pair.second.next_update) {
292 bool const should_dispatch_one_shot_poll = pair.second.status_poll_interval_ms == 0;
293 if (!should_dispatch_one_shot_poll && !detail::status_poll_tracking_active(pair.second, now)) {
295 continue;
296 }
297 // Mark the poll as handed to the main-loop queue so an overdue timestamp does not enqueue
298 // the same status request again on every loop iteration while the first request is pending.
299 pair.second.next_update = 0;
300 this->queue_request_device_status(pair.first);
301 break;
302 }
303 }
304 }
305}
306
308 ESP_LOGCONFIG(detail::TAG, "IO-Homecontrol:");
309 ESP_LOGCONFIG(detail::TAG, " Node ID: %s", this->node_id_str_.c_str());
310 ESP_LOGCONFIG(detail::TAG, " Radio: %s", this->radio_type_.empty() ? "auto-detected" : this->radio_type_.c_str());
311 ESP_LOGCONFIG(detail::TAG, " TX Power: %u dBm", this->tx_power_);
312 LOG_PIN(" RST Pin: ", this->rst_pin_);
313 if (this->dio0_pin_ != nullptr)
314 LOG_PIN(" DIO0 Pin: ", this->dio0_pin_);
315 if (this->dio1_pin_ != nullptr)
316 LOG_PIN(" DIO1 Pin: ", this->dio1_pin_);
317 if (this->dio4_pin_ != nullptr)
318 LOG_PIN(" DIO4 Pin: ", this->dio4_pin_);
319 if (this->busy_pin_ != nullptr)
320 LOG_PIN(" BUSY Pin: ", this->busy_pin_);
321 ESP_LOGCONFIG(detail::TAG, " Devices: %zu", this->devices_.size());
322 if (!this->linked_remotes_.empty()) {
323 ESP_LOGCONFIG(detail::TAG, " Linked Remotes: %zu", this->linked_remotes_.size());
324 for (const auto &pair : this->linked_remotes_) {
325 for (const auto &device_id : pair.second) {
326 ESP_LOGCONFIG(detail::TAG, " - remote %s -> device %s", pair.first.c_str(), device_id.c_str());
327 }
328 }
329 }
330
331 if (this->radio_ != nullptr)
332 this->radio_->dump_debug();
333}
334
335} // namespace home_io_control
336} // namespace esphome
InternalGPIOPin * fem_en_pin_
Front-end module enable.
Definition hub_core.h:422
InternalGPIOPin * fem_pa_pin_
Front-end module PA switch.
Definition hub_core.h:424
std::map< std::string, IoDevice > devices_
Definition hub_core.h:441
InternalGPIOPin * dio4_pin_
SX1276 DIO4 preamble detect (optional).
Definition hub_core.h:419
void reset_exchange_debug_(uint8_t request_cmd)
Definition hub_core.cpp:37
InternalGPIOPin * dio1_pin_
SX1262 DIO1 interrupt.
Definition hub_core.h:420
void dump_config() override
Dump configuration and radio debug info to the log.
Definition hub_core.cpp:307
void record_exchange_debug_(const char *stage, uint8_t tries, bool saw_challenge)
Definition hub_core.cpp:42
virtual IoDevice * get_device(const std::string &device_id)
Retrieve a device by ID; returns nullptr if not found.
Definition hub_core.cpp:258
virtual void queue_request_device_status(const std::string &device_id)
Queue an async status request; returns immediately, executed in loop().
virtual void add_device(const std::string &device_id)
Add a device to the registry by device ID only (legacy/delegating overload).
Definition hub_core.cpp:239
InternalGPIOPin * vfem_pin_
Front-end module power.
Definition hub_core.h:423
virtual void set_device_status_poll_interval(const std::string &device_id, uint32_t poll_interval_ms)
Configure the optional follow-up polling interval for a registered device.
Definition hub_core.cpp:232
void process_pending_operation_()
Pop next pending operation from the queue and execute it (set position, request status,...
void hop_frequency_()
Hop to the next channel in the 3‑channel sequence: CH1 → CH2 → CH3 → CH1.
Definition hub_core.cpp:171
void loop() override
Main loop: process pending operations and drive radio state machine.
Definition hub_core.cpp:265
void log_exchange_debug_(const char *device_id) const
Definition hub_core.cpp:59
std::string radio_type_
"sx1276", "sx1262", or "" (auto-detect)
Definition hub_core.h:429
std::vector< DeviceUpdateCallback > callbacks_
Definition hub_core.h:442
bool transmit_frame_(const IoFrame &frame, uint32_t freq, uint16_t preamble)
Transmit a raw IoFrame on the current frequency with given preamble length.
Definition hub_core.cpp:196
InternalGPIOPin * dio0_pin_
SX1276 DIO0 interrupt.
Definition hub_core.h:418
void notify_device_update_(const std::string &id)
Fire all registered device update callbacks for the given device ID.
Definition hub_core.cpp:222
std::map< std::string, std::vector< std::string > > linked_remotes_
Maps remote node IDs to lists of device IDs they control.
Definition hub_core.h:446
void setup() override
Initialize hardware (radio and device registry).
Definition hub_core.cpp:88
uint8_t tcxo_voltage_
SX1262 TCXO voltage setting (default 1.8 V).
Definition hub_core.h:434
void process_received_packet_(const RadioRxPacket &packet)
Parse a received frame, merge supported device state or metadata, and notify callbacks.
InternalGPIOPin * busy_pin_
SX1262 BUSY pin.
Definition hub_core.h:421
void register_management_actions_()
Register hub-level Home Assistant actions exposed through ESPHome's native API.
SX1262 implementation of RadioDriver.
SX1276 implementation of RadioDriver.
Internal helpers shared by the hub implementation .cpp files.
constexpr const char * TAG
Shared log tag for hub-level messages.
void log_component_capture(const RadioDriver *radio, const char *stage, const uint8_t *buf, uint8_t len, const IoFrame *frame=nullptr)
Log a frame at the "io_capture" tag with structured fields.
void clear_status_poll_tracking(IoDevice &dev)
Clear all bounded follow-up polling state for a device.
bool status_poll_tracking_active(const IoDevice &dev, uint32_t now)
Check whether a device remains inside its bounded follow-up polling window.
void log_frame_issue(IOHomeControlComponent *component, const char *direction, const char *reason, const IoFrame &frame, uint8_t len)
Log a frame‑level issue (unregistered endpoints, unsupported commands).
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_frame.h:81
DeviceType
Device type identifiers reported by IO‑Homecontrol products.
@ UNKNOWN
Unknown/unspecified device.
static constexpr uint8_t LBT_MAX_RETRIES
Max carrier-sense attempts before TX anyway.
Definition proto_frame.h:74
static constexpr uint32_t FREQ_CH1
The protocol uses 3 frequency channels in the 868 MHz ISM band.
Definition proto_frame.h:31
static constexpr uint32_t FREQ_CH3
Channel 3: 869.85 MHz (2W only).
Definition proto_frame.h:33
static constexpr int32_t HOP_TIME_US
Timing constants for frequency hopping and response waiting.
Definition proto_frame.h:60
static constexpr uint8_t FRAME_MAX_SIZE
Maximum frame size (9 header + 23 data).
Definition proto_frame.h:91
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
Definition proto_frame.h:32
static constexpr uint8_t LBT_RETRY_DELAY_MS
Backoff between LBT checks (≥ 5ms per ETSI).
Definition proto_frame.h:75
static constexpr uint8_t AES_KEY_SIZE
AES-128 key size.
Definition proto_frame.h:84
bool hex_to_bytes(const std::string &hex, uint8_t *out, uint8_t len)
Convert a hex string (e.g., "123ABC") to a byte array.
static constexpr int16_t LBT_RSSI_THRESHOLD_DBM
Listen-before-talk (LBT) parameters for ETSI EN 300 220 compliance.
Definition proto_frame.h:73
uint8_t serialize(const IoFrame &f, uint8_t *buf, uint8_t buf_size)
Serialize a parsed frame into a wire buffer (without CRC).
static const char *const TAG
Definition hub_core.cpp:35
SX1262 radio driver for IO-Homecontrol.
SX1276 radio driver for IO-Homecontrol.
Debug snapshot of the last exchange attempt.
Definition hub_core.h:390
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).
Diagnostic capture from a radio operation.
uint8_t frame_len
Number of valid bytes in frame[].
uint16_t irq_status
Raw IRQ status register value.
uint8_t packet_status
Packet status byte (chip-specific).
bool crc_error
True if CRC error detected (SX1276: never set in IoHomeOn mode; SX1262: set on bad CRC).
bool valid
True if capture is valid.
uint8_t reported_len
Length reported by the radio chip.
bool rx_done
True if RxDone IRQ fired.
uint32_t freq_hz
RF frequency of capture (Hz).
int16_t rssi_dbm
Received signal strength (dBm).
Raw packet received from the radio.
Configuration for transmitting a packet: carrier frequency and preamble length.
uint16_t preamble_len
Preamble length in symbol periods (bytes).
uint32_t freq_hz
Carrier frequency in Hz.