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#include "radio_lr1121.h"
20#include "tuning_config.h"
21#include "tuning_registry.h"
22
23#include <cinttypes>
24#include <new>
25#include <vector>
26
27namespace esphome {
28namespace home_io_control {
29
30namespace {
31
32constexpr uint32_t BLOCKING_WARNING_THRESHOLD_MS =
33 250; ///< setup() and exchanges can legitimately block longer than generic ESPHome components.
34
35} // namespace
36
37static const char *const TAG = detail::TAG;
38
39// === Setup ===
40
41/// Initialize the IO‑Homecontrol component and radio hardware.
42///
43/// This is the main setup entry point called by ESPHome during startup.
44/// The sequence:
45/// 1. Parse node_id and system_key from hex strings (fails early if malformed).
46/// 2. Initialize the SPI bus via spi_setup().
47/// 3. Construct the driver named by the required `radio_type` YAML field
48/// ("sx1276", "sx1262", or "lr1121").
49/// 4. Allocate the appropriate RadioDriver (SX1276 needs DIO0; SX1262/LR1121 need BUSY+DIO1,
50/// DIO1 carrying the LR1121's DIO9 IRQ line).
51/// 5. Call radio_->init() which performs chip reset, calibration, and register configuration.
52/// 6. Enter normal loop() operation with radio in RX mode.
53///
54/// @note Blocking operations in setup() temporarily raise the ESPHome WDT threshold
55/// to 250 ms (warn_if_blocking_over_) because radio init can
56/// exceed the default 30–50 ms budget.
58 // IO-homecontrol exchanges are intentionally blocking and often take a few hundred
59 // milliseconds, so use a higher warning threshold than ESPHome's generic 30-50 ms.
60 this->warn_if_blocking_over_ = BLOCKING_WARNING_THRESHOLD_MS;
61#ifdef IOHOME_UNSAFE_LOG_KEY_MATERIAL
62 // Loud, unconditional, every-boot warning so a build left with this flag on by accident can
63 // never stay quiet about it — see log_frame.h::render_frame_hex_redacted() for the full
64 // rationale and safe-use rules.
65 ESP_LOGE(detail::TAG, "########################################");
66 ESP_LOGE(detail::TAG, "IOHOME_UNSAFE_LOG_KEY_MATERIAL IS ENABLED -- FRAME LOGS EXPOSE YOUR SYSTEM KEY");
67 ESP_LOGE(detail::TAG, "This build is NOT safe to run normally or share logs from. Rebuild without this");
68 ESP_LOGE(detail::TAG, "flag as soon as you are done capturing.");
69 ESP_LOGE(detail::TAG, "########################################");
70#endif
71 ESP_LOGI(detail::TAG, "Initializing...");
72 if (!hex_to_bytes(this->node_id_str_, this->node_id_, NODE_ID_SIZE) ||
74 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) — ESPHome's own LOG_STR() macro.
75 this->mark_failed(LOG_STR("Invalid node_id or system_key configuration"));
76 return;
77 }
78
79 this->spi_setup();
80
81 const char *chip_name_for_log = nullptr;
82 this->radio_ = this->select_and_construct_radio_(&chip_name_for_log);
83 if (this->radio_ == nullptr) {
84 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) — ESPHome's own LOG_STR() macro.
85 this->mark_failed(LOG_STR("Radio driver selection/allocation failed (see earlier log for details)"));
86 return;
87 }
88
89#ifdef IOHOME_LR1121_FIRMWARE_UPDATE
90 // Boot-time bootloader-version excursion — deliberately before
91 // init(): nothing has configured the radio yet, so this costs one extra chip reset and needs no
92 // reboot afterward, unlike every other bootloader excursion this feature performs.
93 this->run_lr1121_boot_time_bootloader_read_();
94#endif
95
96 if (!this->radio_->init()) {
97 delete this->radio_;
98 this->radio_ = nullptr;
99#ifdef IOHOME_LR1121_FIRMWARE_UPDATE
100 // Cache the verdict even on a failed init() — a null radio_ is exactly the case
101 // trigger_lr1121_firmware_update()'s guard 0 still allows an attempt for (reflashing is the
102 // recovery), and it needs a cached "installed version unknown" verdict to route through.
103 this->cache_lr1121_flash_verdict_();
104#endif
105 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) — ESPHome's own LOG_STR() macro.
106 this->mark_failed(LOG_STR("Radio hardware initialization failed (see earlier log for details)"));
107 return;
108 }
109
110#ifdef IOHOME_LR1121_FIRMWARE_UPDATE
111 this->cache_lr1121_flash_verdict_();
112#endif
113
114 this->initialized_ = true;
116 this->exchange_engine_.reset_hop_timestamp();
118 if (this->tuning_.active) {
119 std::string const snapshot = tuning_config_full_snapshot(this->tuning_);
120 ESP_LOGI(detail::TAG, "%s", snapshot.c_str());
121 }
122 ESP_LOGI(detail::TAG, "Radio initialized (%s), Node ID: %s", chip_name_for_log, this->node_id_str_.c_str());
123}
124
125// See the declaration in hub_core.h for the full contract. `radio_type_` is one of "lr1121",
126// "sx1262", or "sx1276" — the YAML schema requires the field and validates it against exactly
127// those three values, so the fallthrough below is unreachable in a config-driven build and
128// exists only to fail loudly rather than guess if this method is ever called some other way.
130 if (this->radio_type_ == "lr1121") {
131 *chip_name_out = "LR1121";
132 if (this->busy_pin_ == nullptr || this->dio1_pin_ == nullptr) {
133 ESP_LOGE(detail::TAG, "LR1121 requires busy_pin and dio1_pin (dio1_pin carries the chip's DIO9 IRQ line)");
134 return nullptr;
135 }
136 auto *radio = new (std::nothrow)
137 RadioLR1121(this, this->rst_pin_, this->dio1_pin_, this->busy_pin_, this->tx_power_, this->tcxo_voltage_);
138 if (radio == nullptr)
139 ESP_LOGE(detail::TAG, "Failed to allocate LR1121 radio driver");
140 return radio;
141 }
142
143 if (this->radio_type_ == "sx1262") {
144 *chip_name_out = "SX1262";
145 if (this->busy_pin_ == nullptr || this->dio1_pin_ == nullptr) {
146 ESP_LOGE(detail::TAG, "SX1262 requires busy_pin and dio1_pin");
147 return nullptr;
148 }
149 auto *radio =
150 new (std::nothrow) RadioSX1262(this, this->rst_pin_, this->dio1_pin_, this->busy_pin_, this->tx_power_,
151 this->tcxo_voltage_, this->fem_en_pin_, this->vfem_pin_, this->fem_pa_pin_);
152 if (radio == nullptr)
153 ESP_LOGE(detail::TAG, "Failed to allocate SX1262 radio driver");
154 return radio;
155 }
156
157 if (this->radio_type_ == "sx1276") {
158 *chip_name_out = "SX1276";
159 if (this->dio0_pin_ == nullptr) {
160 ESP_LOGE(detail::TAG, "SX1276 requires dio0_pin");
161 return nullptr;
162 }
163 auto *radio = new (std::nothrow)
164 RadioSX1276(this, this->rst_pin_, this->dio0_pin_, this->dio4_pin_, this->tx_power_, this->pa_pin_);
165 if (radio == nullptr)
166 ESP_LOGE(detail::TAG, "Failed to allocate SX1276 radio driver");
167 return radio;
168 }
169
170 *chip_name_out = "unknown";
171 ESP_LOGE(detail::TAG, "Unrecognized radio_type '%s'", this->radio_type_.c_str());
172 return nullptr;
173}
174
175// === Tuning layer ===
176
177/// Apply the current tuning configuration to the active radio driver.
178///
179/// Only chip-specific parameters are forwarded; the rest are consumed by the
180/// pairing flow and LBT logic. This is called once at the end of setup() and
181/// again whenever a UI-driven change modifies a radio parameter.
183 if (this->radio_ == nullptr)
184 return;
185 this->radio_->apply_tuning(this->tuning_);
186}
187
188/// Update a numeric tuning parameter from a Home Assistant `number` entity.
189///
190/// Parses the parameter name and applies the new value to the in-memory tuning
191/// configuration. Radio-affecting parameters are forwarded to the active driver
192/// immediately; the change is logged in YAML-compatible form so it can be copied
193/// back into the configuration file.
194void IOHomeControlComponent::update_tuning_number(const std::string &name, float value) {
195 const TuningNumberParam *param = find_tuning_number(name);
196 if (param == nullptr) {
197 ESP_LOGW(detail::TAG, "Unknown tuning number parameter: %s", name.c_str());
198 return;
199 }
200 param->set(this->tuning_, value);
201 if (param->applies_to_radio)
203 ESP_LOGI(detail::TAG, "%s", tuning_update_log_line(name, std::to_string(static_cast<int>(value))).c_str());
204}
205
206/// Update a select tuning parameter from a Home Assistant `select` entity.
207///
208/// Parses the selected option string and applies it to the in-memory tuning
209/// configuration. Radio-affecting parameters are forwarded to the active driver
210/// immediately; the change is logged in YAML-compatible form.
211void IOHomeControlComponent::update_tuning_select(const std::string &name, const std::string &value) {
212 const TuningSelectParam *param = find_tuning_select(name);
213 if (param == nullptr) {
214 ESP_LOGW(detail::TAG, "Unknown tuning select parameter: %s", name.c_str());
215 return;
216 }
217 // Radio-affecting parameters re-apply only when the option string actually parsed; the
218 // update is still logged for any known parameter, matching the original dispatch.
219 if (param->set(this->tuning_, value) && param->applies_to_radio)
221 ESP_LOGI(detail::TAG, "%s", tuning_update_log_line(name, value).c_str());
222}
223
224/// Return the current value of a numeric tuning parameter.
225///
226/// Mirror of update_tuning_number(); used by IOHomeTuningNumber::setup() to publish
227/// the boot-time value so the Home Assistant slider reflects the active configuration
228/// (default or YAML override) without restating any default on the Python side.
229float IOHomeControlComponent::get_tuning_number_value(const std::string &name) const {
230 const TuningNumberParam *param = find_tuning_number(name);
231 if (param == nullptr) {
232 ESP_LOGW(detail::TAG, "Unknown tuning number parameter: %s", name.c_str());
233 return 0.0F;
234 }
235 return param->get(this->tuning_);
236}
237
238/// Return the current option string of a select tuning parameter.
239///
240/// Mirror of update_tuning_select(); used by IOHomeTuningSelect::setup() to publish the
241/// boot-time option so the Home Assistant dropdown reflects the active configuration. The
242/// returned strings match the YAML/UI option labels exactly. The command list is returned
243/// as a comma-separated preset string (e.g. "0x28,0x2E") matching the dropdown options.
244std::string IOHomeControlComponent::get_tuning_select_value(const std::string &name) const {
245 const TuningSelectParam *param = find_tuning_select(name);
246 if (param == nullptr) {
247 ESP_LOGW(detail::TAG, "Unknown tuning select parameter: %s", name.c_str());
248 return "";
249 }
250 return param->get(this->tuning_);
251}
252
253// === Protocol send/receive (thin wrappers delegating to ExchangeEngine) ===
254
255/// Delegate channel hop to ExchangeEngine (which owns last_hop_us_).
257
258/// Delegate LBT transmit to ExchangeEngine.
259bool IOHomeControlComponent::transmit_frame_(const IoFrame &frame, uint32_t freq, uint16_t preamble) {
260 return this->exchange_engine_.transmit_frame(frame, freq, preamble);
261}
262
263/// Delegate outbound exchange to ExchangeEngine and manage the busy_ flag.
264bool IOHomeControlComponent::send_and_receive_(const IoFrame &request, IoFrame &response, uint32_t freq) {
265 this->busy_ = true;
266 bool const ok = this->exchange_engine_.send_and_receive(request, response, freq);
267 this->busy_ = false;
268 return ok;
269}
270
271/// Delegate inbound authentication to ExchangeEngine.
272bool IOHomeControlComponent::authenticate_request_(const IoFrame &request, uint32_t freq) {
273 return this->exchange_engine_.authenticate_request(request, freq);
274}
275
276void IOHomeControlComponent::notify_device_update_(const std::string &id) { this->registry_.notify(id); }
277
278// === Device management ===
279
280void IOHomeControlComponent::set_device_status_poll_interval(const std::string &device_id, uint32_t poll_interval_ms) {
281 if (this->get_device(device_id) == nullptr)
282 return;
283 this->poll_policy_.set_interval(device_id, poll_interval_ms);
284}
285
286void IOHomeControlComponent::schedule_background_poll_backoff_(const std::string &device_id, bool auth_like) {
287 uint32_t const now = millis();
288 uint32_t const backoff_ms = this->poll_policy_.on_exchange_failed(device_id, auth_like, now);
289 if (backoff_ms > 0) {
290 ESP_LOGD(TAG,
291 "Background status poll backoff for device %s: delay=%" PRIu32
292 " ms auth_like=%s status_failures=%u auth_failures=%u",
293 device_id.c_str(), backoff_ms, YESNO(auth_like), this->poll_policy_.get_status_poll_failures(device_id),
294 this->poll_policy_.get_auth_poll_failures(device_id));
295 }
296}
297
298void IOHomeControlComponent::add_device(const std::string &device_id) { this->registry_.add(device_id); }
299
300void IOHomeControlComponent::add_device(const std::string &device_id, const DeviceConfig &cfg) {
301 this->registry_.add(device_id, cfg);
302}
303
304IoDevice *IOHomeControlComponent::get_device(const std::string &device_id) { return this->registry_.get(device_id); }
305
306void IOHomeControlComponent::set_device_dimmable(const std::string &device_id, bool dimmable) {
307 this->registry_.set_dimmable(device_id, dimmable);
308}
309
310// === Main loop ===
311
313 if (!this->initialized_)
314 return;
315 if (this->radio_test_mode_)
316 return;
317
318 // Check for received packets (non-blocking)
319 if (!this->busy_) {
320 RadioRxPacket packet{};
321 if (this->radio_->check_for_packet(packet))
322 this->process_received_packet_(packet);
323 }
324
325 // A blocking exchange makes the radio deaf for 1–3 s. When a linked remote's press schedules a
326 // status poll, dispatching it while that same remote is still transmitting would blind the hub
327 // to the rest of the press — so background polls yield for a moment. Control operations never do.
328 if (!this->busy_ && !this->defer_background_poll_())
330
331 // Frequency hopping — protocol specifies 2.7ms per channel, but ESPHome calls
332 // loop() every ~16-30ms. This is acceptable for a controller: we initiate all
333 // exchanges with a long preamble (1024 bytes ≈ 330ms airtime) so the device has
334 // time to detect us regardless of channel alignment. Precise hopping would only
335 // matter for a passive receiver scanning for unsolicited frames.
336 if (!this->busy_)
337 this->exchange_engine_.maybe_hop();
338
339 // Periodic status polling
340 if (!this->busy_) {
341 auto due = this->poll_policy_.pop_due_device(millis());
342 if (due.has_value())
343 this->queue_request_device_status(*due);
344 }
345}
346
348 ESP_LOGCONFIG(detail::TAG, "IO-Homecontrol:");
349 ESP_LOGCONFIG(detail::TAG, " Node ID: %s", this->node_id_str_.c_str());
350 ESP_LOGCONFIG(detail::TAG, " Radio: %s", this->radio_type_.c_str());
351 ESP_LOGCONFIG(detail::TAG, " TX Power: %u dBm", this->tx_power_);
352 LOG_PIN(" RST Pin: ", this->rst_pin_);
353 if (this->dio0_pin_ != nullptr)
354 LOG_PIN(" DIO0 Pin: ", this->dio0_pin_);
355 if (this->dio1_pin_ != nullptr)
356 LOG_PIN(" DIO1 Pin: ", this->dio1_pin_);
357 if (this->dio4_pin_ != nullptr)
358 LOG_PIN(" DIO4 Pin: ", this->dio4_pin_);
359 if (this->busy_pin_ != nullptr)
360 LOG_PIN(" BUSY Pin: ", this->busy_pin_);
361 ESP_LOGCONFIG(detail::TAG, " Devices: %zu", this->registry_.size());
362 if (this->registry_.linked_remote_count() > 0) {
363 ESP_LOGCONFIG(detail::TAG, " Linked Remotes: %zu", this->registry_.linked_remote_count());
364 this->registry_.for_each_linked_remote([](const std::string &remote_id, const std::vector<std::string> &devices) {
365 for (const auto &device_id : devices)
366 ESP_LOGCONFIG("home_io_control", " - remote %s -> device %s", remote_id.c_str(), device_id.c_str());
367 });
368 }
369
370 if (this->radio_ != nullptr)
371 this->radio_->dump_debug();
372
373#ifdef IOHOME_LR1121_FIRMWARE_UPDATE
374 this->dump_lr1121_firmware_update_debug_();
375#endif
376}
377
378} // namespace home_io_control
379} // namespace esphome
InternalGPIOPin * fem_en_pin_
Front-end module enable.
Definition hub_core.h:730
InternalGPIOPin * fem_pa_pin_
Front-end module PA switch.
Definition hub_core.h:732
InternalGPIOPin * dio4_pin_
SX1276 DIO4 preamble detect (optional).
Definition hub_core.h:727
InternalGPIOPin * dio1_pin_
SX1262 DIO1 interrupt; also carries the LR1121's DIO9 IRQ line.
Definition hub_core.h:728
bool send_and_receive_(const IoFrame &request, IoFrame &response, uint32_t freq)
Main request/response exchange with retry and automatic authentication.
Definition hub_core.cpp:264
void dump_config() override
Dump configuration and radio debug info to the log.
Definition hub_core.cpp:347
void update_tuning_select(const std::string &name, const std::string &value)
Receive a select tuning update from a HA select entity.
Definition hub_core.cpp:211
virtual IoDevice * get_device(const std::string &device_id)
Retrieve a device by ID; returns nullptr if not found.
Definition hub_core.cpp:304
virtual void queue_request_device_status(const std::string &device_id)
Queue an async status request; returns immediately, executed in loop().
void update_tuning_number(const std::string &name, float value)
Receive a numeric tuning update from a HA number entity.
Definition hub_core.cpp:194
virtual void add_device(const std::string &device_id)
Add a device to the registry by device ID only (undeclared/legacy path).
Definition hub_core.cpp:298
InternalGPIOPin * vfem_pin_
Front-end module power.
Definition hub_core.h:731
TuningConfig tuning_
Runtime tuning overrides.
Definition hub_core.h:748
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:280
void process_pending_operation_()
Pop next pending operation from the queue and execute it (set position, request status,...
void hop_frequency_()
Delegate channel hop to ExchangeEngine (which owns last_hop_us_).
Definition hub_core.cpp:256
virtual void set_device_dimmable(const std::string &device_id, bool dimmable)
Set a device's dimmable flag (see IoDevice::dimmable).
Definition hub_core.cpp:306
float get_tuning_number_value(const std::string &name) const
Current value of a numeric tuning parameter, used to seed a HA number entity on boot.
Definition hub_core.cpp:229
bool defer_background_poll_() const
Whether loop() should skip dispatching the queue this iteration because the pending work is a backgro...
Definition hub_core.h:543
void loop() override
Main loop: process pending operations and drive radio state machine.
Definition hub_core.cpp:312
void schedule_background_poll_backoff_(const std::string &device_id, bool auth_like)
Apply backoff after a failed background status poll and log the result.
Definition hub_core.cpp:286
std::string radio_type_
"sx1276", "sx1262", or "lr1121"; required by the YAML schema.
Definition hub_core.h:737
RadioDriver * select_and_construct_radio_(const char **chip_name_out)
Select and construct the radio driver named by the required radio_type config field.
Definition hub_core.cpp:129
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:259
bool radio_test_mode_
When true, loop() is suspended for loopback testing.
Definition hub_core.h:747
ExchangeEngine exchange_engine_
Owns all authenticated exchange and LBT/hop logic.
Definition hub_core.h:763
InternalGPIOPin * dio0_pin_
SX1276 DIO0 interrupt.
Definition hub_core.h:726
void notify_device_update_(const std::string &id)
Fire all registered device update callbacks for the given device ID.
Definition hub_core.cpp:276
std::string get_tuning_select_value(const std::string &name) const
Current option string of a select tuning parameter, used to seed a HA select entity on boot.
Definition hub_core.cpp:244
void setup() override
Initialize hardware (radio and device registry).
Definition hub_core.cpp:57
uint8_t tcxo_voltage_
SX1262/LR1121 TCXO voltage setting (default 1.8 V).
Definition hub_core.h:742
void process_received_packet_(const RadioRxPacket &packet)
Parse a received frame, merge supported device state or metadata, and notify callbacks.
bool authenticate_request_(const IoFrame &request, uint32_t freq)
Handle an inbound authenticated command from a device (status updates, etc.).
Definition hub_core.cpp:272
InternalGPIOPin * busy_pin_
SX1262/LR1121 BUSY pin.
Definition hub_core.h:729
void register_management_actions_()
Register hub-level Home Assistant actions; called from setup().
Definition hub_core.h:614
void apply_tuning_to_radio_()
Apply the current tuning configuration to the active radio driver.
Definition hub_core.cpp:182
Abstract radio driver for IO-Homecontrol.
LR1121 implementation of RadioDriver.
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.
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
static constexpr const char * TAG
std::string tuning_config_full_snapshot(const TuningConfig &cfg)
Format the current tuning configuration as a full one-line snapshot.
const TuningSelectParam * find_tuning_select(const std::string &name)
Look up a select tuning parameter by name; returns nullptr if unknown.
std::string tuning_update_log_line(const std::string &name, const std::string &value)
Format a single tuning update for the log.
const TuningNumberParam * find_tuning_number(const std::string &name)
Look up a numeric tuning parameter by name; returns nullptr if unknown.
static constexpr uint8_t AES_KEY_SIZE
AES-128 key size.
Definition proto_sizes.h:23
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.
LR1121 radio driver for IO-Homecontrol.
SX1262 radio driver for IO-Homecontrol.
SX1276 radio driver for IO-Homecontrol.
YAML-declared device metadata for registration; defaults match an undeclared device.
Runtime state of a paired IO‑Homecontrol device.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:71
Raw packet received from the radio.
One numeric tuning parameter: its wire name plus accessors over TuningConfig.
float(* get)(const TuningConfig &)
Read the current value as a float.
bool applies_to_radio
True if changes must be re-applied to the radio.
void(* set)(TuningConfig &, float)
Write a new value (narrowed to the field type).
One select tuning parameter: its wire name plus string accessors over TuningConfig.
std::string(* get)(const TuningConfig &)
Read the current option string.
bool(* set)(TuningConfig &, const std::string &)
Parse/apply an option; false if unparseable.
bool applies_to_radio
True if changes must be re-applied to the radio.
Runtime tuning configuration for pairing and radio diagnostics.
Table-driven registry of runtime tuning parameters.