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