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#include <algorithm>
6
7/// @file hub_operations.cpp
8/// @brief High-level command execution and queued operation dispatch.
9/// @ingroup hioc_hub
10///
11/// This file owns the outbound user-facing operations on the hub:
12/// - cover position and tilt commands,
13/// - explicit status requests,
14/// - light/switch semantic wrappers,
15/// - queued dispatch on the main loop.
16///
17/// Keeping these methods out of hub_core.cpp makes it easier to reason about the
18/// difference between lifecycle/polling logic and the explicit actions initiated
19/// by Home Assistant entities.
20
21namespace esphome {
22namespace home_io_control {
23
24namespace {
25
26/// @brief Return the human-readable verb for a position-style command.
27/// @param dev Device receiving the command.
28/// @param position Requested execute position.
29/// @return Log-friendly action string such as "open", "turn on", or "lock".
30const char *position_command_action(const IoDevice &dev, uint8_t position) {
31 if (position == POS_STOP)
32 return "stop";
33
35 return "set position";
36
37 bool const active_state = position == BINARY_ENTITY_ON_POSITION;
38 switch (device_capability_class(dev.type)) {
41 return active_state ? "turn on" : "turn off";
43 return active_state ? "unlock" : "lock";
49 default:
50 return active_state ? "open" : "close";
51 }
52}
53
54/// @brief Return the effective profile label for a device's "Sending ... (profile=...)" logs.
55/// @param dev Device receiving the command.
56/// @return "dimmable_light" for a LIGHT-class device with IoDevice::dimmable set (a YAML choice
57/// the wire protocol has no signal for, so device_operation_profile_name() alone can't
58/// know it); device_operation_profile_name(dev.type) for every other device.
59const char *operation_profile_name(const IoDevice &dev) {
60 if (dev.dimmable && device_capability_class(dev.type) == DeviceCapabilityClass::LIGHT)
61 return "dimmable_light";
62 return device_operation_profile_name(dev.type);
63}
64
65/// @brief Return the accepted entity/profile label for rejected execute-position logs.
66/// @param dev Device the command was rejected for.
67/// @param position Requested execute position.
68/// @return Expected profile label for detail::log_rejected_operation().
69const char *position_rejection_profile(const IoDevice &dev, uint8_t position) {
70 // A LIGHT-class device only reaches rejection for a genuinely out-of-range value (dimmable
71 // lights already accept the full 0-100 span in known_device_accepts_execute_position()) — the
72 // fix there isn't "needs cover_position", it's "needs a value in 0-100".
74 return "0-100";
75 return detail::is_binary_entity_position(position) ? "cover_position or binary_on_off" : "cover_position";
76}
77
78} // namespace
79
80// Execute an authenticated request on the standard command channel and, on success, feed the
81// device's reply back through the normal inbound status parser so all state normalization stays
82// in one place.
83bool IOHomeControlComponent::execute_request_and_update_(const std::string &device_id, const IoFrame &request,
84 bool warn_on_no_response, uint32_t retry_after_fail_ms) {
85 IoFrame response;
86 if (!this->send_and_receive_(request, response, FREQ_CH2)) {
87 const auto &dbg = this->exchange_engine_.get_debug();
88 if (IoDevice *dev = this->registry_.get(device_id); dev != nullptr) {
89 detail::record_exchange_timeout(*dev, dbg.tries);
90 this->notify_device_update_(device_id);
91 }
92 if (retry_after_fail_ms != 0)
93 this->schedule_background_poll_backoff_(device_id, dbg.saw_challenge);
94 this->log_exchange_debug_(device_id.c_str());
95 if (warn_on_no_response) {
96 ESP_LOGW(detail::TAG, "Command 0x%02X failed for device %s: no valid response (stage=%s tries=%u)", request.cmd,
97 device_id.c_str(), dbg.stage, dbg.tries);
98 }
99 return false;
100 }
101
102 if (response.cmd == CMD_ERROR_RESP) {
103 IoDevice *dev = this->registry_.get(device_id);
104 // An explicit refusal is still a reply from the device: this branch returns before the
105 // update_device_status_() call below, so it must stamp link health itself to keep
106 // update_link_health()'s "every frame from a registered device" contract.
107 if (dev != nullptr)
109 if (response.data_len == 0) {
110 detail::log_frame_issue(this, "rx", "unsupported_payload", response, frame_length(response));
111 } else if (dev != nullptr) {
112 detail::record_command_result(*dev, device_id, response.data[0], request.cmd, true);
113 } else {
114 detail::log_command_result(device_id, response.data[0], request.cmd, true);
115 }
116 if (dev != nullptr)
117 this->notify_device_update_(device_id);
118 if (retry_after_fail_ms != 0)
119 this->schedule_background_poll_backoff_(device_id, this->exchange_engine_.get_debug().saw_challenge);
120 return false;
121 }
122
123 if (retry_after_fail_ms != 0)
124 this->poll_policy_.clear_failure_streaks(device_id);
125
126 // The immediate reply to our own CMD_EXECUTE (position/tilt/stop/favorite/vent) is not
127 // trustworthy for target/current position on at least some devices — see
128 // update_device_status_()'s trust_position doc comment. Every other request we send
129 // (status poll, get name, ...) keeps trusting its reply as before.
130 this->update_device_status_(response, request.cmd != CMD_EXECUTE);
131 return true;
132}
133
134bool IOHomeControlComponent::set_device_position(const std::string &device_id, uint8_t position) {
135 auto *dev = this->get_device(device_id);
136 if (dev == nullptr || !this->initialized_)
137 return false;
138
139 const char *action = position_command_action(*dev, position);
140
141 // Once a device family is known, use the profile helpers to reject YAML/entity mismatches
142 // before they hit the radio path. Unknown types still pass through so discovery and imported
143 // devices keep working as before.
145 detail::log_rejected_operation(device_id, *dev, action, position_rejection_profile(*dev, position));
146 return false;
147 }
148
149 this->begin_status_poll_tracking_(device_id, this->poll_policy_.get_interval(device_id));
150
151 ESP_LOGI(detail::TAG, "Sending %s to device %s (profile=%s)", action, device_id.c_str(),
152 operation_profile_name(*dev));
153
154 IoFrame request;
155 if (!create_execute_position(request, this->node_id_, dev->node_id, true, position)) {
156 this->poll_policy_.clear(device_id);
157 return false;
158 }
159 bool const ok = this->execute_request_and_update_(device_id, request, true, 0);
160 if (!ok) {
161 this->schedule_background_poll_backoff_(device_id, this->exchange_engine_.get_debug().saw_challenge);
162 return false;
163 }
164 if (position != POS_STOP && this->poll_policy_.get_interval(device_id) != 0 &&
165 this->poll_policy_.get_next_update(device_id) == 0)
166 this->begin_status_poll_tracking_(device_id, this->poll_policy_.get_interval(device_id));
167 return true;
168}
169
170bool IOHomeControlComponent::execute_device_command_(const std::string &device_id, CoverCommand cmd) {
171 auto *dev = this->get_device(device_id);
172 if (dev == nullptr || !this->initialized_)
173 return false;
174
176 detail::log_rejected_operation(device_id, *dev, cover_command_name(cmd), "cover entity");
177 return false;
178 }
179
180 this->begin_status_poll_tracking_(device_id, this->poll_policy_.get_interval(device_id));
181
182 ESP_LOGI(detail::TAG, "Sending %s to device %s (profile=%s)", cover_command_name(cmd), device_id.c_str(),
183 operation_profile_name(*dev));
184
185 IoFrame request;
186 // FORCE_OPEN needs the device's own wire-scale "fully open" position (0, or 100 for an
187 // IoDevice::inverted device such as a horizontal awning) — create_execute_command() has no
188 // device access to resolve that, so it's built separately here where dev is in scope.
189 bool const built = cmd == CoverCommand::FORCE_OPEN
190 ? create_force_open(request, this->node_id_, dev->node_id, true, dev->inverted ? 100 : 0)
191 : create_execute_command(request, this->node_id_, dev->node_id, true, cmd);
192 if (!built) {
193 this->poll_policy_.clear(device_id);
194 return false;
195 }
196 bool const ok = this->execute_request_and_update_(device_id, request, true, 0);
197 if (!ok) {
198 this->schedule_background_poll_backoff_(device_id, this->exchange_engine_.get_debug().saw_challenge);
199 return false;
200 }
201 if (cmd != CoverCommand::STOP && this->poll_policy_.get_interval(device_id) != 0 &&
202 this->poll_policy_.get_next_update(device_id) == 0)
203 this->begin_status_poll_tracking_(device_id, this->poll_policy_.get_interval(device_id));
204 // STOP: if the device is still moving (decelerating or settling to a rest position), cap the
205 // settle poll to STOP_SETTLE_POLL_CAP_MS. The private response is the shared reply to both polls
206 // and commands, so it cannot mark itself as a STOP; this is the one place that knows a STOP was
207 // sent and shortens the settle the response handler scheduled (which already folded in any hint).
208 if (cmd == CoverCommand::STOP && dev != nullptr && !dev->is_stopped) {
209 uint32_t const cap = millis() + STOP_SETTLE_POLL_CAP_MS;
210 uint32_t const existing = this->poll_policy_.get_next_update(device_id);
211 if (existing == 0 || cap < existing)
212 this->poll_policy_.set_next_update(device_id, cap);
213 }
214 return true;
215}
216
217bool IOHomeControlComponent::set_device_tilt(const std::string &device_id, uint8_t tilt_percent) {
218 auto *dev = this->get_device(device_id);
219 if (dev == nullptr || !this->initialized_)
220 return false;
221
223 detail::log_rejected_operation(device_id, *dev, "set tilt", "tilt-capable cover");
224 return false;
225 }
226
227 this->begin_status_poll_tracking_(device_id, this->poll_policy_.get_interval(device_id));
228
229 ESP_LOGI(detail::TAG, "Sending tilt=%u%% to device %s (profile=%s)", tilt_percent, device_id.c_str(),
230 operation_profile_name(*dev));
231
232 IoFrame request;
233 if (!create_execute_tilt(request, this->node_id_, dev->node_id, true, tilt_percent)) {
234 this->poll_policy_.clear(device_id);
235 return false;
236 }
237 bool const ok = this->execute_request_and_update_(device_id, request, true, 0);
238 if (!ok) {
239 this->schedule_background_poll_backoff_(device_id, this->exchange_engine_.get_debug().saw_challenge);
240 return false;
241 }
242 if (this->poll_policy_.get_interval(device_id) != 0 && this->poll_policy_.get_next_update(device_id) == 0)
243 this->begin_status_poll_tracking_(device_id, this->poll_policy_.get_interval(device_id));
244 return true;
245}
246
247bool IOHomeControlComponent::set_device_position_and_tilt(const std::string &device_id, uint8_t position,
248 uint8_t tilt_percent) {
249 auto *dev = this->get_device(device_id);
250 if (dev == nullptr || !this->initialized_)
251 return false;
252
254 detail::log_rejected_operation(device_id, *dev, "set position+tilt", "tilt-capable cover");
255 return false;
256 }
257
258 this->begin_status_poll_tracking_(device_id, this->poll_policy_.get_interval(device_id));
259
260 ESP_LOGI(detail::TAG, "Sending position=%u%% tilt=%u%% to device %s (profile=%s)", position, tilt_percent,
261 device_id.c_str(), operation_profile_name(*dev));
262
263 IoFrame request;
264 if (!create_execute_position_and_tilt(request, this->node_id_, dev->node_id, true, position, tilt_percent)) {
265 this->poll_policy_.clear(device_id);
266 return false;
267 }
268 bool const ok = this->execute_request_and_update_(device_id, request, true, 0);
269 if (!ok) {
270 this->schedule_background_poll_backoff_(device_id, this->exchange_engine_.get_debug().saw_challenge);
271 return false;
272 }
273 if (this->poll_policy_.get_interval(device_id) != 0 && this->poll_policy_.get_next_update(device_id) == 0)
274 this->begin_status_poll_tracking_(device_id, this->poll_policy_.get_interval(device_id));
275 return true;
276}
277
278bool IOHomeControlComponent::request_device_status(const std::string &device_id) {
279 auto *dev = this->get_device(device_id);
280 if (dev == nullptr || !this->initialized_)
281 return false;
282
284 detail::log_rejected_operation(device_id, *dev, "status request", "status-capable actuator");
285 return false;
286 }
287
288 IoFrame request;
289 // Tilt-capable covers need the extended 0x03200100 status request so the response includes
290 // the reliable 16-byte tilt block. Other devices stay on the shorter generic request.
291 bool const request_ok = device_supports_tilt(dev->type)
292 ? create_get_status_tilt(request, this->node_id_, dev->node_id)
293 : create_get_status(request, this->node_id_, dev->node_id);
294 if (!request_ok)
295 return false;
296 uint32_t const retry_after_fail_ms =
297 this->poll_policy_.is_tracking_active(device_id, millis()) ? STATUS_RETRY_AFTER_FAIL_MS : 0;
298 return this->execute_request_and_update_(device_id, request, false, retry_after_fail_ms);
299}
300
301bool IOHomeControlComponent::request_device_name(const std::string &device_id) {
302 auto *dev = this->get_device(device_id);
303 if (dev == nullptr || !this->initialized_)
304 return false;
305
306 IoFrame request;
307 if (!create_get_name(request, this->node_id_, dev->node_id, true))
308 return false;
309 return this->execute_request_and_update_(device_id, request, false, 0);
310}
311
312bool IOHomeControlComponent::set_light_position(const std::string &device_id, uint8_t position) {
313 auto *dev = this->get_device(device_id);
314 if (dev == nullptr || !this->initialized_)
315 return false;
316
318 detail::log_rejected_operation(device_id, *dev, "light command", "light entity");
319 return false;
320 }
321
322 // Light entities reuse the controller's existing execute path — the same position encoding
323 // covers use, confirmed on real dimmable hardware (see tests/corpus/captures/somfy_dimmer/).
324 return this->set_device_position(device_id, position);
325}
326
327bool IOHomeControlComponent::set_light_state(const std::string &device_id, bool on) {
329}
330
331bool IOHomeControlComponent::set_switch_state(const std::string &device_id, bool on) {
332 auto *dev = this->get_device(device_id);
333 if (dev == nullptr || !this->initialized_)
334 return false;
335
337 detail::log_rejected_operation(device_id, *dev, "switch command", "switch entity");
338 return false;
339 }
340
341 // Switches share the same transport-level representation as binary lights.
343}
344
345bool IOHomeControlComponent::set_lock_state(const std::string &device_id, bool locked) {
346 auto *dev = this->get_device(device_id);
347 if (dev == nullptr || !this->initialized_)
348 return false;
349
351 detail::log_rejected_operation(device_id, *dev, "lock command", "lock entity");
352 return false;
353 }
354
355 // Lock entities currently reuse the protocol's proven binary execute encoding:
356 // unlock maps to 0 and lock maps to 100.
358}
359
360void IOHomeControlComponent::queue_set_device_position(const std::string &device_id, uint8_t position) {
361 const IoDevice *dev = this->get_device(device_id);
363 detail::log_rejected_operation(device_id, *dev, "queued cover command", "cover entity");
364 return;
365 }
366
367 // Pre-scan for a pending SET_TILT so we can log its value if coalescing happens.
368 uint8_t pending_tilt = 0;
369 for (const auto &op : this->op_queue_) {
370 if (op.type == PendingOperationType::SET_TILT && op.device_id == device_id) {
371 pending_tilt = op.position; // SET_TILT stores tilt in op.position
372 break;
373 }
374 }
375 if (this->op_queue_.enqueue_set_position(device_id, position)) {
376 ESP_LOGI(detail::TAG,
377 "Coalesced SET_POSITION (pos=%u) + pending SET_TILT (tilt=%u) → SET_POSITION_AND_TILT for "
378 "device %s",
379 position, pending_tilt, device_id.c_str());
380 }
381}
382
383bool IOHomeControlComponent::queue_device_command(const std::string &device_id, CoverCommand cmd) {
384 if (!this->initialized_)
385 return false;
386 const IoDevice *dev = this->get_device(device_id);
387 if (dev == nullptr)
388 return false;
390 detail::log_rejected_operation(device_id, *dev, cover_command_name(cmd), "cover entity");
391 return false;
392 }
393 this->op_queue_.enqueue_device_command(device_id, cmd);
394 return true;
395}
396
397void IOHomeControlComponent::queue_set_device_tilt(const std::string &device_id, uint8_t tilt_percent) {
398 const IoDevice *dev = this->get_device(device_id);
399 if (dev != nullptr && !detail::known_device_accepts_execute_tilt(*dev)) {
400 detail::log_rejected_operation(device_id, *dev, "queued tilt command", "tilt-capable cover");
401 return;
402 }
403
404 // Pre-scan for a pending SET_POSITION so we can log its value if coalescing happens.
405 uint8_t pending_pos = 0;
406 for (const auto &op : this->op_queue_) {
407 if (op.type == PendingOperationType::SET_POSITION && op.device_id == device_id) {
408 pending_pos = op.position;
409 break;
410 }
411 }
412 if (this->op_queue_.enqueue_set_tilt(device_id, tilt_percent)) {
413 ESP_LOGI(detail::TAG,
414 "Coalesced pending SET_POSITION (pos=%u) + SET_TILT (tilt=%u) → "
415 "SET_POSITION_AND_TILT for device %s",
416 pending_pos, tilt_percent, device_id.c_str());
417 }
418}
419
420void IOHomeControlComponent::queue_set_device_position_and_tilt(const std::string &device_id, uint8_t position,
421 uint8_t tilt_percent) {
422 const IoDevice *dev = this->get_device(device_id);
423 if (dev != nullptr && !detail::known_device_accepts_execute_tilt(*dev)) {
424 detail::log_rejected_operation(device_id, *dev, "queued position+tilt command", "tilt-capable cover");
425 return;
426 }
427 this->op_queue_.enqueue_set_position_and_tilt(device_id, position, tilt_percent);
428}
429
430void IOHomeControlComponent::queue_request_device_status(const std::string &device_id) {
431 const IoDevice *dev = this->get_device(device_id);
432 if (dev != nullptr && !detail::known_device_supports_status_requests(*dev)) {
433 detail::log_rejected_operation(device_id, *dev, "queued status request", "status-capable actuator");
434 return;
435 }
436 // Keep at most one pending status poll per device. Without this, an overdue next_update can add
437 // the same poll on every main-loop iteration until the first queued request is finally processed.
438 this->op_queue_.enqueue_request_status(device_id);
439}
440
441void IOHomeControlComponent::queue_request_device_name(const std::string &device_id) {
442 if (this->get_device(device_id) == nullptr)
443 return;
444 this->op_queue_.enqueue_request_name(device_id);
445}
446
447/// Queue a discovery-and-pair request with elevated priority.
448///
449/// Flushes any pending status/name poll operations (which would consume time
450/// during the device's limited pairing window) and pushes discovery to the
451/// front of the queue. Duplicate requests are suppressed.
452void IOHomeControlComponent::queue_discover_and_pair() { this->op_queue_.enqueue_discover_and_pair(); }
453
454void IOHomeControlComponent::queue_set_light_position(const std::string &device_id, uint8_t position) {
455 const IoDevice *dev = this->get_device(device_id);
457 detail::log_rejected_operation(device_id, *dev, "queued light command", "light entity");
458 return;
459 }
460 this->op_queue_.enqueue_set_light_position(device_id, position);
461}
462
463void IOHomeControlComponent::queue_set_light_state(const std::string &device_id, bool on) {
465}
466
467void IOHomeControlComponent::queue_set_lock_state(const std::string &device_id, bool locked) {
468 const IoDevice *dev = this->get_device(device_id);
470 detail::log_rejected_operation(device_id, *dev, "queued lock command", "lock entity");
471 return;
472 }
473 this->op_queue_.enqueue_set_lock_state(device_id, locked);
474}
475
476void IOHomeControlComponent::queue_set_switch_state(const std::string &device_id, bool on) {
477 const IoDevice *dev = this->get_device(device_id);
479 detail::log_rejected_operation(device_id, *dev, "queued switch command", "switch entity");
480 return;
481 }
482 this->op_queue_.enqueue_set_switch_state(device_id, on);
483}
484
486 if (this->busy_ || this->op_queue_.empty())
487 return;
488
489 // Pop before dispatch so any handler that re-queues follow-up work sees the queue in its
490 // post-consumption state and cannot accidentally execute the same operation twice.
491 auto opt = this->op_queue_.pop();
492 if (!opt.has_value())
493 return;
494 const PendingOperation &operation = *opt;
495
496 switch (operation.type) {
498 this->set_device_position(operation.device_id, operation.position);
499 break;
501 this->set_device_tilt(operation.device_id, operation.position);
502 break;
504 this->set_device_position_and_tilt(operation.device_id, operation.position, operation.tilt);
505 break;
507 this->execute_device_command_(operation.device_id, operation.command);
508 break;
510 // operation.position already carries the target IO position (0-100) regardless of whether
511 // it was enqueued via queue_set_light_state() (binary extremes) or
512 // queue_set_light_position() (dimmable) — see enqueue_set_light_state()'s thin-wrapper doc.
513 this->set_light_position(operation.device_id, operation.position);
514 break;
516 this->set_lock_state(operation.device_id, operation.position == BINARY_ENTITY_OFF_POSITION);
517 break;
519 this->set_switch_state(operation.device_id, operation.position == BINARY_ENTITY_ON_POSITION);
520 break;
522 this->request_device_status(operation.device_id);
523 break;
525 this->request_device_name(operation.device_id);
526 break;
528 this->discover_and_pair();
529 break;
530 }
531}
532
533} // namespace home_io_control
534} // namespace esphome
virtual bool set_lock_state(const std::string &device_id, bool locked)
Semantic lock helper for lock entities.
virtual bool set_device_position_and_tilt(const std::string &device_id, uint8_t position, uint8_t tilt_percent)
Set both position and tilt of a tilt-capable cover in one atomic command.
virtual bool set_device_tilt(const std::string &device_id, uint8_t tilt_percent)
Send a tilt command to a tilt‑capable cover.
bool send_and_receive_(const IoFrame &request, IoFrame &response, uint32_t freq)
Main request/response exchange with retry and automatic authentication.
Definition hub_core.cpp:264
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:304
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.
void update_device_status_(const IoFrame &frame, bool trust_position=true)
Extract supported position or metadata info from a response frame and merge it into the device record...
virtual void queue_request_device_name(const std::string &device_id)
Queue an async device-name request; returns immediately, executed in loop().
virtual void queue_set_lock_state(const std::string &device_id, bool locked)
Async form of set_lock_state() that keeps radio work serialized on the main loop.
virtual void queue_set_light_position(const std::string &device_id, uint8_t position)
Async form of set_light_position() that keeps radio work serialized on the main loop.
void log_exchange_debug_(const char *device_id) const
Log the last exchange debug snapshot (delegates to exchange_engine_).
Definition hub_core.h:606
void schedule_background_poll_backoff_(const std::string &device_id, bool auth_like)
Apply backoff after a failed background status poll and log the result.
Definition hub_core.cpp:286
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().
virtual bool queue_device_command(const std::string &device_id, CoverCommand cmd)
Queue an async named command (STOP, FAVORITE, VENT, FORCE_OPEN); returns immediately,...
virtual bool discover_and_pair()
Discover and pair a device that is in pairing mode.
ExchangeEngine exchange_engine_
Owns all authenticated exchange and LBT/hop logic.
Definition hub_core.h:763
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.
void notify_device_update_(const std::string &id)
Fire all registered device update callbacks for the given device ID.
Definition hub_core.cpp:276
virtual void queue_set_device_position(const std::string &device_id, uint8_t position)
Queue an async position update; returns immediately, executed in loop().
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.
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.
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.
virtual bool set_light_position(const std::string &device_id, uint8_t position)
Send an arbitrary IO position (0-100) to a light entity.
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 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?
void record_exchange_timeout(IoDevice &dev, uint8_t tries)
Record that an outbound exchange to this device timed out (no valid response).
void update_link_health(IoDevice &dev, RadioDriver *radio)
Update per-device link-health stats from the radio's last capture.
bool is_binary_entity_position(uint8_t position)
Is the given position value an on/off binary encoding?
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.
void record_command_result(IoDevice &dev, const std::string &id, uint8_t result, uint8_t request_cmd=0, bool include_request_cmd=false)
Store a decoded CMD_ERROR_RESP result on the device and log it.
bool create_force_open(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t open_position)
Build a force-open execute frame (0x00): an ordinary position command to the device's wire-scale "ful...
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).
@ SET_LOCK_STATE
set_lock_state call (locked/unlocked).
@ SET_TILT
set_device_tilt call (tilt percentage 0–100).
@ SET_POSITION_AND_TILT
Combined set_device_position_and_tilt call.
@ SET_LIGHT_STATE
set_light_state call (binary on/off).
@ SET_SWITCH_STATE
set_switch_state call (binary on/off).
@ REQUEST_NAME
request_device_name call (poll for stored device name).
@ DISCOVER_AND_PAIR
discover_and_pair call (starts 3-phase pairing flow).
@ REQUEST_STATUS
request_device_status call (poll for current position).
@ DEVICE_COMMAND
Named device command (STOP, FAVORITE, VENT).
@ SET_POSITION
set_device_position call (position 0–100 or special values).
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.
CoverCommand
Named device commands for cover-type actuators.
@ FORCE_OPEN
Move to fully open at elevated priority; intended to bypass soft locks and environmental limits (conf...
bool create_execute_position_and_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t position, uint8_t tilt_percent)
Build a combined position-and-tilt execute command (0x00) — setClosureAndOrientation.
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.
const char * cover_command_name(CoverCommand cmd)
Get a human-readable name for a CoverCommand.
static constexpr uint32_t STATUS_RETRY_AFTER_FAIL_MS
First retry after a silent failure.
static constexpr uint8_t BINARY_ENTITY_ON_POSITION
Position value written for binary ON commands (light on, switch on, lock unlock).
static constexpr uint8_t CMD_EXECUTE
Set position/open/close/stop — requires authentication.
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
bool create_execute_position(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t position)
Build a position execute command (0x00) to move a device to a numeric position.
@ CLIMATE
Climate device (heating/cooling).
@ COVER
Position‑controlled cover (shutter/blind/awning).
static constexpr uint32_t STOP_SETTLE_POLL_CAP_MS
Upper bound on the settle-poll delay after a STOP command.
bool create_execute_command(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, CoverCommand cmd)
Build a named-command execute frame (0x00) for STOP, FAVORITE, or VENT.
static constexpr uint8_t POS_STOP
Position values in the IO protocol.
static constexpr uint8_t BINARY_ENTITY_OFF_POSITION
Position value written for binary OFF commands (light off, switch off, lock lock).
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.
Runtime state of a paired IO‑Homecontrol device.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:71
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes).
Definition proto_frame.h:77
uint8_t data_len
Actual length of data.
Definition proto_frame.h:78
A single queued operation to be dispatched from loop().
CoverCommand command
Named command for DEVICE_COMMAND operations.
std::string device_id
Target device ID (hex string, e.g., "123ABC").
uint8_t tilt
Tilt value for SET_POSITION_AND_TILT (0–100).
uint8_t position
Position/tilt value (0–100) or binary state (ON=0, OFF=100).
PendingOperationType type
Operation type (determines which handler to invoke).