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 if (for_stop)
148 this->poll_policy_.mark_stop_settle(device_id);
149}
150
151// Execute an authenticated request on the standard command channel and, on success, feed the
152// device's reply back through the normal inbound status parser so all state normalization stays
153// in one place.
154bool IOHomeControlComponent::execute_request_and_update_(const std::string &device_id, const IoFrame &request,
155 bool warn_on_no_response, uint32_t retry_after_fail_ms,
156 uint8_t max_tries) {
157 IoFrame response;
158 const ExchangeOutcome outcome = this->send_and_receive_(request, response, FREQ_CH2, max_tries);
159 // An unconfirmed acceptance means the device authenticated the request but never closed the
160 // exchange. Whether that counts as success depends entirely on what the request was *for*:
161 // - a command (CMD_EXECUTE) is treated as done. The device has it, and most devices that end
162 // this way report the result in their own status update a few seconds later. It is not proof
163 // the command was carried out, though, so the engine has already sent it once more where the
164 // silence was an anomaly (decisions::retry_after_unconfirmed_accept_is_safe()), and the
165 // confirmation poll corrects the state if the device did not act;
166 // - a status poll or a name read exists to obtain a payload. Getting none means the question
167 // went unanswered, so it stays a failure and keeps the aggressive auth-shaped poll backoff
168 // that exists for precisely this shape of miss.
169 const bool unconfirmed_counts_as_success = request.cmd == CMD_EXECUTE;
170
171 // Recorded once, here, for every outcome — deliberately above the split below, because the rule
172 // is about the outcome and not about how this request chooses to classify it. The two branches
173 // disagree on whether an unconfirmed acceptance is a failure; they must not disagree on whether it
174 // happened. The unconfirmed count is the only record of the CMD_EXECUTE case, which reports
175 // success, and it is what separates "the device never heard us" from "it heard us and the reply
176 // was lost" in the branch that does count as a failure.
177 if (IoDevice *dev = this->registry_.get(device_id); dev != nullptr)
178 detail::record_exchange_outcome(*dev, request.cmd, outcome);
179
180 if (outcome == ExchangeOutcome::FAILED ||
181 (outcome == ExchangeOutcome::SUCCESS_UNCONFIRMED && !unconfirmed_counts_as_success)) {
182 const auto &dbg = this->exchange_engine_.get_debug();
183 if (IoDevice *dev = this->registry_.get(device_id); dev != nullptr) {
184 detail::record_exchange_timeout(*dev, dbg.tries);
185 // A 0x3C challenge seen during the exchange is itself a frame from this device: the exchange
186 // still failed (our challenge-response or the device's final reply was lost), but the device
187 // demonstrably transmitted. Stamp link health the same way the explicit-refusal branch does,
188 // or a device that challenges every attempt yet never closes the exchange ages exactly like
189 // a powered-off one.
190 if (dbg.saw_challenge)
192 this->notify_device_update_(device_id);
193 }
194 if (retry_after_fail_ms != 0)
195 this->schedule_background_poll_backoff_(device_id, dbg.saw_challenge);
196 this->log_exchange_debug_(device_id.c_str());
197 if (warn_on_no_response) {
198 ESP_LOGW(detail::TAG, "Command 0x%02X failed for device %s: no valid response (stage=%s tries=%u)", request.cmd,
199 device_id.c_str(), dbg.stage, dbg.tries);
200 }
201 return false;
202 }
203
205 // The device challenged the request, so it received it; whether it also received our challenge
206 // answer — and therefore acted — is not observable from here (see the WAIT_FINAL_RESPONSE
207 // branch in ExchangeEngine::send_and_receive()). There is no frame to parse, and inventing a
208 // position from a request we only know was *challenged* would be worse than leaving the last
209 // known state alone — the device's own asynchronous status update supplies the real one, and
210 // that path authenticates now. Clear the failure streaks: this was not a failure.
211 //
212 // Log the snapshot anyway. This branch is the one exchange ending that prints nothing at all
213 // otherwise, so a device that routinely stops replying here leaves no trace to diagnose, and
214 // its final_rx_* fields (did the radio receive anything during the final wait?) are what
215 // separates a lost answer from a lost reply. Its cap_* fields describe the device's challenge,
216 // not that wait.
217 this->log_exchange_unconfirmed_debug_(device_id.c_str());
218 if (retry_after_fail_ms != 0)
219 this->poll_policy_.clear_failure_streaks(device_id);
220 if (IoDevice *dev = this->registry_.get(device_id); dev != nullptr) {
222 this->notify_device_update_(device_id);
223 }
224 return true;
225 }
226
227 if (response.cmd == CMD_ERROR_RESP)
228 return this->handle_error_response_(device_id, request, response, retry_after_fail_ms);
229
230 if (retry_after_fail_ms != 0)
231 this->poll_policy_.clear_failure_streaks(device_id);
232
233 // The immediate reply to our own CMD_EXECUTE (position/tilt/stop/favorite/vent) is not
234 // trustworthy for target/current position on at least some devices — see
235 // update_device_status_()'s trust_position doc comment. Every other request we send
236 // (status poll, get name, ...) keeps trusting its reply as before.
237 this->update_device_status_(response, request.cmd != CMD_EXECUTE);
238 return true;
239}
240
241bool IOHomeControlComponent::handle_error_response_(const std::string &device_id, const IoFrame &request,
242 const IoFrame &response, uint32_t retry_after_fail_ms) {
243 IoDevice *dev = this->registry_.get(device_id);
244 // An explicit refusal is still a reply from the device: this path returns before
245 // execute_request_and_update_()'s update_device_status_() call, so it must stamp link health
246 // itself to keep update_link_health()'s "every frame from a registered device" contract.
247 if (dev != nullptr)
249 if (response.data_len == 0) {
250 detail::log_frame_issue(this, "rx", "unsupported_payload", response, frame_length(response));
251 } else if (dev != nullptr) {
252 detail::record_command_result(*dev, device_id, response.data[0], request.cmd, true);
253 } else {
254 detail::log_command_result(device_id, response.data[0], request.cmd, true);
255 }
256 if (dev != nullptr)
257 this->notify_device_update_(device_id);
258 if (retry_after_fail_ms != 0)
260 return false;
261}
262
263bool IOHomeControlComponent::run_execute_operation_(const std::string &device_id, const ExecuteRequestSpec &spec,
264 const std::function<bool(const IoDevice &)> &accepts,
265 const char *rejection_profile,
266 const std::function<bool(IoFrame &, const IoDevice &)> &build) {
267 // Every false return means this command will not happen: an unregistered or not-yet-initialized
268 // device, a profile guard rejection, a builder failure, or an exchange that ended with no valid
269 // response or an explicit CMD_ERROR_RESP. In all of them the prediction the entity applied at
270 // control() time must be withdrawn, or the Home Assistant cover animates a movement that is not
271 // occurring — indefinitely, since only a frame from the device can settle it.
272 const bool accepted = this->try_execute_operation_(device_id, spec, accepts, rejection_profile, build);
273 if (accepted && spec.settle_as_stop) {
274 this->registry_.confirm_optimistic_stop(device_id);
275 // The receiver was just told to stop, so whatever moving evidence it had is spent.
276 if (IoDevice *dev = this->registry_.get(device_id); dev != nullptr)
278 } else if (accepted) {
279 // An accepted movement command means the receiver is travelling now, whether or not the reply
280 // carried a status to decode (ExchangeOutcome::SUCCESS_UNCONFIRMED has none). Stamped here,
281 // after the exchange, and not when the optimistic overlay is applied before it: see
282 // note_moving_evidence().
283 if (IoDevice *dev = this->registry_.get(device_id); dev != nullptr)
284 note_moving_evidence(*dev, millis());
285 } else {
286 this->registry_.rollback_optimistic(device_id, /*failed_stop=*/spec.settle_as_stop);
287 }
288 return accepted;
289}
290
291bool IOHomeControlComponent::try_execute_operation_(const std::string &device_id, const ExecuteRequestSpec &spec,
292 const std::function<bool(const IoDevice &)> &accepts,
293 const char *rejection_profile,
294 const std::function<bool(IoFrame &, const IoDevice &)> &build) {
295 auto *dev = this->get_device(device_id);
296 if (dev == nullptr || !this->initialized_)
297 return false;
298
299 // Once a device family is known, use the profile helpers to reject YAML/entity mismatches
300 // before they hit the radio path. Unknown types still pass through so discovery and imported
301 // devices keep working as before.
302 if (!accepts(*dev)) {
303 detail::log_rejected_operation(device_id, *dev, spec.action, rejection_profile);
304 return false;
305 }
306
307 this->begin_status_poll_tracking_(device_id, this->poll_policy_.get_interval(device_id));
308
309 ESP_LOGI(detail::TAG, "Sending %s to device %s (profile=%s)", spec.action, device_id.c_str(),
310 operation_profile_name(*dev));
311
312 IoFrame request;
313 if (!build(request, *dev)) {
314 this->poll_policy_.clear(device_id);
315 return false;
316 }
317 if (!this->execute_request_and_update_(device_id, request, true, 0)) {
319 return false;
320 }
321 this->arm_execute_confirmation_poll_(device_id, spec.settle_as_stop);
322 return true;
323}
324
325bool IOHomeControlComponent::set_device_position(const std::string &device_id, uint8_t position) {
326 const auto *dev = this->get_device(device_id);
327 if (dev == nullptr)
328 return false;
329 // action and rejection profile depend on this device's class and the requested value, so
330 // resolve them here where dev is in scope; run_execute_operation_() re-checks dev/initialized_.
331 return this->run_execute_operation_(
332 device_id, {position_command_action(*dev, position), position == POS_STOP},
333 [position](const IoDevice &d) { return detail::known_device_accepts_execute_position(d, position); },
334 position_rejection_profile(*dev, position),
335 [this, position](IoFrame &request, const IoDevice &d) {
336 return create_execute_position(request, this->node_id_, d.node_id, d.low_power, position, d.silent);
337 });
338}
339
340bool IOHomeControlComponent::execute_device_command_(const std::string &device_id, CoverCommand cmd) {
341 return this->run_execute_operation_(
342 device_id, {cover_command_name(cmd), cmd == CoverCommand::STOP},
344 "cover entity",
345 [this, cmd](IoFrame &request, const IoDevice &d) {
346 // FORCE_OPEN needs the device's own wire-scale "fully open" position (0, or 100 for an
347 // IoDevice::inverted device such as a horizontal awning) — create_execute_command() has no
348 // device access to resolve that, so it is built separately here where d is in scope.
349 return cmd == CoverCommand::FORCE_OPEN
350 ? create_force_open(request, this->node_id_, d.node_id, d.low_power,
351 d.inverted ? FORCE_OPEN_WIRE_POSITION_INVERTED : FORCE_OPEN_WIRE_POSITION)
352 : create_execute_command(request, this->node_id_, d.node_id, d.low_power, cmd, d.silent);
353 });
354}
355
356bool IOHomeControlComponent::set_device_tilt(const std::string &device_id, uint8_t tilt_percent) {
357 char action[EXECUTE_ACTION_BUF_SIZE];
358 snprintf(action, sizeof(action), "tilt=%u%%", tilt_percent);
359 return this->run_execute_operation_(
360 device_id, {action, false}, [](const IoDevice &d) { return detail::known_device_accepts_execute_tilt(d); },
361 "tilt-capable cover",
362 [this, tilt_percent](IoFrame &request, const IoDevice &d) {
363 return create_execute_tilt(request, this->node_id_, d.node_id, d.low_power, tilt_percent);
364 });
365}
366
367bool IOHomeControlComponent::set_device_position_and_tilt(const std::string &device_id, uint8_t position,
368 uint8_t tilt_percent) {
369 char action[EXECUTE_ACTION_BUF_SIZE];
370 snprintf(action, sizeof(action), "position=%u%% tilt=%u%%", position, tilt_percent);
371 return this->run_execute_operation_(
372 device_id, {action, false}, [](const IoDevice &d) { return detail::known_device_accepts_execute_tilt(d); },
373 "tilt-capable cover",
374 [this, position, tilt_percent](IoFrame &request, const IoDevice &d) {
375 return create_execute_position_and_tilt(request, this->node_id_, d.node_id, d.low_power, position,
376 tilt_percent);
377 });
378}
379
380bool IOHomeControlComponent::request_device_status(const std::string &device_id) {
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, "status request", "status-capable actuator");
387 return false;
388 }
389
390 IoFrame request;
391 // Tilt-capable covers need the extended 0x03200100 status request so the response includes
392 // the reliable 16-byte tilt block. Other devices stay on the shorter generic request.
393 bool const request_ok = device_supports_tilt(dev->type)
394 ? create_get_status_tilt(request, this->node_id_, dev->node_id, dev->low_power)
395 : create_get_status(request, this->node_id_, dev->node_id, dev->low_power);
396 if (!request_ok)
397 return false;
398 // A poll the scheduler owns (StatusPollPolicy is tracking this device) is re-armed by the backoff
399 // ladder on failure, so the ladder is its retry mechanism and most slots need only one try. The
400 // exception is the middle of the ladder, where the slot that lands just after a manoeuvre ends is
401 // the one chance to catch a duty-cycled receiver, and the poll confirming an accepted STOP — see
402 // decisions::scheduled_poll_max_tries(). A one-off poll with no ladder behind it keeps the full
403 // retry budget.
404 const bool scheduler_managed = this->poll_policy_.is_tracking_active(device_id, millis());
405 const uint32_t retry_after_fail_ms = scheduler_managed ? STATUS_RETRY_AFTER_FAIL_MS : 0;
406 const uint8_t max_tries =
408 this->poll_policy_.get_auth_poll_failures(device_id),
409 this->poll_policy_.take_stop_settle(device_id))
410 : EXCHANGE_RETRY_COUNT;
411 return this->execute_request_and_update_(device_id, request, false, retry_after_fail_ms, max_tries);
412}
413
414bool IOHomeControlComponent::request_device_name(const std::string &device_id) {
415 auto *dev = this->get_device(device_id);
416 if (dev == nullptr || !this->initialized_)
417 return false;
418
419 IoFrame request;
420 if (!create_get_name(request, this->node_id_, dev->node_id, dev->low_power))
421 return false;
422 return this->execute_request_and_update_(device_id, request, false, 0);
423}
424
425bool IOHomeControlComponent::set_light_position(const std::string &device_id, uint8_t position) {
426 auto *dev = this->get_device(device_id);
427 if (dev == nullptr || !this->initialized_)
428 return false;
429
431 detail::log_rejected_operation(device_id, *dev, "light command", "light entity");
432 return false;
433 }
434
435 // Light entities reuse the controller's existing execute path — the same position encoding
436 // covers use, confirmed on real dimmable hardware (see the somfy_izymo_dimmer_* captures).
437 return this->set_device_position(device_id, position);
438}
439
440bool IOHomeControlComponent::set_light_state(const std::string &device_id, bool on) {
441 return this->set_light_position(device_id, on ? BINARY_ENTITY_ON_POSITION : BINARY_ENTITY_OFF_POSITION);
442}
443
444bool IOHomeControlComponent::set_switch_state(const std::string &device_id, bool on) {
445 auto *dev = this->get_device(device_id);
446 if (dev == nullptr || !this->initialized_)
447 return false;
448
450 detail::log_rejected_operation(device_id, *dev, "switch command", "switch entity");
451 return false;
452 }
453
454 // Switches share the same transport-level representation as binary lights.
455 return this->set_device_position(device_id, on ? BINARY_ENTITY_ON_POSITION : BINARY_ENTITY_OFF_POSITION);
456}
457
458bool IOHomeControlComponent::set_lock_state(const std::string &device_id, bool locked) {
459 auto *dev = this->get_device(device_id);
460 if (dev == nullptr || !this->initialized_)
461 return false;
462
464 detail::log_rejected_operation(device_id, *dev, "lock command", "lock entity");
465 return false;
466 }
467
468 // Lock entities currently reuse the protocol's proven binary execute encoding:
469 // unlock maps to 0 and lock maps to 100.
470 return this->set_device_position(device_id, locked ? BINARY_ENTITY_OFF_POSITION : BINARY_ENTITY_ON_POSITION);
471}
472
473bool IOHomeControlComponent::send_heating_command(const std::string &device_id, HeatingFunction fn, float value) {
474 auto *dev = this->get_device(device_id);
475 if (dev == nullptr || !this->initialized_)
476 return false;
477
478 // Capability gate: only via the predicate, never an inline device-type or vendor list.
479 if (!device_supports_climate_control(dev->type)) {
480 detail::log_rejected_operation(device_id, *dev, "heating command", "climate device");
481 return false;
482 }
483
484 uint8_t payload[HEATING_PAYLOAD_MAX_SIZE];
485 const size_t payload_len = encode_heating_payload(fn, value, payload);
486 if (payload_len == 0) {
487 ESP_LOGW(detail::TAG, "Heating command %s for device %s rejected: value %.2f out of range",
488 heating_function_name(fn), device_id.c_str(), value);
489 return false;
490 }
491
492 IoFrame request;
493 if (!create_write_private(request, this->node_id_, dev->node_id, dev->low_power, payload, payload_len)) {
494 ESP_LOGW(detail::TAG, "Heating command %s for device %s: failed to build frame", heating_function_name(fn),
495 device_id.c_str());
496 return false;
497 }
498
499 ESP_LOGI(detail::TAG, "Sending heating command %s to device %s", heating_function_name(fn), device_id.c_str());
500
501 IoFrame response;
502 const ExchangeOutcome outcome = this->send_and_receive_(request, response, FREQ_CH2);
503 const bool got_reply = outcome == ExchangeOutcome::SUCCESS_WITH_RESPONSE;
504 const bool acknowledged = got_reply && response.cmd == CMD_WRITE_PRIVATE_ACK;
505
506 // Feed the device-agnostic link-health / exchange-failure companion sensors either way, so a
507 // climate device is never silently invisible to the Last Contact / Exchange Failures
508 // diagnostics. A CMD_ERROR_RESP is still a reply — record its result code on the device so the
509 // "Last Result Code" diagnostic surfaces it, exactly as the cover path does.
510 // `dev` was resolved above via get_device(), which is registry_.get(); reuse it.
511 IoDevice &d = *dev;
512 // Same rule as execute_request_and_update_(): an unconfirmed acceptance is counted wherever it
513 // happens, or a climate device that authenticates and then goes silent stays invisible in the
514 // very diagnostic built to surface that, while a cover doing the same thing is counted.
515 detail::record_exchange_outcome(d, request.cmd, outcome);
516 if (outcome == ExchangeOutcome::FAILED) {
518 } else {
520 if (got_reply && response.cmd == CMD_ERROR_RESP && response.data_len > 0) {
521 detail::record_command_result(d, device_id, response.data[0], CMD_WRITE_PRIVATE, true);
522 } else if (acknowledged) {
524 }
525 }
526 this->notify_device_update_(device_id);
527
528 if (!acknowledged) {
529 this->log_exchange_debug_(device_id.c_str());
530 ESP_LOGW(detail::TAG, "Heating command %s not acknowledged by device %s (no CMD_WRITE_PRIVATE_ACK)",
531 heating_function_name(fn), device_id.c_str());
532 return false;
533 }
534
535 // 0x60 functions (power_on, midnight_sync) are register reads: the 0x21 ACK carries the answer
536 // (per the iown-homecontrol project's Atlantic/Thermor register map — a paired-device list for
537 // 0x012C, the ~17-byte comfort/eco/auto setpoint block for 0x0130). Nothing decodes it
538 // into an entity, but logging it lets a field tester read back what the radiator reports — for
539 // instance whether its own setpoint block exceeds 25.5 C. Log-only: success is not gated on the
540 // echo, one transcribed capture is not enough to make a mismatch a hard failure.
541 char ack_payload_hex[FRAME_LOG_HEX_BUFFER_SIZE];
542 bytes_to_hex(response.data, response.data_len, ack_payload_hex, sizeof(ack_payload_hex));
543 ESP_LOGD(detail::TAG, "heating %s ACK payload: %s", heating_function_name(fn), ack_payload_hex);
544 return true;
545}
546
547void IOHomeControlComponent::queue_set_device_position(const std::string &device_id, uint8_t position) {
548 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_COVER)) {
549 // control() applies the optimistic prediction before calling this method, so a queue-time
550 // guard rejection is a command that will not happen and must withdraw it, exactly as
551 // run_execute_operation_() does for a dispatch-time failure. No-op when nothing was predicted.
552 this->registry_.rollback_optimistic(device_id);
553 return;
554 }
555
556 // Pre-scan for a pending SET_TILT so we can log its value if coalescing happens.
557 uint8_t pending_tilt = 0;
558 for (const auto &op : this->op_queue_) {
559 if (op.type == PendingOperationType::SET_TILT && op.device_id == device_id) {
560 pending_tilt = op.position; // SET_TILT stores tilt in op.position
561 break;
562 }
563 }
564 if (this->op_queue_.enqueue_set_position(device_id, position)) {
565 ESP_LOGI(detail::TAG,
566 "Coalesced SET_POSITION (pos=%u) + pending SET_TILT (tilt=%u) → SET_POSITION_AND_TILT for "
567 "device %s",
568 position, pending_tilt, device_id.c_str());
569 }
570}
571
572bool IOHomeControlComponent::queue_device_command(const std::string &device_id, CoverCommand cmd) {
573 const IoDevice *dev = this->get_device(device_id);
574 // Every false return below is a command that will not happen; control() (STOP →
575 // apply_optimistic_stop) already predicted, so withdraw it — see queue_set_device_position().
576 const bool is_stop = cmd == CoverCommand::STOP;
577 if (!this->initialized_ || dev == nullptr) {
578 this->registry_.rollback_optimistic(device_id, /*failed_stop=*/is_stop);
579 return false;
580 }
581 // Same COVER guard as QUEUE_GUARD_COVER, kept inline here: this method returns bool and its
582 // rejection noun is the specific command name rather than a fixed "queued cover command".
584 detail::log_rejected_operation(device_id, *dev, cover_command_name(cmd), "cover entity");
585 this->registry_.rollback_optimistic(device_id, /*failed_stop=*/is_stop);
586 return false;
587 }
588 this->op_queue_.enqueue_device_command(device_id, cmd);
589 return true;
590}
591
592void IOHomeControlComponent::queue_set_device_tilt(const std::string &device_id, uint8_t tilt_percent) {
593 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_TILT)) {
594 // Withdraw the entity's optimistic prediction — see queue_set_device_position().
595 this->registry_.rollback_optimistic(device_id);
596 return;
597 }
598
599 // Pre-scan for a pending SET_POSITION so we can log its value if coalescing happens.
600 uint8_t pending_pos = 0;
601 for (const auto &op : this->op_queue_) {
602 if (op.type == PendingOperationType::SET_POSITION && op.device_id == device_id) {
603 pending_pos = op.position;
604 break;
605 }
606 }
607 if (this->op_queue_.enqueue_set_tilt(device_id, tilt_percent)) {
608 ESP_LOGI(detail::TAG,
609 "Coalesced pending SET_POSITION (pos=%u) + SET_TILT (tilt=%u) → "
610 "SET_POSITION_AND_TILT for device %s",
611 pending_pos, tilt_percent, device_id.c_str());
612 }
613}
614
615void IOHomeControlComponent::queue_set_device_position_and_tilt(const std::string &device_id, uint8_t position,
616 uint8_t tilt_percent) {
617 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_POSITION_AND_TILT)) {
618 // Withdraw the entity's optimistic prediction — see queue_set_device_position().
619 this->registry_.rollback_optimistic(device_id);
620 return;
621 }
622 this->op_queue_.enqueue_set_position_and_tilt(device_id, position, tilt_percent);
623}
624
625void IOHomeControlComponent::queue_request_device_status(const std::string &device_id) {
626 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_STATUS))
627 return;
628 // Keep at most one pending status poll per device. Without this, an overdue next_update can add
629 // the same poll on every main-loop iteration until the first queued request is finally processed.
630 this->op_queue_.enqueue_request_status(device_id);
631}
632
633void IOHomeControlComponent::queue_request_device_name(const std::string &device_id) {
634 if (this->get_device(device_id) == nullptr)
635 return;
636 this->op_queue_.enqueue_request_name(device_id);
637}
638
639/// Queue a discovery-and-pair request with elevated priority.
640///
641/// Flushes any pending status/name poll operations (which would consume time
642/// during the device's limited pairing window) and pushes discovery to the
643/// front of the queue. Duplicate requests are suppressed.
645
649
651 if (this->busy_) {
652 // Same event/log path a rejected API call gets (ManagementActions::resolve_device_() and
653 // friends) -- a busy press must not go silent, since it's the one press outcome the API
654 // action can never produce (an automation-triggered press is the only way to reach this
655 // guard at all, see the doc comment on the declaration).
657 result.action = "scan_paired_devices";
658 result.message = "a radio exchange was already in progress; press the button again once it finishes";
660 return;
661 }
663}
664
665void IOHomeControlComponent::queue_set_light_position(const std::string &device_id, uint8_t position) {
666 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_LIGHT))
667 return;
668 this->op_queue_.enqueue_set_light_position(device_id, position);
669}
670
671void IOHomeControlComponent::queue_set_light_state(const std::string &device_id, bool on) {
672 this->queue_set_light_position(device_id, on ? BINARY_ENTITY_ON_POSITION : BINARY_ENTITY_OFF_POSITION);
673}
674
675void IOHomeControlComponent::queue_set_lock_state(const std::string &device_id, bool locked) {
676 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_LOCK))
677 return;
678 this->op_queue_.enqueue_set_lock_state(device_id, locked);
679}
680
681void IOHomeControlComponent::queue_set_switch_state(const std::string &device_id, bool on) {
682 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_SWITCH))
683 return;
684 this->op_queue_.enqueue_set_switch_state(device_id, on);
685}
686
687// === 1W transmit ===
688
689void IOHomeControlComponent::execute_oneway_command_(const std::string &controller_id, CoverCommand cmd) {
690 this->execute_oneway_([&] { this->oneway_transmitter_.send_command(controller_id, cmd); });
691}
692
693void IOHomeControlComponent::execute_oneway_position_(const std::string &controller_id, uint8_t position) {
694 this->execute_oneway_([&] { this->oneway_transmitter_.send_position(controller_id, position); });
695}
696
697void IOHomeControlComponent::execute_oneway_enroll_(const std::string &controller_id) {
698 this->execute_oneway_([&] { this->oneway_transmitter_.send_enrollment(controller_id); });
699}
700
701void IOHomeControlComponent::execute_oneway_unenroll_(const std::string &controller_id) {
702 this->execute_oneway_([&] { this->oneway_transmitter_.send_unenrollment(controller_id); });
703}
704
706 if (this->busy_ || this->op_queue_.empty())
707 return;
708
709 // Pop before dispatch so any handler that re-queues follow-up work sees the queue in its
710 // post-consumption state and cannot accidentally execute the same operation twice.
711 auto opt = this->op_queue_.pop();
712 if (!opt.has_value())
713 return;
714 const PendingOperation &operation = *opt;
715
716 switch (operation.type) {
718 this->set_device_position(operation.device_id, operation.position);
719 break;
721 this->set_device_tilt(operation.device_id, operation.position);
722 break;
724 this->set_device_position_and_tilt(operation.device_id, operation.position, operation.tilt);
725 break;
727 this->execute_device_command_(operation.device_id, operation.command);
728 break;
730 // operation.position already carries the target IO position (0-100) regardless of whether
731 // it was enqueued via queue_set_light_state() (binary extremes) or
732 // queue_set_light_position() (dimmable) — see enqueue_set_light_state()'s thin-wrapper doc.
733 this->set_light_position(operation.device_id, operation.position);
734 break;
736 this->set_lock_state(operation.device_id, operation.position == BINARY_ENTITY_OFF_POSITION);
737 break;
739 this->set_switch_state(operation.device_id, operation.position == BINARY_ENTITY_ON_POSITION);
740 break;
742 // device_id carries the controller-identity handle for 1W ops — see PendingOperation.
743 this->execute_oneway_command_(operation.device_id, operation.command);
744 break;
746 this->execute_oneway_position_(operation.device_id, operation.position);
747 break;
749 this->execute_oneway_enroll_(operation.device_id);
750 break;
752 this->execute_oneway_unenroll_(operation.device_id);
753 break;
755 this->request_device_status(operation.device_id);
756 break;
758 this->request_device_name(operation.device_id);
759 break;
761 this->discover_and_pair();
762 break;
763 }
764}
765
766} // namespace home_io_control
767} // namespace esphome
bool rollback_optimistic(const std::string &device_id, bool failed_stop=false)
Withdraw every prediction for a device after the command that produced them failed,...
IoDevice * get(const std::string &device_id)
Retrieve a registered device by ID.
void confirm_optimistic_stop(const std::string &device_id)
Mark a device's STOP as delivered: a later rollback must not restore the movement it replaced.
const DebugInfo & get_debug() const
Read-only access to the current debug snapshot.
The main IO-Homecontrol component.
Definition hub_core.h:91
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:951
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:334
virtual void queue_request_device_status(const std::string &device_id)
Queue an async status request; returns immediately, executed in loop().
void log_exchange_unconfirmed_debug_(const char *device_id) const
Log the last exchange debug snapshot for an accepted-but-unconfirmed exchange, at INFO.
Definition hub_core.h:988
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:293
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:985
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:316
OneWayTransmitter oneway_transmitter_
Owns the 1W controller identities, their rolling-sequence counters and the transmit burst.
Definition hub_core.h:1136
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:1129
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:150
ManagementActions management_actions_
Owns rename, identify, force-open, scan_paired_devices, and other hub-level HA actions.
Definition hub_core.h:1131
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:306
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.
void publish_result(const ManagementActionResult &result)
Publish a management result as one or more structured log lines (one call per line of result....
void api_scan_paired_devices()
Native API callback: run a roll-call scan and publish the result as a HA event.
bool send_position(const std::string &controller_id, uint8_t position)
Send a numeric position as the identity's controller.
bool send_unenrollment(const std::string &controller_id)
Un-register this identity from every device of its class currently in association mode (CMD 0x39) alo...
bool send_enrollment(const std::string &controller_id)
Register this identity as a controller on every device currently in association mode (the receiver's ...
bool send_command(const std::string &controller_id, CoverCommand cmd)
Send a named command as the identity's controller.
void enqueue_set_lock_state(const std::string &device_id, bool locked)
void enqueue_set_position_and_tilt(const std::string &device_id, uint8_t position, uint8_t tilt_percent)
Enqueue SET_POSITION_AND_TILT directly (no coalescing needed).
std::optional< PendingOperation > pop()
void enqueue_device_command(const std::string &device_id, CoverCommand cmd)
bool enqueue_request_status(const std::string &device_id)
Enqueue REQUEST_STATUS, suppressing duplicates.
bool enqueue_request_name(const std::string &device_id)
Enqueue REQUEST_NAME, suppressing duplicates.
void enqueue_set_switch_state(const std::string &device_id, bool on)
bool enqueue_discover_and_pair()
Enqueue DISCOVER_AND_PAIR with elevated priority.
void enqueue_set_light_position(const std::string &device_id, uint8_t position)
Enqueue SET_LIGHT_STATE with an arbitrary IO position (0-100), for dimmable lights.
void mark_stop_settle(const std::string &device_id)
Mark the device's next poll as the one confirming an accepted STOP, so it gets STOP_SETTLE_POLL_TRIES...
bool is_tracking_active(const std::string &device_id, uint32_t now) const
True when the device has an active bounded polling window (deadline set and not expired).
uint32_t get_next_update(const std::string &device_id) const
uint32_t get_interval(const std::string &device_id) const
Return the configured interval (0 when not set or unconfigured).
void clear_failure_streaks(const std::string &device_id)
Reset failure streaks after a successful exchange response.
void set_next_update(const std::string &device_id, uint32_t abs_time)
Schedule the next poll at an absolute millis() timestamp.
uint8_t get_status_poll_failures(const std::string &device_id) const
void clear(const std::string &device_id)
Stop all polling for a device and reset failure counts. Preserves interval_ms.
Pure transition helpers for hub-owned exchange and pairing frame decisions.
Helpers shared only by the hub's own implementation files (hub_*.cpp).
uint8_t scheduled_poll_max_tries(uint8_t status_poll_failures, uint8_t auth_poll_failures, bool settles_a_stop=false)
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.
Definition log_helpers.h:31
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.
void record_exchange_outcome(IoDevice &dev, uint8_t request_cmd, ExchangeOutcome outcome)
Record what an outbound exchange's ending says about the device, whichever way the caller then classi...
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.
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.
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.
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
void note_moving_evidence(IoDevice &dev, uint32_t now_ms)
Record that a device is (believed to be) travelling right now.
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).
void clear_moving_evidence(IoDevice &dev)
Forget that a device was travelling: it was observed stopped, or was told to stop.
bool device_supports_tilt(DeviceType type)
Does this device type support tilt (slat angle) control?
Command builders for the IO‑Homecontrol protocol.
bool saw_challenge
True if a 0x3C was seen during this exchange.
Everything one execute-family operation needs beyond its own guard and frame builder.
Definition hub_core.h:908
const char * action
Verb/phrase for the "Sending ..." and rejection logs.
Definition hub_core.h:909
bool settle_as_stop
Passed through to arm_execute_confirmation_poll_().
Definition hub_core.h:910
Runtime state of a paired IO‑Homecontrol device.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:99
uint8_t data_len
Actual length of data.
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).