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 "hub_decisions.h"
4#include "proto_commands.h"
5
6#include <algorithm>
7#include <cstdio>
8
9/// @file hub_operations.cpp
10/// @brief High-level command execution and queued operation dispatch.
11/// @ingroup hioc_hub
12///
13/// This file owns the outbound user-facing operations on the hub:
14/// - cover position and tilt commands,
15/// - explicit status requests,
16/// - light/switch semantic wrappers,
17/// - queued dispatch on the main loop.
18///
19/// Keeping these methods out of hub_core.cpp makes it easier to reason about the
20/// difference between lifecycle/polling logic and the explicit actions initiated
21/// by Home Assistant entities.
22
23namespace esphome {
24namespace home_io_control {
25
26namespace {
27
28/// Stack-buffer size for a pre-formatted execute action phrase such as
29/// "position=100%% tilt=100%%".
30constexpr size_t EXECUTE_ACTION_BUF_SIZE = 40;
31
32/// Wire-scale "fully open" position for a FORCE_OPEN. A normal actuator reads fully open as 0;
33/// an IoDevice::inverted actuator (a horizontal awning, say) reads it as 100.
34constexpr uint8_t FORCE_OPEN_WIRE_POSITION = 0;
35constexpr uint8_t FORCE_OPEN_WIRE_POSITION_INVERTED = 100;
36
37/// @brief Return the human-readable verb for a position-style command.
38/// @param dev Device receiving the command.
39/// @param position Requested execute position.
40/// @return Log-friendly action string such as "open", "turn on", or "lock".
41const char *position_command_action(const IoDevice &dev, uint8_t position) {
42 if (position == POS_STOP)
43 return "stop";
44
46 return "set position";
47
48 bool const active_state = position == BINARY_ENTITY_ON_POSITION;
49 switch (device_capability_class(dev.type)) {
52 return active_state ? "turn on" : "turn off";
54 return active_state ? "unlock" : "lock";
60 default:
61 return active_state ? "open" : "close";
62 }
63}
64
65/// @brief Return the effective profile label for a device's "Sending ... (profile=...)" logs.
66/// @param dev Device receiving the command.
67/// @return "dimmable_light" for a LIGHT-class device with IoDevice::dimmable set (a YAML choice
68/// the wire protocol has no signal for, so device_operation_profile_name() alone can't
69/// know it); device_operation_profile_name(dev.type) for every other device.
70const char *operation_profile_name(const IoDevice &dev) {
71 if (dev.dimmable && device_capability_class(dev.type) == DeviceCapabilityClass::LIGHT)
72 return "dimmable_light";
73 return device_operation_profile_name(dev.type);
74}
75
76/// @brief Return the accepted entity/profile label for rejected execute-position logs.
77/// @param dev Device the command was rejected for.
78/// @param position Requested execute position.
79/// @return Expected profile label for detail::log_rejected_operation().
80const char *position_rejection_profile(const IoDevice &dev, uint8_t position) {
81 // A LIGHT-class device only reaches rejection for a genuinely out-of-range value (dimmable
82 // lights already accept the full 0-100 span in known_device_accepts_execute_position()) — the
83 // fix there isn't "needs cover_position", it's "needs a value in 0-100".
85 return "0-100";
86 return detail::is_binary_entity_position(position) ? "cover_position or binary_on_off" : "cover_position";
87}
88
89/// @brief The queue-time capability guard for one family of queued operation.
90///
91/// Every `queue_*` method applies the same early-reject as its execute-time counterpart, for fast
92/// user feedback, with a "queued ..." rejection noun. This table is the one place those pairings
93/// are recorded, so a deliberate asymmetry is a visible row rather than an accident. The lone
94/// `queue_*` method with a guard that is NOT a row here is `queue_device_command`: it returns
95/// bool and uses the command name as its rejection noun, so it keeps its guard inline (see there).
96/// NOTE the asymmetry `queue_set_device_position` carries a COVER guard that `set_device_position`
97/// deliberately does *not* — light/switch/lock all funnel through `set_device_position`, so an
98/// entity-class guard there would break them; at queue time each entity has its own method.
99struct QueueGuard {
100 bool (*accepts)(const IoDevice &dev); ///< false → reject this operation for this device.
101 const char *rejection_noun; ///< e.g. "queued cover command".
102 const char *expected; ///< e.g. "cover entity".
103};
104
105constexpr QueueGuard QUEUE_GUARD_COVER{
107 "queued cover command", "cover entity"};
108constexpr QueueGuard QUEUE_GUARD_TILT{[](const IoDevice &d) { return detail::known_device_accepts_execute_tilt(d); },
109 "queued tilt command", "tilt-capable cover"};
110constexpr QueueGuard QUEUE_GUARD_POSITION_AND_TILT{
111 [](const IoDevice &d) { return detail::known_device_accepts_execute_tilt(d); }, "queued position+tilt command",
112 "tilt-capable cover"};
113constexpr QueueGuard QUEUE_GUARD_LIGHT{
115 "queued light command", "light entity"};
116constexpr QueueGuard QUEUE_GUARD_LOCK{
118 "queued lock command", "lock entity"};
119constexpr QueueGuard QUEUE_GUARD_SWITCH{
121 "queued switch command", "switch entity"};
122constexpr QueueGuard QUEUE_GUARD_STATUS{
123 [](const IoDevice &d) { return detail::known_device_supports_status_requests(d); }, "queued status request",
124 "status-capable actuator"};
125
126/// @brief Apply one QueueGuard. Returns true (and logs) when the operation must be rejected.
127///
128/// Matches the historical guard exactly: an unregistered/unknown device (dev == nullptr) is *not*
129/// rejected here — it passes through so discovery and imported devices keep working.
130bool queue_guard_rejects(IOHomeControlComponent *hub, const std::string &device_id, const QueueGuard &guard) {
131 const IoDevice *dev = hub->get_device(device_id);
132 if (dev != nullptr && !guard.accepts(*dev)) {
133 detail::log_rejected_operation(device_id, *dev, guard.rejection_noun, guard.expected);
134 return true;
135 }
136 return false;
137}
138
139} // namespace
140
141void IOHomeControlComponent::arm_execute_confirmation_poll_(const std::string &device_id, bool for_stop) {
142 uint32_t const existing = this->poll_policy_.get_next_update(device_id);
143 uint32_t const delay_ms = settle_delay_ms(this->poll_policy_.get_interval(device_id), 0, for_stop);
144 this->begin_status_poll_tracking_(device_id, delay_ms);
145 if (existing != 0 && existing < millis() + delay_ms)
146 this->poll_policy_.set_next_update(device_id, existing);
147}
148
149// Execute an authenticated request on the standard command channel and, on success, feed the
150// device's reply back through the normal inbound status parser so all state normalization stays
151// in one place.
152bool IOHomeControlComponent::execute_request_and_update_(const std::string &device_id, const IoFrame &request,
153 bool warn_on_no_response, uint32_t retry_after_fail_ms,
154 uint8_t max_tries) {
155 IoFrame response;
156 const ExchangeOutcome outcome = this->send_and_receive_(request, response, FREQ_CH2, max_tries);
157 // An unconfirmed acceptance means the device authenticated the request but never closed the
158 // exchange. Whether that counts as success depends entirely on what the request was *for*:
159 // - a command (CMD_EXECUTE) is done — the device has it and is acting on it, and its own
160 // asynchronous status update carries the result a few seconds later;
161 // - a status poll or a name read exists to obtain a payload. Getting none means the question
162 // went unanswered, so it stays a failure and keeps the aggressive auth-shaped poll backoff
163 // that exists for precisely this shape of miss.
164 const bool unconfirmed_counts_as_success = request.cmd == CMD_EXECUTE;
165 if (outcome == ExchangeOutcome::FAILED ||
166 (outcome == ExchangeOutcome::SUCCESS_UNCONFIRMED && !unconfirmed_counts_as_success)) {
167 const auto &dbg = this->exchange_engine_.get_debug();
168 if (IoDevice *dev = this->registry_.get(device_id); dev != nullptr) {
169 detail::record_exchange_timeout(*dev, dbg.tries);
170 this->notify_device_update_(device_id);
171 }
172 if (retry_after_fail_ms != 0)
173 this->schedule_background_poll_backoff_(device_id, dbg.saw_challenge);
174 this->log_exchange_debug_(device_id.c_str());
175 if (warn_on_no_response) {
176 ESP_LOGW(detail::TAG, "Command 0x%02X failed for device %s: no valid response (stage=%s tries=%u)", request.cmd,
177 device_id.c_str(), dbg.stage, dbg.tries);
178 }
179 return false;
180 }
181
183 // The device authenticated the request, so it has the command; it just does not close the
184 // exchange with a reply (see ExchangeOutcome). There is no frame to parse, and inventing a
185 // position from a request we only know was *accepted* would be worse than leaving the last
186 // known state alone — the device's own asynchronous status update supplies the real one, and
187 // that path authenticates now. Clear the failure streaks: this was not a failure.
188 if (retry_after_fail_ms != 0)
189 this->poll_policy_.clear_failure_streaks(device_id);
190 if (IoDevice *dev = this->registry_.get(device_id); dev != nullptr) {
192 this->notify_device_update_(device_id);
193 }
194 return true;
195 }
196
197 if (response.cmd == CMD_ERROR_RESP)
198 return this->handle_error_response_(device_id, request, response, retry_after_fail_ms);
199
200 if (retry_after_fail_ms != 0)
201 this->poll_policy_.clear_failure_streaks(device_id);
202
203 // The immediate reply to our own CMD_EXECUTE (position/tilt/stop/favorite/vent) is not
204 // trustworthy for target/current position on at least some devices — see
205 // update_device_status_()'s trust_position doc comment. Every other request we send
206 // (status poll, get name, ...) keeps trusting its reply as before.
207 this->update_device_status_(response, request.cmd != CMD_EXECUTE);
208 return true;
209}
210
211bool IOHomeControlComponent::handle_error_response_(const std::string &device_id, const IoFrame &request,
212 const IoFrame &response, uint32_t retry_after_fail_ms) {
213 IoDevice *dev = this->registry_.get(device_id);
214 // An explicit refusal is still a reply from the device: this path returns before
215 // execute_request_and_update_()'s update_device_status_() call, so it must stamp link health
216 // itself to keep update_link_health()'s "every frame from a registered device" contract.
217 if (dev != nullptr)
219 if (response.data_len == 0) {
220 detail::log_frame_issue(this, "rx", "unsupported_payload", response, frame_length(response));
221 } else if (dev != nullptr) {
222 detail::record_command_result(*dev, device_id, response.data[0], request.cmd, true);
223 } else {
224 detail::log_command_result(device_id, response.data[0], request.cmd, true);
225 }
226 if (dev != nullptr)
227 this->notify_device_update_(device_id);
228 if (retry_after_fail_ms != 0)
229 this->schedule_background_poll_backoff_(device_id, this->exchange_engine_.get_debug().saw_challenge);
230 return false;
231}
232
233bool IOHomeControlComponent::run_execute_operation_(const std::string &device_id, const ExecuteRequestSpec &spec,
234 const std::function<bool(const IoDevice &)> &accepts,
235 const char *rejection_profile,
236 const std::function<bool(IoFrame &, const IoDevice &)> &build) {
237 // Every false return means this command will not happen: an unregistered or not-yet-initialized
238 // device, a profile guard rejection, a builder failure, or an exchange that ended with no valid
239 // response or an explicit CMD_ERROR_RESP. In all of them the prediction the entity applied at
240 // control() time must be withdrawn, or the Home Assistant cover animates a movement that is not
241 // occurring — indefinitely, since only a frame from the device can settle it.
242 const bool accepted = this->try_execute_operation_(device_id, spec, accepts, rejection_profile, build);
243 if (!accepted)
244 this->registry_.rollback_optimistic(device_id);
245 return accepted;
246}
247
248bool IOHomeControlComponent::try_execute_operation_(const std::string &device_id, const ExecuteRequestSpec &spec,
249 const std::function<bool(const IoDevice &)> &accepts,
250 const char *rejection_profile,
251 const std::function<bool(IoFrame &, const IoDevice &)> &build) {
252 auto *dev = this->get_device(device_id);
253 if (dev == nullptr || !this->initialized_)
254 return false;
255
256 // Once a device family is known, use the profile helpers to reject YAML/entity mismatches
257 // before they hit the radio path. Unknown types still pass through so discovery and imported
258 // devices keep working as before.
259 if (!accepts(*dev)) {
260 detail::log_rejected_operation(device_id, *dev, spec.action, rejection_profile);
261 return false;
262 }
263
264 this->begin_status_poll_tracking_(device_id, this->poll_policy_.get_interval(device_id));
265
266 ESP_LOGI(detail::TAG, "Sending %s to device %s (profile=%s)", spec.action, device_id.c_str(),
267 operation_profile_name(*dev));
268
269 IoFrame request;
270 if (!build(request, *dev)) {
271 this->poll_policy_.clear(device_id);
272 return false;
273 }
274 if (!this->execute_request_and_update_(device_id, request, true, 0)) {
275 this->schedule_background_poll_backoff_(device_id, this->exchange_engine_.get_debug().saw_challenge);
276 return false;
277 }
278 this->arm_execute_confirmation_poll_(device_id, spec.settle_as_stop);
279 return true;
280}
281
282bool IOHomeControlComponent::set_device_position(const std::string &device_id, uint8_t position) {
283 const auto *dev = this->get_device(device_id);
284 if (dev == nullptr)
285 return false;
286 // action and rejection profile depend on this device's class and the requested value, so
287 // resolve them here where dev is in scope; run_execute_operation_() re-checks dev/initialized_.
288 return this->run_execute_operation_(
289 device_id, {position_command_action(*dev, position), position == POS_STOP},
290 [position](const IoDevice &d) { return detail::known_device_accepts_execute_position(d, position); },
291 position_rejection_profile(*dev, position),
292 [this, position](IoFrame &request, const IoDevice &d) {
293 return create_execute_position(request, this->node_id_, d.node_id, d.low_power, position, d.silent);
294 });
295}
296
297bool IOHomeControlComponent::execute_device_command_(const std::string &device_id, CoverCommand cmd) {
298 return this->run_execute_operation_(
299 device_id, {cover_command_name(cmd), cmd == CoverCommand::STOP},
301 "cover entity",
302 [this, cmd](IoFrame &request, const IoDevice &d) {
303 // FORCE_OPEN needs the device's own wire-scale "fully open" position (0, or 100 for an
304 // IoDevice::inverted device such as a horizontal awning) — create_execute_command() has no
305 // device access to resolve that, so it is built separately here where d is in scope.
306 return cmd == CoverCommand::FORCE_OPEN
307 ? create_force_open(request, this->node_id_, d.node_id, d.low_power,
308 d.inverted ? FORCE_OPEN_WIRE_POSITION_INVERTED : FORCE_OPEN_WIRE_POSITION)
309 : create_execute_command(request, this->node_id_, d.node_id, d.low_power, cmd, d.silent);
310 });
311}
312
313bool IOHomeControlComponent::set_device_tilt(const std::string &device_id, uint8_t tilt_percent) {
314 char action[EXECUTE_ACTION_BUF_SIZE];
315 snprintf(action, sizeof(action), "tilt=%u%%", tilt_percent);
316 return this->run_execute_operation_(
317 device_id, {action, false}, [](const IoDevice &d) { return detail::known_device_accepts_execute_tilt(d); },
318 "tilt-capable cover",
319 [this, tilt_percent](IoFrame &request, const IoDevice &d) {
320 return create_execute_tilt(request, this->node_id_, d.node_id, d.low_power, tilt_percent);
321 });
322}
323
324bool IOHomeControlComponent::set_device_position_and_tilt(const std::string &device_id, uint8_t position,
325 uint8_t tilt_percent) {
326 char action[EXECUTE_ACTION_BUF_SIZE];
327 snprintf(action, sizeof(action), "position=%u%% tilt=%u%%", position, tilt_percent);
328 return this->run_execute_operation_(
329 device_id, {action, false}, [](const IoDevice &d) { return detail::known_device_accepts_execute_tilt(d); },
330 "tilt-capable cover",
331 [this, position, tilt_percent](IoFrame &request, const IoDevice &d) {
332 return create_execute_position_and_tilt(request, this->node_id_, d.node_id, d.low_power, position,
333 tilt_percent);
334 });
335}
336
337bool IOHomeControlComponent::request_device_status(const std::string &device_id) {
338 auto *dev = this->get_device(device_id);
339 if (dev == nullptr || !this->initialized_)
340 return false;
341
343 detail::log_rejected_operation(device_id, *dev, "status request", "status-capable actuator");
344 return false;
345 }
346
347 IoFrame request;
348 // Tilt-capable covers need the extended 0x03200100 status request so the response includes
349 // the reliable 16-byte tilt block. Other devices stay on the shorter generic request.
350 bool const request_ok = device_supports_tilt(dev->type)
351 ? create_get_status_tilt(request, this->node_id_, dev->node_id, dev->low_power)
352 : create_get_status(request, this->node_id_, dev->node_id, dev->low_power);
353 if (!request_ok)
354 return false;
355 // A poll the scheduler owns (StatusPollPolicy is tracking this device) is re-armed by the backoff
356 // ladder on failure, so the ladder is its retry mechanism and most slots need only one try. The
357 // exception is the middle of the ladder, where the slot that lands just after a manoeuvre ends is
358 // the one chance to catch a duty-cycled receiver — see decisions::scheduled_poll_max_tries(). A
359 // one-off poll with no ladder behind it keeps the full retry budget.
360 const bool scheduler_managed = this->poll_policy_.is_tracking_active(device_id, millis());
361 const uint32_t retry_after_fail_ms = scheduler_managed ? STATUS_RETRY_AFTER_FAIL_MS : 0;
362 const uint8_t max_tries =
363 scheduler_managed ? decisions::scheduled_poll_max_tries(this->poll_policy_.get_status_poll_failures(device_id),
364 this->poll_policy_.get_auth_poll_failures(device_id))
366 return this->execute_request_and_update_(device_id, request, false, retry_after_fail_ms, max_tries);
367}
368
369bool IOHomeControlComponent::request_device_name(const std::string &device_id) {
370 auto *dev = this->get_device(device_id);
371 if (dev == nullptr || !this->initialized_)
372 return false;
373
374 IoFrame request;
375 if (!create_get_name(request, this->node_id_, dev->node_id, dev->low_power))
376 return false;
377 return this->execute_request_and_update_(device_id, request, false, 0);
378}
379
380bool IOHomeControlComponent::set_light_position(const std::string &device_id, uint8_t position) {
381 auto *dev = this->get_device(device_id);
382 if (dev == nullptr || !this->initialized_)
383 return false;
384
386 detail::log_rejected_operation(device_id, *dev, "light command", "light entity");
387 return false;
388 }
389
390 // Light entities reuse the controller's existing execute path — the same position encoding
391 // covers use, confirmed on real dimmable hardware (see the somfy_izymo_dimmer_* captures).
392 return this->set_device_position(device_id, position);
393}
394
395bool IOHomeControlComponent::set_light_state(const std::string &device_id, bool on) {
397}
398
399bool IOHomeControlComponent::set_switch_state(const std::string &device_id, bool on) {
400 auto *dev = this->get_device(device_id);
401 if (dev == nullptr || !this->initialized_)
402 return false;
403
405 detail::log_rejected_operation(device_id, *dev, "switch command", "switch entity");
406 return false;
407 }
408
409 // Switches share the same transport-level representation as binary lights.
411}
412
413bool IOHomeControlComponent::set_lock_state(const std::string &device_id, bool locked) {
414 auto *dev = this->get_device(device_id);
415 if (dev == nullptr || !this->initialized_)
416 return false;
417
419 detail::log_rejected_operation(device_id, *dev, "lock command", "lock entity");
420 return false;
421 }
422
423 // Lock entities currently reuse the protocol's proven binary execute encoding:
424 // unlock maps to 0 and lock maps to 100.
426}
427
428bool IOHomeControlComponent::send_heating_command(const std::string &device_id, HeatingFunction fn, float value) {
429 auto *dev = this->get_device(device_id);
430 if (dev == nullptr || !this->initialized_)
431 return false;
432
433 // Capability gate: only via the predicate, never an inline device-type or vendor list.
434 if (!device_supports_climate_control(dev->type)) {
435 detail::log_rejected_operation(device_id, *dev, "heating command", "climate device");
436 return false;
437 }
438
439 uint8_t payload[HEATING_PAYLOAD_MAX_SIZE];
440 const size_t payload_len = encode_heating_payload(fn, value, payload);
441 if (payload_len == 0) {
442 ESP_LOGW(detail::TAG, "Heating command %s for device %s rejected: value %.2f out of range",
443 heating_function_name(fn), device_id.c_str(), value);
444 return false;
445 }
446
447 IoFrame request;
448 if (!create_write_private(request, this->node_id_, dev->node_id, dev->low_power, payload, payload_len)) {
449 ESP_LOGW(detail::TAG, "Heating command %s for device %s: failed to build frame", heating_function_name(fn),
450 device_id.c_str());
451 return false;
452 }
453
454 ESP_LOGI(detail::TAG, "Sending heating command %s to device %s", heating_function_name(fn), device_id.c_str());
455
456 IoFrame response;
457 const ExchangeOutcome outcome = this->send_and_receive_(request, response, FREQ_CH2);
458 const bool got_reply = outcome == ExchangeOutcome::SUCCESS_WITH_RESPONSE;
459 const bool acknowledged = got_reply && response.cmd == CMD_WRITE_PRIVATE_ACK;
460
461 // Feed the device-agnostic link-health / exchange-failure companion sensors either way, so a
462 // climate device is never silently invisible to the Last Contact / Exchange Failures
463 // diagnostics. A CMD_ERROR_RESP is still a reply — record its result code on the device so the
464 // "Last Result Code" diagnostic surfaces it, exactly as the cover path does.
465 // `dev` was resolved above via get_device(), which is registry_.get(); reuse it.
466 IoDevice &d = *dev;
467 if (outcome == ExchangeOutcome::FAILED) {
468 detail::record_exchange_timeout(d, this->exchange_engine_.get_debug().tries);
469 } else {
471 if (got_reply && response.cmd == CMD_ERROR_RESP && response.data_len > 0) {
472 detail::record_command_result(d, device_id, response.data[0], CMD_WRITE_PRIVATE, true);
473 } else if (acknowledged) {
475 }
476 }
477 this->notify_device_update_(device_id);
478
479 if (!acknowledged) {
480 this->log_exchange_debug_(device_id.c_str());
481 ESP_LOGW(detail::TAG, "Heating command %s not acknowledged by device %s (no CMD_WRITE_PRIVATE_ACK)",
482 heating_function_name(fn), device_id.c_str());
483 return false;
484 }
485
486 // 0x60 functions (power_on, midnight_sync) are register reads: the 0x21 ACK carries the answer
487 // (per the iown-homecontrol project's Atlantic/Thermor register map — a paired-device list for
488 // 0x012C, the ~17-byte comfort/eco/auto setpoint block for 0x0130). Nothing decodes it
489 // into an entity, but logging it lets a field tester read back what the radiator reports — for
490 // instance whether its own setpoint block exceeds 25.5 C. Log-only: success is not gated on the
491 // echo, one transcribed capture is not enough to make a mismatch a hard failure.
492 char ack_payload_hex[FRAME_LOG_HEX_BUFFER_SIZE];
493 bytes_to_hex(response.data, response.data_len, ack_payload_hex, sizeof(ack_payload_hex));
494 ESP_LOGD(detail::TAG, "heating %s ACK payload: %s", heating_function_name(fn), ack_payload_hex);
495 return true;
496}
497
498void IOHomeControlComponent::queue_set_device_position(const std::string &device_id, uint8_t position) {
499 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_COVER)) {
500 // control() applies the optimistic prediction before calling this method, so a queue-time
501 // guard rejection is a command that will not happen and must withdraw it, exactly as
502 // run_execute_operation_() does for a dispatch-time failure. No-op when nothing was predicted.
503 this->registry_.rollback_optimistic(device_id);
504 return;
505 }
506
507 // Pre-scan for a pending SET_TILT so we can log its value if coalescing happens.
508 uint8_t pending_tilt = 0;
509 for (const auto &op : this->op_queue_) {
510 if (op.type == PendingOperationType::SET_TILT && op.device_id == device_id) {
511 pending_tilt = op.position; // SET_TILT stores tilt in op.position
512 break;
513 }
514 }
515 if (this->op_queue_.enqueue_set_position(device_id, position)) {
516 ESP_LOGI(detail::TAG,
517 "Coalesced SET_POSITION (pos=%u) + pending SET_TILT (tilt=%u) → SET_POSITION_AND_TILT for "
518 "device %s",
519 position, pending_tilt, device_id.c_str());
520 }
521}
522
523bool IOHomeControlComponent::queue_device_command(const std::string &device_id, CoverCommand cmd) {
524 const IoDevice *dev = this->get_device(device_id);
525 // Every false return below is a command that will not happen; control() (STOP →
526 // apply_optimistic_stop) already predicted, so withdraw it — see queue_set_device_position().
527 if (!this->initialized_ || dev == nullptr) {
528 this->registry_.rollback_optimistic(device_id);
529 return false;
530 }
531 // Same COVER guard as QUEUE_GUARD_COVER, kept inline here: this method returns bool and its
532 // rejection noun is the specific command name rather than a fixed "queued cover command".
534 detail::log_rejected_operation(device_id, *dev, cover_command_name(cmd), "cover entity");
535 this->registry_.rollback_optimistic(device_id);
536 return false;
537 }
538 this->op_queue_.enqueue_device_command(device_id, cmd);
539 return true;
540}
541
542void IOHomeControlComponent::queue_set_device_tilt(const std::string &device_id, uint8_t tilt_percent) {
543 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_TILT)) {
544 // Withdraw the entity's optimistic prediction — see queue_set_device_position().
545 this->registry_.rollback_optimistic(device_id);
546 return;
547 }
548
549 // Pre-scan for a pending SET_POSITION so we can log its value if coalescing happens.
550 uint8_t pending_pos = 0;
551 for (const auto &op : this->op_queue_) {
552 if (op.type == PendingOperationType::SET_POSITION && op.device_id == device_id) {
553 pending_pos = op.position;
554 break;
555 }
556 }
557 if (this->op_queue_.enqueue_set_tilt(device_id, tilt_percent)) {
558 ESP_LOGI(detail::TAG,
559 "Coalesced pending SET_POSITION (pos=%u) + SET_TILT (tilt=%u) → "
560 "SET_POSITION_AND_TILT for device %s",
561 pending_pos, tilt_percent, device_id.c_str());
562 }
563}
564
565void IOHomeControlComponent::queue_set_device_position_and_tilt(const std::string &device_id, uint8_t position,
566 uint8_t tilt_percent) {
567 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_POSITION_AND_TILT)) {
568 // Withdraw the entity's optimistic prediction — see queue_set_device_position().
569 this->registry_.rollback_optimistic(device_id);
570 return;
571 }
572 this->op_queue_.enqueue_set_position_and_tilt(device_id, position, tilt_percent);
573}
574
575void IOHomeControlComponent::queue_request_device_status(const std::string &device_id) {
576 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_STATUS))
577 return;
578 // Keep at most one pending status poll per device. Without this, an overdue next_update can add
579 // the same poll on every main-loop iteration until the first queued request is finally processed.
580 this->op_queue_.enqueue_request_status(device_id);
581}
582
583void IOHomeControlComponent::queue_request_device_name(const std::string &device_id) {
584 if (this->get_device(device_id) == nullptr)
585 return;
586 this->op_queue_.enqueue_request_name(device_id);
587}
588
589/// Queue a discovery-and-pair request with elevated priority.
590///
591/// Flushes any pending status/name poll operations (which would consume time
592/// during the device's limited pairing window) and pushes discovery to the
593/// front of the queue. Duplicate requests are suppressed.
594void IOHomeControlComponent::queue_discover_and_pair() { this->op_queue_.enqueue_discover_and_pair(); }
595
599
601 if (this->busy_) {
602 // Same event/log path a rejected API call gets (ManagementActions::resolve_device_() and
603 // friends) -- a busy press must not go silent, since it's the one press outcome the API
604 // action can never produce (an automation-triggered press is the only way to reach this
605 // guard at all, see the doc comment on the declaration).
607 result.action = "scan_paired_devices";
608 result.message = "a radio exchange was already in progress; press the button again once it finishes";
609 this->management_actions_.publish_result(result);
610 return;
611 }
612 this->management_actions_.api_scan_paired_devices();
613}
614
615void IOHomeControlComponent::queue_set_light_position(const std::string &device_id, uint8_t position) {
616 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_LIGHT))
617 return;
618 this->op_queue_.enqueue_set_light_position(device_id, position);
619}
620
621void IOHomeControlComponent::queue_set_light_state(const std::string &device_id, bool on) {
623}
624
625void IOHomeControlComponent::queue_set_lock_state(const std::string &device_id, bool locked) {
626 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_LOCK))
627 return;
628 this->op_queue_.enqueue_set_lock_state(device_id, locked);
629}
630
631void IOHomeControlComponent::queue_set_switch_state(const std::string &device_id, bool on) {
632 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_SWITCH))
633 return;
634 this->op_queue_.enqueue_set_switch_state(device_id, on);
635}
636
637// === 1W transmit ===
638
639void IOHomeControlComponent::execute_oneway_command_(const std::string &controller_id, CoverCommand cmd) {
640 this->execute_oneway_([&] { this->oneway_transmitter_.send_command(controller_id, cmd); });
641}
642
643void IOHomeControlComponent::execute_oneway_position_(const std::string &controller_id, uint8_t position) {
644 this->execute_oneway_([&] { this->oneway_transmitter_.send_position(controller_id, position); });
645}
646
647void IOHomeControlComponent::execute_oneway_enroll_(const std::string &controller_id) {
648 this->execute_oneway_([&] { this->oneway_transmitter_.send_enrollment(controller_id); });
649}
650
651void IOHomeControlComponent::execute_oneway_unenroll_(const std::string &controller_id) {
652 this->execute_oneway_([&] { this->oneway_transmitter_.send_unenrollment(controller_id); });
653}
654
656 if (this->busy_ || this->op_queue_.empty())
657 return;
658
659 // Pop before dispatch so any handler that re-queues follow-up work sees the queue in its
660 // post-consumption state and cannot accidentally execute the same operation twice.
661 auto opt = this->op_queue_.pop();
662 if (!opt.has_value())
663 return;
664 const PendingOperation &operation = *opt;
665
666 switch (operation.type) {
668 this->set_device_position(operation.device_id, operation.position);
669 break;
671 this->set_device_tilt(operation.device_id, operation.position);
672 break;
674 this->set_device_position_and_tilt(operation.device_id, operation.position, operation.tilt);
675 break;
677 this->execute_device_command_(operation.device_id, operation.command);
678 break;
680 // operation.position already carries the target IO position (0-100) regardless of whether
681 // it was enqueued via queue_set_light_state() (binary extremes) or
682 // queue_set_light_position() (dimmable) — see enqueue_set_light_state()'s thin-wrapper doc.
683 this->set_light_position(operation.device_id, operation.position);
684 break;
686 this->set_lock_state(operation.device_id, operation.position == BINARY_ENTITY_OFF_POSITION);
687 break;
689 this->set_switch_state(operation.device_id, operation.position == BINARY_ENTITY_ON_POSITION);
690 break;
692 // device_id carries the controller-identity handle for 1W ops — see PendingOperation.
693 this->execute_oneway_command_(operation.device_id, operation.command);
694 break;
696 this->execute_oneway_position_(operation.device_id, operation.position);
697 break;
699 this->execute_oneway_enroll_(operation.device_id);
700 break;
702 this->execute_oneway_unenroll_(operation.device_id);
703 break;
705 this->request_device_status(operation.device_id);
706 break;
708 this->request_device_name(operation.device_id);
709 break;
711 this->discover_and_pair();
712 break;
713 }
714}
715
716} // namespace home_io_control
717} // namespace esphome
The main IO-Homecontrol component.
Definition hub_core.h:90
std::string describe_last_commander(const IoDevice &dev) const
Render a device's "last commanded by" string, resolving this hub's own node ID.
virtual bool set_lock_state(const std::string &device_id, bool locked)
Semantic lock helper for lock entities.
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.
void execute_oneway_position_(const std::string &controller_id, uint8_t position)
Send a queued 1W numeric position.
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.
void execute_oneway_enroll_(const std::string &controller_id)
Send a queued 1W enrollment (add-controller).
virtual bool send_heating_command(const std::string &device_id, HeatingFunction fn, float value)
The single hub-side transmit path for 2W heating/climate control (CMD_WRITE_PRIVATE 0x20).
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 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:928
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.
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 IoDevice * get_device(const std::string &device_id)
Retrieve a device by ID; returns nullptr if not found.
Definition hub_core.cpp:313
virtual void queue_request_device_status(const std::string &device_id)
Queue an async status request; returns immediately, executed in loop().
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:272
void process_pending_operation_()
Pop next pending operation from the queue and execute it (set position, request status,...
void execute_oneway_unenroll_(const std::string &controller_id)
Send a queued 1W un-enrollment (remove-controller).
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.
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:962
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:295
OneWayTransmitter oneway_transmitter_
Owns the 1W controller identities, their rolling-sequence counters and the transmit burst.
Definition hub_core.h:1100
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:1093
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:136
ManagementActions management_actions_
Owns rename, identify, force-open, scan_paired_devices, and other hub-level HA actions.
Definition hub_core.h:1095
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:285
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.
void trigger_scan_paired_devices()
Entry point for the "Scan Paired Devices" button: run the roll-call and publish its report to the log...
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.
void execute_oneway_command_(const std::string &controller_id, CoverCommand cmd)
Send a queued 1W named command.
Pure transition helpers for hub-owned exchange and pairing frame decisions.
Internal helpers shared by the hub implementation .cpp files.
uint8_t scheduled_poll_max_tries(uint8_t status_poll_failures, uint8_t auth_poll_failures)
Transmit-attempt budget for a scheduler-owned status poll, by backoff-ladder position.
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?
std::string describe_last_commander(const IoDevice &dev, const uint8_t *hub_node_id)
Render the "Last Commanded By" sensor string.
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 clear_command_result(IoDevice &dev)
Clear a previously recorded CMD_ERROR_RESP result, if any.
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).
uint32_t settle_delay_ms(uint32_t interval_ms, uint32_t hint_delay_ms, bool cap_for_stop)
Resolve the follow-up settle-poll delay while a device may still be moving.
void bytes_to_hex(const uint8_t *data, uint8_t len, char *out, size_t out_size)
Definition log_frame.h:22
bool create_get_status(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a get-status request (0x03). The device responds with its current position.
constexpr size_t HEATING_PAYLOAD_MAX_SIZE
Largest payload any function produces — SET_TEMPERATURE's 6-byte form (iohcCozyDevice2W....
bool create_write_private(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, const uint8_t *payload, size_t payload_len)
Build a generic CMD_WRITE_PRIVATE (0x20) frame around a caller-supplied payload — the one builder beh...
@ 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).
@ ONEWAY_COMMAND
1W named command sent as a controller identity.
@ DISCOVER_AND_PAIR
discover_and_pair call (starts 3-phase pairing flow).
@ REQUEST_STATUS
request_device_status call (poll for current position).
@ ONEWAY_UNENROLL
1W remove-controller (0x39) un-registering an identity.
@ DEVICE_COMMAND
Named device command (STOP, FAVORITE, VENT).
@ SET_POSITION
set_device_position call (position 0–100 or special values).
@ ONEWAY_ENROLL
1W add-controller (0x30) registering an identity.
@ ONEWAY_POSITION
1W numeric position sent as a controller identity.
static constexpr uint8_t CMD_ERROR_RESP
Error response to any command.
size_t encode_heating_payload(HeatingFunction fn, float value, uint8_t out[HEATING_PAYLOAD_MAX_SIZE])
Encode one heating function into a CMD_WRITE_PRIVATE (0x20) payload.
HeatingFunction
Heating functions, one per user-pressable radiator button in the reference.
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 device_supports_climate_control(DeviceType type)
Does this device type support 2W climate/heating control (CMD_WRITE_PRIVATE 0x20)?
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.
static constexpr uint8_t CMD_WRITE_PRIVATE
Write private register (climate/heating devices).
const char * heating_function_name(HeatingFunction fn)
Stable lowercase name for a heating function ("power_on", "set_temperature", ...).
uint8_t frame_length(const IoFrame &f)
Get total frame length from ctrl0.
static constexpr uint8_t CMD_WRITE_PRIVATE_ACK
Acknowledgment to CMD_WRITE_PRIVATE.
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.
bool create_execute_position(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t position, bool silent)
Build a position execute command (0x00) to move a device to a numeric position.
const char * cover_command_name(CoverCommand cmd)
Get a human-readable name for a CoverCommand.
bool create_execute_command(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, CoverCommand cmd, bool silent)
Build a named-command execute frame (0x00) for STOP, FAVORITE, or VENT.
ExchangeOutcome
Authenticated exchange engine — outbound and inbound protocol flows.
@ SUCCESS_WITH_RESPONSE
Device replied; the caller's response frame is populated.
@ SUCCESS_UNCONFIRMED
Device authenticated the request — so it received and accepted it — but sent no final response.
@ FAILED
No usable reply; the device may never have heard the request.
constexpr size_t FRAME_LOG_HEX_BUFFER_SIZE
Fits a full 32-byte frame rendered as spaced hex text.
Definition log_frame.h:19
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 EXCHANGE_RETRY_COUNT
Attempts per command before reporting failure.
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_get_status_tilt(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a tilt-aware get-status request (0x03) that returns the extended 16-byte tilt payload.
@ CLIMATE
Climate device (heating/cooling).
@ COVER
Position‑controlled cover (shutter/blind/awning).
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 device_supports_tilt(DeviceType type)
Does this device type support tilt (slat angle) control?
Command builders for the IO‑Homecontrol protocol.
Everything one execute-family operation needs beyond its own guard and frame builder.
Definition hub_core.h:885
const char * action
Verb/phrase for the "Sending ..." and rejection logs.
Definition hub_core.h:886
bool settle_as_stop
Passed through to arm_execute_confirmation_poll_().
Definition hub_core.h:887
Runtime state of a paired IO‑Homecontrol device.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:88
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:94
uint8_t data_len
Actual length of data.
Definition proto_frame.h:95
std::string action
Action name, e.g. "rename_device".
std::string message
Human-readable outcome summary.
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") — except for the ONEWAY_* types, where it carries the c...
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).