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 "proto_heating.h"
36#include "radio_interface.h"
37#include "tuning_config.h"
38#include "hub_exchange.h"
39#include "hub_decisions.h"
40#include "hub_pairing.h"
41#include "device_registry.h"
42#include "status_poll_policy.h"
43#include "rain_poll_policy.h"
44#include "operation_queue.h"
45#include "exchange_engine.h"
46#include "pairing_engine.h"
47#include "management_actions.h"
49#include "oneway_controller.h"
50#include "oneway_transmitter.h"
51#include "oneway_key_adoption.h"
52// lr1121_firmware_update_controller.h forward-declares FlashDecision / BootloaderUpgradePath /
53// Lr1121FirmwareUpdater so lr1121_firmware_decisions.h and radio_lr1121_firmware_updater.h are
54// NOT pulled into hub_core.h — that header weight stays in the collaborator's .cpp alone.
56#include <map>
57#include <vector>
58#include <functional>
59
60namespace esphome {
61namespace home_io_control {
62
63inline constexpr uint8_t DEFAULT_TX_POWER_DBM = 17; ///< Default TX power used unless YAML overrides it.
64inline constexpr uint8_t DEFAULT_PA_PIN_PA_BOOST = 0x80; ///< SX1276 PA_CONFIG selector for the PA_BOOST output path.
65inline constexpr uint8_t DEFAULT_TCXO_VOLTAGE_SETTING_1P8V = 0x02; ///< 0-based TCXO voltage code for 1.8 V,
66 ///< passed verbatim to the SX1262/LR1121 chip.
67inline constexpr size_t POSITION_TEXT_BUFFER_SIZE = 16; ///< Buffer for formatted position strings such as "100%".
68
69#ifdef IOHOME_LR1121_FIRMWARE_UPDATE
70/// Max value representable by Component::warn_if_blocking_over_ (a centisecond uint8_t, ~2550 ms
71/// ceiling). Raised for a flash excursion so the log fills with progress output rather than
72/// component-blocking warnings — a flash still runs far longer than 2550 ms, so this reduces
73/// warning spam, it cannot eliminate it. Lives here rather than in the collaborator because
74/// warn_if_blocking_over_ is protected on ESPHome's Component and only the initializer-list lambda
75/// below legitimately writes it.
76inline constexpr uint8_t LR1121_FLASH_WARN_BLOCKING_MAX_CS = 255;
77#endif
78
79/// @brief How long after a pairing attempt ends a Discover & Pair press counts as made during it.
80///
81/// Pairing blocks loop() for up to about 20 s, so a press made meanwhile is handled a few milliseconds
82/// after the attempt ends (issue #165: the queued second attempt found nothing, the radiator had left
83/// its pairing mode). A press a person makes after reading the result comes seconds later.
84static constexpr uint32_t PAIRING_STALE_PRESS_MS = 1000;
85
86// ============================================================================
87// Main Component
88// ============================================================================
89
90/// The main IO-Homecontrol component. Manages the protocol layer and delegates
91/// radio operations to a RadioDriver instance.
92///
93/// Inherits SPIDevice so that ESPHome's Python codegen can configure SPI pins.
94/// Implements SpiAccess to provide the radio driver with SPI bus access.
95/// @ingroup hioc_hub
96class IOHomeControlComponent : public Component,
97 public api::CustomAPIDevice,
98 public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW,
99 spi::CLOCK_PHASE_LEADING, spi::DATA_RATE_8MHZ>,
100 public SpiAccess {
101 public:
102 /// Initialize ExchangeEngine, PairingEngine, and ManagementActions with double-pointer/
103 /// reference indirection so that test assignments (`comp.radio_ = &mock`) propagate
104 /// through all collaborators without calling setup().
110 // Capturing `this` is safe here: the callback is only ever invoked from send_burst(),
111 // long after construction. It injects the *ability* to transmit rather than a reference
112 // to whichever collaborator currently owns the radio. `&tuning_` is read per burst
113 // (never cached), same pattern as exchange_engine_ above, so a live change to
114 // `normal_start_preamble` takes effect on the next 1W command without a reboot.
115 oneway_transmitter_([this](const IoFrame &frame, uint32_t freq,
116 uint16_t preamble) { return this->transmit_frame_(frame, freq, preamble); },
117 &tuning_),
118 // set_timeout() is protected on the real ESPHome Component (only public in the host stub),
119 // so a lambda defined here — with protected access — is the one legitimate caller. See
120 // oneway_key_adoption.h's NamedTimeoutFn.
121 oneway_key_adoption_([this](const char *name, uint32_t delay_ms, std::function<void()> cb) {
122 this->set_timeout(name, delay_ms, std::move(cb));
123 }),
126 [this](const IoFrame &frame, uint32_t freq, uint16_t preamble) {
127 return this->transmit_frame_(frame, freq, preamble);
128 },
129 [this](const char *name, uint32_t delay_ms, std::function<void()> cb) {
130 this->set_timeout(name, delay_ms, std::move(cb));
131 })
132#ifdef IOHOME_LR1121_FIRMWARE_UPDATE
133 // Guarded initializer for the guarded member. `busy_`/`warn_if_blocking_over_` are
134 // protected: the busy pointer is taken here, and the lambda — with protected access — is
135 // the one legitimate writer of warn_if_blocking_over_. `this` doubles as SpiAccess* and as
136 // the self key for App.scheduler.set_timeout(), never to reach a protected member.
137 ,
138 lr1121_firmware_update_(
139 &radio_, this, &rst_pin_, &busy_pin_, &busy_,
140 [this]() { this->warn_if_blocking_over_ = LR1121_FLASH_WARN_BLOCKING_MAX_CS; }, this)
141#endif
142 {
143 // The hub only supplies what it knows about a target; the engine draws its own decisions from
144 // it (the wake belief and the re-send of an unconfirmed movement command). Installed here
145 // rather than in setup() so a component that never runs setup() (the host tests) is wired
146 // exactly like production.
147 this->exchange_engine_.set_target_evidence_provider([this](const uint8_t *dst, decisions::TargetEvidence &out) {
148 const IoDevice *dev = this->registry_.get(node_id_to_string(dst));
149 if (dev == nullptr)
150 return false;
151 out = decisions::target_evidence(*dev);
152 return true;
153 });
154 this->exchange_engine_.set_device_key_provider([this](const uint8_t *node) -> const uint8_t * {
155 const IoDevice *dev = this->registry_.get(node_id_to_string(node));
156 return dev != nullptr && dev->has_system_key ? dev->system_key : nullptr;
157 });
158 this->exchange_engine_.set_wake_evidence_spent_handler([this](const uint8_t *dst) {
159 if (IoDevice *dev = this->registry_.get(node_id_to_string(dst)); dev != nullptr)
161 });
162 }
163
164 /// @brief Result payload used by hub-level management actions such as rename.
165 /// Alias of the standalone esphome::home_io_control::ManagementActionResult struct so that
166 /// callers using the nested name IOHomeControlComponent::ManagementActionResult continue to work.
168
169 /// @brief Initialize hardware (radio and device registry).
170 void setup() override;
171 /// @brief Main loop: process pending operations and drive radio state machine.
172 void loop() override;
173 /// @brief Dump configuration and radio debug info to the log.
174 void dump_config() override;
175 /// @brief Get setup priority (HARDWARE to initialize early).
176 /// @return setup_priority::HARDWARE.
177 [[nodiscard]] float get_setup_priority() const override { return setup_priority::HARDWARE; }
178
179 // --- SpiAccess implementation (delegates to SPIDevice) ---
180 /// @brief Enable the SPI bus.
181 void spi_enable() override { this->enable(); }
182 /// @brief Disable the SPI bus.
183 void spi_disable() override { this->disable(); }
184 /// @brief Transfer one byte full‑duplex.
185 /// @param data Byte to send.
186 /// @return Received byte.
187 uint8_t spi_transfer(uint8_t data) override { return this->transfer_byte(data); }
188 /// @brief Write one byte (MOSI only).
189 /// @param data Byte to send.
190 void spi_write(uint8_t data) override { this->write_byte(data); }
191 /// @brief Read one byte (MISO only).
192 /// @return Received byte.
193 uint8_t spi_read() override { return this->read_byte(); }
194
195 /// @brief Suspend the hub's normal loop (packet processing, hopping, polling).
196 /// Used by loopback test configs to take exclusive control of the radio.
197 void set_radio_test_mode(bool active) { this->radio_test_mode_ = active; }
198
199 /// @brief Get the underlying radio driver (for diagnostics and test tooling).
200 [[nodiscard]] RadioDriver *get_radio() const { return this->radio_; }
201
202 // --- YAML configuration setters (called by generated code) ---
203 /// Set the radio reset pin.
204 void set_rst_pin(InternalGPIOPin *pin) { this->rst_pin_ = pin; }
205 /// Set the DIO0 interrupt pin (SX1276).
206 void set_dio0_pin(InternalGPIOPin *pin) { this->dio0_pin_ = pin; }
207 /// Set the DIO4 preamble‑detect pin (SX1276, optional).
208 void set_dio4_pin(InternalGPIOPin *pin) { this->dio4_pin_ = pin; }
209 /// Set the DIO1 interrupt pin (SX1262; also carries the LR1121's DIO9 IRQ line).
210 void set_dio1_pin(InternalGPIOPin *pin) { this->dio1_pin_ = pin; }
211 /// Set the BUSY pin (SX1262/LR1121).
212 void set_busy_pin(InternalGPIOPin *pin) { this->busy_pin_ = pin; }
213 /// Set the front‑end module enable pin.
214 void set_fem_en_pin(InternalGPIOPin *pin) { this->fem_en_pin_ = pin; }
215 /// Set the VFEM power pin.
216 void set_vfem_pin(InternalGPIOPin *pin) { this->vfem_pin_ = pin; }
217 /// Set the FEM PA switch pin.
218 void set_fem_pa_pin(InternalGPIOPin *pin) { this->fem_pa_pin_ = pin; }
219 /// Set which FEM part fem_pa_pin is wired to (`fem:` in YAML) — selects whether the SX1262
220 /// driver drives that pin per-transmission (GC1109/KCT8103L/XY16P35) or leaves it static-HIGH
221 /// (NONE, the legacy raw-pin behaviour).
222 void set_fem_profile(FemProfile profile) { this->fem_profile_ = profile; }
223 /// Set the controller's node ID (hex string).
224 void set_node_id(const std::string &id) { this->node_id_str_ = id; }
225 /// Set the system key (hex string).
226 void set_system_key(const std::string &key) { this->system_key_str_ = key; }
227 /// Set transmit power (dBm).
228 void set_tx_power(uint8_t power) { this->tx_power_ = power; }
229 /// Set PA boost pin configuration.
230 void set_pa_pin(uint8_t pa_pin) { this->pa_pin_ = pa_pin; }
231 /// Set radio type ("sx1276", "sx1262", or "lr1121"); required by the YAML schema.
232 void set_radio_type(const std::string &type) { this->radio_type_ = type; }
233 /// Set the SX1262/LR1121 TCXO control-voltage code (0-based, `TCXO_VOLTAGE_OPTIONS` in
234 /// `hub_validators.py`: `1_6V`=0x00 .. `3_3V`=0x07), or `TCXO_VOLTAGE_NONE` (0xFF) for a board with a
235 /// bare crystal and no DIO3-controlled TCXO.
236 void set_tcxo_voltage(uint8_t voltage) { this->tcxo_voltage_ = voltage; }
237
238 /// Apply the tuning configuration generated from YAML / UI entities.
239 void set_tuning_config(const TuningConfig &config) { this->tuning_ = config; }
240 /// Receive a numeric tuning update from a HA `number` entity.
241 void update_tuning_number(const std::string &name, float value);
242 /// Receive a select tuning update from a HA `select` entity.
243 void update_tuning_select(const std::string &name, const std::string &value);
244 /// Current value of a numeric tuning parameter, used to seed a HA `number` entity on boot.
245 /// @param name YAML key of the parameter.
246 /// @return Current value, or 0 for an unknown key.
247 [[nodiscard]] float get_tuning_number_value(const std::string &name) const;
248 /// Current option string of a select tuning parameter, used to seed a HA `select` entity on boot.
249 /// @param name YAML key of the parameter.
250 /// @return Current value formatted as its YAML option string, or empty for an unknown key.
251 [[nodiscard]] std::string get_tuning_select_value(const std::string &name) const;
252
253 /// @brief Render a device's "last commanded by" string, resolving this hub's own node ID.
254 ///
255 /// Thin wrapper over detail::describe_last_commander() (entity_helpers.h); exists because the
256 /// hub's node ID is not reachable from a companion entity.
257 /// @param dev Device record to read.
258 /// @return See detail::describe_last_commander().
259 [[nodiscard]] std::string describe_last_commander(const IoDevice &dev) const;
260
261 /// Declare that a remote (identified by its node ID) controls a registered device.
262 /// When activity from this remote is overheard, a status poll is scheduled for the device.
263 /// This is needed for 1W remotes whose destination address differs from the device's 2W ID.
264 /// @param remote_id Node ID of the remote control.
265 /// @param device_id Node ID of the device it controls.
266 void add_linked_remote(const std::string &remote_id, const std::string &device_id) {
267 this->registry_.add_linked_remote(remote_id, device_id);
268 }
269
270 /// Declare that a device class's typed 1W broadcasts (e.g. "all awnings") also apply to
271 /// @p device_id, matching how 1W remotes address a device class rather than a single node.
272 /// @param type Device class the broadcast targets.
273 /// @param device_id Node ID of the device to add to that class.
274 void add_linked_remote_class(DeviceType type, const std::string &device_id) {
275 this->registry_.add_linked_remote_class(type, device_id);
276 }
277
278 /// Set an optimistic target position ahead of a confirming poll/response, and notify.
279 /// No-op when the device is unknown or has `optimistic_state == false`. See
280 /// DeviceRegistry::apply_optimistic_target() for the full contract.
281 /// Virtual (like add_device/get_device) so platform unit tests can substitute a mock registry.
282 /// @param device_id Target device ID.
283 /// @param target_io_position Target position in IO units (0=open, 100=closed).
284 /// @return true if the optimistic state was applied.
285 virtual bool apply_optimistic_target(const std::string &device_id, float target_io_position) {
286 return this->registry_.apply_optimistic_target(device_id, target_io_position);
287 }
288
289 /// Predict that a device has stopped (e.g. on STOP), and notify.
290 /// No-op when the device is unknown or has `optimistic_state == false`. See
291 /// DeviceRegistry::apply_optimistic_stop() for why this records a prediction rather than a clear.
292 /// Restorable: if the STOP then fails, the movement prediction it replaced comes back.
293 /// Virtual (like add_device/get_device) so platform unit tests can substitute a mock registry.
294 /// @param device_id Target device ID.
295 /// @return true if the optimistic stop was applied.
296 virtual bool apply_optimistic_stop(const std::string &device_id) {
297 // Restorable: this is the entity's own STOP, which the hub is about to send and which can fail.
298 return this->registry_.apply_optimistic_stop(device_id, /*restorable=*/true);
299 }
300
301 /// Set an optimistic slat angle ahead of a confirming status poll, and notify.
302 /// No-op when the device is unknown, has `optimistic_state == false`, or is not tilt-capable.
303 /// See DeviceRegistry::apply_optimistic_tilt() for the full contract and for why a tilt
304 /// command cannot rely on its own reply the way a position command can.
305 /// Virtual like the other device-registry accessors so a test double can override it if it
306 /// needs to; MockPlatformHubBase deliberately does not, and exercises the real registry.
307 /// @param device_id Target device ID.
308 /// @param tilt_percent Slat angle in the same percent scale as `IoDevice::tilt` (0-100).
309 /// @return true if the optimistic tilt was applied.
310 virtual bool apply_optimistic_tilt(const std::string &device_id, float tilt_percent) {
311 return this->registry_.apply_optimistic_tilt(device_id, tilt_percent);
312 }
313
314 /// Allow a 1W sender (identified by its node ID) to fire the `esphome.home_io_control_sender_event`
315 /// event to Home Assistant. "Sender" is deliberately broader than "remote": the same 1W broadcast
316 /// mechanism carries handheld/wall remotes and wind/rain sensors alike (they differ only in the
317 /// `originator` byte inside the payload, not in how they address the radio) — see `decode_1w_frame()`.
318 /// Overheard 1W traffic is always DEBUG-logged regardless of this list; this only controls which
319 /// senders are allowed to reach Home Assistant as an event, independent of whether the sender is
320 /// also linked to a device via `add_linked_remote`. Empty by default — a sender must be explicitly
321 /// opted in.
322 /// @param sender_id Node ID of the 1W sender (remote or sensor).
323 void add_exposed_sender(const std::string &sender_id) { this->exposed_senders_.push_back(sender_id); }
324
325 /// Register a configured 1W controller identity (see oneway_controller.h). Called once per
326 /// `oneway_controllers:` entry from generated code. Both the source address and the key are
327 /// already resolved at schema time — a derived address is computed there so a collision with
328 /// the hub's own address or another identity fails the build rather than silently desyncing a
329 /// transmitter at runtime.
330 /// @param identity Fully-resolved controller identity.
332 this->oneway_transmitter_.add_identity(identity);
333 }
334
335 /// @return The configured 1W controller identities.
336 [[nodiscard]] const OneWayControllerRegistry &oneway_controllers() const {
337 return this->oneway_transmitter_.identities();
338 }
339
340 /// @brief Queue a 1W named command, sent as the given controller identity.
341 ///
342 /// Goes through the operation queue like every other radio operation (ADR 0013), so a 1W burst
343 /// can never interleave with a 2W exchange. Unlike a 2W command this reports nothing back: 1W
344 /// has no reply, so a queued command that a device ignores is indistinguishable from one it
345 /// obeyed. The "Last 1W Command" diagnostic reports what was *transmitted*, which is the only
346 /// half of that the hub can know.
347 /// @param controller_id Controller-identity handle from `oneway_controllers:`.
348 /// @param cmd Named command (STOP, FAVORITE, VENT, FORCE_OPEN).
349 void send_oneway_command(const std::string &controller_id, CoverCommand cmd) {
350 this->op_queue_.enqueue_oneway_command(controller_id, cmd);
351 }
352
353 /// @brief Queue a 1W numeric position, sent as the given controller identity.
354 /// @param controller_id Controller-identity handle from `oneway_controllers:`.
355 /// @param position Target position 0–100 (0 = fully open, 100 = fully closed).
356 void send_oneway_position(const std::string &controller_id, uint8_t position) {
357 this->op_queue_.enqueue_oneway_position(controller_id, position);
358 }
359
360 /// @brief Queue whichever of position/command a generated button's action resolves to.
361 ///
362 /// The mapping is applied here, at enqueue time, so the queue only ever holds concrete
363 /// operations — OPEN and CLOSE are positions on the wire, not commands, and nothing downstream
364 /// should have to know that twice.
365 /// @param controller_id Controller-identity handle from `oneway_controllers:`.
366 /// @param action Button action to send.
367 void send_oneway_action(const std::string &controller_id, OneWayButtonAction action) {
368 const OneWayActionEncoding encoding = encode_oneway_action(action);
369 if (encoding.is_position) {
370 this->send_oneway_position(controller_id, encoding.position);
371 } else {
372 this->send_oneway_command(controller_id, encoding.command);
373 }
374 }
375
376 /// @brief Queue a 1W enrollment for the given controller identity — the enroll button's press
377 /// handler.
378 ///
379 /// Sends `0x39` (self-directed) then `0x30`, back to back — see
380 /// OneWayTransmitter::send_enrollment().
381 /// @param controller_id Controller-identity handle from `oneway_controllers:`.
382 void send_oneway_enroll(const std::string &controller_id) { this->op_queue_.enqueue_oneway_enroll(controller_id); }
383
384 /// @brief Queue a standalone 1W un-enrollment (remove-controller) for the given controller
385 /// identity, reached only through the explicitly-named `oneway_remove_controller` native API
386 /// action — the same `0x39` send_oneway_enroll() also fires as its own prelude, but here alone.
387 ///
388 /// @warning **Unconfirmed standalone on real hardware.** Firing `0x39` alone has had no
389 /// observable effect on this project's test hardware; the leading hypothesis is that it needs
390 /// the same association-mode window enrollment does. See ADR 0026 § Consequences.
391 /// @param controller_id Controller-identity handle from `oneway_controllers:`.
392 void send_oneway_unenroll(const std::string &controller_id) {
393 this->op_queue_.enqueue_oneway_unenroll(controller_id);
394 }
395
396 /// @brief Subscribe to the report emitted after every 1W command attempt.
397 ///
398 /// A list rather than a single slot: each identity gets its own "Last 1W Command" sensor, and
399 /// each filters the reports down to its own handle.
400 /// @param callback Invoked for every attempt, successful or not.
402 this->oneway_report_callbacks_.push_back(std::move(callback));
403 }
404
405 /// @return The 1W transmit collaborator, for diagnostics and the sequence-resync path.
407
408 /// @return The telemetry recorded for the most recent (or in-progress) pairing attempt.
409 [[nodiscard]] const PairingTelemetry &pairing_telemetry() const { return this->pairing_telemetry_; }
410
411 /// Register a callback invoked once, right after every `discover_and_pair()` attempt
412 /// completes — used by the "Last Pairing Result" text sensor to publish a fresh value.
413 /// Single-slot: only one platform instance is expected per hub.
414 /// @param cb Callable with no arguments.
415 void set_pairing_result_callback(std::function<void()> cb) { this->pairing_result_callback_ = std::move(cb); }
416
417 /// @brief Arm or disarm the "Recover System Key" (key extraction) responder.
418 ///
419 /// Thin forwarder to the KeyExtractionResponder collaborator (key_extraction_responder.h).
420 /// Arming picks a fresh throwaway node ID, resets the pairing_responder state machine to
421 /// ARMED_IDLE, and schedules a 10-minute auto-off. While armed, the 0x28/0x2C/0x31/0x32 branches
422 /// in process_received_packet_() emulate an unpaired device so a user's existing hub can pair to
423 /// it and hand over its node_id/system_key (see pairing_responder.h). Disarming — manual, via
424 /// the HA switch, on successful extraction, or on auto-off — immediately stops those branches
425 /// from responding; it never touches the real device registry or the hub's own node_id_/
426 /// system_key_. Virtual so platform unit tests can substitute a mock hub, matching every other
427 /// queue_*/set_* entry point on this component.
428 /// @param armed Desired state.
429 virtual void set_key_extraction_armed(bool armed) { this->key_extraction_.set_armed(armed); }
430
431 /// Register a callback invoked whenever the key-extraction armed state changes — manual
432 /// toggle, successful extraction, or auto-off timeout — so the switch entity can keep its
433 /// displayed state in sync when the hub disarms itself rather than the user. Single-slot,
434 /// mirrors set_pairing_result_callback().
435 /// @param cb Callable receiving the new armed state.
436 void set_key_extraction_armed_callback(std::function<void(bool)> cb) {
437 this->key_extraction_.set_armed_callback(std::move(cb));
438 }
439
440 /// Arm or disarm the 1W controller-key adoption listener. Thin forwarder to the OnewayKeyAdoption
441 /// collaborator (oneway_key_adoption.h) — while armed, an overheard CMD_ONEWAY_ADD_CONTROLLER
442 /// broadcast is decrypted and reported once, after which the listener disarms itself (one
443 /// adoption per arm). Receive-only: unlike 2W key extraction this never transmits, it only
444 /// listens for a frame a 1W device broadcasts of its own accord. Virtual so platform unit tests
445 /// can substitute a mock hub, matching every other queue_*/set_* entry point on this component.
446 /// @param armed Desired state.
447 virtual void set_oneway_key_adoption_armed(bool armed) { this->oneway_key_adoption_.set_armed(armed); }
448
449 /// Register a callback invoked whenever the 1W key-adoption armed state changes — manual
450 /// toggle, successful adoption, or auto-off timeout — so the switch entity stays in sync when
451 /// the hub disarms itself rather than the user. Single-slot, mirrors
452 /// set_key_extraction_armed_callback().
453 /// @param cb Callable receiving the new armed state.
454 void set_oneway_key_adoption_armed_callback(std::function<void(bool)> cb) {
455 this->oneway_key_adoption_.set_armed_callback(std::move(cb));
456 }
457
458 /// Whether the 1W key-adoption listener is currently armed.
459 /// @return true while armed.
460 [[nodiscard]] bool oneway_key_adoption_armed() const { return this->oneway_key_adoption_.armed(); }
461
462 /// @brief Set whether ManagementActions::probe_device()/probe_sweep() are allowed to run.
463 ///
464 /// Set once from the `diagnostic_probes:` YAML boolean (`__init__.py`); off by default, so a
465 /// build that doesn't opt in never sends an undecoded probe opcode. Not a runtime toggle: there
466 /// is no entity and nothing else calls this after setup — the gate is "was this build
467 /// configured with `diagnostic_probes: true`", not a state a user flips per session.
468 /// @param enabled Desired state.
469 void set_diagnostic_probes_enabled(bool enabled) { this->diagnostic_probes_enabled_ = enabled; }
470
471 /// @brief Whether diagnostic probes are enabled for this build.
472 [[nodiscard]] bool diagnostic_probes_enabled() const { return this->diagnostic_probes_enabled_; }
473
474 // --- Device management (called by platform entities during setup) ---
475 /// Add a device to the registry by device ID only (undeclared/legacy path).
476 /// Type, subtype, inverted, and optimistic_state default to UNKNOWN / 0 / false / true; use the
477 /// `DeviceConfig` overload when metadata comes from a YAML declaration.
478 /// @param device_id Hexadecimal node ID string.
479 virtual void add_device(const std::string &device_id);
480 /// Add a device to the registry with full metadata from a YAML declaration.
481 /// @param device_id Hexadecimal node ID string.
482 /// @param cfg Device type/subtype/inversion/optimistic-state metadata.
483 virtual void add_device(const std::string &device_id, const DeviceConfig &cfg);
484 /// Retrieve a device by ID; returns nullptr if not found.
485 /// @param device_id Hexadecimal node ID.
486 /// @return Pointer to IoDevice, or nullptr.
487 virtual IoDevice *get_device(const std::string &device_id);
488 /// Set a device's `dimmable` flag (see IoDevice::dimmable). Called by platform_light.cpp's
489 /// setup(), not folded into add_device() since it's a light-only YAML choice. No-op if the
490 /// device isn't registered.
491 /// @param device_id Hexadecimal node ID string.
492 /// @param dimmable New value for IoDevice::dimmable.
493 virtual void set_device_dimmable(const std::string &device_id, bool dimmable);
494
495 /// Select a device's travel profile at runtime (see IOHomeCoverSilentSwitch).
496 /// Virtual for the same reason as set_device_dimmable: platform tests substitute a mock registry.
497 /// @param device_id Hexadecimal node ID string.
498 /// @param silent True to send position moves in "silent operation" (slower) mode.
499 virtual void set_device_silent(const std::string &device_id, bool silent);
500 /// Register a callback invoked when any device updates.
501 /// @param cb Callable with signature void(const std::string&, const IoDevice&).
502 virtual void register_device_callback(DeviceUpdateCallback cb) { this->registry_.subscribe(std::move(cb)); }
503 /// Configure the optional follow-up polling interval for a registered device.
504 /// @param device_id Target device ID.
505 /// @param poll_interval_ms Poll interval in milliseconds; zero keeps the legacy one-shot settle poll only.
506 virtual void set_device_status_poll_interval(const std::string &device_id, uint32_t poll_interval_ms);
507 /// Opt a registered device into the periodic rain sensor poll (a read of its minimum limitation).
508 /// @param device_id Target device ID.
509 /// @param interval_ms Poll interval in milliseconds; zero removes the device from the schedule.
510 virtual void set_device_rain_poll_interval(const std::string &device_id, uint32_t interval_ms);
511
512 // --- High-level operations ---
513 /// Send a position command to a device.
514 /// @param device_id Target device ID.
515 /// @param position Desired position, 0–100 (open→closed). Named commands (STOP, FAVORITE,
516 /// VENT) go through execute_device_command_()/create_execute_command() instead.
517 /// @return true if device acknowledged; false on timeout or radio error.
518 virtual bool set_device_position(const std::string &device_id, uint8_t position);
519 /// Send a tilt command to a tilt‑capable cover.
520 /// @param device_id Target device ID.
521 /// @param tilt_percent Desired tilt (0–100).
522 /// @return true if device acknowledged; false otherwise.
523 virtual bool set_device_tilt(const std::string &device_id, uint8_t tilt_percent);
524 /// Set both position and tilt of a tilt-capable cover in one atomic command.
525 /// @param device_id Target device ID.
526 /// @param position Desired position (0–100, open→closed).
527 /// @param tilt_percent Desired tilt (0–100).
528 /// @return true if device acknowledged; false otherwise.
529 virtual bool set_device_position_and_tilt(const std::string &device_id, uint8_t position, uint8_t tilt_percent);
530 /// Request current status from a device.
531 /// @param device_id Target device ID.
532 /// @return true if status frame was received and processed.
533 virtual bool request_device_status(const std::string &device_id);
534 /// Read the device's minimum limitation and derive its rain sensor state from the reply.
535 ///
536 /// Unauthenticated read (CMD_LIMITATION_STATUS_REQ); the reply is accepted only inside this
537 /// exchange (ADR 0022) and never reaches update_device_status_(), so a poll cannot move the
538 /// device's position or target. A moving device is not asked: the poll is deferred instead.
539 /// @param device_id Target device ID.
540 /// @return true if a usable limitation reply arrived and was applied.
541 virtual bool request_device_limitation(const std::string &device_id);
542 /// Request the stored device name from a device.
543 /// @param device_id Target device ID.
544 /// @return true if a name response frame was received and processed.
545 virtual bool request_device_name(const std::string &device_id);
546 /// Rename a device and verify the result by reading the name back.
547 /// @param device_id Target device ID.
548 /// @param new_name Requested UTF-8 device name.
549 /// @return Structured result describing success, verification, and any validation failure.
550 virtual ManagementActionResult rename_device(const std::string &device_id, const std::string &new_name) {
551 return this->management_actions_.rename_device(device_id, new_name);
552 }
553 /// Trigger a device's physical identify (brief jog/flash) so a user can confirm which
554 /// physical motor a device ID maps to.
555 /// @param device_id Target device ID.
556 /// @return Structured result describing success and any validation failure. `verified` is
557 /// always false — there is no readback for a physical identify jog.
558 virtual ManagementActionResult identify_device(const std::string &device_id) {
559 return this->management_actions_.identify_device(device_id);
560 }
561 /// @brief Move a cover device to fully open at elevated priority, intended to bypass
562 /// wind/rain soft locks.
563 ///
564 /// Safety-sensitive: queues CoverCommand::FORCE_OPEN through the normal cover-command dispatch
565 /// path. Only confirms the command was queued; the movement outcome arrives later via the
566 /// device's normal cover-state/polling pipeline, so `verified` is always false. The lock-bypass
567 /// behavior itself is experimental and unconfirmed against an active lock — see
568 /// ManagementActions::force_open_device()'s doxygen for details.
569 /// @param device_id Target device ID.
570 /// @return Structured result describing whether the command was queued.
571 virtual ManagementActionResult force_open_device(const std::string &device_id) {
572 return this->management_actions_.force_open_device(device_id);
573 }
574 /// Broadcast a roll-call and report every device that answers (see
575 /// ManagementActions::scan_paired_devices() for the full contract: only key-holding devices
576 /// answer, DeviceRegistry is never written, and zero replies is a successful result).
577 /// @return Structured result whose `message` is the full multi-line report.
579 /// Send a single diagnostic probe frame to a registered device and report the raw reply (see
580 /// ManagementActions::probe_device() for the full contract, argument formats, and safety
581 /// gating). Protocol-research instrumentation for opcodes this codebase has not decoded — see
582 /// docs/diagnostic-probes.md and ADR 0024.
583 /// @param device_id Target device ID.
584 /// @param probe Probe name ("private_fn", "status_ext", "general_info3", "private2",
585 /// "private2_short", "status_mp_fp", or "limitation").
586 /// @param index Function ID / selector block / modifier, as a decimal or `0x`-prefixed hex
587 /// string; ignored for "general_info3" and "status_mp_fp"; 0 (minimum) or 1 (maximum) for "limitation".
588 /// @return Structured result whose `message` carries the reply's command byte and raw hex.
589 virtual ManagementActionResult probe_device(const std::string &device_id, const std::string &probe,
590 const std::string &index) {
591 return this->management_actions_.probe_device(device_id, probe, index);
592 }
593 /// Walk a bounded index range, one probe_device() call per index (see
594 /// ManagementActions::probe_sweep()).
595 /// @param device_id Target device ID.
596 /// @param probe Probe name, same as probe_device().
597 /// @param first_index First index in the sweep (inclusive).
598 /// @param last_index Last index in the sweep (inclusive).
599 /// @return Structured result whose `message` is one line per index.
600 virtual ManagementActionResult probe_sweep(const std::string &device_id, const std::string &probe,
601 const std::string &first_index, const std::string &last_index) {
602 return this->management_actions_.probe_sweep(device_id, probe, first_index, last_index);
603 }
604 /// @brief Run one 2W heating/climate function (CMD_WRITE_PRIVATE 0x20) against a registered
605 /// climate device — the `heating_control` hub action.
606 ///
607 /// Experimental: the register protocol is unvalidated on real Atlantic/Thermor/Sauter hardware.
608 /// A write is reported as acknowledged by the device, never as confirmed; the register reads
609 /// (`window_open`) put the decoded value into the result message, and `power_on` / `midnight_sync`
610 /// log their ACK payload at DEBUG. See ManagementActions::heating_control() for argument formats.
611 /// @param device_id Target device ID.
612 /// @param function Heating function name.
613 /// @param value Function-specific value string.
614 /// @return Structured result describing success and any validation/exchange failure.
615 virtual ManagementActionResult heating_control(const std::string &device_id, const std::string &function,
616 const std::string &value) {
617 return this->management_actions_.heating_control(device_id, function, value);
618 }
619 /// Discover and pair a device that is in pairing mode.
620 /// @return true if pairing completed successfully; false otherwise.
621 virtual bool discover_and_pair();
622 /// Send an arbitrary IO position (0-100) to a light entity. Internally mapped to the shared
623 /// execute path. set_light_state() is a thin binary-position wrapper around this, used by
624 /// dimmable lights to send anything other than the two binary extremes.
625 /// @param device_id Target device ID.
626 /// @param position Desired IO position (0-100); this device family's convention maps 0 to full
627 /// brightness and 100 to off, the same 0-100 scale platform_cover.cpp uses.
628 /// @return true if device acknowledged.
629 virtual bool set_light_position(const std::string &device_id, uint8_t position);
630 /// Send a heat level to an exterior heater (a fan entity) as an IO position. Mapped to the shared
631 /// execute path, like set_light_position().
632 /// @param device_id Target device ID.
633 /// @param position Desired IO position (0-100): 0 = full heat, 100 = off (the KLF 200 exterior
634 /// heating mapping, MP = position x 0x200).
635 /// @return true if device acknowledged.
636 virtual bool set_fan_position(const std::string &device_id, uint8_t position);
637 /// Semantic binary helper for light entities. Internally mapped to the shared execute path.
638 /// @param device_id Target device ID.
639 /// @param on Desired on/off state.
640 /// @return true if device acknowledged.
641 virtual bool set_light_state(const std::string &device_id, bool on);
642 /// Semantic binary helper for switch entities. Internally mapped to the shared execute path.
643 /// @param device_id Target device ID.
644 /// @param on Desired on/off state.
645 /// @return true if device acknowledged.
646 virtual bool set_switch_state(const std::string &device_id, bool on);
647 /// Semantic lock helper for lock entities. Internally mapped to the shared execute path.
648 /// @param device_id Target device ID.
649 /// @param locked Desired locked/unlocked state.
650 /// @return true if device acknowledged.
651 virtual bool set_lock_state(const std::string &device_id, bool locked);
652 /// @brief Run one heating function on the heater's private register protocol (CMD_WRITE_PRIVATE
653 /// 0x20): a write for a settable register, a read for a read-only one (`power_on`, `midnight_sync`).
654 /// Both the `heating_control` hub action and the climate entity's writes call this; together with
655 /// read_heating_register() it is the only place heating frames are built and sent, through one
656 /// shared exchange helper.
657 ///
658 /// Flow: registry lookup -> device_supports_climate_control() gate (rejected via
659 /// detail::log_rejected_operation()) -> encode_heating_payload() -> create_write_private() (with
660 /// the device's `low_power` flag) -> a plain send_and_receive_(). Deliberately NOT routed
661 /// through execute_request_and_update_(): that helper is cover/position-shaped (status decode,
662 /// poll backoff). A CMD_WRITE_PRIVATE_ACK (0x21) reply is success; anything else (including
663 /// CMD_ERROR_RESP) is failure. The exchange still feeds the device-agnostic Last Contact /
664 /// Exchange Failures link-health sensors. The ack carries no state: a caller that wants the
665 /// value the heater now holds reads it back with read_heating_register(). An acknowledged write
666 /// also moves the heater's next poll to HEATING_POST_WRITE_POLL_DELAY_MS from now, so the regulator
667 /// state (heating / idle) follows the change.
668 /// @param device_id Target device ID (hex string).
669 /// @param fn Heating function to send.
670 /// @param value Function-specific value (degrees C, a HeatingMode as float, 0/1, or ignored) —
671 /// see encode_heating_payload().
672 /// @return true only if the device answered with CMD_WRITE_PRIVATE_ACK.
673 virtual bool send_heating_command(const std::string &device_id, HeatingFunction fn, float value);
674 /// @brief Set the heating level of a heater that is commanded with levels (the plain electrical heater).
675 ///
676 /// A main-parameter-only EXECUTE through the execute-family funnel; the settle poll that follows
677 /// reads the level back with the ordinary status read, and the climate entity shows that reading.
678 /// Experimental: unconfirmed on hardware.
679 /// @param device_id Target device ID (hex string).
680 /// @param level Heating level low byte (heating_level::*).
681 /// @return true when the device accepted the command.
682 virtual bool set_heating_level(const std::string &device_id, uint8_t level);
683 /// @brief Command a heater that carries a temperature or an alias in its main parameter (the heating
684 /// temperature interface and the heat pump).
685 ///
686 /// A main-parameter-only EXECUTE, or one that also carries functional parameter 9 when the profile
687 /// sends mode flags, through the execute-family funnel; the settle poll that follows reads the
688 /// state back with the ordinary status read. Experimental: unconfirmed on hardware.
689 /// @param device_id Target device ID (hex string).
690 /// @param main_parameter The 16-bit main parameter (encode_temperature_main_parameter() or an alias).
691 /// @param fp The functional parameter that accompanies it, if any (kind NONE sends a main-parameter-only
692 /// EXECUTE).
693 /// @return true when the device accepted the command.
694 virtual bool set_heating_temperature(const std::string &device_id, uint16_t main_parameter, const HeatingFp &fp);
695 /// @brief Set the temperature encoding range of a heater that carries a temperature in its main
696 /// parameter (YAML `min_temperature:` / `max_temperature:`).
697 /// @param device_id Target device ID.
698 /// @param min_c Lower end of the range in degrees Celsius.
699 /// @param max_c Upper end; must exceed `min_c`.
700 virtual void set_device_temperature_range(const std::string &device_id, float min_c, float max_c);
701 /// @brief Read one register of a heater and fold the decoded value into IoDevice::heating.
702 ///
703 /// Same gate, frame builder and exchange bookkeeping as send_heating_command(); a reply that
704 /// decodes (decode_heating_reply()) updates the device record and notifies the entity callbacks,
705 /// so what an entity shows is what the heater reported.
706 /// @param device_id Target device ID (hex string).
707 /// @param fn Register to read; must be readable (see heating_function_is_readable()).
708 /// @param[out] value_out Decoded value (NaN for a room-temperature sensor fault); pass nullptr when
709 /// only the stored reading is wanted.
710 /// @param max_tries Transmit-attempt cap: EXCHANGE_RETRY_COUNT for a read the user waits on (the
711 /// action, the read-back of a write), the scheduled-poll band for the background poll.
712 /// @return true only if the device answered and the reply decoded to a value for `fn`.
713 virtual bool read_heating_register(const std::string &device_id, HeatingFunction fn, float *value_out,
714 uint8_t max_tries);
715 /// @brief Queue an async position update; returns immediately, executed in loop().
716 ///
717 /// If a pending SET_TILT operation for the same device is already in the queue, the two are
718 /// coalesced into a single SET_POSITION_AND_TILT command to avoid two radio exchanges.
719 /// This transparently handles Home Assistant sending cover.set_cover_position and
720 /// cover.set_cover_tilt_position as separate rapid calls.
721 /// @param device_id Target device ID.
722 /// @param position Desired position (0–100).
723 virtual void queue_set_device_position(const std::string &device_id, uint8_t position);
724 /// @brief Queue an async named command (STOP, FAVORITE, VENT, FORCE_OPEN); returns immediately,
725 /// executed in loop().
726 ///
727 /// Existing entity/button callers (cover, favorite button, vent button) intentionally ignore
728 /// the return value — they always target a known, already-registered device. It exists so
729 /// force_open_device() can report enqueue rejection distinctly from a queued-but-not-yet-run
730 /// command.
731 /// @param device_id Target device ID.
732 /// @param cmd Named command to send.
733 /// @return true if the hub is initialized, the device is registered, and the command matches
734 /// its capability class (so the command was enqueued); false otherwise.
735 virtual bool queue_device_command(const std::string &device_id, CoverCommand cmd);
736 /// @brief Queue an async tilt update; returns immediately, executed in loop().
737 ///
738 /// If a pending SET_POSITION operation for the same device is already in the queue, the two are
739 /// coalesced into a single SET_POSITION_AND_TILT command to avoid two radio exchanges.
740 /// This transparently handles Home Assistant sending cover.set_cover_position and
741 /// cover.set_cover_tilt_position as separate rapid calls.
742 /// @param device_id Target device ID.
743 /// @param tilt_percent Desired tilt (0–100).
744 virtual void queue_set_device_tilt(const std::string &device_id, uint8_t tilt_percent);
745 /// Queue an async combined position+tilt update; returns immediately, executed in loop().
746 /// @param device_id Target device ID.
747 /// @param position Desired position (0–100).
748 /// @param tilt_percent Desired tilt (0–100).
749 virtual void queue_set_device_position_and_tilt(const std::string &device_id, uint8_t position, uint8_t tilt_percent);
750 /// Queue an async status request; returns immediately, executed in loop().
751 /// @param device_id Target device ID.
752 virtual void queue_request_device_status(const std::string &device_id);
753 /// Queue an async minimum-limitation read for the rain sensor poll; executed in loop() as a
754 /// background operation (yields to commands and 1W activity).
755 /// @param device_id Target device ID.
756 virtual void queue_request_device_limitation(const std::string &device_id);
757 /// Queue an async device-name request; returns immediately, executed in loop().
758 /// @param device_id Target device ID.
759 virtual void queue_request_device_name(const std::string &device_id);
760 /// Queue a pairing operation; executed in loop() when radio idle.
761 ///
762 /// A press handled within PAIRING_STALE_PRESS_MS of the end of an attempt is dropped with a log
763 /// line: pairing blocks loop(), so a press made while it ran is only handled once it ends, when the
764 /// device has usually left its pairing mode.
765 virtual void queue_discover_and_pair();
766 /// @brief Entry point for the "Scan Paired Devices" button: run the roll-call and publish its
767 /// report to the log and the Home Assistant result event, exactly as the native API action does.
768 ///
769 /// Deliberately not queued through OperationQueue, unlike queue_discover_and_pair(): the
770 /// roll-call has no priority, coalescing or dedup semantics to preserve, and it already runs
771 /// this way from the native API action. What it *is* guarded on is `busy_` — a button, unlike an
772 /// API action, is also reachable from an ESPHome automation, which can fire from inside a
773 /// blocking exchange (an entity callback -> on_value: -> button.press: chain) and would
774 /// otherwise re-enter ExchangeEngine mid-exchange. See ADR 0013 for why one blocking radio
775 /// operation at a time is the whole concurrency model. A press while `busy_` still fires the
776 /// log/event pair (a failed result), matching every other rejected management action rather
777 /// than going silent.
778 ///
779 /// Blocks the ESPHome loop for up to roughly 6 × `pairing_discovery_wait_ms` (both power-class
780 /// passes, three channels each; fewer if `scan_power_classes` narrows the sweep) and will log the
781 /// "operation took a long time" warning, same as the action — see docs/pairing.md.
783 /// Async form of set_light_position() that keeps radio work serialized on the main loop.
784 /// queue_set_light_state() is a thin binary-position wrapper around this.
785 /// @param device_id Target device ID.
786 /// @param position Desired IO position (0-100).
787 virtual void queue_set_light_position(const std::string &device_id, uint8_t position);
788 /// Async form of set_fan_position() that keeps radio work serialized on the main loop.
789 /// @param device_id Target device ID.
790 /// @param position Desired IO position (0-100).
791 virtual void queue_set_fan_position(const std::string &device_id, uint8_t position);
792 /// Async form of set_light_state() that keeps radio work serialized on the main loop.
793 /// @param device_id Target device ID.
794 /// @param on Desired on/off state.
795 virtual void queue_set_light_state(const std::string &device_id, bool on);
796 /// Async form of set_switch_state() that keeps radio work serialized on the main loop.
797 /// @param device_id Target device ID.
798 /// @param on Desired on/off state.
799 virtual void queue_set_switch_state(const std::string &device_id, bool on);
800 /// Async form of set_lock_state() that keeps radio work serialized on the main loop.
801 /// @param device_id Target device ID.
802 /// @param locked Desired locked/unlocked state.
803 virtual void queue_set_lock_state(const std::string &device_id, bool locked);
804
805#ifdef IOHOME_LR1121_FIRMWARE_UPDATE
806 /// @brief Entry point for the "Flash LR1121 Radio Firmware" button. Thin forwarder to the
807 /// Lr1121FirmwareUpdateController collaborator (lr1121_firmware_update_controller.h).
808 ///
809 /// Only exists when a `lr1121_firmware_update:` block is configured. See
810 /// lr1121_firmware_update_controller.cpp for the full contract, including the safety invariant
811 /// that every bootloader excursion this triggers must end in either radio_->init() or
812 /// App.safe_reboot() — there is no third option.
813 void trigger_lr1121_firmware_update() { this->lr1121_firmware_update_.trigger(); }
814
815#ifdef IOHOME_LR1121_BOOTLOADER_UPDATE
816 /// @brief Set by the "Allow LR1121 Bootloader Rewrite (Irreversible)" switch's write_state().
817 /// Thin forwarder to the Lr1121FirmwareUpdateController collaborator.
818 ///
819 /// A permission, not an override: this can only convert a cached
820 /// BootloaderUpgradePath::AVAILABLE verdict into "run the three-stage sequence" (bootloader
821 /// ADR 0021) -- it never affects REJECT_WRONG_CHIP, the post-entry sanity
822 /// check, the busy_ guard, or any other verdict. Read once, at button-press time
823 /// (trigger_lr1121_firmware_update()); the ESPHome loop is blocked for the whole three-stage
824 /// sequence once it starts, so the switch cannot change mid-flash. Deliberately not named
825 /// anything with "armed" -- lr1121_flash_confirmation_armed_ already means the two-press window
826 /// this switch *replaces* for its own path, and a reader must never have to guess which is meant.
827 void set_bootloader_rewrite_allowed(bool allowed) {
828 this->lr1121_firmware_update_.set_bootloader_rewrite_allowed(allowed);
829 }
830#endif
831#endif
832
833 protected:
834 // --- Protocol-level operations ---
835 /// Transmit a raw IoFrame on the current frequency with given preamble length.
836 /// @param frame IoFrame to transmit.
837 /// @param freq RF frequency in Hz.
838 /// @param preamble Preamble length in bytes (e.g. `LONG_PREAMBLE`, `SHORT_PREAMBLE`, or a
839 /// tuning-configured value such as `normal_start_preamble`).
840 bool transmit_frame_(const IoFrame &frame, uint32_t freq, uint16_t preamble);
841 /// Main request/response exchange with retry and automatic authentication.
842 /// @param request Outbound request IoFrame.
843 /// @param response Output: received response IoFrame.
844 /// @param freq RF frequency in Hz.
845 /// @param max_tries Transmit-attempt cap, forwarded to ExchangeEngine::send_and_receive().
846 /// @return true if exchange succeeded; false otherwise.
847 ExchangeOutcome send_and_receive_(const IoFrame &request, IoFrame &response, uint32_t freq,
848 uint8_t max_tries = EXCHANGE_RETRY_COUNT);
849 /// Handle an inbound authenticated command from a device (status updates, etc.).
850 /// @param request Inbound authenticated request (e.g., CMD_STATUS_UPDATE).
851 /// @param freq RF frequency the packet arrived on.
852 /// @return true if authentication succeeded; false otherwise.
853 bool authenticate_request_(const IoFrame &request, uint32_t freq);
854 /// Parse a received frame, merge supported device state or metadata, and notify callbacks.
855 /// @param packet Raw radio packet containing a parsed IoFrame.
856 void process_received_packet_(const RadioRxPacket &packet);
857
858 /// True while the key-extraction responder is mid-attempt and still within its bounded CH2-hold
859 /// window. Thin forwarder to KeyExtractionResponder::awaiting_reply() (key_extraction_responder.h)
860 /// — kept on the hub because defer_background_poll_() and tests/hub/hub_core_test.cpp reach it here,
861 /// mirroring the two set_key_extraction_armed* bindings.
862 [[nodiscard]] bool key_extraction_awaiting_reply_() const { return this->key_extraction_.awaiting_reply(); }
863
864 /// Extract supported position or metadata info from a response frame and merge it into the device record.
865 /// @param frame IoFrame containing a supported inbound command such as CMD_PRIVATE_RESP,
866 /// CMD_STATUS_UPDATE, CMD_GET_NAME_RESP, or CMD_GET_INFO2_RESP.
867 /// @param trust_position False to apply `is_stopped` but skip target/position decode for a
868 /// CMD_PRIVATE_RESP — the immediate reply to our own just-sent CMD_EXECUTE echoes stale
869 /// pre-command target/current values on at least some devices (see
870 /// tests/corpus/captures/exchange/somfy_awning_exchange_ack_reports_stale_target_*.yaml), so
871 /// execute_request_and_update_() passes false there; every other caller trusts as before.
872 void update_device_status_(const IoFrame &frame, bool trust_position = true);
873 /// Record that a 1W frame just went out on the radio — ours or someone else's — updating
874 /// last_1w_activity_ms_ and — when this frame starts a new burst (see
875 /// decisions::oneway_burst_started_fresh()) — first_1w_activity_ms_.
876 ///
877 /// Called both from process_received_packet_() for an overheard remote's frame and from
878 /// execute_oneway_command_()/execute_oneway_position_() (hub_operations.cpp) for a frame this
879 /// hub just transmitted itself. That second case is deliberate, not a misuse of a receive-path
880 /// hook: our own burst should defer background polls exactly like a remote's does, because it
881 /// puts the same 1W traffic on the same shared channel the polls would otherwise use, and the
882 /// devices it targets need the same settling time either way.
883 /// @param now millis() at which this frame was seen or sent.
884 void record_1w_activity_(uint32_t now);
885 /// If `frame` matches a 1W remote's pairing gesture (decisions::is_one_way_pairing_gesture()),
886 /// remember it (src/dst/cmd plus the radio's last-capture RSSI) in
887 /// recent_oneway_pairing_sighting_ so a fresh discover_and_pair() attempt can seed its telemetry
888 /// with it — see RecentOneWayPairingSighting's doc comment (issue #27/#65). A no-op for any
889 /// other frame. Called from process_received_packet_()'s 1W-frame path, unconditionally (before
890 /// the burst-dedup check, like record_1w_activity_()) so a repeated gesture frame still
891 /// refreshes the timestamp (and RSSI).
892 /// @param frame Parsed 1W frame (CTRL0 1W bit already confirmed set by the caller).
893 /// @param now millis() at which this frame was seen.
894 void record_oneway_pairing_gesture_(const IoFrame &frame, uint32_t now);
895 /// Schedule a delayed status poll for a registered device using the Component timeout API.
896 /// @param device_id ID of the device to poll.
897 /// @param delay_ms Delay in milliseconds before polling.
898 /// @note Uses ESPHome's set_timeout() mechanism; the callback executes in loop().
899 /// A zero delay schedules immediately on the next loop iteration.
900 void schedule_status_poll_(const std::string &device_id, uint32_t delay_ms);
901 /// Arm the heater's regular-cadence poll timer. Own timer id (decisions::heating_poll_timer_id()),
902 /// so remote-activity polls scheduled by schedule_status_poll_() never replace it.
903 /// @param device_id ID of the heater.
904 /// @param delay_ms Delay in milliseconds before the poll is queued.
905 void schedule_heating_poll_(const std::string &device_id, uint32_t delay_ms);
906 /// Begin bounded follow-up polling for a device after a command or overheard remote activity.
907 /// @param device_id ID of the device to poll.
908 /// @param initial_delay_ms Delay before the first follow-up poll.
909 void begin_status_poll_tracking_(const std::string &device_id, uint32_t initial_delay_ms);
910 /// Arm the confirming poll that follows a command, because a CMD_EXECUTE reply is never trusted
911 /// for position (see update_device_status_()'s trust_position parameter) and therefore leaves the
912 /// hub with no idea where the device actually is. Re-arms the bounded tracking window rather than
913 /// only setting a due time: the same untrusted reply clears that window whenever it claims the
914 /// device is stopped, and pop_due_device() discards a due poll that has no active window. An
915 /// already-scheduled earlier poll wins.
916 /// @param device_id Device the command was sent to.
917 /// @param for_stop True for STOP (and position POS_STOP), which settles under
918 /// STOP_SETTLE_POLL_CAP_MS instead of the normal settle cadence.
919 void arm_execute_confirmation_poll_(const std::string &device_id, bool for_stop);
920 /// Schedule status polls for a fixed list of devices (shared by the id-linked and
921 /// class-linked 1W paths, and by schedule_linked_remote_polls_()).
922 /// @param device_ids Devices to poll.
923 /// @param delay_ms Poll delay in milliseconds.
924 void schedule_device_polls_(const std::vector<std::string> &device_ids, uint32_t delay_ms);
925 /// Whether loop() should skip dispatching the queue this iteration because the pending work is a
926 /// background poll and either a 1W remote transmitted very recently, or a key-extraction attempt
927 /// is mid-flight. Thin wrapper binding the component's state to
928 /// decisions::defer_background_poll_for_1w_activity(), plus a second, independent yield condition:
929 /// a background poll is a blocking exchange that owns the radio for 1-3 s, and dispatching one
930 /// while the key-extraction responder is holding CH2 for an expected CMD_KEY_TRANSFER (0x32)
931 /// would swallow it just as thoroughly as a mistimed hop — see loop()'s hop branch (hub_core.cpp)
932 /// for the other half of that hold. Only background polls yield here, same as the 1W rule: a user
933 /// command must never wait on either kind of background activity.
934 [[nodiscard]] bool defer_background_poll_() const {
935 const bool next_op_is_background =
937 if (next_op_is_background && this->key_extraction_awaiting_reply_())
938 return true;
940 this->last_1w_activity_ms_, millis(),
941 ONEWAY_QUIET_PERIOD_MS, ONEWAY_POLL_DEFER_CAP_MS);
942 }
943 /// Schedule status polls for all devices associated with a linked remote.
944 /// @param remote_id Source node ID of the remote.
945 /// @param delay_ms Poll delay; default REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS. A STOP intent
946 /// passes 0 (position settles immediately, no need to wait out the usual travel-time
947 /// assumption behind the default delay).
948 void schedule_linked_remote_polls_(const std::string &remote_id,
949 uint32_t delay_ms = REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS);
950 /// Resolve the set of devices a 1W frame should affect: devices linked to the sending remote
951 /// by node ID, plus — when the frame targets a typed broadcast (e.g. "all awnings") — devices
952 /// linked to that device class, deduplicated so a device linked both ways is touched once.
953 /// @param info Already-decoded 1W frame info (see decode_1w_frame()).
954 /// @param src_id Sender's node ID as a string (already computed by the caller).
955 /// @return Deduplicated device IDs (may be empty).
956 [[nodiscard]] std::vector<std::string> resolve_1w_target_devices_(const OneWayFrameInfo &info,
957 const std::string &src_id) const;
958 /// Apply optimistic target state to every device in @p device_ids, when the decoded frame
959 /// carries a resolvable intent. Skips devices whose known type doesn't match the frame's
960 /// typed-broadcast target (an "all awnings" press must not optimistically move a linked
961 /// shutter — it is still polled by schedule_device_polls_()). No-op per device when that
962 /// device has `optimistic_state == false` (see DeviceRegistry::apply_optimistic_target()).
963 /// @param info Already-decoded 1W frame info (see decode_1w_frame()).
964 /// @param device_ids Devices to apply optimistic state to (see resolve_1w_target_devices_()).
965 /// @return true if the intent resolved to a STOP (caller should poll immediately).
966 bool apply_optimistic_linked_state_(const OneWayFrameInfo &info, const std::vector<std::string> &device_ids);
967 /// Fire the sender HA event for a decoded 1W frame, if the sender is exposed.
968 /// DEBUG-logs the reason when it does not fire (API disconnected / sender not exposed) so a
969 /// live log capture can distinguish "never reached this check" from "reached it and skipped".
970 /// @param info Already-decoded 1W frame info (see decode_1w_frame()).
971 /// @param linked True if the sender is linked to at least one registered device.
972 /// @param src_id Sender's node ID as a string (already computed by the caller).
973 void maybe_fire_sender_event_(const OneWayFrameInfo &info, bool linked, const std::string &src_id);
974 /// Handle an explicit CMD_ERROR_RESP refusal from the device: record the result code, stamp link
975 /// health, and schedule the poll backoff. Split out of exchange_and_record_() to keep that
976 /// function's outcome dispatch readable — a refusal is a distinct concern from "what did the
977 /// exchange achieve".
978 /// @param device_id Target device ID.
979 /// @param request Outbound request frame that drew the refusal.
980 /// @param response The CMD_ERROR_RESP frame.
981 /// @param retry_after_fail_ms If non-zero, schedules next status poll after this delay.
982 /// @return Always false; a refusal is never a success.
983 bool handle_error_response_(const std::string &device_id, const IoFrame &request, const IoFrame &response,
984 uint32_t retry_after_fail_ms);
985
986 /// Fold one rain poll outcome into the device's rain sensor state and tell subscribers.
987 /// @param device_id Target device ID.
988 /// @param reading DRY or RAIN for an answered poll, UNKNOWN for a miss.
989 /// @param limit_text Rendering of the reported limit for the state-change log, empty for a miss.
990 void apply_rain_poll_result_(const std::string &device_id, RainSensorState reading, const std::string &limit_text);
991
992 /// @brief What exchange_and_record_() found.
993 enum class RequestResult : uint8_t {
994 FAILED, ///< No usable reply (silence, or an unconfirmed acceptance of a request that needs a payload).
995 UNCONFIRMED, ///< A CMD_EXECUTE the device accepted but never closed; counted as success, no reply frame.
996 ERROR_REPLY, ///< The device refused with CMD_ERROR_RESP; handle_error_response_() has recorded it.
997 REPLY, ///< The device answered; the caller interprets the reply frame.
998 };
999
1000 /// @brief Per-request knobs of exchange_and_record_().
1002 bool warn_on_no_response; ///< Log a warning when no response arrives.
1003 uint32_t retry_after_fail_ms; ///< If non-zero, drives the status-poll backoff and failure streaks.
1004 uint8_t max_tries; ///< Transmit-attempt cap forwarded to send_and_receive_().
1005 };
1006
1007 /// Run one authenticated request/response exchange on the standard command channel and record
1008 /// its outcome: exchange-outcome and timeout counters, link health, challenge-seen handling,
1009 /// CMD_ERROR_RESP handling, debug logs and (when requested) the status-poll backoff. It does not
1010 /// interpret a reply — callers decide what a REPLY means.
1011 /// @param device_id Target device ID.
1012 /// @param request Outbound request frame.
1013 /// @param[out] response The device's reply, valid for RequestResult::REPLY and ERROR_REPLY.
1014 /// @param options See RequestOptions.
1015 /// @return What happened, see RequestResult.
1016 RequestResult exchange_and_record_(const std::string &device_id, const IoFrame &request, IoFrame &response,
1017 const RequestOptions &options);
1018
1019 /// Shared request/response helper for high-level operations: exchange_and_record_() plus
1020 /// update_device_status_() on a reply.
1021 /// @param device_id Target device ID.
1022 /// @param request Outbound request frame.
1023 /// @param warn_on_no_response If true, logs a warning when no response is received.
1024 /// @param retry_after_fail_ms If non-zero, schedules next status poll after this delay on failure.
1025 /// @param max_tries Transmit-attempt cap, forwarded to send_and_receive_(). Defaults to the full
1026 /// EXCHANGE_RETRY_COUNT; a scheduler-owned poll passes SCHEDULED_POLL_MAX_TRIES.
1027 /// @return true when the device replied, or when a CMD_EXECUTE was accepted without a reply —
1028 /// every other command's unconfirmed acceptance is still a failure here (see
1029 /// @ref ExchangeOutcome and decisions::retry_after_unconfirmed_accept_is_safe()).
1030 bool execute_request_and_update_(const std::string &device_id, const IoFrame &request, bool warn_on_no_response,
1031 uint32_t retry_after_fail_ms = 0, uint8_t max_tries = EXCHANGE_RETRY_COUNT);
1032
1033 /// @brief Everything one execute-family operation needs beyond its own guard and frame builder.
1035 const char *action; ///< Verb/phrase for the "Sending ..." and rejection logs.
1036 bool settle_as_stop; ///< Passed through to arm_execute_confirmation_poll_().
1037 };
1038 /// Funnel for the four execute-family operations: runs try_execute_operation_() and, on any
1039 /// false return (the command will not reach the device), withdraws the optimistic prediction the
1040 /// entity applied at control() time via DeviceRegistry::rollback_optimistic(). Wrapping rather
1041 /// than inlining the rollback keeps every current and future failure exit covered by one call.
1042 /// @param device_id Target device ID.
1043 /// @param spec Pre-formatted action phrase and the settle-as-stop flag.
1044 /// @param accepts Capability guard; returns false to reject the operation for this device.
1045 /// @param rejection_profile Expected-profile label for the rejection log.
1046 /// @param build Fills the request frame from the resolved device; returns false on failure.
1047 /// @return true when the exchange succeeded and the settle poll was armed.
1048 bool run_execute_operation_(const std::string &device_id, const ExecuteRequestSpec &spec,
1049 const std::function<bool(const IoDevice &)> &accepts, const char *rejection_profile,
1050 const std::function<bool(IoFrame &, const IoDevice &)> &build);
1051
1052 /// Shared skeleton for the four execute-family operations (position, named command, tilt,
1053 /// position+tilt): device lookup + initialized guard, capability guard, poll-tracking start,
1054 /// "Sending ..." log, frame build, exchange, failure backoff, and the settle poll. The four
1055 /// public methods supply only their guard predicate, rejection profile label, and frame
1056 /// builder. Called only through run_execute_operation_(), which owns the failure rollback.
1057 /// @param device_id Target device ID.
1058 /// @param spec Pre-formatted action phrase and the settle-as-stop flag.
1059 /// @param accepts Capability guard; returns false to reject the operation for this device.
1060 /// @param rejection_profile Expected-profile label for the rejection log.
1061 /// @param build Fills the request frame from the resolved device; returns false on failure.
1062 /// @return true when the exchange succeeded and the settle poll was armed.
1063 bool try_execute_operation_(const std::string &device_id, const ExecuteRequestSpec &spec,
1064 const std::function<bool(const IoDevice &)> &accepts, const char *rejection_profile,
1065 const std::function<bool(IoFrame &, const IoDevice &)> &build);
1066
1067 /// Execute a named device command (STOP, FAVORITE, VENT, FORCE_OPEN) via the authenticated exchange.
1068 /// @param device_id Target device ID.
1069 /// @param cmd Named command to execute.
1070 /// @return true if device acknowledged; false otherwise.
1071 bool execute_device_command_(const std::string &device_id, CoverCommand cmd);
1072 /// Shared bookkeeping for every 1W transmit: mark the radio busy for the duration of `send`,
1073 /// then record it as 1W activity so background polls back off for it exactly as they do for a
1074 /// remote's burst — the radio is equally busy either way. Every 1W execute must go through this;
1075 /// a future one that skips it would compile, pass, and silently break poll-deferral.
1076 /// @param send Callable that performs the actual transmit; takes no arguments.
1077 template<typename F> void execute_oneway_(F &&send) {
1078 this->busy_ = true;
1079 send();
1080 this->busy_ = false;
1081 this->record_1w_activity_(millis());
1082 }
1083 /// Send a queued 1W named command. Unlike its 2W sibling this returns nothing: there is no
1084 /// acknowledgement to report, and success here would only mean "bytes left the radio".
1085 /// @param controller_id Controller-identity handle.
1086 /// @param cmd Named command to send.
1087 void execute_oneway_command_(const std::string &controller_id, CoverCommand cmd);
1088 /// Send a queued 1W numeric position. See execute_oneway_command_().
1089 /// @param controller_id Controller-identity handle.
1090 /// @param position Target position 0–100.
1091 void execute_oneway_position_(const std::string &controller_id, uint8_t position);
1092 /// Send a queued 1W enrollment (add-controller). See execute_oneway_command_().
1093 /// @param controller_id Controller-identity handle.
1094 void execute_oneway_enroll_(const std::string &controller_id);
1095 /// Send a queued 1W un-enrollment (remove-controller). See execute_oneway_command_().
1096 /// @param controller_id Controller-identity handle.
1097 void execute_oneway_unenroll_(const std::string &controller_id);
1098 /// Fire all registered device update callbacks for the given device ID.
1099 /// @param id Device ID that updated.
1100 void notify_device_update_(const std::string &id);
1101 /// Apply backoff after a failed background status poll and log the result.
1102 /// @param device_id Target device ID.
1103 /// @param auth_like True when the failed exchange saw a 0x3C challenge.
1104 void schedule_background_poll_backoff_(const std::string &device_id, bool auth_like);
1105 /// @brief The exchange every heating frame shares: send on the command channel, record the
1106 /// outcome (exchange counters, link health, error result code), notify the entities and report
1107 /// whether the device answered with CMD_WRITE_PRIVATE_ACK.
1108 /// @param device_id Target device ID.
1109 /// @param label What was sent, for the log ("set_temperature", "read room_temperature").
1110 /// @param request The framed CMD_WRITE_PRIVATE request.
1111 /// @param[out] response The device's reply; valid when true is returned.
1112 /// @param max_tries Transmit-attempt cap forwarded to send_and_receive_().
1113 /// @return true only if the reply is a CMD_WRITE_PRIVATE_ACK.
1114 bool exchange_heating_(const std::string &device_id, const char *label, const IoFrame &request, IoFrame &response,
1115 uint8_t max_tries);
1116 /// @brief Poll a heater: read its room temperature, setpoint, mode and regulator state (stopping
1117 /// at the first failure), plus its maximum setpoint until that has been read once.
1118 ///
1119 /// A failure moves the retry onto the StatusPollPolicy ladder; either way the next regular poll
1120 /// is armed one interval out (the device's `status_poll_interval`, else
1121 /// HEATING_POLL_DEFAULT_INTERVAL_MS). Called from request_device_status() for climate devices.
1122 /// @param device_id Target device ID.
1123 /// @return true when all four registers were read.
1124 bool poll_heating_device_(const std::string &device_id);
1125 /// Pop next pending operation from the queue and execute it (set position, request status, discover).
1127
1128 // --- Exchange helpers (thin wrappers delegating to ExchangeEngine) ---
1129
1130 /// Log the last exchange debug snapshot (delegates to exchange_engine_).
1131 void log_exchange_debug_(const char *device_id) const { this->exchange_engine_.log_debug(device_id); }
1132
1133 /// Log the last exchange debug snapshot for an accepted-but-unconfirmed exchange, at INFO.
1134 void log_exchange_unconfirmed_debug_(const char *device_id) const {
1135 this->exchange_engine_.log_debug_unconfirmed(device_id);
1136 }
1137
1138 // --- Tuning ---
1139 /// Apply the current tuning configuration to the active radio driver.
1141 /// @brief Resolve `normal_start_preamble` from the driver when YAML did not set it (ADR 0042).
1143
1144 // --- Management actions (thin wrappers delegating to management_actions_) ---
1145 /// Register hub-level Home Assistant actions; called from setup().
1147 /// Native API callback: rename a registered device.
1148 void api_rename_device_(const std::string &device_id, const std::string &new_name) {
1149 this->management_actions_.api_rename_device(device_id, new_name);
1150 }
1151 /// Native API callback: trigger a registered device's physical identify.
1152 void api_identify_device_(const std::string &device_id) { this->management_actions_.api_identify_device(device_id); }
1153 /// Native API callback: force-open a registered cover device.
1154 void api_force_open_device_(const std::string &device_id) {
1156 }
1157 /// Native API callback: broadcast a roll-call scan of already-paired devices.
1159 /// Native API callback: queue a 1W position for a controller identity.
1160 void api_oneway_set_position_(const std::string &controller_id, const std::string &position) {
1161 this->management_actions_.api_oneway_set_position(controller_id, position);
1162 }
1163 /// Native API callback: queue a 1W un-enrollment (remove-controller) for a controller identity.
1164 void api_oneway_remove_controller_(const std::string &controller_id) {
1166 }
1167 /// Native API callback: run a single diagnostic probe against a registered device.
1168 void api_probe_device_(const std::string &device_id, const std::string &probe, const std::string &index) {
1169 this->management_actions_.api_probe_device(device_id, probe, index);
1170 }
1171 /// Native API callback: run a bounded diagnostic probe sweep against a registered device.
1172 void api_probe_sweep_(const std::string &device_id, const std::string &probe, const std::string &first_index,
1173 const std::string &last_index) {
1174 this->management_actions_.api_probe_sweep(device_id, probe, first_index, last_index);
1175 }
1176 /// Native API callback: run a heating/climate function (CMD_WRITE_PRIVATE 0x20) against a
1177 /// registered climate device.
1178 void api_heating_control_(const std::string &device_id, const std::string &function, const std::string &value) {
1179 this->management_actions_.api_heating_control(device_id, function, value);
1180 }
1181
1182 // --- Frequency hopping ---
1183 void hop_frequency_();
1184
1185 // --- Radio driver selection (called once from setup()) ---
1186 /// Select and construct the radio driver named by the required `radio_type` config field.
1187 ///
1188 /// Kept as its own method rather than inlined into setup(): the three-way chip branch
1189 /// (SX1276/SX1262/LR1121) is enough logic on its own that folding it into setup() pushes
1190 /// that function's cognitive complexity past clang-tidy's threshold.
1191 /// Validates the pins each driver needs and logs a clear error (without calling
1192 /// mark_failed() itself — the caller decides how to react) when a required pin is
1193 /// missing. On success, `*chip_name_out` is set to a static string naming the selected
1194 /// chip (used for logging), and the returned pointer is the heap-allocated (not yet
1195 /// initialized) driver instance.
1196 /// @param chip_name_out Output: human-readable chip name for logging (always set,
1197 /// even on failure, to the best-known name for error messages).
1198 /// @return Newly allocated RadioDriver, or nullptr if pin validation or allocation failed.
1199 RadioDriver *select_and_construct_radio_(const char **chip_name_out);
1200
1201 /// @brief Emit the 1W controller identities to the config dump — node, class, and the resolved
1202 /// ACEI / broadcast (ADR 0031). Factored out of dump_config() to keep its cognitive complexity
1203 /// under the clang-tidy threshold.
1205
1206#ifdef IOHOME_LR1121_FIRMWARE_UPDATE
1207 // --- LR1121 firmware update: thin forwarders to the Lr1121FirmwareUpdateController collaborator
1208 // (lr1121_firmware_update_controller.h). setup()/dump_config() keep calling these names; the
1209 // orchestration and every safety invariant live in the collaborator's .cpp. ---
1210 /// Boot-time bootloader-version excursion — forwards. Called from setup() after
1211 /// select_and_construct_radio_() and before radio_->init().
1212 void run_lr1121_boot_time_bootloader_read_() { this->lr1121_firmware_update_.run_boot_time_bootloader_read(); }
1213 /// Compute and cache the flash verdict once radio_->init() has produced (or failed to produce)
1214 /// an installed-firmware-version read — forwards. Called from setup() regardless of whether
1215 /// init() succeeded.
1216 void cache_lr1121_flash_verdict_() { this->lr1121_firmware_update_.cache_flash_verdict(); }
1217 /// Emit the bootloader version and cached flash verdict to the config dump — forwards. Called
1218 /// from dump_config(), next to the existing radio_->dump_debug() call.
1219 void dump_lr1121_firmware_update_debug_() const { this->lr1121_firmware_update_.dump_debug(); }
1220#endif
1221
1222 // --- Radio driver ---
1224
1225 // --- Hardware pins (set by YAML codegen, passed to radio driver in setup) ---
1226 InternalGPIOPin *rst_pin_{nullptr};
1227 InternalGPIOPin *dio0_pin_{nullptr}; ///< SX1276 DIO0 interrupt
1228 InternalGPIOPin *dio4_pin_{nullptr}; ///< SX1276 DIO4 preamble detect (optional)
1229 InternalGPIOPin *dio1_pin_{nullptr}; ///< SX1262 DIO1 interrupt; also carries the LR1121's DIO9 IRQ line
1230 InternalGPIOPin *busy_pin_{nullptr}; ///< SX1262/LR1121 BUSY pin
1231 InternalGPIOPin *fem_en_pin_{nullptr}; ///< Front-end module enable
1232 InternalGPIOPin *vfem_pin_{nullptr}; ///< Front-end module power
1233 InternalGPIOPin *fem_pa_pin_{nullptr}; ///< Front-end module PA switch
1234 FemProfile fem_profile_{FemProfile::NONE}; ///< Which FEM part fem_pa_pin_ belongs to, if any.
1235
1236 // --- Configuration (from YAML) ---
1237 std::string node_id_str_;
1238 std::string system_key_str_;
1239 std::string radio_type_; ///< "sx1276", "sx1262", or "lr1121"; required by the YAML schema.
1240 /// Why setup() gave up on the radio, or empty. Printed by dump_config so log clients that connect
1241 /// after boot see the cause, not only ESPHome's generic "marked FAILED" line.
1243 uint8_t node_id_[NODE_ID_SIZE]{};
1244 uint8_t system_key_[AES_KEY_SIZE]{};
1247 uint8_t tcxo_voltage_{DEFAULT_TCXO_VOLTAGE_SETTING_1P8V}; ///< SX1262/LR1121 TCXO voltage setting (default 1.8 V)
1248
1249 // --- Runtime state ---
1250 bool initialized_{false};
1251 bool busy_{false};
1252 /// millis() when the last pairing attempt ended; 0 before the first one (see queue_discover_and_pair()).
1254 bool radio_test_mode_{false}; ///< When true, loop() is suspended for loopback testing.
1255 TuningConfig tuning_{}; ///< Runtime tuning overrides.
1257 /// 1W sender node IDs (remotes or sensors) allowed to fire the sender HA event
1258 /// (`add_exposed_sender`). Config-time list (populated once from YAML), not a per-frame allocation.
1259 std::vector<std::string> exposed_senders_;
1260 /// Invoked once after every pairing attempt completes; see set_pairing_result_callback().
1261 std::function<void()> pairing_result_callback_;
1262 /// Whether diagnostic probes (ManagementActions::probe_device()/probe_sweep()) are enabled.
1263 /// False by default so a build that didn't opt in via `diagnostic_probes: true` never sends an
1264 /// undecoded probe opcode. See set_diagnostic_probes_enabled().
1267 RainPollPolicy rain_poll_policy_; ///< Schedule of the opt-in rain sensor poll.
1269 /// Per-attempt pairing telemetry. PairingEngine records into it and, during an attempt, attaches it
1270 /// to ExchangeEngine as its TransmitObserver.
1272 /// Most recent 1W pairing-gesture frame seen on the hub's normal passive RX path (e.g. a PROG
1273 /// press's WRITE_PRIVATE/1W-remove/discover-alt broadcast), remembered so PairingEngine can seed
1274 /// a fresh discover_and_pair() attempt's telemetry with it — see record_oneway_pairing_gesture_()
1275 /// and RecentOneWayPairingSighting's doc comment (issue #27/#65). Declared before pairing_engine_,
1276 /// which holds a reference to it, so member-init order matches the initializer list.
1278 ExchangeEngine exchange_engine_; ///< Owns all authenticated exchange and LBT/hop logic.
1279 PairingEngine pairing_engine_; ///< Owns the three-phase device pairing flow.
1280 ManagementActions management_actions_; ///< Owns rename, identify, force-open, scan_paired_devices, and other
1281 ///< hub-level HA actions.
1282 /// Owns the 1W controller identities, their rolling-sequence counters and the transmit burst.
1283 /// The third collaborator that drives the radio (ADR 0004), and the only one that awaits
1284 /// nothing — 1W has no reply to wait for.
1286 /// Opt-in, receive-only 1W controller-key adoption listener (oneway_key_adoption.cpp). Armed via
1287 /// the "Recover 1W Controller Key" switch; observes an overheard CMD_ONEWAY_ADD_CONTROLLER and
1288 /// reports the key once, then disarms. Declared after oneway_transmitter_ so member-init order
1289 /// matches the initializer list.
1291 /// Device-role responder for the "Recover System Key" feature (key_extraction_responder.cpp).
1292 /// Owns the pairing_responder::ResponderContext, the throwaway-ID/auto-off/grace-window
1293 /// machinery, and the device-role reply TX. Declared after registry_/radio_/tuning_/node_id_
1294 /// (which it references) and after oneway_key_adoption_ so member-init order matches the
1295 /// initializer list.
1297#ifdef IOHOME_LR1121_FIRMWARE_UPDATE
1298 /// Orchestrates the compile-gated LR1121 transceiver-firmware-update feature
1299 /// (lr1121_firmware_update_controller.cpp): boot-time bootloader read, cached flash verdict,
1300 /// two-press confirmation window, and the button-triggered flash/bootloader-rewrite sequences.
1301 /// Guarded member AND guarded initializer — an unguarded initializer for a guarded member is the
1302 /// classic way this breaks only under make firmware-test.
1303 Lr1121FirmwareUpdateController lr1121_firmware_update_;
1304#endif
1305 /// Subscribers to the per-command 1W report; one per "Last 1W Command" sensor.
1306 std::vector<OneWayCommandReportFn> oneway_report_callbacks_;
1307
1308 /// Identity of the last processed 1W frame, for burst suppression; see
1309 /// decisions::is_duplicate_1w_frame() for why the intent bytes are part of the key.
1311 /// millis() of the most recent 1W frame of any kind, including ones dropped as duplicates —
1312 /// a repeat still means the remote is transmitting. 0 until the first is seen. Gates background
1313 /// polls in loop(); see decisions::defer_background_poll_for_1w_activity().
1315 /// millis() of the first 1W frame in the current burst. Advances to the new frame's timestamp
1316 /// whenever the gap since last_1w_activity_ms_ reaches ONEWAY_QUIET_PERIOD_MS (the previous burst
1317 /// has already released any deferred poll, so this one starts fresh); otherwise holds at the
1318 /// burst's start. Bounds defer_background_poll_() via ONEWAY_POLL_DEFER_CAP_MS.
1320};
1321
1322// ----------------------------------------------------------------------------
1323// Test-visible helpers (inline for host unit tests)
1324// ----------------------------------------------------------------------------
1325
1326/// Format a position float as a human‑readable string (e.g. "50%", "unknown").
1327/// @param pos Position value (0–100 or UNKNOWN_POSITION).
1328/// @return String like "50%" or "unknown".
1329inline std::string format_position(float pos) {
1330 if (pos == UNKNOWN_POSITION) {
1331 return "unknown";
1332 }
1333 char buf[POSITION_TEXT_BUFFER_SIZE];
1334 snprintf(buf, sizeof(buf), "%.0f%%", pos);
1335 return buf;
1336}
1337
1338} // namespace home_io_control
1339} // namespace esphome
Owns the per-hub device table, update callbacks, and linked-remote associations.
bool apply_optimistic_tilt(const std::string &device_id, float tilt_percent)
Set an optimistic slat angle ahead of a confirming poll, and notify.
void add_linked_remote_class(DeviceType type, const std::string &device_id)
Record that a remote's typed-broadcast presses (e.g.
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.
bool apply_optimistic_stop(const std::string &device_id, bool restorable=false)
Predict that a device has stopped (e.g.
void add_linked_remote(const std::string &remote_id, const std::string &device_id)
Record that a remote node controls a registered device.
void subscribe(DeviceUpdateCallback cb)
Register a callback that fires whenever a device's state changes.
IoDevice * get(const std::string &device_id)
Retrieve a registered device by ID.
void set_wake_evidence_spent_handler(WakeEvidenceSpentHandler handler)
Install what to do when an exchange planned on an AWAKE belief drew no frame at all: the receiver did...
void log_debug_unconfirmed(const char *device_id) const
Log the debug snapshot for an exchange that ended accepted-but-unconfirmed, at INFO.
void log_debug(const char *device_id) const
Log the debug snapshot as a WARN-level structured line.
void set_device_key_provider(DeviceKeyProvider provider)
Install the source of per-device system keys.
void set_target_evidence_provider(TargetEvidenceProvider provider)
Install the source of per-target evidence.
InternalGPIOPin * fem_en_pin_
Front-end module enable.
Definition hub_core.h:1231
InternalGPIOPin * fem_pa_pin_
Front-end module PA switch.
Definition hub_core.h:1233
std::string describe_last_commander(const IoDevice &dev) const
Render a device's "last commanded by" string, resolving this hub's own node ID.
void send_oneway_command(const std::string &controller_id, CoverCommand cmd)
Queue a 1W named command, sent as the given controller identity.
Definition hub_core.h:349
virtual bool set_lock_state(const std::string &device_id, bool locked)
Semantic lock helper for lock entities.
virtual ManagementActionResult scan_paired_devices()
Broadcast a roll-call and report every device that answers (see ManagementActions::scan_paired_device...
Definition hub_core.h:578
void dump_oneway_controllers_config_() const
Emit the 1W controller identities to the config dump — node, class, and the resolved ACEI / broadcast...
Definition hub_core.cpp:496
virtual bool set_fan_position(const std::string &device_id, uint8_t position)
Send a heat level to an exterior heater (a fan entity) as an IO position.
void set_tx_power(uint8_t power)
Set transmit power (dBm).
Definition hub_core.h:228
InternalGPIOPin * dio4_pin_
SX1276 DIO4 preamble detect (optional).
Definition hub_core.h:1228
virtual void set_key_extraction_armed(bool armed)
Arm or disarm the "Recover System Key" (key extraction) responder.
Definition hub_core.h:429
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, uint8_t max_tries=EXCHANGE_RETRY_COUNT)
Shared request/response helper for high-level operations: exchange_and_record_() plus update_device_s...
void api_probe_device_(const std::string &device_id, const std::string &probe, const std::string &index)
Native API callback: run a single diagnostic probe against a registered device.
Definition hub_core.h:1168
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:323
void api_force_open_device_(const std::string &device_id)
Native API callback: force-open a registered cover device.
Definition hub_core.h:1154
void execute_oneway_position_(const std::string &controller_id, uint8_t position)
Send a queued 1W numeric position.
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 queue_set_fan_position(const std::string &device_id, uint8_t position)
Async form of set_fan_position() that keeps radio work serialized on the main loop.
void set_node_id(const std::string &id)
Set the controller's node ID (hex string).
Definition hub_core.h:224
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.
void add_oneway_controller(const OneWayControllerIdentity &identity)
Register a configured 1W controller identity (see oneway_controller.h).
Definition hub_core.h:331
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:1229
void execute_oneway_enroll_(const std::string &controller_id)
Send a queued 1W enrollment (add-controller).
IOHomeControlComponent()
Initialize ExchangeEngine, PairingEngine, and ManagementActions with double-pointer/ reference indire...
Definition hub_core.h:105
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:1314
virtual bool send_heating_command(const std::string &device_id, HeatingFunction fn, float value)
Run one heating function on the heater's private register protocol (CMD_WRITE_PRIVATE 0x20): a write ...
void send_oneway_position(const std::string &controller_id, uint8_t position)
Queue a 1W numeric position, sent as the given controller identity.
Definition hub_core.h:356
virtual bool set_switch_state(const std::string &device_id, bool on)
Semantic binary helper for switch entities.
bool run_execute_operation_(const std::string &device_id, const ExecuteRequestSpec &spec, const std::function< bool(const IoDevice &)> &accepts, const char *rejection_profile, const std::function< bool(IoFrame &, const IoDevice &)> &build)
Funnel for the four execute-family operations: runs try_execute_operation_() and, on any false return...
void api_scan_paired_devices_()
Native API callback: broadcast a roll-call scan of already-paired devices.
Definition hub_core.h:1158
void set_rst_pin(InternalGPIOPin *pin)
Set the radio reset pin.
Definition hub_core.h:204
virtual bool apply_optimistic_stop(const std::string &device_id)
Predict that a device has stopped (e.g.
Definition hub_core.h:296
void arm_execute_confirmation_poll_(const std::string &device_id, bool for_stop)
Arm the confirming poll that follows a command, because a CMD_EXECUTE reply is never trusted for posi...
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 execute_oneway_(F &&send)
Shared bookkeeping for every 1W transmit: mark the radio busy for the duration of send,...
Definition hub_core.h:1077
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:434
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:250
virtual void register_device_callback(DeviceUpdateCallback cb)
Register a callback invoked when any device updates.
Definition hub_core.h:502
void set_pa_pin(uint8_t pa_pin)
Set PA boost pin configuration.
Definition hub_core.h:230
bool handle_error_response_(const std::string &device_id, const IoFrame &request, const IoFrame &response, uint32_t retry_after_fail_ms)
Handle an explicit CMD_ERROR_RESP refusal from the device: record the result code,...
virtual bool set_heating_temperature(const std::string &device_id, uint16_t main_parameter, const HeatingFp &fp)
Command a heater that carries a temperature or an alias in its main parameter (the heating temperatur...
virtual IoDevice * get_device(const std::string &device_id)
Retrieve a device by ID; returns nullptr if not found.
Definition hub_core.cpp:350
bool diagnostic_probes_enabled() const
Whether diagnostic probes are enabled for this build.
Definition hub_core.h:472
virtual void queue_request_device_status(const std::string &device_id)
Queue an async status request; returns immediately, executed in loop().
void api_probe_sweep_(const std::string &device_id, const std::string &probe, const std::string &first_index, const std::string &last_index)
Native API callback: run a bounded diagnostic probe sweep against a registered device.
Definition hub_core.h:1172
void update_tuning_number(const std::string &name, float value)
Receive a numeric tuning update from a HA number entity.
Definition hub_core.cpp:233
void record_oneway_pairing_gesture_(const IoFrame &frame, uint32_t now)
If frame matches a 1W remote's pairing gesture (decisions::is_one_way_pairing_gesture()),...
void spi_enable() override
Enable the SPI bus.
Definition hub_core.h:181
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:344
void set_fem_en_pin(InternalGPIOPin *pin)
Set the front‑end module enable pin.
Definition hub_core.h:214
virtual void set_oneway_key_adoption_armed(bool armed)
Arm or disarm the 1W controller-key adoption listener.
Definition hub_core.h:447
InternalGPIOPin * vfem_pin_
Front-end module power.
Definition hub_core.h:1232
TuningConfig tuning_
Runtime tuning overrides.
Definition hub_core.h:1255
void log_exchange_unconfirmed_debug_(const char *device_id) const
Log the last exchange debug snapshot for an accepted-but-unconfirmed exchange, at INFO.
Definition hub_core.h:1134
ExchangeOutcome send_and_receive_(const IoFrame &request, IoFrame &response, uint32_t freq, uint8_t max_tries=EXCHANGE_RETRY_COUNT)
Main request/response exchange with retry and automatic authentication.
Definition hub_core.cpp:303
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:320
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:295
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:266
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:352
virtual bool read_heating_register(const std::string &device_id, HeatingFunction fn, float *value_out, uint8_t max_tries)
Read one register of a heater and fold the decoded value into IoDevice::heating.
void execute_oneway_unenroll_(const std::string &controller_id)
Send a queued 1W un-enrollment (remove-controller).
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:268
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:934
void set_dio1_pin(InternalGPIOPin *pin)
Set the DIO1 interrupt pin (SX1262; also carries the LR1121's DIO9 IRQ line).
Definition hub_core.h:210
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.
Definition hub_core.h:571
const PairingTelemetry & pairing_telemetry() const
Definition hub_core.h:409
bool diagnostic_probes_enabled_
Whether diagnostic probes (ManagementActions::probe_device()/probe_sweep()) are enabled.
Definition hub_core.h:1265
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...
void api_heating_control_(const std::string &device_id, const std::string &function, const std::string &value)
Native API callback: run a heating/climate function (CMD_WRITE_PRIVATE 0x20) against a registered cli...
Definition hub_core.h:1178
virtual void queue_request_device_name(const std::string &device_id)
Queue an async device-name request; returns immediately, executed in loop().
virtual void queue_request_device_limitation(const std::string &device_id)
Queue an async minimum-limitation read for the rain sensor poll; executed in loop() as a background o...
void resolve_start_preamble_default_()
Resolve normal_start_preamble from the driver when YAML did not set it (ADR 0042).
Definition hub_core.cpp:219
void set_radio_test_mode(bool active)
Suspend the hub's normal loop (packet processing, hopping, polling).
Definition hub_core.h:197
RecentOneWayPairingSighting recent_oneway_pairing_sighting_
Most recent 1W pairing-gesture frame seen on the hub's normal passive RX path (e.g.
Definition hub_core.h:1277
void set_radio_type(const std::string &type)
Set radio type ("sx1276", "sx1262", or "lr1121"); required by the YAML schema.
Definition hub_core.h:232
void api_oneway_remove_controller_(const std::string &controller_id)
Native API callback: queue a 1W un-enrollment (remove-controller) for a controller identity.
Definition hub_core.h:1164
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.
uint32_t last_pairing_end_ms_
millis() when the last pairing attempt ended; 0 before the first one (see queue_discover_and_pair()).
Definition hub_core.h:1253
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:362
bool try_execute_operation_(const std::string &device_id, const ExecuteRequestSpec &spec, const std::function< bool(const IoDevice &)> &accepts, const char *rejection_profile, const std::function< bool(IoFrame &, const IoDevice &)> &build)
Shared skeleton for the four execute-family operations (position, named command, tilt,...
void log_exchange_debug_(const char *device_id) const
Log the last exchange debug snapshot (delegates to exchange_engine_).
Definition hub_core.h:1131
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:415
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:332
PairingTelemetry pairing_telemetry_
Per-attempt pairing telemetry.
Definition hub_core.h:1271
void send_oneway_unenroll(const std::string &controller_id)
Queue a standalone 1W un-enrollment (remove-controller) for the given controller identity,...
Definition hub_core.h:392
OneWayTransmitter oneway_transmitter_
Owns the 1W controller identities, their rolling-sequence counters and the transmit burst.
Definition hub_core.h:1285
virtual ManagementActionResult heating_control(const std::string &device_id, const std::string &function, const std::string &value)
Run one 2W heating/climate function (CMD_WRITE_PRIVATE 0x20) against a registered climate device — th...
Definition hub_core.h:615
void add_oneway_command_report_callback(OneWayCommandReportFn callback)
Subscribe to the report emitted after every 1W command attempt.
Definition hub_core.h:401
std::string radio_failure_reason_
Why setup() gave up on the radio, or empty.
Definition hub_core.h:1242
virtual void set_device_rain_poll_interval(const std::string &device_id, uint32_t interval_ms)
Opt a registered device into the periodic rain sensor poll (a read of its minimum limitation).
Definition hub_core.cpp:326
std::string radio_type_
"sx1276", "sx1262", or "lr1121"; required by the YAML schema.
Definition hub_core.h:1239
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:1148
virtual bool request_device_limitation(const std::string &device_id)
Read the device's minimum limitation and derive its rain sensor state from the reply.
float get_setup_priority() const override
Get setup priority (HARDWARE to initialize early).
Definition hub_core.h:177
void record_1w_activity_(uint32_t now)
Record that a 1W frame just went out on the radio — ours or someone else's — updating last_1w_activit...
bool key_extraction_awaiting_reply_() const
True while the key-extraction responder is mid-attempt and still within its bounded CH2-hold window.
Definition hub_core.h:862
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:212
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 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...
Definition hub_core.h:558
void apply_rain_poll_result_(const std::string &device_id, RainSensorState reading, const std::string &limit_text)
Fold one rain poll outcome into the device's rain sensor state and tell subscribers.
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:154
bool poll_heating_device_(const std::string &device_id)
Poll a heater: read its room temperature, setpoint, mode and regulator state (stopping at the first f...
void api_oneway_set_position_(const std::string &controller_id, const std::string &position)
Native API callback: queue a 1W position for a controller identity.
Definition hub_core.h:1160
uint8_t spi_read() override
Read one byte (MISO only).
Definition hub_core.h:193
void api_identify_device_(const std::string &device_id)
Native API callback: trigger a registered device's physical identify.
Definition hub_core.h:1152
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:298
virtual bool set_heating_level(const std::string &device_id, uint8_t level)
Set the heating level of a heater that is commanded with levels (the plain electrical heater).
void set_dio0_pin(InternalGPIOPin *pin)
Set the DIO0 interrupt pin (SX1276).
Definition hub_core.h:206
void set_tuning_config(const TuningConfig &config)
Apply the tuning configuration generated from YAML / UI entities.
Definition hub_core.h:239
void send_oneway_action(const std::string &controller_id, OneWayButtonAction action)
Queue whichever of position/command a generated button's action resolves to.
Definition hub_core.h:367
bool radio_test_mode_
When true, loop() is suspended for loopback testing.
Definition hub_core.h:1254
void set_vfem_pin(InternalGPIOPin *pin)
Set the VFEM power pin.
Definition hub_core.h:216
ExchangeEngine exchange_engine_
Owns all authenticated exchange and LBT/hop logic.
Definition hub_core.h:1278
RequestResult exchange_and_record_(const std::string &device_id, const IoFrame &request, IoFrame &response, const RequestOptions &options)
Run one authenticated request/response exchange on the standard command channel and record its outcom...
RequestResult
What exchange_and_record_() found.
Definition hub_core.h:993
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:310
const OneWayControllerRegistry & oneway_controllers() const
Definition hub_core.h:336
void schedule_heating_poll_(const std::string &device_id, uint32_t delay_ms)
Arm the heater's regular-cadence poll timer.
virtual ManagementActionResult probe_sweep(const std::string &device_id, const std::string &probe, const std::string &first_index, const std::string &last_index)
Walk a bounded index range, one probe_device() call per index (see ManagementActions::probe_sweep()).
Definition hub_core.h:600
void set_oneway_key_adoption_armed_callback(std::function< void(bool)> cb)
Register a callback invoked whenever the 1W key-adoption armed state changes — manual toggle,...
Definition hub_core.h:454
bool oneway_key_adoption_armed() const
Whether the 1W key-adoption listener is currently armed.
Definition hub_core.h:460
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:167
PairingEngine pairing_engine_
Owns the three-phase device pairing flow.
Definition hub_core.h:1279
virtual void set_device_silent(const std::string &device_id, bool silent)
Select a device's travel profile at runtime (see IOHomeCoverSilentSwitch).
Definition hub_core.cpp:356
InternalGPIOPin * dio0_pin_
SX1276 DIO0 interrupt.
Definition hub_core.h:1227
void set_system_key(const std::string &key)
Set the system key (hex string).
Definition hub_core.h:226
ManagementActions management_actions_
Owns rename, identify, force-open, scan_paired_devices, and other hub-level HA actions.
Definition hub_core.h:1280
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:190
std::vector< OneWayCommandReportFn > oneway_report_callbacks_
Subscribers to the per-command 1W report; one per "Last 1W Command" sensor.
Definition hub_core.h:1306
void set_tcxo_voltage(uint8_t voltage)
Set the SX1262/LR1121 TCXO control-voltage code (0-based, TCXO_VOLTAGE_OPTIONS in hub_validators....
Definition hub_core.h:236
OnewayKeyAdoption oneway_key_adoption_
Opt-in, receive-only 1W controller-key adoption listener (oneway_key_adoption.cpp).
Definition hub_core.h:1290
void notify_device_update_(const std::string &id)
Fire all registered device update callbacks for the given device ID.
Definition hub_core.cpp:316
virtual void queue_set_device_position(const std::string &device_id, uint8_t position)
Queue an async position update; returns immediately, executed in loop().
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.
Definition hub_core.h:550
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:283
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:208
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:1310
bool exchange_heating_(const std::string &device_id, const char *label, const IoFrame &request, IoFrame &response, uint8_t max_tries)
The exchange every heating frame shares: send on the command channel, record the outcome (exchange co...
void send_oneway_enroll(const std::string &controller_id)
Queue a 1W enrollment for the given controller identity — the enroll button's press handler.
Definition hub_core.h:382
std::function< void()> pairing_result_callback_
Invoked once after every pairing attempt completes; see set_pairing_result_callback().
Definition hub_core.h:1261
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:67
KeyExtractionResponder key_extraction_
Device-role responder for the "Recover System Key" feature (key_extraction_responder....
Definition hub_core.h:1296
uint8_t spi_transfer(uint8_t data) override
Transfer one byte full‑duplex.
Definition hub_core.h:187
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:1259
uint8_t tcxo_voltage_
SX1262/LR1121 TCXO voltage setting (default 1.8 V).
Definition hub_core.h:1247
virtual void set_device_temperature_range(const std::string &device_id, float min_c, float max_c)
Set the temperature encoding range of a heater that carries a temperature in its main parameter (YAML...
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:285
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.
void set_fem_profile(FemProfile profile)
Set which FEM part fem_pa_pin is wired to (fem: in YAML) — selects whether the SX1262 driver drives t...
Definition hub_core.h:222
bool authenticate_request_(const IoFrame &request, uint32_t freq)
Handle an inbound authenticated command from a device (status updates, etc.).
Definition hub_core.cpp:312
void spi_disable() override
Disable the SPI bus.
Definition hub_core.h:183
RainPollPolicy rain_poll_policy_
Schedule of the opt-in rain sensor poll.
Definition hub_core.h:1267
InternalGPIOPin * busy_pin_
SX1262/LR1121 BUSY pin.
Definition hub_core.h:1230
void trigger_scan_paired_devices()
Entry point for the "Scan Paired Devices" button: run the roll-call and publish its report to the log...
void register_management_actions_()
Register hub-level Home Assistant actions; called from setup().
Definition hub_core.h:1146
void apply_tuning_to_radio_()
Apply the current tuning configuration to the active radio driver.
Definition hub_core.cpp:207
FemProfile fem_profile_
Which FEM part fem_pa_pin_ belongs to, if any.
Definition hub_core.h:1234
virtual void queue_discover_and_pair()
Queue a pairing operation; executed in loop() when radio idle.
uint32_t first_1w_activity_ms_
millis() of the first 1W frame in the current burst.
Definition hub_core.h:1319
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:274
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...
virtual ManagementActionResult probe_device(const std::string &device_id, const std::string &probe, const std::string &index)
Send a single diagnostic probe frame to a registered device and report the raw reply (see ManagementA...
Definition hub_core.h:589
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:436
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 execute_oneway_command_(const std::string &controller_id, CoverCommand cmd)
Send a queued 1W named command.
void set_diagnostic_probes_enabled(bool enabled)
Set whether ManagementActions::probe_device()/probe_sweep() are allowed to run.
Definition hub_core.h:469
void set_fem_pa_pin(InternalGPIOPin *pin)
Set the FEM PA switch pin.
Definition hub_core.h:218
RadioDriver * get_radio() const
Get the underlying radio driver (for diagnostics and test tooling).
Definition hub_core.h:200
Device-role responder for the "Recover System Key" feature.
void set_armed(bool armed)
Arm or disarm the "Recover System Key" (key extraction) responder.
void set_armed_callback(std::function< void(bool)> cb)
Register a callback invoked whenever the key-extraction armed state changes — manual toggle,...
bool awaiting_reply() const
True whenever the responder has replied at least once, is waiting on the hub's next step,...
Encapsulates hub-level management operations exposed as Home Assistant actions.
void api_probe_device(const std::string &device_id, const std::string &probe, const std::string &index)
Native API callback: run a single diagnostic probe and publish the result as a HA event.
void register_actions()
Register all management actions (rename, identify, force-open, ...) with ESPHome's native API server.
void api_rename_device(const std::string &device_id, const std::string &new_name)
Native API callback: rename a device and publish the result as a HA event.
void api_oneway_set_position(const std::string &controller_id, const std::string &position)
Native API callback: queue a 1W position for a controller identity.
ManagementActionResult scan_paired_devices()
Broadcast a roll-call and report every device that answers.
ManagementActionResult probe_device(const std::string &device_id, const std::string &probe, const std::string &index)
Send a single diagnostic probe frame to an already-paired device and report the raw reply.
ManagementActionResult rename_device(const std::string &device_id, const std::string &new_name)
Rename a registered device and verify the result by reading the name back.
void api_oneway_remove_controller(const std::string &controller_id)
Native API callback: queue a standalone 1W un-enrollment (remove-controller, CMD 0x39) for a controll...
void api_heating_control(const std::string &device_id, const std::string &function, const std::string &value)
Native API callback: run a heating/climate function and publish the result as a HA event.
void api_force_open_device(const std::string &device_id)
Native API callback: force-open a device and publish the result as a HA event.
ManagementActionResult identify_device(const std::string &device_id)
Trigger a registered device's physical identify (brief jog/flash).
ManagementActionResult force_open_device(const std::string &device_id)
Move a registered cover device to fully open at elevated priority, intended to bypass wind/rain soft ...
void api_probe_sweep(const std::string &device_id, const std::string &probe, const std::string &first_index, const std::string &last_index)
Native API callback: run a bounded probe sweep and publish the result as a HA event.
void api_scan_paired_devices()
Native API callback: run a roll-call scan and publish the result as a HA event.
void api_identify_device(const std::string &device_id)
Native API callback: trigger a device's physical identify and publish the result as a HA event.
ManagementActionResult heating_control(const std::string &device_id, const std::string &function, const std::string &value)
Send one 2W heating/climate function (CMD_WRITE_PRIVATE 0x20) to a registered climate device.
ManagementActionResult probe_sweep(const std::string &device_id, const std::string &probe, const std::string &first_index, const std::string &last_index)
Walk a bounded index range, one probe_device() call per index, in one user gesture.
The configured 1W controller identities, in YAML declaration order.
Sends 1W commands as the repeated bursts real remotes send.
void add_identity(const OneWayControllerIdentity &identity)
Register a configured controller identity.
const OneWayControllerRegistry & identities() const
Opt-in, receive-only listener that adopts an overheard 1W controller key.
bool armed() const
Whether the listener is currently armed.
void set_armed_callback(std::function< void(bool)> cb)
Register a callback invoked whenever the armed state changes (manual toggle, successful adoption,...
void set_armed(bool armed)
Arm or disarm the 1W controller-key adoption listener.
Serialized pending-operation queue with coalescing, deduplication, and two-band ordering.
const PendingOperation & front() const
void enqueue_oneway_unenroll(const std::string &controller_id)
Enqueue a 1W un-enrollment (remove-controller) for a controller identity.
void enqueue_oneway_command(const std::string &controller_id, CoverCommand cmd)
Enqueue a 1W named command for a controller identity.
void enqueue_oneway_position(const std::string &controller_id, uint8_t position)
Enqueue a 1W numeric position for a controller identity.
static bool is_background_op(PendingOperationType t)
True for background poll types (REQUEST_STATUS, REQUEST_NAME, REQUEST_LIMITATION) that yield to contr...
void enqueue_oneway_enroll(const std::string &controller_id)
Enqueue a 1W enrollment (add-controller) for a controller identity.
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.
Per-device periodic limitation-read schedule and miss counter.
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.
OneWayActionEncoding encode_oneway_action(OneWayButtonAction action)
Resolve a button action to the call that sends it.
OneWayButtonAction
The command a generated 1W button sends.
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.
"Recover System Key" (key extraction) — device-role responder collaborator.
LR1121 transceiver-firmware-update feature — orchestration collaborator.
Hub-level management operations exposed as Home Assistant actions.
TargetEvidence target_evidence(const IoDevice &dev)
Build the exchange engine's view of a device record.
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.
RainSensorState
What the hub's latest minimum-limitation read says about a rain-protection limit.
HeatingFunction
Registers and functions of a 2W heater, one row of the descriptor table each.
FemProfile
Which RF front-end module (if any) sits between the SX1262 and the antenna.
@ NONE
No FEM, or a board with the FEM pins wired but no behaviour profile set – see the NONE-specific note ...
constexpr size_t POSITION_TEXT_BUFFER_SIZE
Buffer for formatted position strings such as "100%".
Definition hub_core.h:67
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.
DeviceType
IO-Homecontrol node types.
std::string format_position(float pos)
Format a position float as a human‑readable string (e.g.
Definition hub_core.h:1329
static constexpr uint32_t PAIRING_STALE_PRESS_MS
How long after a pairing attempt ends a Discover & Pair press counts as made during it.
Definition hub_core.h:84
ExchangeOutcome
Authenticated exchange engine — outbound and inbound protocol flows.
@ FAILED
No usable reply; the device may never have heard the request.
std::function< void(const OneWayCommandReport &report)> OneWayCommandReportFn
Invoked once per attempted 1W command, successful or not.
constexpr uint8_t DEFAULT_TCXO_VOLTAGE_SETTING_1P8V
0-based TCXO voltage code for 1.8 V, passed verbatim to the SX1262/LR1121 chip.
Definition hub_core.h:65
std::string node_id_to_string(const uint8_t id[NODE_ID_SIZE])
Format a 3‑byte node ID as a 6‑character uppercase hex string.
void clear_moving_evidence(IoDevice &dev)
Forget that a device was travelling: it reported itself stopped (including in the ack to a STOP),...
constexpr uint8_t DEFAULT_PA_PIN_PA_BOOST
SX1276 PA_CONFIG selector for the PA_BOOST output path.
Definition hub_core.h:64
constexpr uint8_t DEFAULT_TX_POWER_DBM
Default TX power used unless YAML overrides it.
Definition hub_core.h:63
Controller identities for the one-way (1W) protocol.
Opt-in, receive-only adoption of a 1W installation's controller key.
One-way (1W) transmit collaborator.
Pending-operation queue with per-type coalescing and deduplication.
Device discovery and key-exchange engine for IO-Homecontrol pairing.
Device-name, address-classification and 1W-frame codecs.
IO-Homecontrol 2W frame container: control bytes, IoFrame and (de)serialization.
Pure codec for the private register protocol of 2W heaters (CMD_WRITE_PRIVATE 0x20).
Heating profiles: what a climate card offers for each kind of heater, and how.
Radio abstraction layer for IO-Homecontrol.
Schedule and failure bookkeeping for the opt-in rain sensor poll.
Per-device poll scheduling, failure backoff, and follow-up-poll state machine.
YAML-declared device metadata for registration; defaults match an undeclared device.
A functional parameter and its value.
Everything one execute-family operation needs beyond its own guard and frame builder.
Definition hub_core.h:1034
const char * action
Verb/phrase for the "Sending ..." and rejection logs.
Definition hub_core.h:1035
bool settle_as_stop
Passed through to arm_execute_confirmation_poll_().
Definition hub_core.h:1036
Per-request knobs of exchange_and_record_().
Definition hub_core.h:1001
uint8_t max_tries
Transmit-attempt cap forwarded to send_and_receive_().
Definition hub_core.h:1004
uint32_t retry_after_fail_ms
If non-zero, drives the status-poll backoff and failure streaks.
Definition hub_core.h:1003
bool warn_on_no_response
Log a warning when no response arrives.
Definition hub_core.h:1002
Runtime state of a paired IO‑Homecontrol device.
bool has_system_key
YAML-declared (system_key:): this device keeps a key of its own.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
Result of a hub-level management action such as rename.
How a OneWayButtonAction reaches the wire.
bool is_position
True when the action is sent as a numeric position.
uint8_t position
Position to send when is_position.
CoverCommand command
Named command to send otherwise.
One configured 1W controller identity.
Decoded representation of a 1W remote frame.
PendingOperationType type
Operation type (determines which handler to invoke).
A 1W pairing-gesture frame observed on the hub's normal passive RX path, remembered so a fresh discov...
All runtime tunable parameters for pairing and radio diagnostics.
Key fields of the last processed 1W frame, used to collapse a remote's repeat burst.
Runtime tuning configuration for pairing and radio diagnostics.