Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
hub_operations.cpp
Go to the documentation of this file.
1#include "hub_internal.h"
2
3#include "proto_commands.h"
4
5/// @file hub_operations.cpp
6/// @brief High-level command execution and queued operation dispatch.
7///
8/// This file owns the outbound user-facing operations on the hub:
9/// - cover position and tilt commands,
10/// - explicit status requests,
11/// - light/switch semantic wrappers,
12/// - queued dispatch on the main loop.
13///
14/// Keeping these methods out of hub_core.cpp makes it easier to reason about the
15/// difference between lifecycle/polling logic and the explicit actions initiated
16/// by Home Assistant entities.
17
18namespace esphome {
19namespace home_io_control {
20
21namespace {
22
23/// @brief Apply adaptive backoff after a failed background status poll.
24///
25/// The backoff is per-device and per-failure-class. Plain timeouts ramp up gradually, while
26/// auth-shaped failures ramp up faster because they already prove the device answered with 0x3C
27/// and we are likely just burning airtime with repeated 0x3D responses.
28///
29/// @param dev Device record to update.
30/// @param auth_like_failure True if the failed exchange saw a challenge request.
31/// @return Delay in milliseconds until the next automatic poll.
32uint32_t apply_status_poll_failure_backoff(IoDevice &dev, bool auth_like_failure) {
33 if (auth_like_failure) {
34 dev.status_poll_failures = 0;
35 if (dev.auth_poll_failures < UINT8_MAX)
36 dev.auth_poll_failures++;
37 return detail::status_poll_retry_delay_ms(dev.auth_poll_failures, true);
38 }
39
40 dev.auth_poll_failures = 0;
41 if (dev.status_poll_failures < UINT8_MAX)
42 dev.status_poll_failures++;
43 return detail::status_poll_retry_delay_ms(dev.status_poll_failures, false);
44}
45
46/// @brief Clear background status-poll failure streaks after a successful reply.
47/// @param dev Device record to reset.
48void clear_status_poll_failure_backoff(IoDevice &dev) {
49 dev.status_poll_failures = 0;
50 dev.auth_poll_failures = 0;
51}
52
53} // namespace
54
55// Execute an authenticated request on the standard command channel and, on success, feed the
56// device's reply back through the normal inbound status parser so all state normalization stays
57// in one place.
58bool IOHomeControlComponent::execute_request_and_update_(const std::string &device_id, const IoFrame &request,
59 bool warn_on_no_response, uint32_t retry_after_fail_ms) {
60 IoFrame response;
61 if (!this->send_and_receive_(request, response, FREQ_CH2)) {
62 if (retry_after_fail_ms != 0) {
63 if (auto *dev = this->get_device(device_id); dev != nullptr) {
64 const bool auth_like_failure = this->last_exchange_debug_.saw_challenge;
65 const uint32_t backoff_ms = apply_status_poll_failure_backoff(*dev, auth_like_failure);
66 uint32_t const now = millis();
67 if (detail::status_poll_tracking_active(*dev, now) && now + backoff_ms <= dev->poll_deadline) {
68 dev->next_update = now + backoff_ms;
69 ESP_LOGD(detail::TAG,
70 "Background status poll backoff for device %s: delay=%u ms auth_like=%s status_failures=%u "
71 "auth_failures=%u",
72 device_id.c_str(), backoff_ms, YESNO(auth_like_failure), dev->status_poll_failures,
73 dev->auth_poll_failures);
74 } else {
76 }
77 }
78 }
79 this->log_exchange_debug_(device_id.c_str());
80 if (warn_on_no_response) {
81 ESP_LOGW(detail::TAG, "No response from device %s", device_id.c_str());
82 }
83 return false;
84 }
85
86 if (retry_after_fail_ms != 0) {
87 if (auto *dev = this->get_device(device_id); dev != nullptr)
88 clear_status_poll_failure_backoff(*dev);
89 }
90
91 this->update_device_status_(response);
92 return true;
93}
94
95bool IOHomeControlComponent::set_device_position(const std::string &device_id, uint8_t position) {
96 auto *dev = this->get_device(device_id);
97 if (dev == nullptr || !this->initialized_)
98 return false;
99
100 const char *action = "set position";
101 if (position == detail::BINARY_ENTITY_ON_POSITION) {
102 action = "open";
103 } else if (position == detail::BINARY_ENTITY_OFF_POSITION) {
104 action = "close";
105 } else if (position == POS_STOP) {
106 action = "stop";
107 }
108
109 // Once a device family is known, use the profile helpers to reject YAML/entity mismatches
110 // before they hit the radio path. Unknown types still pass through so discovery and imported
111 // devices keep working as before.
114 device_id, *dev, action,
115 detail::is_binary_entity_position(position) ? "cover_position or binary_on_off" : "cover_position");
116 return false;
117 }
118
119 if (position == POS_STOP) {
121 } else {
122 dev->single_follow_up_poll_pending = dev->status_poll_interval_ms == 0;
123 this->begin_status_poll_tracking_(device_id, dev->status_poll_interval_ms);
124 }
125
126 ESP_LOGI(detail::TAG, "Sending %s to device %s (profile=%s)", action, device_id.c_str(),
128
129 IoFrame request;
130 if (!create_execute(request, this->node_id_, dev->node_id, true, position)) {
131 if (position != POS_STOP)
133 return false;
134 }
135 bool const ok = this->execute_request_and_update_(device_id, request, true, 0);
136 if (!ok) {
137 if (position != POS_STOP)
139 return false;
140 }
141 if (position != POS_STOP && dev->status_poll_interval_ms != 0 && dev->next_update == 0)
142 this->begin_status_poll_tracking_(device_id, dev->status_poll_interval_ms);
143 return true;
144}
145
146bool IOHomeControlComponent::set_device_tilt(const std::string &device_id, uint8_t tilt_percent) {
147 auto *dev = this->get_device(device_id);
148 if (dev == nullptr || !this->initialized_)
149 return false;
150
152 detail::log_rejected_operation(device_id, *dev, "set tilt", "tilt-capable cover");
153 return false;
154 }
155
156 dev->single_follow_up_poll_pending = dev->status_poll_interval_ms == 0;
157 this->begin_status_poll_tracking_(device_id, dev->status_poll_interval_ms);
158
159 ESP_LOGI(detail::TAG, "Sending tilt=%u%% to device %s (profile=%s)", tilt_percent, device_id.c_str(),
161
162 IoFrame request;
163 if (!create_execute_tilt(request, this->node_id_, dev->node_id, true, tilt_percent)) {
165 return false;
166 }
167 bool const ok = this->execute_request_and_update_(device_id, request, true, 0);
168 if (!ok) {
170 return false;
171 }
172 if (dev->status_poll_interval_ms != 0 && dev->next_update == 0)
173 this->begin_status_poll_tracking_(device_id, dev->status_poll_interval_ms);
174 return true;
175}
176
177bool IOHomeControlComponent::request_device_status(const std::string &device_id) {
178 auto *dev = this->get_device(device_id);
179 if (dev == nullptr || !this->initialized_)
180 return false;
181
183 detail::log_rejected_operation(device_id, *dev, "status request", "status-capable actuator");
184 return false;
185 }
186
187 IoFrame request;
188 // Tilt-capable covers need the extended 0x03200100 status request so the response includes
189 // the reliable 16-byte tilt block. Other devices stay on the shorter generic request.
190 bool const request_ok = device_supports_tilt(dev->type)
191 ? create_get_status_tilt(request, this->node_id_, dev->node_id)
192 : create_get_status(request, this->node_id_, dev->node_id);
193 if (!request_ok)
194 return false;
195 uint32_t const retry_after_fail_ms =
197 return this->execute_request_and_update_(device_id, request, false, retry_after_fail_ms);
198}
199
200bool IOHomeControlComponent::set_light_state(const std::string &device_id, bool on) {
201 auto *dev = this->get_device(device_id);
202 if (dev == nullptr || !this->initialized_)
203 return false;
204
206 detail::log_rejected_operation(device_id, *dev, "light command", "light entity");
207 return false;
208 }
209
210 // Light entities are binary-only for now, so they intentionally reuse the controller's
211 // existing execute path with the proven on/off position encoding.
212 return this->set_device_position(device_id,
214}
215
216bool IOHomeControlComponent::set_switch_state(const std::string &device_id, bool on) {
217 auto *dev = this->get_device(device_id);
218 if (dev == nullptr || !this->initialized_)
219 return false;
220
222 detail::log_rejected_operation(device_id, *dev, "switch command", "switch entity");
223 return false;
224 }
225
226 // Switches share the same transport-level representation as binary lights.
227 return this->set_device_position(device_id,
229}
230
231void IOHomeControlComponent::queue_set_device_position(const std::string &device_id, uint8_t position) {
232 IoDevice *dev = this->get_device(device_id);
234 detail::log_rejected_operation(device_id, *dev, "queued cover command", "cover entity");
235 return;
236 }
237 this->pending_operations_.push_back({PendingOperationType::SET_POSITION, device_id, position});
238}
239
240void IOHomeControlComponent::queue_set_device_tilt(const std::string &device_id, uint8_t tilt_percent) {
241 IoDevice *dev = this->get_device(device_id);
242 if (dev != nullptr && !detail::known_device_accepts_execute_tilt(*dev)) {
243 detail::log_rejected_operation(device_id, *dev, "queued tilt command", "tilt-capable cover");
244 return;
245 }
246 this->pending_operations_.push_back({PendingOperationType::SET_TILT, device_id, tilt_percent});
247}
248
249void IOHomeControlComponent::queue_request_device_status(const std::string &device_id) {
250 IoDevice *dev = this->get_device(device_id);
251 if (dev != nullptr && !detail::known_device_supports_status_requests(*dev)) {
252 detail::log_rejected_operation(device_id, *dev, "queued status request", "status-capable actuator");
253 return;
254 }
255
256 // Keep at most one pending status poll per device. Without this, an overdue next_update can add
257 // the same poll on every main-loop iteration until the first queued request is finally processed.
258 for (const auto &operation : this->pending_operations_) {
259 if (operation.type == PendingOperationType::REQUEST_STATUS && operation.device_id == device_id)
260 return;
261 }
262
263 this->pending_operations_.push_back({PendingOperationType::REQUEST_STATUS, device_id, 0});
264}
265
267 // Pairing is globally exclusive work. Keep at most one queued request so repeated button presses
268 // while the radio is busy do not stack duplicate discovery attempts.
269 for (const auto &operation : this->pending_operations_) {
270 if (operation.type == PendingOperationType::DISCOVER_AND_PAIR)
271 return;
272 }
273 this->pending_operations_.push_back({PendingOperationType::DISCOVER_AND_PAIR, {}, 0});
274}
275
276void IOHomeControlComponent::queue_set_light_state(const std::string &device_id, bool on) {
277 IoDevice *dev = this->get_device(device_id);
279 detail::log_rejected_operation(device_id, *dev, "queued light command", "light entity");
280 return;
281 }
282
283 // Queue through the same scheduler as covers so radio work stays serialized while still keeping
284 // the light-vs-switch semantics available for capability checks at dispatch time.
287}
288
289void IOHomeControlComponent::queue_set_switch_state(const std::string &device_id, bool on) {
290 IoDevice *dev = this->get_device(device_id);
292 detail::log_rejected_operation(device_id, *dev, "queued switch command", "switch entity");
293 return;
294 }
295
296 // Queue through the same scheduler as covers so radio work stays serialized while still keeping
297 // the light-vs-switch semantics available for capability checks at dispatch time.
300}
301
303 if (this->busy_ || this->pending_operations_.empty())
304 return;
305
306 // Pop before dispatch so any handler that re-queues follow-up work sees the queue in its
307 // post-consumption state and cannot accidentally execute the same operation twice.
308 PendingOperation const operation = std::move(this->pending_operations_.front());
309 this->pending_operations_.pop_front();
310
311 switch (operation.type) {
313 this->set_device_position(operation.device_id, operation.position);
314 break;
316 this->set_device_tilt(operation.device_id, operation.position);
317 break;
320 break;
323 break;
325 this->request_device_status(operation.device_id);
326 break;
328 this->discover_and_pair();
329 break;
330 }
331}
332
333} // namespace home_io_control
334} // namespace esphome
virtual bool set_device_tilt(const std::string &device_id, uint8_t tilt_percent)
Send a tilt command to a tilt‑capable cover.
bool send_and_receive_(const IoFrame &request, IoFrame &response, uint32_t freq)
Main request/response exchange with retry and automatic authentication.
virtual bool set_switch_state(const std::string &device_id, bool on)
Semantic binary helper for switch entities.
virtual void queue_set_device_tilt(const std::string &device_id, uint8_t tilt_percent)
Queue an async tilt update; returns immediately, executed in loop().
void begin_status_poll_tracking_(const std::string &device_id, uint32_t initial_delay_ms)
Begin bounded follow-up polling for a device after a command or overheard remote activity.
virtual IoDevice * get_device(const std::string &device_id)
Retrieve a device by ID; returns nullptr if not found.
Definition hub_core.cpp:256
virtual void queue_request_device_status(const std::string &device_id)
Queue an async status request; returns immediately, executed in loop().
void process_pending_operation_()
Pop next pending operation from the queue and execute it (set position, request status,...
bool execute_request_and_update_(const std::string &device_id, const IoFrame &request, bool warn_on_no_response, uint32_t retry_after_fail_ms=0)
Shared request/response helper for high-level operations.
@ SET_TILT
Queue a set_device_tilt call (tilt percentage 0–100).
Definition hub_core.h:317
@ SET_LIGHT_STATE
Queue a set_light_state call (binary on/off).
Definition hub_core.h:318
@ SET_SWITCH_STATE
Queue a set_switch_state call (binary on/off).
Definition hub_core.h:319
@ DISCOVER_AND_PAIR
Queue a discover_and_pair call (starts 3‑phase pairing flow).
Definition hub_core.h:321
@ REQUEST_STATUS
Queue a request_device_status call (poll for current position).
Definition hub_core.h:320
@ SET_POSITION
Queue a set_device_position call (position 0–100 or special values).
Definition hub_core.h:316
void log_exchange_debug_(const char *device_id) const
Definition hub_core.cpp:58
std::deque< PendingOperation > pending_operations_
Definition hub_core.h:385
virtual bool discover_and_pair()
Discover and pair a device that is in pairing mode.
void update_device_status_(const IoFrame &frame)
Extract position/status info from a status or status-update frame and merge into device record.
virtual bool set_light_state(const std::string &device_id, bool on)
Semantic binary helper for light entities.
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.
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 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.
virtual bool set_device_position(const std::string &device_id, uint8_t position)
Send a position command to a device.
virtual void queue_discover_and_pair()
Queue a pairing operation; executed in loop() when radio idle.
virtual bool request_device_status(const std::string &device_id)
Request current status from a device.
Internal helpers shared by the hub implementation .cpp files.
bool known_device_accepts_execute_tilt(const IoDevice &dev)
Can this device accept a tilt command?
bool known_device_matches_entity_class(const IoDevice &dev, DeviceCapabilityClass expected)
Does the device's type match the expected HA entity class?
void log_rejected_operation(const std::string &device_id, const IoDevice &dev, const char *operation, const char *expected)
Log a rejected operation with capability mismatch details.
bool known_device_accepts_execute_position(const IoDevice &dev, uint8_t position)
Can this device accept an execute (position) command?
constexpr uint32_t STATUS_RETRY_AFTER_FAIL_MS
First retry after a silent status-poll failure.
constexpr const char * TAG
Shared log tag for hub-level messages.
bool known_device_supports_status_requests(const IoDevice &dev)
Does the device support status requests?
constexpr uint8_t BINARY_ENTITY_ON_POSITION
constexpr uint8_t BINARY_ENTITY_OFF_POSITION
bool is_binary_entity_position(uint8_t position)
Is the given position value an on/off binary encoding?
uint32_t status_poll_retry_delay_ms(uint8_t consecutive_failures, bool auth_like_failure)
Compute the next background status-poll retry delay after a failed exchange.
void clear_status_poll_tracking(IoDevice &dev)
Clear all bounded follow-up polling state for a device.
bool status_poll_tracking_active(const IoDevice &dev, uint32_t now)
Check whether a device remains inside its bounded follow-up polling window.
const char * device_operation_profile_name(DeviceType type)
Human‑readable operation profile name for a device type.
bool create_get_status(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a get-status request (0x03). The device responds with its current position.
bool create_execute(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t position)
Build an execute command (0x00) to control a device.
bool create_execute_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t tilt_percent)
Build a tilt execute command (0x00) for devices that support slat angle control.
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
Definition proto_frame.h:31
@ COVER
Position‑controlled cover (shutter/blind/awning).
static constexpr uint8_t POS_STOP
Position values in the IO protocol.
bool create_get_status_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a tilt-aware get-status request (0x03) that returns the extended 16-byte tilt payload.
bool device_supports_tilt(DeviceType type)
Does this device type support tilt (slat angle) control?
Command builders for the IO‑Homecontrol protocol.
A single queued operation to be processed in loop().
Definition hub_core.h:325
std::string device_id
Target device ID (hex string, e.g., "123ABC").
Definition hub_core.h:327
PendingOperationType type
Operation type (determines which queue handler to invoke).
Definition hub_core.h:326
uint8_t position
Position/tilt value (0–100) or binary state (ON=0, OFF=100 for lights/switches).
Definition hub_core.h:328
Runtime state of a paired IO‑Homecontrol device.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).