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/// @ingroup hioc_hub
8///
9/// This file owns the outbound user-facing operations on the hub:
10/// - cover position and tilt commands,
11/// - explicit status requests,
12/// - light/switch semantic wrappers,
13/// - queued dispatch on the main loop.
14///
15/// Keeping these methods out of hub_core.cpp makes it easier to reason about the
16/// difference between lifecycle/polling logic and the explicit actions initiated
17/// by Home Assistant entities.
18
19namespace esphome {
20namespace home_io_control {
21
22namespace {
23
24/// @brief Apply adaptive backoff after a failed background status poll.
25///
26/// The backoff is per-device and per-failure-class. Plain timeouts ramp up gradually, while
27/// auth-shaped failures ramp up faster because they already prove the device answered with 0x3C
28/// and we are likely just burning airtime with repeated 0x3D responses.
29///
30/// @param dev Device record to update.
31/// @param auth_like_failure True if the failed exchange saw a challenge request.
32/// @return Delay in milliseconds until the next automatic poll.
33uint32_t apply_status_poll_failure_backoff(IoDevice &dev, bool auth_like_failure) {
34 if (auth_like_failure) {
35 dev.status_poll_failures = 0;
36 if (dev.auth_poll_failures < UINT8_MAX)
37 dev.auth_poll_failures++;
38 return detail::status_poll_retry_delay_ms(dev.auth_poll_failures, true);
39 }
40
41 dev.auth_poll_failures = 0;
42 if (dev.status_poll_failures < UINT8_MAX)
43 dev.status_poll_failures++;
44 return detail::status_poll_retry_delay_ms(dev.status_poll_failures, false);
45}
46
47/// @brief Clear background status-poll failure streaks after a successful reply.
48/// @param dev Device record to reset.
49void clear_status_poll_failure_backoff(IoDevice &dev) {
50 dev.status_poll_failures = 0;
51 dev.auth_poll_failures = 0;
52}
53
54/// @brief Apply tracked-poll failure bookkeeping after an explicit exchange failure.
55/// @param component Owning hub component.
56/// @param device_id Target device ID.
57/// @param retry_after_fail_ms Non-zero only for tracked background status polling.
58/// @param auth_like_failure True when the failed exchange saw an auth challenge.
59void handle_failed_exchange(IOHomeControlComponent *component, const std::string &device_id,
60 uint32_t retry_after_fail_ms, bool auth_like_failure) {
61 if (retry_after_fail_ms == 0)
62 return;
63
64 if (auto *dev = component->get_device(device_id); dev != nullptr) {
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
80/// @brief Return the human-readable verb for a position-style command.
81/// @param dev Device receiving the command.
82/// @param position Requested execute position.
83/// @return Log-friendly action string such as "open", "turn on", or "lock".
84const char *position_command_action(const IoDevice &dev, uint8_t position) {
85 if (position == POS_STOP)
86 return "stop";
87
89 return "set position";
90
91 bool const active_state = position == detail::BINARY_ENTITY_ON_POSITION;
92 switch (device_capability_class(dev.type)) {
95 return active_state ? "turn on" : "turn off";
97 return active_state ? "unlock" : "lock";
103 default:
104 return active_state ? "open" : "close";
105 }
106}
107
108/// @brief Return the accepted entity/profile label for rejected execute-position logs.
109/// @param position Requested execute position.
110/// @return Expected profile label for detail::log_rejected_operation().
111const char *position_rejection_profile(uint8_t position) {
112 return detail::is_binary_entity_position(position) ? "cover_position or binary_on_off" : "cover_position";
113}
114
115/// @brief Update device poll-tracking state before an execute-position request is sent.
116/// @param dev Device record to update.
117/// @param position Requested execute position.
118/// @return true when active tracked polling should be started immediately by the caller.
119bool prepare_position_poll_tracking(IoDevice &dev, uint8_t position) {
120 if (position == POS_STOP) {
122 return false;
123 }
124
125 dev.single_follow_up_poll_pending = dev.status_poll_interval_ms == 0;
126 return true;
127}
128
129/// @brief Roll back tracked polling after a failed execute-position request.
130/// @param dev Device record to update.
131/// @param position Requested execute position.
132void rollback_position_poll_tracking(IoDevice &dev, uint8_t position) {
133 if (position != POS_STOP)
135}
136
137/// @brief Decide whether post-success tracked polling should be scheduled.
138/// @param dev Device record to update.
139/// @param position Requested execute position.
140/// @return true when the caller should arm tracked polling after a successful request.
141bool should_finalize_position_poll_tracking(const IoDevice &dev, uint8_t position) {
142 return position != POS_STOP && dev.status_poll_interval_ms != 0 && dev.next_update == 0;
143}
144
145} // namespace
146
147// Execute an authenticated request on the standard command channel and, on success, feed the
148// device's reply back through the normal inbound status parser so all state normalization stays
149// in one place.
150bool IOHomeControlComponent::execute_request_and_update_(const std::string &device_id, const IoFrame &request,
151 bool warn_on_no_response, uint32_t retry_after_fail_ms) {
152 IoFrame response;
153 if (!this->send_and_receive_(request, response, FREQ_CH2)) {
154 handle_failed_exchange(this, device_id, retry_after_fail_ms, this->last_exchange_debug_.saw_challenge);
155 this->log_exchange_debug_(device_id.c_str());
156 if (warn_on_no_response) {
157 ESP_LOGW(detail::TAG, "Command 0x%02X failed for device %s: no valid response (stage=%s tries=%u)", request.cmd,
158 device_id.c_str(), this->last_exchange_debug_.stage, this->last_exchange_debug_.tries);
159 }
160 return false;
161 }
162
163 if (response.cmd == CMD_ERROR_RESP) {
164 if (response.data_len == 0) {
165 detail::log_frame_issue(this, "rx", "unsupported_payload", response, frame_length(response));
166 } else {
167 detail::log_command_result(device_id, response.data[0], request.cmd, true);
168 }
169 handle_failed_exchange(this, device_id, retry_after_fail_ms, this->last_exchange_debug_.saw_challenge);
170 return false;
171 }
172
173 if (retry_after_fail_ms != 0) {
174 if (auto *dev = this->get_device(device_id); dev != nullptr)
175 clear_status_poll_failure_backoff(*dev);
176 }
177
178 this->update_device_status_(response);
179 return true;
180}
181
182bool IOHomeControlComponent::set_device_position(const std::string &device_id, uint8_t position) {
183 auto *dev = this->get_device(device_id);
184 if (dev == nullptr || !this->initialized_)
185 return false;
186
187 const char *action = position_command_action(*dev, position);
188
189 // Once a device family is known, use the profile helpers to reject YAML/entity mismatches
190 // before they hit the radio path. Unknown types still pass through so discovery and imported
191 // devices keep working as before.
193 detail::log_rejected_operation(device_id, *dev, action, position_rejection_profile(position));
194 return false;
195 }
196
197 if (prepare_position_poll_tracking(*dev, position))
198 this->begin_status_poll_tracking_(device_id, dev->status_poll_interval_ms);
199
200 ESP_LOGI(detail::TAG, "Sending %s to device %s (profile=%s)", action, device_id.c_str(),
202
203 IoFrame request;
204 if (!create_execute(request, this->node_id_, dev->node_id, true, position)) {
205 rollback_position_poll_tracking(*dev, position);
206 return false;
207 }
208 bool const ok = this->execute_request_and_update_(device_id, request, true, 0);
209 if (!ok) {
210 rollback_position_poll_tracking(*dev, position);
211 return false;
212 }
213 if (should_finalize_position_poll_tracking(*dev, position))
214 this->begin_status_poll_tracking_(device_id, dev->status_poll_interval_ms);
215 return true;
216}
217
218bool IOHomeControlComponent::set_device_tilt(const std::string &device_id, uint8_t tilt_percent) {
219 auto *dev = this->get_device(device_id);
220 if (dev == nullptr || !this->initialized_)
221 return false;
222
224 detail::log_rejected_operation(device_id, *dev, "set tilt", "tilt-capable cover");
225 return false;
226 }
227
228 dev->single_follow_up_poll_pending = dev->status_poll_interval_ms == 0;
229 this->begin_status_poll_tracking_(device_id, dev->status_poll_interval_ms);
230
231 ESP_LOGI(detail::TAG, "Sending tilt=%u%% to device %s (profile=%s)", tilt_percent, device_id.c_str(),
233
234 IoFrame request;
235 if (!create_execute_tilt(request, this->node_id_, dev->node_id, true, tilt_percent)) {
237 return false;
238 }
239 bool const ok = this->execute_request_and_update_(device_id, request, true, 0);
240 if (!ok) {
242 return false;
243 }
244 if (dev->status_poll_interval_ms != 0 && dev->next_update == 0)
245 this->begin_status_poll_tracking_(device_id, dev->status_poll_interval_ms);
246 return true;
247}
248
249bool IOHomeControlComponent::request_device_status(const std::string &device_id) {
250 auto *dev = this->get_device(device_id);
251 if (dev == nullptr || !this->initialized_)
252 return false;
253
255 detail::log_rejected_operation(device_id, *dev, "status request", "status-capable actuator");
256 return false;
257 }
258
259 IoFrame request;
260 // Tilt-capable covers need the extended 0x03200100 status request so the response includes
261 // the reliable 16-byte tilt block. Other devices stay on the shorter generic request.
262 bool const request_ok = device_supports_tilt(dev->type)
263 ? create_get_status_tilt(request, this->node_id_, dev->node_id)
264 : create_get_status(request, this->node_id_, dev->node_id);
265 if (!request_ok)
266 return false;
267 uint32_t const retry_after_fail_ms =
269 return this->execute_request_and_update_(device_id, request, false, retry_after_fail_ms);
270}
271
272bool IOHomeControlComponent::request_device_name(const std::string &device_id) {
273 auto *dev = this->get_device(device_id);
274 if (dev == nullptr || !this->initialized_)
275 return false;
276
277 IoFrame request;
278 if (!create_get_name(request, this->node_id_, dev->node_id, true))
279 return false;
280 return this->execute_request_and_update_(device_id, request, false, 0);
281}
282
283bool IOHomeControlComponent::set_light_state(const std::string &device_id, bool on) {
284 auto *dev = this->get_device(device_id);
285 if (dev == nullptr || !this->initialized_)
286 return false;
287
289 detail::log_rejected_operation(device_id, *dev, "light command", "light entity");
290 return false;
291 }
292
293 // Light entities are binary-only for now, so they intentionally reuse the controller's
294 // existing execute path with the proven on/off position encoding.
295 return this->set_device_position(device_id,
297}
298
299bool IOHomeControlComponent::set_switch_state(const std::string &device_id, bool on) {
300 auto *dev = this->get_device(device_id);
301 if (dev == nullptr || !this->initialized_)
302 return false;
303
305 detail::log_rejected_operation(device_id, *dev, "switch command", "switch entity");
306 return false;
307 }
308
309 // Switches share the same transport-level representation as binary lights.
310 return this->set_device_position(device_id,
312}
313
314bool IOHomeControlComponent::set_lock_state(const std::string &device_id, bool locked) {
315 auto *dev = this->get_device(device_id);
316 if (dev == nullptr || !this->initialized_)
317 return false;
318
320 detail::log_rejected_operation(device_id, *dev, "lock command", "lock entity");
321 return false;
322 }
323
324 // Lock entities currently reuse the protocol's proven binary execute encoding:
325 // unlock maps to 0 and lock maps to 100.
326 return this->set_device_position(device_id,
328}
329
330void IOHomeControlComponent::queue_set_device_position(const std::string &device_id, uint8_t position) {
331 IoDevice *dev = this->get_device(device_id);
333 detail::log_rejected_operation(device_id, *dev, "queued cover command", "cover entity");
334 return;
335 }
336 this->pending_operations_.push_back({PendingOperationType::SET_POSITION, device_id, position});
337}
338
339void IOHomeControlComponent::queue_set_device_tilt(const std::string &device_id, uint8_t tilt_percent) {
340 IoDevice *dev = this->get_device(device_id);
341 if (dev != nullptr && !detail::known_device_accepts_execute_tilt(*dev)) {
342 detail::log_rejected_operation(device_id, *dev, "queued tilt command", "tilt-capable cover");
343 return;
344 }
345 this->pending_operations_.push_back({PendingOperationType::SET_TILT, device_id, tilt_percent});
346}
347
348void IOHomeControlComponent::queue_request_device_status(const std::string &device_id) {
349 IoDevice *dev = this->get_device(device_id);
350 if (dev != nullptr && !detail::known_device_supports_status_requests(*dev)) {
351 detail::log_rejected_operation(device_id, *dev, "queued status request", "status-capable actuator");
352 return;
353 }
354
355 // Keep at most one pending status poll per device. Without this, an overdue next_update can add
356 // the same poll on every main-loop iteration until the first queued request is finally processed.
357 for (const auto &operation : this->pending_operations_) {
358 if (operation.type == PendingOperationType::REQUEST_STATUS && operation.device_id == device_id)
359 return;
360 }
361
362 this->pending_operations_.push_back({PendingOperationType::REQUEST_STATUS, device_id, 0});
363}
364
365void IOHomeControlComponent::queue_request_device_name(const std::string &device_id) {
366 if (this->get_device(device_id) == nullptr)
367 return;
368
369 for (const auto &operation : this->pending_operations_) {
370 if (operation.type == PendingOperationType::REQUEST_NAME && operation.device_id == device_id)
371 return;
372 }
373
374 this->pending_operations_.push_back({PendingOperationType::REQUEST_NAME, device_id, 0});
375}
376
378 // Pairing is globally exclusive work. Keep at most one queued request so repeated button presses
379 // while the radio is busy do not stack duplicate discovery attempts.
380 for (const auto &operation : this->pending_operations_) {
381 if (operation.type == PendingOperationType::DISCOVER_AND_PAIR)
382 return;
383 }
384 this->pending_operations_.push_back({PendingOperationType::DISCOVER_AND_PAIR, {}, 0});
385}
386
387void IOHomeControlComponent::queue_set_light_state(const std::string &device_id, bool on) {
388 IoDevice *dev = this->get_device(device_id);
390 detail::log_rejected_operation(device_id, *dev, "queued light command", "light entity");
391 return;
392 }
393
394 // Queue through the same scheduler as covers so radio work stays serialized while still keeping
395 // the light-vs-switch semantics available for capability checks at dispatch time.
398}
399
400void IOHomeControlComponent::queue_set_lock_state(const std::string &device_id, bool locked) {
401 IoDevice *dev = this->get_device(device_id);
403 detail::log_rejected_operation(device_id, *dev, "queued lock command", "lock entity");
404 return;
405 }
406
407 this->pending_operations_.push_back(
410}
411
412void IOHomeControlComponent::queue_set_switch_state(const std::string &device_id, bool on) {
413 IoDevice *dev = this->get_device(device_id);
415 detail::log_rejected_operation(device_id, *dev, "queued switch command", "switch entity");
416 return;
417 }
418
419 // Queue through the same scheduler as covers so radio work stays serialized while still keeping
420 // the light-vs-switch semantics available for capability checks at dispatch time.
423}
424
426 if (this->busy_ || this->pending_operations_.empty())
427 return;
428
429 // Pop before dispatch so any handler that re-queues follow-up work sees the queue in its
430 // post-consumption state and cannot accidentally execute the same operation twice.
431 PendingOperation const operation = std::move(this->pending_operations_.front());
432 this->pending_operations_.pop_front();
433
434 switch (operation.type) {
436 this->set_device_position(operation.device_id, operation.position);
437 break;
439 this->set_device_tilt(operation.device_id, operation.position);
440 break;
443 break;
446 break;
449 break;
451 this->request_device_status(operation.device_id);
452 break;
454 this->request_device_name(operation.device_id);
455 break;
457 this->discover_and_pair();
458 break;
459 }
460}
461
462} // namespace home_io_control
463} // namespace esphome
The main IO-Homecontrol component.
Definition hub_core.h:68
virtual bool set_lock_state(const std::string &device_id, bool locked)
Semantic lock helper for lock entities.
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:258
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.
virtual void queue_request_device_name(const std::string &device_id)
Queue an async device-name request; returns immediately, executed in loop().
@ SET_LOCK_STATE
Queue a set_lock_state call (locked/unlocked).
Definition hub_core.h:375
@ SET_TILT
Queue a set_device_tilt call (tilt percentage 0–100).
Definition hub_core.h:373
@ SET_LIGHT_STATE
Queue a set_light_state call (binary on/off).
Definition hub_core.h:374
@ SET_SWITCH_STATE
Queue a set_switch_state call (binary on/off).
Definition hub_core.h:376
@ REQUEST_NAME
Queue a request_device_name call (poll for stored device name).
Definition hub_core.h:378
@ DISCOVER_AND_PAIR
Queue a discover_and_pair call (starts 3‑phase pairing flow).
Definition hub_core.h:379
@ REQUEST_STATUS
Queue a request_device_status call (poll for current position).
Definition hub_core.h:377
@ SET_POSITION
Queue a set_device_position call (position 0–100 or special values).
Definition hub_core.h:372
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.
void log_exchange_debug_(const char *device_id) const
Definition hub_core.cpp:59
std::deque< PendingOperation > pending_operations_
Definition hub_core.h:443
virtual bool discover_and_pair()
Discover and pair a device that is in pairing mode.
void update_device_status_(const IoFrame &frame)
Extract supported position or metadata info from a response frame and merge it into the 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 bool request_device_name(const std::string &device_id)
Request the stored device name from 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.
void log_frame_issue(IOHomeControlComponent *component, const char *direction, const char *reason, const IoFrame &frame, uint8_t len)
Log a frame‑level issue (unregistered endpoints, unsupported commands).
void log_command_result(const std::string &id, uint8_t result, uint8_t request_cmd=0, bool include_request_cmd=false)
Log a decoded CMD_ERROR_RESP result with optional request-command context.
const char * device_operation_profile_name(DeviceType type)
Human‑readable operation profile name for a device type.
bool create_get_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a get-name request (0x50).
static constexpr uint8_t CMD_ERROR_RESP
Error response to any command.
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.
DeviceCapabilityClass device_capability_class(DeviceType type)
Map a raw IO‑Homecontrol type to the closest ESPHome/Home Assistant entity family.
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.
uint8_t frame_length(const IoFrame &f)
Get total frame length from ctrl0.
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:32
@ CLIMATE
Climate device (heating/cooling).
@ 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:383
std::string device_id
Target device ID (hex string, e.g., "123ABC").
Definition hub_core.h:385
PendingOperationType type
Operation type (determines which queue handler to invoke).
Definition hub_core.h:384
uint8_t position
Position/tilt value (0–100) or binary state (ON/UNLOCK=0, OFF/LOCK=100).
Definition hub_core.h:386
Runtime state of a paired IO‑Homecontrol device.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes).
uint8_t data_len
Actual length of data.