Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
hub_core.h
Go to the documentation of this file.
1#pragma once
2
3/// @file hub_core.h
4/// @brief IO-Homecontrol ESPHome component — protocol controller.
5/// @ingroup hioc_hub
6///
7/// This component manages the IO-Homecontrol 2W protocol: sending commands,
8/// receiving responses with automatic authentication, device discovery/pairing,
9/// and device state tracking. Radio hardware is delegated to a RadioDriver
10/// implementation (SX1276, SX1262, etc.).
11///
12/// SPI configuration: MSB first, CPOL=0, CPHA=0 (Mode 0), 8 MHz clock.
13/// The component inherits SPIDevice and implements SpiAccess to bridge
14/// the ESPHome SPI framework to the radio driver.
15///
16/// Architecture notes:
17/// - setup() initializes radio, waits for YAML-driven device registration, and enters RX mode.
18/// - loop() drains the OperationQueue collaborator (serializes all radio work).
19/// - All outbound commands go through send_and_receive_, a thin wrapper around
20/// ExchangeEngine which owns retry, timing, and challenge-response auth.
21/// - Inbound frames are processed in process_received_packet_ and may trigger
22/// inbound authentication (ExchangeEngine::authenticate_request) if the device proves itself.
23/// - DeviceRegistry and its callbacks provide fan-out to platform entities
24/// (covers/lights/switches/locks); StatusPollPolicy schedules follow-up polls;
25/// PairingEngine owns pairing; ManagementActions owns the rename/identify/force-open
26/// hub-level Home Assistant actions.
27
28#include "esphome/core/component.h"
29#include "esphome/core/hal.h"
30#include "esphome/components/api/custom_api_device.h"
31#include "esphome/components/spi/spi.h"
32#include "proto_codecs.h"
33#include "proto_frame.h"
34#include "radio_interface.h"
35#include "tuning_config.h"
36#include "hub_exchange.h"
37#include "hub_decisions.h"
38#include "hub_pairing.h"
39#include "device_registry.h"
40#include "status_poll_policy.h"
41#include "operation_queue.h"
42#include "exchange_engine.h"
43#include "pairing_engine.h"
44#include "management_actions.h"
45#include "pairing_responder.h"
48#include <map>
49#include <vector>
50#include <functional>
51
52namespace esphome {
53namespace home_io_control {
54
55inline constexpr uint8_t DEFAULT_TX_POWER_DBM = 17; ///< Default TX power used unless YAML overrides it.
56inline constexpr uint8_t DEFAULT_PA_PIN_PA_BOOST = 0x80; ///< SX1276 PA_CONFIG selector for the PA_BOOST output path.
57inline constexpr uint8_t DEFAULT_TCXO_VOLTAGE_SETTING_1P8V = 0x03; ///< SX1262 DIO3 setting value for a 1.8 V TCXO.
58inline constexpr size_t POSITION_TEXT_BUFFER_SIZE = 16; ///< Buffer for formatted position strings such as "100%".
59
60// ============================================================================
61// Main Component
62// ============================================================================
63
64/// The main IO-Homecontrol component. Manages the protocol layer and delegates
65/// radio operations to a RadioDriver instance.
66///
67/// Inherits SPIDevice so that ESPHome's Python codegen can configure SPI pins.
68/// Implements SpiAccess to provide the radio driver with SPI bus access.
69/// @ingroup hioc_hub
70class IOHomeControlComponent : public Component,
71 public api::CustomAPIDevice,
72 public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW,
73 spi::CLOCK_PHASE_LEADING, spi::DATA_RATE_8MHZ>,
74 public SpiAccess {
75 public:
76 /// Initialize ExchangeEngine, PairingEngine, and ManagementActions with double-pointer/
77 /// reference indirection so that test assignments (`comp.radio_ = &mock`) propagate
78 /// through all collaborators without calling setup().
83
84 /// @brief Result payload used by hub-level management actions such as rename.
85 /// Alias of the standalone esphome::home_io_control::ManagementActionResult struct so that
86 /// callers using the nested name IOHomeControlComponent::ManagementActionResult continue to work.
88
89 /// @brief Initialize hardware (radio and device registry).
90 void setup() override;
91 /// @brief Main loop: process pending operations and drive radio state machine.
92 void loop() override;
93 /// @brief Dump configuration and radio debug info to the log.
94 void dump_config() override;
95 /// @brief Get setup priority (HARDWARE to initialize early).
96 /// @return setup_priority::HARDWARE.
97 [[nodiscard]] float get_setup_priority() const override { return setup_priority::HARDWARE; }
98
99 // --- SpiAccess implementation (delegates to SPIDevice) ---
100 /// @brief Enable the SPI bus.
101 void spi_enable() override { this->enable(); }
102 /// @brief Disable the SPI bus.
103 void spi_disable() override { this->disable(); }
104 /// @brief Transfer one byte full‑duplex.
105 /// @param data Byte to send.
106 /// @return Received byte.
107 uint8_t spi_transfer(uint8_t data) override { return this->transfer_byte(data); }
108 /// @brief Write one byte (MOSI only).
109 /// @param data Byte to send.
110 void spi_write(uint8_t data) override { this->write_byte(data); }
111 /// @brief Read one byte (MISO only).
112 /// @return Received byte.
113 uint8_t spi_read() override { return this->read_byte(); }
114
115 /// @brief Suspend the hub's normal loop (packet processing, hopping, polling).
116 /// Used by loopback test configs to take exclusive control of the radio.
117 void set_radio_test_mode(bool active) { this->radio_test_mode_ = active; }
118
119 /// @brief Get the underlying radio driver (for diagnostics and test tooling).
120 [[nodiscard]] RadioDriver *get_radio() const { return this->radio_; }
121
122 // --- YAML configuration setters (called by generated code) ---
123 /// Set the radio reset pin.
124 void set_rst_pin(InternalGPIOPin *pin) { this->rst_pin_ = pin; }
125 /// Set the DIO0 interrupt pin (SX1276).
126 void set_dio0_pin(InternalGPIOPin *pin) { this->dio0_pin_ = pin; }
127 /// Set the DIO4 preamble‑detect pin (SX1276, optional).
128 void set_dio4_pin(InternalGPIOPin *pin) { this->dio4_pin_ = pin; }
129 /// Set the DIO1 interrupt pin (SX1262; also carries the LR1121's DIO9 IRQ line).
130 void set_dio1_pin(InternalGPIOPin *pin) { this->dio1_pin_ = pin; }
131 /// Set the BUSY pin (SX1262/LR1121).
132 void set_busy_pin(InternalGPIOPin *pin) { this->busy_pin_ = pin; }
133 /// Set the front‑end module enable pin.
134 void set_fem_en_pin(InternalGPIOPin *pin) { this->fem_en_pin_ = pin; }
135 /// Set the VFEM power pin.
136 void set_vfem_pin(InternalGPIOPin *pin) { this->vfem_pin_ = pin; }
137 /// Set the FEM PA switch pin.
138 void set_fem_pa_pin(InternalGPIOPin *pin) { this->fem_pa_pin_ = pin; }
139 /// Set the controller's node ID (hex string).
140 void set_node_id(const std::string &id) { this->node_id_str_ = id; }
141 /// Set the system key (hex string).
142 void set_system_key(const std::string &key) { this->system_key_str_ = key; }
143 /// Set transmit power (dBm).
144 void set_tx_power(uint8_t power) { this->tx_power_ = power; }
145 /// Set PA boost pin configuration.
146 void set_pa_pin(uint8_t pa_pin) { this->pa_pin_ = pa_pin; }
147 /// Set radio type ("sx1276", "sx1262", or "lr1121"); required by the YAML schema.
148 void set_radio_type(const std::string &type) { this->radio_type_ = type; }
149 /// Set TCXO voltage for SX1262/LR1121 (1.8V / 3.3V).
150 void set_tcxo_voltage(uint8_t voltage) { this->tcxo_voltage_ = voltage; }
151
152 /// Apply the tuning configuration generated from YAML / UI entities.
153 void set_tuning_config(const TuningConfig &config) { this->tuning_ = config; }
154 /// Receive a numeric tuning update from a HA `number` entity.
155 void update_tuning_number(const std::string &name, float value);
156 /// Receive a select tuning update from a HA `select` entity.
157 void update_tuning_select(const std::string &name, const std::string &value);
158 /// Current value of a numeric tuning parameter, used to seed a HA `number` entity on boot.
159 /// @param name YAML key of the parameter.
160 /// @return Current value, or 0 for an unknown key.
161 [[nodiscard]] float get_tuning_number_value(const std::string &name) const;
162 /// Current option string of a select tuning parameter, used to seed a HA `select` entity on boot.
163 /// @param name YAML key of the parameter.
164 /// @return Current value formatted as its YAML option string, or empty for an unknown key.
165 [[nodiscard]] std::string get_tuning_select_value(const std::string &name) const;
166
167 /// Declare that a remote (identified by its node ID) controls a registered device.
168 /// When activity from this remote is overheard, a status poll is scheduled for the device.
169 /// This is needed for 1W remotes whose destination address differs from the device's 2W ID.
170 /// @param remote_id Node ID of the remote control.
171 /// @param device_id Node ID of the device it controls.
172 void add_linked_remote(const std::string &remote_id, const std::string &device_id) {
173 this->registry_.add_linked_remote(remote_id, device_id);
174 }
175
176 /// Declare that a device class's typed 1W broadcasts (e.g. "all awnings") also apply to
177 /// @p device_id, matching how 1W remotes address a device class rather than a single node.
178 /// @param type Device class the broadcast targets.
179 /// @param device_id Node ID of the device to add to that class.
180 void add_linked_remote_class(DeviceType type, const std::string &device_id) {
181 this->registry_.add_linked_remote_class(type, device_id);
182 }
183
184 /// Set an optimistic target position ahead of a confirming poll/response, and notify.
185 /// No-op when the device is unknown or has `optimistic_state == false`. See
186 /// DeviceRegistry::apply_optimistic_target() for the full contract.
187 /// Virtual (like add_device/get_device) so platform unit tests can substitute a mock registry.
188 /// @param device_id Target device ID.
189 /// @param target_io_position Target position in IO units (0=open, 100=closed).
190 /// @return true if the optimistic state was applied.
191 virtual bool apply_optimistic_target(const std::string &device_id, float target_io_position) {
192 return this->registry_.apply_optimistic_target(device_id, target_io_position);
193 }
194
195 /// Clear a device's optimistic target (e.g. on STOP), and notify.
196 /// No-op when the device is unknown or has `optimistic_state == false`.
197 /// Virtual (like add_device/get_device) so platform unit tests can substitute a mock registry.
198 /// @param device_id Target device ID.
199 /// @return true if the optimistic target was cleared.
200 virtual bool clear_optimistic_target(const std::string &device_id) {
201 return this->registry_.clear_optimistic_target(device_id);
202 }
203
204 /// Set an optimistic slat angle ahead of a confirming status poll, and notify.
205 /// No-op when the device is unknown, has `optimistic_state == false`, or is not tilt-capable.
206 /// See DeviceRegistry::apply_optimistic_tilt() for the full contract and for why a tilt
207 /// command cannot rely on its own reply the way a position command can.
208 /// Virtual like the other device-registry accessors so a test double can override it if it
209 /// needs to; MockPlatformHubBase deliberately does not, and exercises the real registry.
210 /// @param device_id Target device ID.
211 /// @param tilt_percent Slat angle in the same percent scale as `IoDevice::tilt` (0-100).
212 /// @return true if the optimistic tilt was applied.
213 virtual bool apply_optimistic_tilt(const std::string &device_id, float tilt_percent) {
214 return this->registry_.apply_optimistic_tilt(device_id, tilt_percent);
215 }
216
217 /// Allow a 1W sender (identified by its node ID) to fire the `esphome.home_io_control_sender_event`
218 /// event to Home Assistant. "Sender" is deliberately broader than "remote": the same 1W broadcast
219 /// mechanism carries handheld/wall remotes and wind/rain sensors alike (they differ only in the
220 /// `originator` byte inside the payload, not in how they address the radio) — see `decode_1w_frame()`.
221 /// Overheard 1W traffic is always DEBUG-logged regardless of this list; this only controls which
222 /// senders are allowed to reach Home Assistant as an event, independent of whether the sender is
223 /// also linked to a device via `add_linked_remote`. Empty by default — a sender must be explicitly
224 /// opted in.
225 /// @param sender_id Node ID of the 1W sender (remote or sensor).
226 void add_exposed_sender(const std::string &sender_id) { this->exposed_senders_.push_back(sender_id); }
227
228 /// @return The telemetry recorded for the most recent (or in-progress) pairing attempt.
229 [[nodiscard]] const PairingTelemetry &pairing_telemetry() const { return this->pairing_telemetry_; }
230
231 /// Register a callback invoked once, right after every `discover_and_pair()` attempt
232 /// completes — used by the "Last Pairing Result" text sensor to publish a fresh value.
233 /// Single-slot: only one platform instance is expected per hub.
234 /// @param cb Callable with no arguments.
235 void set_pairing_result_callback(std::function<void()> cb) { this->pairing_result_callback_ = std::move(cb); }
236
237 /// @brief Arm or disarm the "Accept Foreign Pairing (Key Extraction)" responder.
238 ///
239 /// Arming picks a fresh throwaway node ID, resets the pairing_responder state machine to
240 /// ARMED_IDLE, and schedules a 10-minute auto-off. While armed, the 0x28/0x2C/0x31/0x32 branches
241 /// in process_received_packet_() emulate an unpaired device so a user's existing hub can pair to
242 /// it and hand over its node_id/system_key (see pairing_responder.h). Disarming — manual, via
243 /// the HA switch, on successful extraction, or on auto-off — immediately stops those branches
244 /// from responding; it never touches the real device registry or the hub's own node_id_/
245 /// system_key_. Virtual so platform unit tests can substitute a mock hub, matching every other
246 /// queue_*/set_* entry point on this component.
247 /// @param armed Desired state.
248 virtual void set_key_extraction_armed(bool armed);
249
250 /// Register a callback invoked whenever the key-extraction armed state changes — manual
251 /// toggle, successful extraction, or auto-off timeout — so the switch entity can keep its
252 /// displayed state in sync when the hub disarms itself rather than the user. Single-slot,
253 /// mirrors set_pairing_result_callback().
254 /// @param cb Callable receiving the new armed state.
255 void set_key_extraction_armed_callback(std::function<void(bool)> cb) {
256 this->key_extraction_armed_callback_ = std::move(cb);
257 }
258
259 // --- Device management (called by platform entities during setup) ---
260 /// Add a device to the registry by device ID only (undeclared/legacy path).
261 /// Type, subtype, inverted, and optimistic_state default to UNKNOWN / 0 / false / true; use the
262 /// `DeviceConfig` overload when metadata comes from a YAML declaration.
263 /// @param device_id Hexadecimal node ID string.
264 virtual void add_device(const std::string &device_id);
265 /// Add a device to the registry with full metadata from a YAML declaration.
266 /// @param device_id Hexadecimal node ID string.
267 /// @param cfg Device type/subtype/inversion/optimistic-state metadata.
268 virtual void add_device(const std::string &device_id, const DeviceConfig &cfg);
269 /// Retrieve a device by ID; returns nullptr if not found.
270 /// @param device_id Hexadecimal node ID.
271 /// @return Pointer to IoDevice, or nullptr.
272 virtual IoDevice *get_device(const std::string &device_id);
273 /// Set a device's `dimmable` flag (see IoDevice::dimmable). Called by platform_light.cpp's
274 /// setup(), not folded into add_device() since it's a light-only YAML choice. No-op if the
275 /// device isn't registered.
276 /// @param device_id Hexadecimal node ID string.
277 /// @param dimmable New value for IoDevice::dimmable.
278 virtual void set_device_dimmable(const std::string &device_id, bool dimmable);
279 /// Register a callback invoked when any device updates.
280 /// @param cb Callable with signature void(const std::string&, const IoDevice&).
281 virtual void register_device_callback(DeviceUpdateCallback cb) { this->registry_.subscribe(std::move(cb)); }
282 /// Configure the optional follow-up polling interval for a registered device.
283 /// @param device_id Target device ID.
284 /// @param poll_interval_ms Poll interval in milliseconds; zero keeps the legacy one-shot settle poll only.
285 virtual void set_device_status_poll_interval(const std::string &device_id, uint32_t poll_interval_ms);
286
287 // --- High-level operations ---
288 /// Send a position command to a device.
289 /// @param device_id Target device ID.
290 /// @param position Desired position, 0–100 (open→closed). Named commands (STOP, FAVORITE,
291 /// VENT) go through execute_device_command_()/create_execute_command() instead.
292 /// @return true if device acknowledged; false on timeout or radio error.
293 virtual bool set_device_position(const std::string &device_id, uint8_t position);
294 /// Send a tilt command to a tilt‑capable cover.
295 /// @param device_id Target device ID.
296 /// @param tilt_percent Desired tilt (0–100).
297 /// @return true if device acknowledged; false otherwise.
298 virtual bool set_device_tilt(const std::string &device_id, uint8_t tilt_percent);
299 /// Set both position and tilt of a tilt-capable cover in one atomic command.
300 /// @param device_id Target device ID.
301 /// @param position Desired position (0–100, open→closed).
302 /// @param tilt_percent Desired tilt (0–100).
303 /// @return true if device acknowledged; false otherwise.
304 virtual bool set_device_position_and_tilt(const std::string &device_id, uint8_t position, uint8_t tilt_percent);
305 /// Request current status from a device.
306 /// @param device_id Target device ID.
307 /// @return true if status frame was received and processed.
308 virtual bool request_device_status(const std::string &device_id);
309 /// Request the stored device name from a device.
310 /// @param device_id Target device ID.
311 /// @return true if a name response frame was received and processed.
312 virtual bool request_device_name(const std::string &device_id);
313 /// Rename a device and verify the result by reading the name back.
314 /// @param device_id Target device ID.
315 /// @param new_name Requested UTF-8 device name.
316 /// @return Structured result describing success, verification, and any validation failure.
317 virtual ManagementActionResult rename_device(const std::string &device_id, const std::string &new_name);
318 /// Trigger a device's physical identify (brief jog/flash) so a user can confirm which
319 /// physical motor a device ID maps to.
320 /// @param device_id Target device ID.
321 /// @return Structured result describing success and any validation failure. `verified` is
322 /// always false — there is no readback for a physical identify jog.
323 virtual ManagementActionResult identify_device(const std::string &device_id);
324 /// @brief Move a cover device to fully open at elevated priority, intended to bypass
325 /// wind/rain soft locks.
326 ///
327 /// Safety-sensitive: queues CoverCommand::FORCE_OPEN through the normal cover-command dispatch
328 /// path. Only confirms the command was queued; the movement outcome arrives later via the
329 /// device's normal cover-state/polling pipeline, so `verified` is always false. The lock-bypass
330 /// behavior itself is experimental and unconfirmed against an active lock — see
331 /// ManagementActions::force_open_device()'s doxygen for details.
332 /// @param device_id Target device ID.
333 /// @return Structured result describing whether the command was queued.
334 virtual ManagementActionResult force_open_device(const std::string &device_id);
335 /// Broadcast a roll-call and report every device that answers (see
336 /// ManagementActions::scan_paired_devices() for the full contract: only key-holding devices
337 /// answer, DeviceRegistry is never written, and zero replies is a successful result).
338 /// @return Structured result whose `message` is the full multi-line report.
340 /// Discover and pair a device that is in pairing mode.
341 /// @return true if pairing completed successfully; false otherwise.
342 virtual bool discover_and_pair();
343 /// Send an arbitrary IO position (0-100) to a light entity. Internally mapped to the shared
344 /// execute path. set_light_state() is a thin binary-position wrapper around this, used by
345 /// dimmable lights to send anything other than the two binary extremes.
346 /// @param device_id Target device ID.
347 /// @param position Desired IO position (0-100); this device family's convention maps 0 to full
348 /// brightness and 100 to off, the same 0-100 scale platform_cover.cpp uses.
349 /// @return true if device acknowledged.
350 virtual bool set_light_position(const std::string &device_id, uint8_t position);
351 /// Semantic binary helper for light entities. Internally mapped to the shared execute path.
352 /// @param device_id Target device ID.
353 /// @param on Desired on/off state.
354 /// @return true if device acknowledged.
355 virtual bool set_light_state(const std::string &device_id, bool on);
356 /// Semantic binary helper for switch entities. Internally mapped to the shared execute path.
357 /// @param device_id Target device ID.
358 /// @param on Desired on/off state.
359 /// @return true if device acknowledged.
360 virtual bool set_switch_state(const std::string &device_id, bool on);
361 /// Semantic lock helper for lock entities. Internally mapped to the shared execute path.
362 /// @param device_id Target device ID.
363 /// @param locked Desired locked/unlocked state.
364 /// @return true if device acknowledged.
365 virtual bool set_lock_state(const std::string &device_id, bool locked);
366 /// @brief Queue an async position update; returns immediately, executed in loop().
367 ///
368 /// If a pending SET_TILT operation for the same device is already in the queue, the two are
369 /// coalesced into a single SET_POSITION_AND_TILT command to avoid two radio exchanges.
370 /// This transparently handles Home Assistant sending cover.set_cover_position and
371 /// cover.set_cover_tilt_position as separate rapid calls.
372 /// @param device_id Target device ID.
373 /// @param position Desired position (0–100).
374 virtual void queue_set_device_position(const std::string &device_id, uint8_t position);
375 /// @brief Queue an async named command (STOP, FAVORITE, VENT, FORCE_OPEN); returns immediately,
376 /// executed in loop().
377 ///
378 /// Existing entity/button callers (cover, favorite button, vent button) intentionally ignore
379 /// the return value — they always target a known, already-registered device. It exists so
380 /// force_open_device() can report enqueue rejection distinctly from a queued-but-not-yet-run
381 /// command.
382 /// @param device_id Target device ID.
383 /// @param cmd Named command to send.
384 /// @return true if the hub is initialized, the device is registered, and the command matches
385 /// its capability class (so the command was enqueued); false otherwise.
386 virtual bool queue_device_command(const std::string &device_id, CoverCommand cmd);
387 /// @brief Queue an async tilt update; returns immediately, executed in loop().
388 ///
389 /// If a pending SET_POSITION operation for the same device is already in the queue, the two are
390 /// coalesced into a single SET_POSITION_AND_TILT command to avoid two radio exchanges.
391 /// This transparently handles Home Assistant sending cover.set_cover_position and
392 /// cover.set_cover_tilt_position as separate rapid calls.
393 /// @param device_id Target device ID.
394 /// @param tilt_percent Desired tilt (0–100).
395 virtual void queue_set_device_tilt(const std::string &device_id, uint8_t tilt_percent);
396 /// Queue an async combined position+tilt update; returns immediately, executed in loop().
397 /// @param device_id Target device ID.
398 /// @param position Desired position (0–100).
399 /// @param tilt_percent Desired tilt (0–100).
400 virtual void queue_set_device_position_and_tilt(const std::string &device_id, uint8_t position, uint8_t tilt_percent);
401 /// Queue an async status request; returns immediately, executed in loop().
402 /// @param device_id Target device ID.
403 virtual void queue_request_device_status(const std::string &device_id);
404 /// Queue an async device-name request; returns immediately, executed in loop().
405 /// @param device_id Target device ID.
406 virtual void queue_request_device_name(const std::string &device_id);
407 /// Queue a pairing operation; executed in loop() when radio idle.
408 virtual void queue_discover_and_pair();
409 /// Async form of set_light_position() that keeps radio work serialized on the main loop.
410 /// queue_set_light_state() is a thin binary-position wrapper around this.
411 /// @param device_id Target device ID.
412 /// @param position Desired IO position (0-100).
413 virtual void queue_set_light_position(const std::string &device_id, uint8_t position);
414 /// Async form of set_light_state() that keeps radio work serialized on the main loop.
415 /// @param device_id Target device ID.
416 /// @param on Desired on/off state.
417 virtual void queue_set_light_state(const std::string &device_id, bool on);
418 /// Async form of set_switch_state() that keeps radio work serialized on the main loop.
419 /// @param device_id Target device ID.
420 /// @param on Desired on/off state.
421 virtual void queue_set_switch_state(const std::string &device_id, bool on);
422 /// Async form of set_lock_state() that keeps radio work serialized on the main loop.
423 /// @param device_id Target device ID.
424 /// @param locked Desired locked/unlocked state.
425 virtual void queue_set_lock_state(const std::string &device_id, bool locked);
426
427#ifdef IOHOME_LR1121_FIRMWARE_UPDATE
428 /// @brief Entry point for the "Flash LR1121 Radio Firmware" button.
429 ///
430 /// Only exists when a `lr1121_firmware_update:` block is configured. See
431 /// hub_lr1121_firmware_update.cpp for the full contract, including the safety invariant that
432 /// every bootloader excursion this triggers must end in either radio_->init() or
433 /// App.safe_reboot() — there is no third option.
434 void trigger_lr1121_firmware_update();
435
436#ifdef IOHOME_LR1121_BOOTLOADER_UPDATE
437 /// @brief Set by the "Allow LR1121 Bootloader Rewrite (Irreversible)" switch's write_state().
438 ///
439 /// A permission, not an override: this can only convert a cached
440 /// BootloaderUpgradePath::AVAILABLE verdict into "run the three-stage sequence" (bootloader
441 /// ADR 0021) -- it never affects REJECT_WRONG_CHIP, the post-entry sanity
442 /// check, the busy_ guard, or any other verdict. Read once, at button-press time
443 /// (trigger_lr1121_firmware_update()); the ESPHome loop is blocked for the whole three-stage
444 /// sequence once it starts, so the switch cannot change mid-flash. Deliberately not named
445 /// anything with "armed" -- lr1121_flash_confirmation_armed_ already means the two-press window
446 /// this switch *replaces* for its own path, and a reader must never have to guess which is meant.
447 void set_bootloader_rewrite_allowed(bool allowed) { this->bootloader_rewrite_allowed_ = allowed; }
448#endif
449#endif
450
451 protected:
452 // --- Protocol-level operations ---
453 /// Transmit a raw IoFrame on the current frequency with given preamble length.
454 /// @param frame IoFrame to transmit.
455 /// @param freq RF frequency in Hz.
456 /// @param preamble Preamble length in bytes (LONG_PREAMBLE or SHORT_PREAMBLE).
457 bool transmit_frame_(const IoFrame &frame, uint32_t freq, uint16_t preamble);
458 /// Main request/response exchange with retry and automatic authentication.
459 /// @param request Outbound request IoFrame.
460 /// @param response Output: received response IoFrame.
461 /// @param freq RF frequency in Hz.
462 /// @return true if exchange succeeded; false otherwise.
463 bool send_and_receive_(const IoFrame &request, IoFrame &response, uint32_t freq);
464 /// Handle an inbound authenticated command from a device (status updates, etc.).
465 /// @param request Inbound authenticated request (e.g., CMD_STATUS_UPDATE).
466 /// @param freq RF frequency the packet arrived on.
467 /// @return true if authentication succeeded; false otherwise.
468 bool authenticate_request_(const IoFrame &request, uint32_t freq);
469 /// Parse a received frame, merge supported device state or metadata, and notify callbacks.
470 /// @param packet Raw radio packet containing a parsed IoFrame.
471 void process_received_packet_(const RadioRxPacket &packet);
472
473 // --- Key-extraction responder ("Accept Foreign Pairing") RX handlers ---
474 // Small delegating wrappers called from process_received_packet_(); the actual decision logic
475 // lives in pairing_responder.h so it stays pure and host-testable. See hub_key_extraction.cpp.
476
477 /// Dispatch a frame to the key-extraction responder if it's one of its 0x28/0x2C/0x31/0x32
478 /// frames and the responder is armed. Factored out of process_received_packet_() purely to keep
479 /// that function's cognitive complexity under the clang-tidy threshold, mirroring
480 /// PairingEngine::record_discovery_rx_telemetry_()'s reason for existing.
481 /// @param frame Parsed inbound frame.
482 /// @return true if the frame was handled (caller should stop further dispatch for it).
483 bool try_handle_key_extraction_frame_(const IoFrame &frame);
484 /// Handle an inbound CMD_DISCOVER_REQ (0x28) while the key-extraction responder is armed.
485 /// @param frame Parsed inbound discovery broadcast.
486 void handle_key_extraction_discover_(const IoFrame &frame);
487 /// Handle an inbound CMD_DISCOVER_CONFIRM (0x2C) addressed to our throwaway node ID while armed.
488 /// @param frame Parsed inbound discovery-confirm frame.
489 void handle_key_extraction_discover_confirm_(const IoFrame &frame);
490 /// Handle an inbound CMD_KEY_INIT (0x31) addressed to our throwaway node ID while armed.
491 /// @param frame Parsed inbound key-init frame.
492 void handle_key_extraction_key_init_(const IoFrame &frame);
493 /// Handle an inbound CMD_KEY_TRANSFER (0x32) addressed to our throwaway node ID while armed.
494 /// @param frame Parsed inbound key-transfer frame.
495 void handle_key_extraction_key_transfer_(const IoFrame &frame);
496 /// Generate a random throwaway node ID for one key-extraction arm cycle, avoiding collisions
497 /// with the broadcast addresses, this hub's own real node ID, and any registered device.
498 /// @param out Output: 3-byte node ID.
500 /// Transmit a key-extraction reply frame on all 3 IO-homecontrol channels, using the radio
501 /// driver's response_preamble() rather than a fixed SHORT_PREAMBLE/LONG_PREAMBLE constant —
502 /// long enough that a channel-hopping receiver reliably lands on it, short enough that 3
503 /// sequential transmissions don't block the main loop for the better part of a second (see the
504 /// implementation comment in hub_key_extraction.cpp for the hardware-confirmed reasoning).
505 /// Shared by every RX handler so the preamble choice and channel list are defined once.
506 /// @param frame Frame to broadcast (already built by the caller).
507 void broadcast_key_extraction_reply_(const IoFrame &frame);
508 /// Emit the security-sensitive "system key extracted" log block (see redaction.h — this is the
509 /// one deliberate, explicit exception to that file's masking, not a loosening of it).
511
512 /// Extract supported position or metadata info from a response frame and merge it into the device record.
513 /// @param frame IoFrame containing a supported inbound command such as CMD_PRIVATE_RESP,
514 /// CMD_STATUS_UPDATE, CMD_GET_NAME_RESP, or CMD_GET_INFO2_RESP.
515 /// @param trust_position False to apply `is_stopped` but skip target/position decode for a
516 /// CMD_PRIVATE_RESP — the immediate reply to our own just-sent CMD_EXECUTE echoes stale
517 /// pre-command target/current values on at least some devices (see
518 /// tests/corpus/captures/somfy_awning/execute_ack_reports_stale_target_*.yaml), so
519 /// execute_request_and_update_() passes false there; every other caller trusts as before.
520 void update_device_status_(const IoFrame &frame, bool trust_position = true);
521 /// Record that a 1W frame just arrived, updating last_1w_activity_ms_ and — when this frame
522 /// starts a new burst (see decisions::oneway_burst_started_fresh()) — first_1w_activity_ms_.
523 /// @param now millis() at which this frame arrived.
524 void record_1w_activity_(uint32_t now);
525 /// Schedule a delayed status poll for a registered device using the Component timeout API.
526 /// @param device_id ID of the device to poll.
527 /// @param delay_ms Delay in milliseconds before polling.
528 /// @note Uses ESPHome's set_timeout() mechanism; the callback executes in loop().
529 /// A zero delay schedules immediately on the next loop iteration.
530 void schedule_status_poll_(const std::string &device_id, uint32_t delay_ms);
531 /// Begin bounded follow-up polling for a device after a command or overheard remote activity.
532 /// @param device_id ID of the device to poll.
533 /// @param initial_delay_ms Delay before the first follow-up poll.
534 void begin_status_poll_tracking_(const std::string &device_id, uint32_t initial_delay_ms);
535 /// Schedule status polls for a fixed list of devices (shared by the id-linked and
536 /// class-linked 1W paths, and by schedule_linked_remote_polls_()).
537 /// @param device_ids Devices to poll.
538 /// @param delay_ms Poll delay in milliseconds.
539 void schedule_device_polls_(const std::vector<std::string> &device_ids, uint32_t delay_ms);
540 /// Whether loop() should skip dispatching the queue this iteration because the pending work is a
541 /// background poll and a 1W remote transmitted very recently. Thin wrapper binding the component's
542 /// state to decisions::defer_background_poll_for_1w_activity().
543 [[nodiscard]] bool defer_background_poll_() const {
545 !this->op_queue_.empty() && OperationQueue::is_background_op(this->op_queue_.front().type),
546 this->first_1w_activity_ms_, this->last_1w_activity_ms_, millis(), ONEWAY_QUIET_PERIOD_MS,
548 }
549 /// Schedule status polls for all devices associated with a linked remote.
550 /// @param remote_id Source node ID of the remote.
551 /// @param delay_ms Poll delay; default REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS. A STOP intent
552 /// passes 0 (position settles immediately, no need to wait out the usual travel-time
553 /// assumption behind the default delay).
554 void schedule_linked_remote_polls_(const std::string &remote_id,
555 uint32_t delay_ms = REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS);
556 /// Resolve the set of devices a 1W frame should affect: devices linked to the sending remote
557 /// by node ID, plus — when the frame targets a typed broadcast (e.g. "all awnings") — devices
558 /// linked to that device class, deduplicated so a device linked both ways is touched once.
559 /// @param info Already-decoded 1W frame info (see decode_1w_frame()).
560 /// @param src_id Sender's node ID as a string (already computed by the caller).
561 /// @return Deduplicated device IDs (may be empty).
562 [[nodiscard]] std::vector<std::string> resolve_1w_target_devices_(const OneWayFrameInfo &info,
563 const std::string &src_id) const;
564 /// Apply optimistic target state to every device in @p device_ids, when the decoded frame
565 /// carries a resolvable intent. Skips devices whose known type doesn't match the frame's
566 /// typed-broadcast target (an "all awnings" press must not optimistically move a linked
567 /// shutter — it is still polled by schedule_device_polls_()). No-op per device when that
568 /// device has `optimistic_state == false` (see DeviceRegistry::apply_optimistic_target()).
569 /// @param info Already-decoded 1W frame info (see decode_1w_frame()).
570 /// @param device_ids Devices to apply optimistic state to (see resolve_1w_target_devices_()).
571 /// @return true if the intent resolved to a STOP (caller should poll immediately).
572 bool apply_optimistic_linked_state_(const OneWayFrameInfo &info, const std::vector<std::string> &device_ids);
573 /// Fire the sender HA event for a decoded 1W frame, if the sender is exposed.
574 /// DEBUG-logs the reason when it does not fire (API disconnected / sender not exposed) so a
575 /// live log capture can distinguish "never reached this check" from "reached it and skipped".
576 /// @param info Already-decoded 1W frame info (see decode_1w_frame()).
577 /// @param linked True if the sender is linked to at least one registered device.
578 /// @param src_id Sender's node ID as a string (already computed by the caller).
579 void maybe_fire_sender_event_(const OneWayFrameInfo &info, bool linked, const std::string &src_id);
580 /// Shared request/response helper for high-level operations.
581 /// @param device_id Target device ID.
582 /// @param request Outbound request frame.
583 /// @param warn_on_no_response If true, logs a warning when no response is received.
584 /// @param retry_after_fail_ms If non-zero, schedules next status poll after this delay on failure.
585 /// @return true if device acknowledged; false otherwise.
586 bool execute_request_and_update_(const std::string &device_id, const IoFrame &request, bool warn_on_no_response,
587 uint32_t retry_after_fail_ms = 0);
588 /// Execute a named device command (STOP, FAVORITE, VENT, FORCE_OPEN) via the authenticated exchange.
589 /// @param device_id Target device ID.
590 /// @param cmd Named command to execute.
591 /// @return true if device acknowledged; false otherwise.
592 bool execute_device_command_(const std::string &device_id, CoverCommand cmd);
593 /// Fire all registered device update callbacks for the given device ID.
594 /// @param id Device ID that updated.
595 void notify_device_update_(const std::string &id);
596 /// Apply backoff after a failed background status poll and log the result.
597 /// @param device_id Target device ID.
598 /// @param auth_like True when the failed exchange saw a 0x3C challenge.
599 void schedule_background_poll_backoff_(const std::string &device_id, bool auth_like);
600 /// Pop next pending operation from the queue and execute it (set position, request status, discover).
602
603 // --- Exchange helpers (thin wrappers delegating to ExchangeEngine) ---
604
605 /// Log the last exchange debug snapshot (delegates to exchange_engine_).
606 void log_exchange_debug_(const char *device_id) const { this->exchange_engine_.log_debug(device_id); }
607
608 // --- Tuning ---
609 /// Apply the current tuning configuration to the active radio driver.
611
612 // --- Management actions (thin wrappers delegating to management_actions_) ---
613 /// Register hub-level Home Assistant actions; called from setup().
614 void register_management_actions_() { this->management_actions_.register_actions(); }
615 /// Native API callback: rename a registered device.
616 void api_rename_device_(const std::string &device_id, const std::string &new_name) {
617 this->management_actions_.api_rename_device(device_id, new_name);
618 }
619 /// Native API callback: trigger a registered device's physical identify.
620 void api_identify_device_(const std::string &device_id) { this->management_actions_.api_identify_device(device_id); }
621 /// Native API callback: force-open a registered cover device.
622 void api_force_open_device_(const std::string &device_id) {
623 this->management_actions_.api_force_open_device(device_id);
624 }
625 /// Native API callback: broadcast a roll-call scan of already-paired devices.
626 void api_scan_paired_devices_() { this->management_actions_.api_scan_paired_devices(); }
627
628 // --- Frequency hopping ---
629 void hop_frequency_();
630
631 // --- Radio driver selection (called once from setup()) ---
632 /// Select and construct the radio driver named by the required `radio_type` config field.
633 ///
634 /// Kept as its own method rather than inlined into setup(): the three-way chip branch
635 /// (SX1276/SX1262/LR1121) is enough logic on its own that folding it into setup() pushes
636 /// that function's cognitive complexity past clang-tidy's threshold.
637 /// Validates the pins each driver needs and logs a clear error (without calling
638 /// mark_failed() itself — the caller decides how to react) when a required pin is
639 /// missing. On success, `*chip_name_out` is set to a static string naming the selected
640 /// chip (used for logging), and the returned pointer is the heap-allocated (not yet
641 /// initialized) driver instance.
642 /// @param chip_name_out Output: human-readable chip name for logging (always set,
643 /// even on failure, to the best-known name for error messages).
644 /// @return Newly allocated RadioDriver, or nullptr if pin validation or allocation failed.
645 RadioDriver *select_and_construct_radio_(const char **chip_name_out);
646
647#ifdef IOHOME_LR1121_FIRMWARE_UPDATE
648 // --- LR1121 firmware update (hub_lr1121_firmware_update.cpp) ---
649 /// @brief Boot-time bootloader-version excursion.
650 ///
651 /// Called from setup() after select_and_construct_radio_() and before radio_->init() — at that
652 /// point nothing has configured the radio yet, so a bootloader excursion costs one extra chip
653 /// reset and needs no reboot afterward (unlike every other bootloader excursion in this
654 /// feature). Constructs lr1121_firmware_updater_, runs the excursion, and caches the bootloader
655 /// version/type. Never fails setup(): a failed read just leaves the bootloader version
656 /// "unknown" and lets radio_->init() proceed normally.
657 void run_lr1121_boot_time_bootloader_read_();
658 /// @brief Compute and cache the flash verdict once radio_->init() has produced (or failed to
659 /// produce) an installed-firmware-version read.
660 ///
661 /// Must run after init(), not during the boot-time excursion above: the installed version comes
662 /// from configure_radio_(), which runs inside init(). Called from setup() regardless of whether
663 /// init() succeeded — see the null-radio_ recovery-path reasoning in
664 /// trigger_lr1121_firmware_update()'s guard 0.
665 void cache_lr1121_flash_verdict_();
666 /// @brief Emit the bootloader version and cached flash verdict to the config dump.
667 /// Called from dump_config(), next to the existing radio_->dump_debug() call.
668 void dump_lr1121_firmware_update_debug_() const;
669 /// @brief Pure content behind dump_lr1121_firmware_update_debug_(), factored out so it is
670 /// testable without a log-capturing harness (ESP_LOGCONFIG is a no-op in host tests). Always
671 /// includes the verdict line when the verdict is known, independent of whether the bootloader
672 /// version is -- see the implementation for why that independence matters.
673 std::vector<std::string> lr1121_firmware_update_debug_lines_() const;
674 /// @brief Human-readable explanation of the cached verdict, shared by
675 /// dump_lr1121_firmware_update_debug_() and trigger_lr1121_firmware_update() so the boot-time
676 /// config dump and a button-press log always say the same thing.
677 /// @return A complete log message (no trailing newline).
678 std::string describe_lr1121_flash_verdict_() const;
679
680#ifdef IOHOME_LR1121_BOOTLOADER_UPDATE
681 /// @brief User-facing text for a bootloader-rewrite refusal at button-press time.
682 ///
683 /// Separate from describe_lr1121_flash_verdict_() rather than a suffix on it: that function
684 /// opens with "CANNOT PROCEED", which reads as final and would then contradict an explanation
685 /// that the rewrite is available. Leads with the outcome (nothing happened), then the reason,
686 /// then the next action. Returns a string rather than logging directly so it stays testable --
687 /// host builds compile the ESP_LOG* macros to no-ops.
688 /// @param path The cached upgrade path that produced the refusal.
689 /// @return The message to log.
690 std::string describe_lr1121_bootloader_refusal_(BootloaderUpgradePath path) const;
691#endif
692 /// @brief Arm the two-press confirmation window and schedule its auto-disarm.
693 /// Mirrors hub_key_extraction.cpp's KEY_EXTRACTION_AUTO_OFF_MS idiom (named set_timeout,
694 /// guard against a stale callback after a fresh press already disarmed).
695 void arm_lr1121_flash_confirmation_();
696 /// @brief The bootloader-entry-through-post-flash-verify sequence, run only once
697 /// trigger_lr1121_firmware_update() has decided to actually flash. Split out from that method
698 /// to keep its own cognitive complexity within clang-tidy's threshold (see the implementation
699 /// plan's ground rules).
700 ///
701 /// Per the ground rules' safety invariant: once this method's bootloader entry succeeds, every
702 /// exit — including every failure path — ends in `App.safe_reboot()`. There is no `return` in
703 /// here that leaves the chip unconfigured without also rebooting the ESP32.
704 void run_lr1121_flash_sequence_();
705
706#ifdef IOHOME_LR1121_BOOTLOADER_UPDATE
707 /// @brief The three-stage bootloader-rewrite sequence (ADR 0021): write the loader image,
708 /// reboot into it, rewrite the bootloader via 0x81xx, then write the transceiver image. Run
709 /// only once trigger_lr1121_firmware_update() has decided the switch permits it and
710 /// the cached path is BootloaderUpgradePath::AVAILABLE.
711 ///
712 /// Same safety invariant as run_lr1121_flash_sequence_(): every exit past the first
713 /// enter_bootloader() call is App.safe_reboot(), including every stage's failure path -- a
714 /// stage-2 failure must reboot, not fall through to stage 3. Stage 2 (0x8100 in flight) is the
715 /// one step with no recovery path in this project; every log line in that stage must say so
716 /// plainly and must never reuse this component's ordinary "press again" phrasing.
717 void run_lr1121_bootloader_upgrade_sequence_();
718#endif
719#endif
720
721 // --- Radio driver ---
723
724 // --- Hardware pins (set by YAML codegen, passed to radio driver in setup) ---
725 InternalGPIOPin *rst_pin_{nullptr};
726 InternalGPIOPin *dio0_pin_{nullptr}; ///< SX1276 DIO0 interrupt
727 InternalGPIOPin *dio4_pin_{nullptr}; ///< SX1276 DIO4 preamble detect (optional)
728 InternalGPIOPin *dio1_pin_{nullptr}; ///< SX1262 DIO1 interrupt; also carries the LR1121's DIO9 IRQ line
729 InternalGPIOPin *busy_pin_{nullptr}; ///< SX1262/LR1121 BUSY pin
730 InternalGPIOPin *fem_en_pin_{nullptr}; ///< Front-end module enable
731 InternalGPIOPin *vfem_pin_{nullptr}; ///< Front-end module power
732 InternalGPIOPin *fem_pa_pin_{nullptr}; ///< Front-end module PA switch
733
734 // --- Configuration (from YAML) ---
735 std::string node_id_str_;
736 std::string system_key_str_;
737 std::string radio_type_; ///< "sx1276", "sx1262", or "lr1121"; required by the YAML schema.
742 uint8_t tcxo_voltage_{DEFAULT_TCXO_VOLTAGE_SETTING_1P8V}; ///< SX1262/LR1121 TCXO voltage setting (default 1.8 V)
743
744 // --- Runtime state ---
745 bool initialized_{false};
746 bool busy_{false};
747 bool radio_test_mode_{false}; ///< When true, loop() is suspended for loopback testing.
748 TuningConfig tuning_{}; ///< Runtime tuning overrides.
750 /// 1W sender node IDs (remotes or sensors) allowed to fire the sender HA event
751 /// (`add_exposed_sender`). Config-time list (populated once from YAML), not a per-frame allocation.
752 std::vector<std::string> exposed_senders_;
753 /// Invoked once after every pairing attempt completes; see set_pairing_result_callback().
754 std::function<void()> pairing_result_callback_;
755 /// State for the current "Accept Foreign Pairing" (key-extraction) arm cycle; DISARMED by
756 /// default so a fresh boot never responds to foreign pairing traffic. See pairing_responder.h.
758 /// Invoked whenever the key-extraction armed state changes; see set_key_extraction_armed_callback().
759 std::function<void(bool)> key_extraction_armed_callback_;
762 PairingTelemetry pairing_telemetry_; ///< Per-attempt pairing telemetry, shared with ExchangeEngine/PairingEngine.
763 ExchangeEngine exchange_engine_; ///< Owns all authenticated exchange and LBT/hop logic.
764 PairingEngine pairing_engine_; ///< Owns the three-phase device pairing flow.
765 ManagementActions management_actions_; ///< Owns rename, identify, force-open, scan_paired_devices, and other
766 ///< hub-level HA actions.
767
768 /// Identity of the last processed 1W frame, for burst suppression; see
769 /// decisions::is_duplicate_1w_frame() for why the intent bytes are part of the key.
771 /// millis() of the most recent 1W frame of any kind, including ones dropped as duplicates —
772 /// a repeat still means the remote is transmitting. 0 until the first is seen. Gates background
773 /// polls in loop(); see decisions::defer_background_poll_for_1w_activity().
775 /// millis() of the first 1W frame in the current burst. Advances to the new frame's timestamp
776 /// whenever the gap since last_1w_activity_ms_ reaches ONEWAY_QUIET_PERIOD_MS (the previous burst
777 /// has already released any deferred poll, so this one starts fresh); otherwise holds at the
778 /// burst's start. Bounds defer_background_poll_() via ONEWAY_POLL_DEFER_CAP_MS.
780
781#ifdef IOHOME_LR1121_FIRMWARE_UPDATE
782 // --- LR1121 firmware update state (hub_lr1121_firmware_update.cpp) ---
783 /// Heap-allocated in setup(), like radio_ — constructed once, used by both the boot-time
784 /// excursion and any later button press. Never deleted/reconstructed at runtime.
785 Lr1121FirmwareUpdater *lr1121_firmware_updater_{nullptr};
786 bool lr1121_bootloader_version_known_{false}; ///< False until the boot-time excursion succeeds.
787 uint8_t lr1121_bootloader_chip_type_{0}; ///< `type` byte from the boot-time bootloader GetVersion.
788 uint16_t lr1121_bootloader_version_{0}; ///< Bootloader version from the boot-time excursion.
789 bool lr1121_flash_verdict_known_{false}; ///< False until cache_lr1121_flash_verdict_() has run.
790 FlashDecision lr1121_flash_verdict_{FlashDecision::NEEDS_CONFIRMATION}; ///< Cached verdict (see decisions header).
791 uint16_t lr1121_installed_fw_{0}; ///< Installed firmware version at the time the verdict was cached (0=unknown).
792 /// `device_type` byte from the same normal-mode GetVersion that produced lr1121_installed_fw_
793 /// (0=unknown, e.g. after a failed init()) -- kept alongside it so describe_lr1121_flash_verdict_()
794 /// can name which chip a REJECT_WRONG_CHIP verdict actually saw. See lr1121_flash_decision()'s
795 /// `device_type` parameter (layer 3) for why this is a distinct value from
796 /// lr1121_bootloader_chip_type_ above (layer 4, bootloader-mode).
797 uint8_t lr1121_installed_device_type_{0};
798 bool lr1121_flash_confirmation_armed_{false}; ///< True during the two-press confirmation window.
799#ifdef IOHOME_LR1121_BOOTLOADER_UPDATE
800 /// Set by the arming switch (IOHomeLr1121BootloaderRewriteSwitch); see
801 /// set_bootloader_rewrite_allowed()'s comment above for what this may and may not affect.
802 bool bootloader_rewrite_allowed_{false};
803#endif
804#endif
805};
806
807// ----------------------------------------------------------------------------
808// Test-visible helpers (inline for host unit tests)
809// ----------------------------------------------------------------------------
810
811/// Check if a stored node ID is valid (not all-zero, not all-0xFF).
812/// @param id 3‑byte node ID buffer.
813/// @return true if the ID is non-zero and non-0xFF.
814inline bool stored_node_id_is_valid(const uint8_t id[NODE_ID_SIZE]) {
815 bool all_zero = true;
816 bool all_ff = true;
817 for (uint8_t i = 0; i < NODE_ID_SIZE; i++) {
818 all_zero = all_zero && id[i] == 0;
819 all_ff = all_ff && id[i] == UINT8_MAX;
820 }
821 return !all_zero && !all_ff;
822}
823
824/// Format a position float as a human‑readable string (e.g. "50%", "unknown").
825/// @param pos Position value (0–100 or UNKNOWN_POSITION).
826/// @return String like "50%" or "unknown".
827inline std::string format_position(float pos) {
828 if (pos == UNKNOWN_POSITION) {
829 return "unknown";
830 }
832 snprintf(buf, sizeof(buf), "%.0f%%", pos);
833 return buf;
834}
835
836} // namespace home_io_control
837} // namespace esphome
Owns the per-hub device table, update callbacks, and linked-remote associations.
Authenticated exchange engine — outbound and inbound protocol flows.
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
virtual ManagementActionResult force_open_device(const std::string &device_id)
Move a cover device to fully open at elevated priority, intended to bypass wind/rain soft locks.
virtual bool set_lock_state(const std::string &device_id, bool locked)
Semantic lock helper for lock entities.
void handle_key_extraction_discover_confirm_(const IoFrame &frame)
Handle an inbound CMD_DISCOVER_CONFIRM (0x2C) addressed to our throwaway node ID while armed.
virtual ManagementActionResult rename_device(const std::string &device_id, const std::string &new_name)
Rename a device and verify the result by reading the name back.
void log_key_extraction_result_()
Emit the security-sensitive "system key extracted" log block (see redaction.h — this is the one delib...
void set_tx_power(uint8_t power)
Set transmit power (dBm).
Definition hub_core.h:144
InternalGPIOPin * dio4_pin_
SX1276 DIO4 preamble detect (optional).
Definition hub_core.h:727
void add_exposed_sender(const std::string &sender_id)
Allow a 1W sender (identified by its node ID) to fire the esphome.home_io_control_sender_event event ...
Definition hub_core.h:226
void api_force_open_device_(const std::string &device_id)
Native API callback: force-open a registered cover device.
Definition hub_core.h:622
void maybe_fire_sender_event_(const OneWayFrameInfo &info, bool linked, const std::string &src_id)
Fire the sender HA event for a decoded 1W frame, if the sender is exposed.
virtual void set_key_extraction_armed(bool armed)
Arm or disarm the "Accept Foreign Pairing (Key Extraction)" responder.
void set_node_id(const std::string &id)
Set the controller's node ID (hex string).
Definition hub_core.h:140
virtual ManagementActionResult identify_device(const std::string &device_id)
Trigger a device's physical identify (brief jog/flash) so a user can confirm which physical motor a d...
virtual bool set_device_position_and_tilt(const std::string &device_id, uint8_t position, uint8_t tilt_percent)
Set both position and tilt of a tilt-capable cover in one atomic command.
virtual bool set_device_tilt(const std::string &device_id, uint8_t tilt_percent)
Send a tilt command to a tilt‑capable cover.
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
IOHomeControlComponent()
Initialize ExchangeEngine, PairingEngine, and ManagementActions with double-pointer/ reference indire...
Definition hub_core.h:79
uint32_t last_1w_activity_ms_
millis() of the most recent 1W frame of any kind, including ones dropped as duplicates — a repeat sti...
Definition hub_core.h:774
virtual bool set_switch_state(const std::string &device_id, bool on)
Semantic binary helper for switch entities.
void api_scan_paired_devices_()
Native API callback: broadcast a roll-call scan of already-paired devices.
Definition hub_core.h:626
void set_rst_pin(InternalGPIOPin *pin)
Set the radio reset pin.
Definition hub_core.h:124
virtual void queue_set_device_tilt(const std::string &device_id, uint8_t tilt_percent)
Queue an async tilt update; returns immediately, executed in loop().
void begin_status_poll_tracking_(const std::string &device_id, uint32_t initial_delay_ms)
Begin bounded follow-up polling for a device after a command or overheard remote activity.
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 void register_device_callback(DeviceUpdateCallback cb)
Register a callback invoked when any device updates.
Definition hub_core.h:281
void set_pa_pin(uint8_t pa_pin)
Set PA boost pin configuration.
Definition hub_core.h:146
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
void spi_enable() override
Enable the SPI bus.
Definition hub_core.h:101
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
void set_fem_en_pin(InternalGPIOPin *pin)
Set the front‑end module enable pin.
Definition hub_core.h:134
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
std::vector< std::string > resolve_1w_target_devices_(const OneWayFrameInfo &info, const std::string &src_id) const
Resolve the set of devices a 1W frame should affect: devices linked to the sending remote by node ID,...
void process_pending_operation_()
Pop next pending operation from the queue and execute it (set position, request status,...
void schedule_status_poll_(const std::string &device_id, uint32_t delay_ms)
Schedule a delayed status poll for a registered device using the Component timeout API.
void hop_frequency_()
Delegate channel hop to ExchangeEngine (which owns last_hop_us_).
Definition hub_core.cpp:256
void add_linked_remote(const std::string &remote_id, const std::string &device_id)
Declare that a remote (identified by its node ID) controls a registered device.
Definition hub_core.h:172
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
void handle_key_extraction_key_init_(const IoFrame &frame)
Handle an inbound CMD_KEY_INIT (0x31) addressed to our throwaway node ID while armed.
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
bool execute_request_and_update_(const std::string &device_id, const IoFrame &request, bool warn_on_no_response, uint32_t retry_after_fail_ms=0)
Shared request/response helper for high-level operations.
void set_dio1_pin(InternalGPIOPin *pin)
Set the DIO1 interrupt pin (SX1262; also carries the LR1121's DIO9 IRQ line).
Definition hub_core.h:130
std::function< void(bool)> key_extraction_armed_callback_
Invoked whenever the key-extraction armed state changes; see set_key_extraction_armed_callback().
Definition hub_core.h:759
const PairingTelemetry & pairing_telemetry() const
Definition hub_core.h:229
void generate_key_extraction_throwaway_id_(uint8_t out[NODE_ID_SIZE])
Generate a random throwaway node ID for one key-extraction arm cycle, avoiding collisions with the br...
void update_device_status_(const IoFrame &frame, bool trust_position=true)
Extract supported position or metadata info from a response frame and merge it into the device record...
virtual void queue_request_device_name(const std::string &device_id)
Queue an async device-name request; returns immediately, executed in loop().
void handle_key_extraction_key_transfer_(const IoFrame &frame)
Handle an inbound CMD_KEY_TRANSFER (0x32) addressed to our throwaway node ID while armed.
void set_radio_test_mode(bool active)
Suspend the hub's normal loop (packet processing, hopping, polling).
Definition hub_core.h:117
void set_radio_type(const std::string &type)
Set radio type ("sx1276", "sx1262", or "lr1121"); required by the YAML schema.
Definition hub_core.h:148
virtual void queue_set_lock_state(const std::string &device_id, bool locked)
Async form of set_lock_state() that keeps radio work serialized on the main loop.
virtual void queue_set_light_position(const std::string &device_id, uint8_t position)
Async form of set_light_position() that keeps radio work serialized on the main loop.
void schedule_device_polls_(const std::vector< std::string > &device_ids, uint32_t delay_ms)
Schedule status polls for a fixed list of devices (shared by the id-linked and class-linked 1W paths,...
void loop() override
Main loop: process pending operations and drive radio state machine.
Definition hub_core.cpp:312
void log_exchange_debug_(const char *device_id) const
Log the last exchange debug snapshot (delegates to exchange_engine_).
Definition hub_core.h:606
void set_pairing_result_callback(std::function< void()> cb)
Register a callback invoked once, right after every discover_and_pair() attempt completes — used by t...
Definition hub_core.h:235
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
PairingTelemetry pairing_telemetry_
Per-attempt pairing telemetry, shared with ExchangeEngine/PairingEngine.
Definition hub_core.h:762
std::string radio_type_
"sx1276", "sx1262", or "lr1121"; required by the YAML schema.
Definition hub_core.h:737
virtual bool clear_optimistic_target(const std::string &device_id)
Clear a device's optimistic target (e.g.
Definition hub_core.h:200
void api_rename_device_(const std::string &device_id, const std::string &new_name)
Native API callback: rename a registered device.
Definition hub_core.h:616
float get_setup_priority() const override
Get setup priority (HARDWARE to initialize early).
Definition hub_core.h:97
void record_1w_activity_(uint32_t now)
Record that a 1W frame just arrived, updating last_1w_activity_ms_ and — when this frame starts a new...
virtual void queue_set_device_position_and_tilt(const std::string &device_id, uint8_t position, uint8_t tilt_percent)
Queue an async combined position+tilt update; returns immediately, executed in loop().
void set_busy_pin(InternalGPIOPin *pin)
Set the BUSY pin (SX1262/LR1121).
Definition hub_core.h:132
virtual bool queue_device_command(const std::string &device_id, CoverCommand cmd)
Queue an async named command (STOP, FAVORITE, VENT, FORCE_OPEN); returns immediately,...
virtual bool discover_and_pair()
Discover and pair a device that is in pairing mode.
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
uint8_t spi_read() override
Read one byte (MISO only).
Definition hub_core.h:113
void api_identify_device_(const std::string &device_id)
Native API callback: trigger a registered device's physical identify.
Definition hub_core.h:620
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
void set_dio0_pin(InternalGPIOPin *pin)
Set the DIO0 interrupt pin (SX1276).
Definition hub_core.h:126
void set_tuning_config(const TuningConfig &config)
Apply the tuning configuration generated from YAML / UI entities.
Definition hub_core.h:153
bool radio_test_mode_
When true, loop() is suspended for loopback testing.
Definition hub_core.h:747
void set_vfem_pin(InternalGPIOPin *pin)
Set the VFEM power pin.
Definition hub_core.h:136
ExchangeEngine exchange_engine_
Owns all authenticated exchange and LBT/hop logic.
Definition hub_core.h:763
virtual bool apply_optimistic_tilt(const std::string &device_id, float tilt_percent)
Set an optimistic slat angle ahead of a confirming status poll, and notify.
Definition hub_core.h:213
virtual bool set_light_state(const std::string &device_id, bool on)
Semantic binary helper for light entities.
esphome::home_io_control::ManagementActionResult ManagementActionResult
Result payload used by hub-level management actions such as rename.
Definition hub_core.h:87
PairingEngine pairing_engine_
Owns the three-phase device pairing flow.
Definition hub_core.h:764
void handle_key_extraction_discover_(const IoFrame &frame)
Handle an inbound CMD_DISCOVER_REQ (0x28) while the key-extraction responder is armed.
InternalGPIOPin * dio0_pin_
SX1276 DIO0 interrupt.
Definition hub_core.h:726
void set_system_key(const std::string &key)
Set the system key (hex string).
Definition hub_core.h:142
ManagementActions management_actions_
Owns rename, identify, force-open, scan_paired_devices, and other hub-level HA actions.
Definition hub_core.h:765
virtual void queue_set_light_state(const std::string &device_id, bool on)
Async form of set_light_state() that keeps radio work serialized on the main loop.
void schedule_linked_remote_polls_(const std::string &remote_id, uint32_t delay_ms=REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS)
Schedule status polls for all devices associated with a linked remote.
void spi_write(uint8_t data) override
Write one byte (MOSI only).
Definition hub_core.h:110
void set_tcxo_voltage(uint8_t voltage)
Set TCXO voltage for SX1262/LR1121 (1.8V / 3.3V).
Definition hub_core.h:150
void notify_device_update_(const std::string &id)
Fire all registered device update callbacks for the given device ID.
Definition hub_core.cpp:276
virtual void queue_set_device_position(const std::string &device_id, uint8_t position)
Queue an async position update; returns immediately, executed in loop().
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
virtual void queue_set_switch_state(const std::string &device_id, bool on)
Async form of set_switch_state() that keeps radio work serialized on the main loop.
void set_dio4_pin(InternalGPIOPin *pin)
Set the DIO4 preamble‑detect pin (SX1276, optional).
Definition hub_core.h:128
decisions::OneWayDedupState last_1w_logged_
Identity of the last processed 1W frame, for burst suppression; see decisions::is_duplicate_1w_frame(...
Definition hub_core.h:770
std::function< void()> pairing_result_callback_
Invoked once after every pairing attempt completes; see set_pairing_result_callback().
Definition hub_core.h:754
virtual bool set_device_position(const std::string &device_id, uint8_t position)
Send a position command to a device.
void setup() override
Initialize hardware (radio and device registry).
Definition hub_core.cpp:57
uint8_t spi_transfer(uint8_t data) override
Transfer one byte full‑duplex.
Definition hub_core.h:107
virtual bool request_device_name(const std::string &device_id)
Request the stored device name from a device.
std::vector< std::string > exposed_senders_
1W sender node IDs (remotes or sensors) allowed to fire the sender HA event (add_exposed_sender).
Definition hub_core.h:752
pairing_responder::ResponderContext key_extraction_ctx_
State for the current "Accept Foreign Pairing" (key-extraction) arm cycle; DISARMED by default so a f...
Definition hub_core.h:757
uint8_t tcxo_voltage_
SX1262/LR1121 TCXO voltage setting (default 1.8 V).
Definition hub_core.h:742
virtual bool apply_optimistic_target(const std::string &device_id, float target_io_position)
Set an optimistic target position ahead of a confirming poll/response, and notify.
Definition hub_core.h:191
void process_received_packet_(const RadioRxPacket &packet)
Parse a received frame, merge supported device state or metadata, and notify callbacks.
bool execute_device_command_(const std::string &device_id, CoverCommand cmd)
Execute a named device command (STOP, FAVORITE, VENT, FORCE_OPEN) via the authenticated exchange.
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
bool try_handle_key_extraction_frame_(const IoFrame &frame)
Dispatch a frame to the key-extraction responder if it's one of its 0x28/0x2C/0x31/0x32 frames and th...
void spi_disable() override
Disable the SPI bus.
Definition hub_core.h:103
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
virtual void queue_discover_and_pair()
Queue a pairing operation; executed in loop() when radio idle.
void broadcast_key_extraction_reply_(const IoFrame &frame)
Transmit a key-extraction reply frame on all 3 IO-homecontrol channels, using the radio driver's resp...
uint32_t first_1w_activity_ms_
millis() of the first 1W frame in the current burst.
Definition hub_core.h:779
virtual bool request_device_status(const std::string &device_id)
Request current status from a device.
void add_linked_remote_class(DeviceType type, const std::string &device_id)
Declare that a device class's typed 1W broadcasts (e.g.
Definition hub_core.h:180
virtual ManagementActionResult scan_paired_devices()
Broadcast a roll-call and report every device that answers (see ManagementActions::scan_paired_device...
bool apply_optimistic_linked_state_(const OneWayFrameInfo &info, const std::vector< std::string > &device_ids)
Apply optimistic target state to every device in device_ids, when the decoded frame carries a resolva...
void set_key_extraction_armed_callback(std::function< void(bool)> cb)
Register a callback invoked whenever the key-extraction armed state changes — manual toggle,...
Definition hub_core.h:255
virtual bool set_light_position(const std::string &device_id, uint8_t position)
Send an arbitrary IO position (0-100) to a light entity.
void set_fem_pa_pin(InternalGPIOPin *pin)
Set the FEM PA switch pin.
Definition hub_core.h:138
RadioDriver * get_radio() const
Get the underlying radio driver (for diagnostics and test tooling).
Definition hub_core.h:120
Encapsulates hub-level management operations exposed as Home Assistant actions.
Serialized pending-operation queue with coalescing, deduplication, and two-band ordering.
static bool is_background_op(PendingOperationType t)
True for background poll types (REQUEST_STATUS, REQUEST_NAME) that yield to control operations.
Owns and drives all three phases of the IO-Homecontrol device pairing flow.
Fixed-size per-attempt telemetry recorder for the pairing flow.
Abstract radio driver for IO-Homecontrol.
Interface for SPI bus access.
Per-hub poll scheduling and failure-backoff policy.
Per-hub device table, update-callback fan-out, and linked-remote map.
Self-contained authenticated exchange engine for IO-Homecontrol 2W.
Pure transition helpers for hub-owned exchange and pairing frame decisions.
Internal exchange-state model for hub-owned authenticated non‑pairing flows.
Internal pairing-state model for hub‑owned discovery and key‑exchange flows.
Pure decision logic for the LR1121 transceiver-firmware-update feature.
Hub-level management operations exposed as Home Assistant actions.
bool defer_background_poll_for_1w_activity(bool next_op_is_background, uint32_t first_1w_activity_ms, uint32_t last_1w_activity_ms, uint32_t now, uint32_t quiet_ms, uint32_t max_defer_ms)
Decide whether to hold back a queued background poll because a 1W remote is still transmitting.
static constexpr uint32_t ONEWAY_QUIET_PERIOD_MS
Hold queued background polls back for this long after any 1W frame, so a poll the hub itself schedule...
static constexpr float UNKNOWN_POSITION
Sentinel value meaning "position is not known yet".
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
DeviceType
Device type identifiers reported by IO‑Homecontrol products.
constexpr size_t POSITION_TEXT_BUFFER_SIZE
Buffer for formatted position strings such as "100%".
Definition hub_core.h:58
std::function< void(const std::string &device_id, const IoDevice &device)> DeviceUpdateCallback
Callback type invoked when a device's state changes.
CoverCommand
Named device commands for cover-type actuators.
static constexpr uint32_t ONEWAY_POLL_DEFER_CAP_MS
Hard cap on how long sustained 1W traffic may hold a background poll back in total,...
std::string format_position(float pos)
Format a position float as a human‑readable string (e.g.
Definition hub_core.h:827
static constexpr uint32_t REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS
Delay before polling after overheard remote traffic.
BootloaderUpgradePath
Whether the three-stage bootloader-rewrite sequence (ADR 0021) is applicable, and if not,...
bool stored_node_id_is_valid(const uint8_t id[NODE_ID_SIZE])
Check if a stored node ID is valid (not all-zero, not all-0xFF).
Definition hub_core.h:814
constexpr uint8_t DEFAULT_TCXO_VOLTAGE_SETTING_1P8V
SX1262 DIO3 setting value for a 1.8 V TCXO.
Definition hub_core.h:57
FlashDecision
Outcome of lr1121_flash_decision().
@ NEEDS_CONFIRMATION
Not unsafe, but not an unambiguous "yes" either — needs a second press.
static constexpr uint8_t AES_KEY_SIZE
AES-128 key size.
Definition proto_sizes.h:23
constexpr uint8_t DEFAULT_PA_PIN_PA_BOOST
SX1276 PA_CONFIG selector for the PA_BOOST output path.
Definition hub_core.h:56
constexpr uint8_t DEFAULT_TX_POWER_DBM
Default TX power used unless YAML overrides it.
Definition hub_core.h:55
Pending-operation queue with per-type coalescing and deduplication.
Device discovery and key-exchange engine for IO-Homecontrol pairing.
Pure decision logic for the device-role "Accept Foreign Pairing" (system-key extraction) responder.
Device-name, address-classification and 1W-frame codecs.
IO-Homecontrol 2W frame container: control bytes, IoFrame and (de)serialization.
Radio abstraction layer for IO-Homecontrol.
LR1121 bootloader-mode-*and*-loader-mode SPI transport, standalone from the running RadioDriver.
Per-device poll scheduling, failure backoff, and follow-up-poll state machine.
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
Result of a hub-level management action such as rename.
Decoded representation of a 1W remote frame.
All runtime tunable parameters for pairing and radio diagnostics.
Key fields of the last processed 1W frame, used to collapse a remote's repeat burst.
Runtime tuning configuration for pairing and radio diagnostics.