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"
7
8#include <cmath>
9#include <algorithm>
10#include <cstdio>
11
12/// @file hub_operations.cpp
13/// @brief High-level command execution and queued operation dispatch.
14/// @ingroup hioc_hub
15///
16/// This file owns the outbound user-facing operations on the hub:
17/// - cover position and tilt commands,
18/// - explicit status requests,
19/// - light/switch semantic wrappers,
20/// - queued dispatch on the main loop.
21///
22/// Keeping these methods out of hub_core.cpp makes it easier to reason about the
23/// difference between lifecycle/polling logic and the explicit actions initiated
24/// by Home Assistant entities.
25
26namespace esphome {
27namespace home_io_control {
28
29namespace {
30
31/// Stack-buffer size for a pre-formatted execute action phrase such as
32/// "position=100%% tilt=100%%".
33constexpr size_t EXECUTE_ACTION_BUF_SIZE = 40;
34
35/// Wire-scale "fully open" position for a FORCE_OPEN. A normal actuator reads fully open as 0;
36/// an IoDevice::inverted actuator (a horizontal awning, say) reads it as 100.
37constexpr uint8_t FORCE_OPEN_WIRE_POSITION = 0;
38constexpr uint8_t FORCE_OPEN_WIRE_POSITION_INVERTED = 100;
39
40/// @brief Return the human-readable verb for a position-style command.
41/// @param dev Device receiving the command.
42/// @param position Requested execute position.
43/// @return Log-friendly action string such as "open", "turn on", or "lock".
44const char *position_command_action(const IoDevice &dev, uint8_t position) {
45 if (position == POS_STOP)
46 return "stop";
47
49 return "set position";
50
51 bool const active_state = position == BINARY_ENTITY_ON_POSITION;
52 switch (device_capability_class(dev.type)) {
56 return active_state ? "turn on" : "turn off";
58 return active_state ? "unlock" : "lock";
64 default:
65 return active_state ? "open" : "close";
66 }
67}
68
69/// @brief Return the effective profile label for a device's "Sending ... (profile=...)" logs.
70/// @param dev Device receiving the command.
71/// @return "dimmable_light" for a LIGHT-class device with IoDevice::dimmable set (a YAML choice
72/// the wire protocol has no signal for, so device_operation_profile_name() alone can't
73/// know it); device_operation_profile_name(dev.type) for every other device.
74const char *operation_profile_name(const IoDevice &dev) {
75 if (dev.dimmable && device_capability_class(dev.type) == DeviceCapabilityClass::LIGHT)
76 return "dimmable_light";
77 return device_operation_profile_name(dev.type);
78}
79
80/// @brief Return the accepted entity/profile label for rejected execute-position logs.
81/// @param dev Device the command was rejected for.
82/// @param position Requested execute position.
83/// @return Expected profile label for detail::log_rejected_operation().
84const char *position_rejection_profile(const IoDevice &dev, uint8_t position) {
85 // A LIGHT-class device only reaches rejection for a genuinely out-of-range value (dimmable
86 // lights already accept the full 0-100 span in known_device_accepts_execute_position()) — the
87 // fix there isn't "needs cover_position", it's "needs a value in 0-100".
90 return "0-100";
91 return detail::is_binary_entity_position(position) ? "cover_position or binary_on_off" : "cover_position";
92}
93
94/// @brief The queue-time capability guard for one family of queued operation.
95///
96/// Every `queue_*` method applies the same early-reject as its execute-time counterpart, for fast
97/// user feedback, with a "queued ..." rejection noun. This table is the one place those pairings
98/// are recorded, so a deliberate asymmetry is a visible row rather than an accident. The lone
99/// `queue_*` method with a guard that is NOT a row here is `queue_device_command`: it returns
100/// bool and uses the command name as its rejection noun, so it keeps its guard inline (see there).
101/// NOTE the asymmetry `queue_set_device_position` carries a COVER guard that `set_device_position`
102/// deliberately does *not* — light/switch/lock all funnel through `set_device_position`, so an
103/// entity-class guard there would break them; at queue time each entity has its own method.
104struct QueueGuard {
105 bool (*accepts)(const IoDevice &dev); ///< false → reject this operation for this device.
106 const char *rejection_noun; ///< e.g. "queued cover command".
107 const char *expected; ///< e.g. "cover entity".
108};
109
110constexpr QueueGuard QUEUE_GUARD_COVER{
112 "queued cover command", "cover entity"};
113constexpr QueueGuard QUEUE_GUARD_TILT{[](const IoDevice &d) { return detail::known_device_accepts_execute_tilt(d); },
114 "queued tilt command", "tilt-capable cover"};
115constexpr QueueGuard QUEUE_GUARD_POSITION_AND_TILT{
116 [](const IoDevice &d) { return detail::known_device_accepts_execute_tilt(d); }, "queued position+tilt command",
117 "tilt-capable cover"};
118constexpr QueueGuard QUEUE_GUARD_LIGHT{
120 "queued light command", "light entity"};
121constexpr QueueGuard QUEUE_GUARD_FAN{
123 "queued fan command", "exterior heating (fan entity)"};
124constexpr QueueGuard QUEUE_GUARD_LOCK{
126 "queued lock command", "lock entity"};
127constexpr QueueGuard QUEUE_GUARD_SWITCH{
129 "queued switch command", "switch entity"};
130constexpr QueueGuard QUEUE_GUARD_STATUS{
131 [](const IoDevice &d) { return detail::known_device_supports_status_requests(d); }, "queued status request",
132 "status-capable actuator"};
133
134/// @brief Apply one QueueGuard. Returns true (and logs) when the operation must be rejected.
135///
136/// Matches the historical guard exactly: an unregistered/unknown device (dev == nullptr) is *not*
137/// rejected here — it passes through so discovery and imported devices keep working.
138bool queue_guard_rejects(IOHomeControlComponent *hub, const std::string &device_id, const QueueGuard &guard) {
139 const IoDevice *dev = hub->get_device(device_id);
140 if (dev != nullptr && !guard.accepts(*dev)) {
141 detail::log_rejected_operation(device_id, *dev, guard.rejection_noun, guard.expected);
142 return true;
143 }
144 return false;
145}
146
147} // namespace
148
149void IOHomeControlComponent::arm_execute_confirmation_poll_(const std::string &device_id, bool for_stop) {
150 uint32_t const existing = this->poll_policy_.get_next_update(device_id);
151 uint32_t const delay_ms = settle_delay_ms(this->poll_policy_.get_interval(device_id), 0, for_stop);
152 this->begin_status_poll_tracking_(device_id, delay_ms);
153 if (existing != 0 && existing < millis() + delay_ms)
154 this->poll_policy_.set_next_update(device_id, existing);
155 if (for_stop)
156 this->poll_policy_.mark_stop_settle(device_id);
157}
158
159// Run an authenticated request on the standard command channel and do the per-outcome bookkeeping
160// every caller shares; what the reply means is left to the caller.
162 const IoFrame &request,
163 IoFrame &response,
164 const RequestOptions &options) {
165 const bool warn_on_no_response = options.warn_on_no_response;
166 const uint32_t retry_after_fail_ms = options.retry_after_fail_ms;
167 const ExchangeOutcome outcome = this->send_and_receive_(request, response, FREQ_CH2, options.max_tries);
168 // An unconfirmed acceptance means the device authenticated the request but never closed the
169 // exchange. Whether that counts as success depends entirely on what the request was *for*:
170 // - a command (CMD_EXECUTE) is treated as done. The device has it, and most devices that end
171 // this way report the result in their own status update a few seconds later. It is not proof
172 // the command was carried out, though, so the engine has already sent it once more where the
173 // silence was an anomaly (decisions::retry_after_unconfirmed_accept_is_safe()), and the
174 // confirmation poll corrects the state if the device did not act;
175 // - a status poll or a name read exists to obtain a payload. Getting none means the question
176 // went unanswered, so it stays a failure and keeps the aggressive auth-shaped poll backoff
177 // that exists for precisely this shape of miss.
178 const bool unconfirmed_counts_as_success = request.cmd == CMD_EXECUTE;
179
180 // Recorded once, here, for every outcome — deliberately above the split below, because the rule
181 // is about the outcome and not about how this request chooses to classify it. The two branches
182 // disagree on whether an unconfirmed acceptance is a failure; they must not disagree on whether it
183 // happened. The unconfirmed count is the only record of the CMD_EXECUTE case, which reports
184 // success, and it is what separates "the device never heard us" from "it heard us and the reply
185 // was lost" in the branch that does count as a failure.
186 if (IoDevice *dev = this->registry_.get(device_id); dev != nullptr)
187 detail::record_exchange_outcome(*dev, request.cmd, outcome);
188
189 if (outcome == ExchangeOutcome::FAILED ||
190 (outcome == ExchangeOutcome::SUCCESS_UNCONFIRMED && !unconfirmed_counts_as_success)) {
191 const auto &dbg = this->exchange_engine_.get_debug();
192 if (IoDevice *dev = this->registry_.get(device_id); dev != nullptr) {
193 detail::record_exchange_timeout(*dev, dbg.tries);
194 // A 0x3C challenge seen during the exchange is itself a frame from this device: the exchange
195 // still failed (our challenge-response or the device's final reply was lost), but the device
196 // demonstrably transmitted. Stamp link health the same way the explicit-refusal branch does,
197 // or a device that challenges every attempt yet never closes the exchange ages exactly like
198 // a powered-off one.
199 if (dbg.saw_challenge)
201 this->notify_device_update_(device_id);
202 }
203 if (retry_after_fail_ms != 0)
204 this->schedule_background_poll_backoff_(device_id, dbg.saw_challenge);
205 this->log_exchange_debug_(device_id.c_str());
206 if (warn_on_no_response) {
207 ESP_LOGW(detail::TAG, "Command 0x%02X failed for device %s: no valid response (stage=%s tries=%u)", request.cmd,
208 device_id.c_str(), dbg.stage, dbg.tries);
209 }
211 }
212
214 // The device challenged the request, so it received it; whether it also received our challenge
215 // answer — and therefore acted — is not observable from here (see the WAIT_FINAL_RESPONSE
216 // branch in ExchangeEngine::send_and_receive()). There is no frame to parse, and inventing a
217 // position from a request we only know was *challenged* would be worse than leaving the last
218 // known state alone — the device's own asynchronous status update supplies the real one, and
219 // that path authenticates now. Clear the failure streaks: this was not a failure.
220 //
221 // Log the snapshot anyway. This branch is the one exchange ending that prints nothing at all
222 // otherwise, so a device that routinely stops replying here leaves no trace to diagnose, and
223 // its final_rx_* fields (did the radio receive anything during the final wait?) are what
224 // separates a lost answer from a lost reply. Its cap_* fields describe the device's challenge,
225 // not that wait.
226 this->log_exchange_unconfirmed_debug_(device_id.c_str());
227 if (retry_after_fail_ms != 0)
228 this->poll_policy_.clear_failure_streaks(device_id);
229 if (IoDevice *dev = this->registry_.get(device_id); dev != nullptr) {
231 this->notify_device_update_(device_id);
232 }
234 }
235
236 if (response.cmd == CMD_ERROR_RESP) {
237 this->handle_error_response_(device_id, request, response, retry_after_fail_ms);
239 }
240
241 if (retry_after_fail_ms != 0)
242 this->poll_policy_.clear_failure_streaks(device_id);
244}
245
246// Execute an authenticated request and, on a reply, feed it back through the normal inbound status
247// parser so all state normalization stays in one place.
248bool IOHomeControlComponent::execute_request_and_update_(const std::string &device_id, const IoFrame &request,
249 bool warn_on_no_response, uint32_t retry_after_fail_ms,
250 uint8_t max_tries) {
251 IoFrame response;
252 switch (
253 this->exchange_and_record_(device_id, request, response, {warn_on_no_response, retry_after_fail_ms, max_tries})) {
256 return false;
258 return true;
260 break;
261 }
262
263 // The immediate reply to our own CMD_EXECUTE (position/tilt/stop/favorite/vent) is not
264 // trustworthy for target/current position on at least some devices — see
265 // update_device_status_()'s trust_position doc comment. Every other request we send
266 // (status poll, get name, ...) keeps trusting its reply as before.
267 this->update_device_status_(response, request.cmd != CMD_EXECUTE);
268 return true;
269}
270
271bool IOHomeControlComponent::handle_error_response_(const std::string &device_id, const IoFrame &request,
272 const IoFrame &response, uint32_t retry_after_fail_ms) {
273 IoDevice *dev = this->registry_.get(device_id);
274 // An explicit refusal is still a reply from the device, but no caller feeds it to
275 // update_device_status_(), so stamp link health here to keep update_link_health()'s "every frame
276 // from a registered device" contract.
277 if (dev != nullptr)
279 if (response.data_len == 0) {
280 detail::log_frame_issue(this, "rx", "unsupported_payload", response, frame_length(response));
281 } else if (dev != nullptr) {
282 detail::record_command_result(*dev, device_id, response.data[0], request.cmd, true);
283 } else {
284 detail::log_command_result(device_id, response.data[0], request.cmd, true);
285 }
286 if (dev != nullptr)
287 this->notify_device_update_(device_id);
288 if (retry_after_fail_ms != 0)
290 return false;
291}
292
293bool IOHomeControlComponent::run_execute_operation_(const std::string &device_id, const ExecuteRequestSpec &spec,
294 const std::function<bool(const IoDevice &)> &accepts,
295 const char *rejection_profile,
296 const std::function<bool(IoFrame &, const IoDevice &)> &build) {
297 // Every false return means this command will not happen: an unregistered or not-yet-initialized
298 // device, a profile guard rejection, a builder failure, or an exchange that ended with no valid
299 // response or an explicit CMD_ERROR_RESP. In all of them the prediction the entity applied at
300 // control() time must be withdrawn, or the Home Assistant cover animates a movement that is not
301 // occurring — indefinitely, since only a frame from the device can settle it.
302 const bool accepted = this->try_execute_operation_(device_id, spec, accepts, rejection_profile, build);
303 if (accepted && spec.settle_as_stop) {
304 this->registry_.confirm_optimistic_stop(device_id);
305 // The moving evidence is left to the STOP's own ack (apply_private_response_status()): a motor
306 // that reports "stopped" clears it, one still winding down keeps it, because right after a STOP
307 // a VELUX SSL can still be in the travelling state that hears only the short preamble. An ack
308 // with no status to decode leaves the evidence as it was; a settle poll that draws nothing spends it.
309 } else if (accepted) {
310 // An accepted movement command means the receiver is travelling now, whether or not the reply
311 // carried a status to decode (ExchangeOutcome::SUCCESS_UNCONFIRMED has none). Stamped here,
312 // after the exchange, and not when the optimistic overlay is applied before it: see
313 // note_moving_evidence().
314 if (IoDevice *dev = this->registry_.get(device_id); dev != nullptr)
315 note_moving_evidence(*dev, millis());
316 } else {
317 this->registry_.rollback_optimistic(device_id, /*failed_stop=*/spec.settle_as_stop);
318 }
319 return accepted;
320}
321
322bool IOHomeControlComponent::try_execute_operation_(const std::string &device_id, const ExecuteRequestSpec &spec,
323 const std::function<bool(const IoDevice &)> &accepts,
324 const char *rejection_profile,
325 const std::function<bool(IoFrame &, const IoDevice &)> &build) {
326 auto *dev = this->get_device(device_id);
327 if (dev == nullptr || !this->initialized_)
328 return false;
329
330 // Once a device family is known, use the profile helpers to reject YAML/entity mismatches
331 // before they hit the radio path. Unknown types still pass through so discovery and imported
332 // devices keep working as before.
333 if (!accepts(*dev)) {
334 detail::log_rejected_operation(device_id, *dev, spec.action, rejection_profile);
335 return false;
336 }
337
338 this->begin_status_poll_tracking_(device_id, this->poll_policy_.get_interval(device_id));
339
340 ESP_LOGI(detail::TAG, "Sending %s to device %s (profile=%s)", spec.action, device_id.c_str(),
341 operation_profile_name(*dev));
342
343 IoFrame request;
344 if (!build(request, *dev)) {
345 this->poll_policy_.clear(device_id);
346 return false;
347 }
348 if (!this->execute_request_and_update_(device_id, request, true, 0)) {
350 return false;
351 }
352 this->arm_execute_confirmation_poll_(device_id, spec.settle_as_stop);
353 return true;
354}
355
356bool IOHomeControlComponent::set_device_position(const std::string &device_id, uint8_t position) {
357 const auto *dev = this->get_device(device_id);
358 if (dev == nullptr)
359 return false;
360 // action and rejection profile depend on this device's class and the requested value, so
361 // resolve them here where dev is in scope; run_execute_operation_() re-checks dev/initialized_.
362 return this->run_execute_operation_(
363 device_id, {position_command_action(*dev, position), position == POS_STOP},
364 [position](const IoDevice &d) { return detail::known_device_accepts_execute_position(d, position); },
365 position_rejection_profile(*dev, position),
366 [this, position](IoFrame &request, const IoDevice &d) {
367 if (device_takes_main_parameter_only(d.type, d.subtype))
368 return create_execute_main_parameter(request, this->node_id_, d.node_id, d.low_power, position);
369 return create_execute_position(request, this->node_id_, d.node_id, d.low_power, position, d.silent);
370 });
371}
372
373bool IOHomeControlComponent::execute_device_command_(const std::string &device_id, CoverCommand cmd) {
374 return this->run_execute_operation_(
375 device_id, {cover_command_name(cmd), cmd == CoverCommand::STOP},
377 "cover entity",
378 [this, cmd, &device_id](IoFrame &request, const IoDevice &d) {
379 if (device_takes_main_parameter_only(d.type, d.subtype)) {
380 // These devices refuse the favourite alias (issue #163: RESULT_PARAMETER_UNKNOWN), so
381 // say so here instead of sending a frame that can only be refused.
382 if (cmd == CoverCommand::FAVORITE || cmd == CoverCommand::VENT) {
383 ESP_LOGW(detail::TAG, "Device %s: %s is not supported on this device (%s)", device_id.c_str(),
384 cover_command_name(cmd), format_device_kind(d.type, d.subtype).c_str());
385 return false;
386 }
387 if (cmd == CoverCommand::FORCE_OPEN) {
389 request, this->node_id_, d.node_id, d.low_power,
390 d.inverted ? FORCE_OPEN_WIRE_POSITION_INVERTED : FORCE_OPEN_WIRE_POSITION,
391 /*force_open_priority=*/true);
392 }
393 }
394 // FORCE_OPEN needs the device's own wire-scale "fully open" position (0, or 100 for an
395 // IoDevice::inverted device such as a horizontal awning) — create_execute_command() has no
396 // device access to resolve that, so it is built separately here where d is in scope.
397 return cmd == CoverCommand::FORCE_OPEN
398 ? create_force_open(request, this->node_id_, d.node_id, d.low_power,
399 d.inverted ? FORCE_OPEN_WIRE_POSITION_INVERTED : FORCE_OPEN_WIRE_POSITION)
400 : create_execute_command(request, this->node_id_, d.node_id, d.low_power, cmd, d.silent);
401 });
402}
403
404bool IOHomeControlComponent::set_device_tilt(const std::string &device_id, uint8_t tilt_percent) {
405 char action[EXECUTE_ACTION_BUF_SIZE];
406 snprintf(action, sizeof(action), "tilt=%u%%", tilt_percent);
407 return this->run_execute_operation_(
408 device_id, {action, false}, [](const IoDevice &d) { return detail::known_device_accepts_execute_tilt(d); },
409 "tilt-capable cover",
410 [this, tilt_percent](IoFrame &request, const IoDevice &d) {
411 return create_execute_tilt(request, this->node_id_, d.node_id, d.low_power, tilt_percent);
412 });
413}
414
415bool IOHomeControlComponent::set_device_position_and_tilt(const std::string &device_id, uint8_t position,
416 uint8_t tilt_percent) {
417 char action[EXECUTE_ACTION_BUF_SIZE];
418 snprintf(action, sizeof(action), "position=%u%% tilt=%u%%", position, tilt_percent);
419 return this->run_execute_operation_(
420 device_id, {action, false}, [](const IoDevice &d) { return detail::known_device_accepts_execute_tilt(d); },
421 "tilt-capable cover",
422 [this, position, tilt_percent](IoFrame &request, const IoDevice &d) {
423 return create_execute_position_and_tilt(request, this->node_id_, d.node_id, d.low_power, position,
424 tilt_percent);
425 });
426}
427
428bool IOHomeControlComponent::request_device_status(const std::string &device_id) {
429 auto *dev = this->get_device(device_id);
430 if (dev == nullptr || !this->initialized_)
431 return false;
432
434 detail::log_rejected_operation(device_id, *dev, "status request", "status-capable actuator");
435 return false;
436 }
437
438 // A heater with private registers has no 0x03 status: its state is a handful of registers.
439 const HeatingProfile *heating = find_heating_profile(dev->type, dev->subtype);
440 if (heating != nullptr && heating->path == HeatingControlPath::PRIVATE_REGISTERS)
441 return this->poll_heating_device_(device_id);
442
443 IoFrame request;
444 // Tilt-capable covers need the extended 0x03200100 status request so the response includes
445 // the reliable 16-byte tilt block. Other devices stay on the shorter generic request.
446 bool const request_ok = device_supports_tilt(dev->type)
447 ? create_get_status_tilt(request, this->node_id_, dev->node_id, dev->low_power)
448 : create_get_status(request, this->node_id_, dev->node_id, dev->low_power);
449 if (!request_ok)
450 return false;
451 // A poll the scheduler owns (StatusPollPolicy is tracking this device) is re-armed by the backoff
452 // ladder on failure, so the ladder is its retry mechanism and most slots need only one try. The
453 // exception is the middle of the ladder, where the slot that lands just after a manoeuvre ends is
454 // the one chance to catch a duty-cycled receiver, and the poll confirming an accepted STOP — see
455 // decisions::scheduled_poll_max_tries(). A one-off poll with no ladder behind it keeps the full
456 // retry budget.
457 // A heating-level heater is polled on the heater cadence with the same status read; its failure
458 // ladder needs a tracking window, which a cover only has after a command.
459 if (heating != nullptr && !this->poll_policy_.is_tracking_active(device_id, millis()))
460 this->begin_status_poll_tracking_(device_id, 0);
461 const bool scheduler_managed = this->poll_policy_.is_tracking_active(device_id, millis());
462 const uint32_t retry_after_fail_ms = scheduler_managed ? STATUS_RETRY_AFTER_FAIL_MS : 0;
463 const uint8_t max_tries =
465 this->poll_policy_.get_auth_poll_failures(device_id),
466 this->poll_policy_.take_stop_settle(device_id))
467 : EXCHANGE_RETRY_COUNT;
468 const bool ok = this->execute_request_and_update_(device_id, request, false, retry_after_fail_ms, max_tries);
469 if (heating != nullptr) {
470 // The regular cadence continues whatever this poll found, as for a register heater.
471 const uint32_t configured_ms = this->poll_policy_.get_interval(device_id);
472 this->schedule_heating_poll_(device_id, configured_ms != 0 ? configured_ms : HEATING_POLL_DEFAULT_INTERVAL_MS);
473 }
474 return ok;
475}
476
477bool IOHomeControlComponent::request_device_limitation(const std::string &device_id) {
478 IoDevice *dev = this->registry_.get(device_id);
479 if (dev == nullptr || !this->initialized_)
480 return false;
481
482 // Not a failure and not a reading: a moving device is left alone (the same caution the diagnostic
483 // probe applies), and asking again soon is cheaper than waiting out a whole interval.
484 if (!effective_is_stopped(*dev)) {
485 ESP_LOGD(detail::TAG, "Device %s: rain sensor poll deferred, device is moving", device_id.c_str());
486 this->rain_poll_policy_.defer(device_id, RAIN_POLL_MOVING_RETRY_MS, millis());
487 return false;
488 }
489
490 IoFrame request;
492 return false;
493
494 // retry_after_fail_ms = 0 keeps the status-poll backoff ladder out of a rain poll's way; a miss
495 // here is counted by the rain schedule instead.
496 IoFrame response;
497 const RequestResult result =
498 this->exchange_and_record_(device_id, request, response, {false, 0, RAIN_POLL_MAX_TRIES});
499
500 // A reply consumed inside the blocking exchange never reaches the normal receive path.
501 dev = this->registry_.get(device_id);
503 std::string limit_text;
504 if (result == RequestResult::REPLY && dev != nullptr) {
505 // The device transmitted, whatever it said: stamp link health the way the status path does.
507 uint8_t raw[FRAME_MAX_SIZE] = {0};
508 const uint8_t raw_len = serialize(response, raw, sizeof(raw));
509 detail::log_component_capture(this->radio_, "limitation_rx", raw, raw_len, &response);
510 LimitationStatus status;
511 if (response.cmd == CMD_LIMITATION_STATUS_RESP && decode_limitation_status(response, status)) {
512 ESP_LOGD(detail::TAG, "Device %s: %s", device_id.c_str(), detail::describe_limitation_reply(response).c_str());
514 limit_text = detail::format_limitation_value(status.value_raw);
515 } else {
516 ESP_LOGD(detail::TAG, "Device %s: unexpected reply 0x%02X to the limitation read", device_id.c_str(),
517 response.cmd);
518 }
519 } else if (result == RequestResult::ERROR_REPLY && this->rain_poll_policy_.first_error_reply(device_id)) {
520 ESP_LOGW(detail::TAG, "Device %s answers the limitation read with an error; its rain sensor stays unknown",
521 device_id.c_str());
522 }
523
524 this->apply_rain_poll_result_(device_id, reading, limit_text);
525 return reading != RainSensorState::UNKNOWN;
526}
527
528void IOHomeControlComponent::apply_rain_poll_result_(const std::string &device_id, RainSensorState reading,
529 const std::string &limit_text) {
530 IoDevice *dev = this->registry_.get(device_id);
531 if (dev == nullptr)
532 return;
533 RainSensorState state = dev->rain_sensor;
534 if (reading != RainSensorState::UNKNOWN) {
535 this->rain_poll_policy_.on_poll_succeeded(device_id);
536 state = reading;
537 } else if (this->rain_poll_policy_.on_poll_failed(device_id)) {
538 state = RainSensorState::UNKNOWN; // the last reading is too old to keep showing
539 }
540 if (state != dev->rain_sensor) {
541 ESP_LOGI(detail::TAG, "Device %s: rain sensor %s -> %s%s%s%s", device_id.c_str(),
543 limit_text.empty() ? "" : " (minimum limit ", limit_text.c_str(), limit_text.empty() ? "" : ")");
544 dev->rain_sensor = state;
545 }
546 // Notify on every outcome: link health moved too, so the link sensors need the update.
547 this->notify_device_update_(device_id);
548}
549
550bool IOHomeControlComponent::request_device_name(const std::string &device_id) {
551 auto *dev = this->get_device(device_id);
552 if (dev == nullptr || !this->initialized_)
553 return false;
554
555 IoFrame request;
556 if (!create_get_name(request, this->node_id_, dev->node_id, dev->low_power))
557 return false;
558 return this->execute_request_and_update_(device_id, request, false, 0);
559}
560
561bool IOHomeControlComponent::set_light_position(const std::string &device_id, uint8_t position) {
562 auto *dev = this->get_device(device_id);
563 if (dev == nullptr || !this->initialized_)
564 return false;
565
567 detail::log_rejected_operation(device_id, *dev, "light command", "light entity");
568 return false;
569 }
570
571 // Light entities reuse the controller's existing execute path — the same position encoding
572 // covers use, confirmed on real dimmable hardware (see the somfy_izymo_dimmer_* captures).
573 return this->set_device_position(device_id, position);
574}
575
576bool IOHomeControlComponent::set_fan_position(const std::string &device_id, uint8_t position) {
577 auto *dev = this->get_device(device_id);
578 if (dev == nullptr || !this->initialized_)
579 return false;
580
582 detail::log_rejected_operation(device_id, *dev, "fan command", "exterior heating (fan entity)");
583 return false;
584 }
585
586 // An exterior heater takes the same position execute as a dimmable light; the heat level is the
587 // IO position (0 = full heat), see platform_fan.cpp.
588 return this->set_device_position(device_id, position);
589}
590
591bool IOHomeControlComponent::set_light_state(const std::string &device_id, bool on) {
592 return this->set_light_position(device_id, on ? BINARY_ENTITY_ON_POSITION : BINARY_ENTITY_OFF_POSITION);
593}
594
595bool IOHomeControlComponent::set_switch_state(const std::string &device_id, bool on) {
596 auto *dev = this->get_device(device_id);
597 if (dev == nullptr || !this->initialized_)
598 return false;
599
601 detail::log_rejected_operation(device_id, *dev, "switch command", "switch entity");
602 return false;
603 }
604
605 // Switches share the same transport-level representation as binary lights.
606 return this->set_device_position(device_id, on ? BINARY_ENTITY_ON_POSITION : BINARY_ENTITY_OFF_POSITION);
607}
608
609bool IOHomeControlComponent::set_lock_state(const std::string &device_id, bool locked) {
610 auto *dev = this->get_device(device_id);
611 if (dev == nullptr || !this->initialized_)
612 return false;
613
615 detail::log_rejected_operation(device_id, *dev, "lock command", "lock entity");
616 return false;
617 }
618
619 // Lock entities currently reuse the protocol's proven binary execute encoding:
620 // unlock maps to 0 and lock maps to 100.
621 return this->set_device_position(device_id, locked ? BINARY_ENTITY_OFF_POSITION : BINARY_ENTITY_ON_POSITION);
622}
623
624bool IOHomeControlComponent::set_heating_level(const std::string &device_id, uint8_t level) {
625 // The same funnel as the other execute-family operations: gate, frame, exchange, and a settle poll
626 // that reads the level back (see arm_execute_confirmation_poll_()).
627 return this->run_execute_operation_(
628 device_id, {"heating level", false},
629 [](const IoDevice &d) { return device_uses_heating_levels(d.type, d.subtype); }, "heating-level climate device",
630 [this, level](IoFrame &request, const IoDevice &d) {
631 return create_execute_heating_level(request, this->node_id_, d.node_id, d.low_power, level);
632 });
633}
634
635bool IOHomeControlComponent::set_heating_temperature(const std::string &device_id, uint16_t main_parameter,
636 const HeatingFp &fp) {
637 return this->run_execute_operation_(
638 device_id, {"heating temperature", false},
639 [](const IoDevice &d) { return device_uses_heating_temperature(d.type, d.subtype); },
640 "temperature-controlled climate device",
641 [this, main_parameter, fp](IoFrame &request, const IoDevice &d) {
642 switch (fp.kind) {
644 return create_execute_heating_with_mode_flags(request, this->node_id_, d.node_id, d.low_power,
645 main_parameter, fp.value);
647 return create_execute_heating_with_fp16(request, this->node_id_, d.node_id, d.low_power, main_parameter,
648 fp.value);
650 break;
651 }
652 return create_execute_heating_main_parameter(request, this->node_id_, d.node_id, d.low_power, main_parameter);
653 });
654}
655
656void IOHomeControlComponent::set_device_temperature_range(const std::string &device_id, float min_c, float max_c) {
657 IoDevice *dev = this->registry_.get(device_id);
658 if (dev == nullptr)
659 return;
660 dev->temperature_range_min_c = min_c;
661 dev->temperature_range_max_c = max_c;
662}
663
664bool IOHomeControlComponent::exchange_heating_(const std::string &device_id, const char *label, const IoFrame &request,
665 IoFrame &response, uint8_t max_tries) {
666 // A read and a write of the same register share the label, so the log names which one it was: a
667 // read-back that fails after an acknowledged write must not read as a refused write.
668 const bool is_read = request.data_len > 1 && request.data[1] == HEATING_DIRECTION_GET;
669 const char *direction = is_read ? "read" : "write";
670 ESP_LOGI(detail::TAG, "Sending heating %s %s to device %s", direction, label, device_id.c_str());
671
672 const ExchangeOutcome outcome = this->send_and_receive_(request, response, FREQ_CH2, max_tries);
673 const bool got_reply = outcome == ExchangeOutcome::SUCCESS_WITH_RESPONSE;
674 const bool acknowledged = got_reply && response.cmd == CMD_WRITE_PRIVATE_ACK;
675
676 // Feed the device-agnostic link-health / exchange-failure companion sensors either way, so a
677 // climate device is never silently invisible to the Last Contact / Exchange Failures
678 // diagnostics. A CMD_ERROR_RESP is still a reply — record its result code on the device so the
679 // "Last Result Code" diagnostic surfaces it, exactly as the cover path does.
680 IoDevice *dev = this->get_device(device_id);
681 if (dev == nullptr)
682 return false;
683 // Same rule as execute_request_and_update_(): an unconfirmed acceptance is counted wherever it
684 // happens, or a climate device that authenticates and then goes silent stays invisible in the
685 // very diagnostic built to surface that, while a cover doing the same thing is counted.
686 detail::record_exchange_outcome(*dev, request.cmd, outcome);
687 if (outcome == ExchangeOutcome::FAILED) {
689 } else {
691 if (got_reply && response.cmd == CMD_ERROR_RESP && response.data_len > 0) {
692 detail::record_command_result(*dev, device_id, response.data[0], CMD_WRITE_PRIVATE, true);
693 } else if (acknowledged) {
695 }
696 }
697 this->notify_device_update_(device_id);
698
699 if (!acknowledged) {
700 this->log_exchange_debug_(device_id.c_str());
701 ESP_LOGW(detail::TAG, "Heating %s %s: no CMD_WRITE_PRIVATE_ACK from device %s", direction, label,
702 device_id.c_str());
703 return false;
704 }
705
706 // Logging the ack payload lets a field tester read back what the heater reports even for the
707 // registers nothing decodes (`power_on`, `midnight_sync`).
708 char ack_payload_hex[FRAME_LOG_HEX_BUFFER_SIZE];
709 bytes_to_hex(response.data, response.data_len, ack_payload_hex, sizeof(ack_payload_hex));
710 ESP_LOGD(detail::TAG, "heating %s %s ACK payload: %s", direction, label, ack_payload_hex);
711 return true;
712}
713
714bool IOHomeControlComponent::send_heating_command(const std::string &device_id, HeatingFunction fn, float value) {
715 auto *dev = this->get_device(device_id);
716 if (dev == nullptr || !this->initialized_)
717 return false;
718
719 // Capability gate: only via the predicate, never an inline device-type or vendor list.
720 if (!device_uses_heating_registers(dev->type, dev->subtype)) {
721 detail::log_rejected_operation(device_id, *dev, "heating command", "heater with private registers");
722 return false;
723 }
724
725 uint8_t payload[HEATING_PAYLOAD_MAX_SIZE];
726 const size_t payload_len = encode_heating_payload(fn, value, payload);
727 if (payload_len == 0) {
728 ESP_LOGW(detail::TAG, "Heating command %s for device %s rejected: value %.2f out of range",
729 heating_function_name(fn), device_id.c_str(), value);
730 return false;
731 }
732
733 IoFrame request;
734 if (!create_write_private(request, this->node_id_, dev->node_id, dev->low_power, payload, payload_len)) {
735 ESP_LOGW(detail::TAG, "Heating command %s for device %s: failed to build frame", heating_function_name(fn),
736 device_id.c_str());
737 return false;
738 }
739
740 IoFrame response;
741 // A write is something the user waits on: the full retry budget.
742 if (!this->exchange_heating_(device_id, heating_function_name(fn), request, response, EXCHANGE_RETRY_COUNT))
743 return false;
744 // The regulator answers the new setpoint or mode only after a while: poll the heater again soon, so
745 // heating / idle catches up without waiting a whole poll interval.
746 this->schedule_heating_poll_(device_id, HEATING_POST_WRITE_POLL_DELAY_MS);
747 return true;
748}
749
750bool IOHomeControlComponent::read_heating_register(const std::string &device_id, HeatingFunction fn, float *value_out,
751 uint8_t max_tries) {
752 auto *dev = this->get_device(device_id);
753 if (dev == nullptr || !this->initialized_)
754 return false;
755
756 if (!device_uses_heating_registers(dev->type, dev->subtype)) {
757 detail::log_rejected_operation(device_id, *dev, "heating read", "heater with private registers");
758 return false;
759 }
760
761 uint8_t payload[HEATING_PAYLOAD_MAX_SIZE];
762 const size_t payload_len = encode_heating_read(fn, payload);
763 if (payload_len == 0) {
764 ESP_LOGW(detail::TAG, "Heating read %s for device %s rejected: not a readable register", heating_function_name(fn),
765 device_id.c_str());
766 return false;
767 }
768
769 IoFrame request;
770 if (!create_write_private(request, this->node_id_, dev->node_id, dev->low_power, payload, payload_len)) {
771 ESP_LOGW(detail::TAG, "Heating read %s for device %s: failed to build frame", heating_function_name(fn),
772 device_id.c_str());
773 return false;
774 }
775
776 IoFrame response;
777 if (!this->exchange_heating_(device_id, heating_function_name(fn), request, response, max_tries))
778 return false;
779
780 float value = NAN;
781 if (!decode_heating_reply(response, fn, value)) {
782 char payload_hex[FRAME_LOG_HEX_BUFFER_SIZE];
783 bytes_to_hex(response.data, response.data_len, payload_hex, sizeof(payload_hex));
784 ESP_LOGD(detail::TAG, "heating %s reply from device %s carries no value: %s", heating_function_name(fn),
785 device_id.c_str(), payload_hex);
786 return false;
787 }
788
789 // exchange_heating_() ran get_device() before the exchange; the pointer is re-resolved here
790 // because the exchange blocks and the registry may have been touched by a callback meanwhile.
791 dev = this->get_device(device_id);
792 if (dev == nullptr)
793 return false;
794 dev->heating.store(fn, value);
795 this->notify_device_update_(device_id);
796 if (value_out != nullptr)
797 *value_out = value;
798 return true;
799}
800
801bool IOHomeControlComponent::poll_heating_device_(const std::string &device_id) {
802 const IoDevice *dev = this->get_device(device_id);
803 if (dev == nullptr || !this->initialized_)
804 return false;
805 // The registers one poll reads come from the device's profile, so there is no per-device list here.
806 const HeatingProfile *profile = find_heating_profile(dev->type, dev->subtype);
807 if (profile == nullptr || profile->path != HeatingControlPath::PRIVATE_REGISTERS)
808 return false;
809
810 // The failure ladder lives in StatusPollPolicy and only runs inside a tracking window. Open one
811 // unless one is already running: begin_tracking() resets the failure counters, which would flatten
812 // the ladder to its first step on every retry.
813 if (!this->poll_policy_.is_tracking_active(device_id, millis()))
814 this->begin_status_poll_tracking_(device_id, 0);
815
816 // A poll always runs inside a tracking window, so the ladder is its retry mechanism: the same try
817 // band as a scheduled cover poll (decisions::scheduled_poll_max_tries()).
818 const uint8_t max_tries = decisions::scheduled_poll_max_tries(this->poll_policy_.get_status_poll_failures(device_id),
819 this->poll_policy_.get_auth_poll_failures(device_id));
820
821 bool ok = true;
822 for (size_t i = 0; i < profile->poll_register_count; i++) {
823 if (!this->read_heating_register(device_id, profile->poll_registers[i], nullptr, max_tries)) {
824 ok = false;
825 break;
826 }
827 }
828
829 // Properties and settings that rarely change are read until they have been read once. A miss here
830 // is not a poll failure; the next poll asks again.
831 for (size_t i = 0; ok && i < profile->boot_register_count; i++) {
832 dev = this->get_device(device_id);
833 if (dev != nullptr && !dev->heating.has(profile->boot_registers[i]))
834 this->read_heating_register(device_id, profile->boot_registers[i], nullptr, max_tries);
835 }
836
837 if (ok) {
838 this->poll_policy_.clear(device_id);
839 } else {
841 }
842 // The regular cadence continues whatever this poll found; a failed poll is additionally retried
843 // sooner by the ladder above.
844 const uint32_t configured_ms = this->poll_policy_.get_interval(device_id);
845 this->schedule_heating_poll_(device_id, configured_ms != 0 ? configured_ms : HEATING_POLL_DEFAULT_INTERVAL_MS);
846 return ok;
847}
848
849void IOHomeControlComponent::queue_set_device_position(const std::string &device_id, uint8_t position) {
850 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_COVER)) {
851 // control() applies the optimistic prediction before calling this method, so a queue-time
852 // guard rejection is a command that will not happen and must withdraw it, exactly as
853 // run_execute_operation_() does for a dispatch-time failure. No-op when nothing was predicted.
854 this->registry_.rollback_optimistic(device_id);
855 return;
856 }
857
858 // Pre-scan for a pending SET_TILT so we can log its value if coalescing happens.
859 uint8_t pending_tilt = 0;
860 for (const auto &op : this->op_queue_) {
861 if (op.type == PendingOperationType::SET_TILT && op.device_id == device_id) {
862 pending_tilt = op.position; // SET_TILT stores tilt in op.position
863 break;
864 }
865 }
866 if (this->op_queue_.enqueue_set_position(device_id, position)) {
867 ESP_LOGI(detail::TAG,
868 "Coalesced SET_POSITION (pos=%u) + pending SET_TILT (tilt=%u) → SET_POSITION_AND_TILT for "
869 "device %s",
870 position, pending_tilt, device_id.c_str());
871 }
872}
873
874bool IOHomeControlComponent::queue_device_command(const std::string &device_id, CoverCommand cmd) {
875 const IoDevice *dev = this->get_device(device_id);
876 // Every false return below is a command that will not happen; control() (STOP →
877 // apply_optimistic_stop) already predicted, so withdraw it — see queue_set_device_position().
878 const bool is_stop = cmd == CoverCommand::STOP;
879 if (!this->initialized_ || dev == nullptr) {
880 this->registry_.rollback_optimistic(device_id, /*failed_stop=*/is_stop);
881 return false;
882 }
883 // Same COVER guard as QUEUE_GUARD_COVER, kept inline here: this method returns bool and its
884 // rejection noun is the specific command name rather than a fixed "queued cover command".
886 detail::log_rejected_operation(device_id, *dev, cover_command_name(cmd), "cover entity");
887 this->registry_.rollback_optimistic(device_id, /*failed_stop=*/is_stop);
888 return false;
889 }
890 this->op_queue_.enqueue_device_command(device_id, cmd);
891 return true;
892}
893
894void IOHomeControlComponent::queue_set_device_tilt(const std::string &device_id, uint8_t tilt_percent) {
895 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_TILT)) {
896 // Withdraw the entity's optimistic prediction — see queue_set_device_position().
897 this->registry_.rollback_optimistic(device_id);
898 return;
899 }
900
901 // Pre-scan for a pending SET_POSITION so we can log its value if coalescing happens.
902 uint8_t pending_pos = 0;
903 for (const auto &op : this->op_queue_) {
904 if (op.type == PendingOperationType::SET_POSITION && op.device_id == device_id) {
905 pending_pos = op.position;
906 break;
907 }
908 }
909 if (this->op_queue_.enqueue_set_tilt(device_id, tilt_percent)) {
910 ESP_LOGI(detail::TAG,
911 "Coalesced pending SET_POSITION (pos=%u) + SET_TILT (tilt=%u) → "
912 "SET_POSITION_AND_TILT for device %s",
913 pending_pos, tilt_percent, device_id.c_str());
914 }
915}
916
917void IOHomeControlComponent::queue_set_device_position_and_tilt(const std::string &device_id, uint8_t position,
918 uint8_t tilt_percent) {
919 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_POSITION_AND_TILT)) {
920 // Withdraw the entity's optimistic prediction — see queue_set_device_position().
921 this->registry_.rollback_optimistic(device_id);
922 return;
923 }
924 this->op_queue_.enqueue_set_position_and_tilt(device_id, position, tilt_percent);
925}
926
927void IOHomeControlComponent::queue_request_device_status(const std::string &device_id) {
928 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_STATUS))
929 return;
930 // Keep at most one pending status poll per device. Without this, an overdue next_update can add
931 // the same poll on every main-loop iteration until the first queued request is finally processed.
932 this->op_queue_.enqueue_request_status(device_id);
933}
934
936 if (this->get_device(device_id) == nullptr)
937 return;
938 this->op_queue_.enqueue_request_limitation(device_id);
939}
940
941void IOHomeControlComponent::queue_request_device_name(const std::string &device_id) {
942 if (this->get_device(device_id) == nullptr)
943 return;
944 this->op_queue_.enqueue_request_name(device_id);
945}
946
947/// Queue a discovery-and-pair request with elevated priority.
948///
949/// Flushes any pending status/name poll operations (which would consume time
950/// during the device's limited pairing window) and pushes discovery to the
951/// front of the queue. Duplicate requests are suppressed.
953 if (this->last_pairing_end_ms_ != 0 && millis() - this->last_pairing_end_ms_ < PAIRING_STALE_PRESS_MS) {
954 ESP_LOGW(detail::TAG, "Discover & Pair pressed while the previous attempt was running: ignored. Put the device "
955 "back into pairing mode and press again.");
956 return;
957 }
959}
960
964
966 if (this->busy_) {
967 // Same event/log path a rejected API call gets (ManagementActions::resolve_device_() and
968 // friends) -- a busy press must not go silent, since it's the one press outcome the API
969 // action can never produce (an automation-triggered press is the only way to reach this
970 // guard at all, see the doc comment on the declaration).
972 result.action = "scan_paired_devices";
973 result.message = "a radio exchange was already in progress; press the button again once it finishes";
975 return;
976 }
978}
979
980void IOHomeControlComponent::queue_set_light_position(const std::string &device_id, uint8_t position) {
981 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_LIGHT))
982 return;
983 this->op_queue_.enqueue_set_light_position(device_id, position);
984}
985
986void IOHomeControlComponent::queue_set_fan_position(const std::string &device_id, uint8_t position) {
987 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_FAN))
988 return;
989 this->op_queue_.enqueue_set_fan_position(device_id, position);
990}
991
992void IOHomeControlComponent::queue_set_light_state(const std::string &device_id, bool on) {
993 this->queue_set_light_position(device_id, on ? BINARY_ENTITY_ON_POSITION : BINARY_ENTITY_OFF_POSITION);
994}
995
996void IOHomeControlComponent::queue_set_lock_state(const std::string &device_id, bool locked) {
997 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_LOCK))
998 return;
999 this->op_queue_.enqueue_set_lock_state(device_id, locked);
1000}
1001
1002void IOHomeControlComponent::queue_set_switch_state(const std::string &device_id, bool on) {
1003 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_SWITCH))
1004 return;
1005 this->op_queue_.enqueue_set_switch_state(device_id, on);
1006}
1007
1008// === 1W transmit ===
1009
1010void IOHomeControlComponent::execute_oneway_command_(const std::string &controller_id, CoverCommand cmd) {
1011 this->execute_oneway_([&] { this->oneway_transmitter_.send_command(controller_id, cmd); });
1012}
1013
1014void IOHomeControlComponent::execute_oneway_position_(const std::string &controller_id, uint8_t position) {
1015 this->execute_oneway_([&] { this->oneway_transmitter_.send_position(controller_id, position); });
1016}
1017
1018void IOHomeControlComponent::execute_oneway_enroll_(const std::string &controller_id) {
1019 this->execute_oneway_([&] { this->oneway_transmitter_.send_enrollment(controller_id); });
1020}
1021
1022void IOHomeControlComponent::execute_oneway_unenroll_(const std::string &controller_id) {
1023 this->execute_oneway_([&] { this->oneway_transmitter_.send_unenrollment(controller_id); });
1024}
1025
1027 if (this->busy_ || this->op_queue_.empty())
1028 return;
1029
1030 // Pop before dispatch so any handler that re-queues follow-up work sees the queue in its
1031 // post-consumption state and cannot accidentally execute the same operation twice.
1032 auto opt = this->op_queue_.pop();
1033 if (!opt.has_value())
1034 return;
1035 const PendingOperation &operation = *opt;
1036
1037 switch (operation.type) {
1039 this->set_device_position(operation.device_id, operation.position);
1040 break;
1042 this->set_device_tilt(operation.device_id, operation.position);
1043 break;
1045 this->set_device_position_and_tilt(operation.device_id, operation.position, operation.tilt);
1046 break;
1048 this->execute_device_command_(operation.device_id, operation.command);
1049 break;
1051 // operation.position already carries the target IO position (0-100) regardless of whether
1052 // it was enqueued via queue_set_light_state() (binary extremes) or
1053 // queue_set_light_position() (dimmable) — see enqueue_set_light_state()'s thin-wrapper doc.
1054 this->set_light_position(operation.device_id, operation.position);
1055 break;
1057 this->set_fan_position(operation.device_id, operation.position);
1058 break;
1060 this->set_lock_state(operation.device_id, operation.position == BINARY_ENTITY_OFF_POSITION);
1061 break;
1063 this->set_switch_state(operation.device_id, operation.position == BINARY_ENTITY_ON_POSITION);
1064 break;
1066 // device_id carries the controller-identity handle for 1W ops — see PendingOperation.
1067 this->execute_oneway_command_(operation.device_id, operation.command);
1068 break;
1070 this->execute_oneway_position_(operation.device_id, operation.position);
1071 break;
1073 this->execute_oneway_enroll_(operation.device_id);
1074 break;
1076 this->execute_oneway_unenroll_(operation.device_id);
1077 break;
1079 this->request_device_status(operation.device_id);
1080 break;
1082 this->request_device_name(operation.device_id);
1083 break;
1085 this->request_device_limitation(operation.device_id);
1086 break;
1088 this->discover_and_pair();
1089 break;
1090 }
1091}
1092
1093} // namespace home_io_control
1094} // 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:100
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.
virtual bool set_fan_position(const std::string &device_id, uint8_t position)
Send a heat level to an exterior heater (a fan entity) as an IO position.
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: exchange_and_record_() plus update_device_s...
void execute_oneway_position_(const std::string &controller_id, uint8_t position)
Send a queued 1W numeric position.
virtual void queue_set_fan_position(const std::string &device_id, uint8_t position)
Async form of set_fan_position() that keeps radio work serialized on the main loop.
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)
Run one heating function on the heater's private register protocol (CMD_WRITE_PRIVATE 0x20): a write ...
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:1077
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 bool set_heating_temperature(const std::string &device_id, uint16_t main_parameter, const HeatingFp &fp)
Command a heater that carries a temperature or an alias in its main parameter (the heating temperatur...
virtual IoDevice * get_device(const std::string &device_id)
Retrieve a device by ID; returns nullptr if not found.
Definition hub_core.cpp:350
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:1134
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:303
void process_pending_operation_()
Pop next pending operation from the queue and execute it (set position, request status,...
virtual bool read_heating_register(const std::string &device_id, HeatingFunction fn, float *value_out, uint8_t max_tries)
Read one register of a heater and fold the decoded value into IoDevice::heating.
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_request_device_limitation(const std::string &device_id)
Queue an async minimum-limitation read for the rain sensor poll; executed in loop() as a background o...
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.
uint32_t last_pairing_end_ms_
millis() when the last pairing attempt ended; 0 before the first one (see queue_discover_and_pair()).
Definition hub_core.h:1253
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:1131
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:332
OneWayTransmitter oneway_transmitter_
Owns the 1W controller identities, their rolling-sequence counters and the transmit burst.
Definition hub_core.h:1285
virtual bool request_device_limitation(const std::string &device_id)
Read the device's minimum limitation and derive its rain sensor state from the reply.
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,...
void apply_rain_poll_result_(const std::string &device_id, RainSensorState reading, const std::string &limit_text)
Fold one rain poll outcome into the device's rain sensor state and tell subscribers.
virtual bool discover_and_pair()
Discover and pair a device that is in pairing mode.
bool poll_heating_device_(const std::string &device_id)
Poll a heater: read its room temperature, setpoint, mode and regulator state (stopping at the first f...
virtual bool set_heating_level(const std::string &device_id, uint8_t level)
Set the heating level of a heater that is commanded with levels (the plain electrical heater).
ExchangeEngine exchange_engine_
Owns all authenticated exchange and LBT/hop logic.
Definition hub_core.h:1278
RequestResult exchange_and_record_(const std::string &device_id, const IoFrame &request, IoFrame &response, const RequestOptions &options)
Run one authenticated request/response exchange on the standard command channel and record its outcom...
RequestResult
What exchange_and_record_() found.
Definition hub_core.h:993
@ REPLY
The device answered; the caller interprets the reply frame.
Definition hub_core.h:997
@ UNCONFIRMED
A CMD_EXECUTE the device accepted but never closed; counted as success, no reply frame.
Definition hub_core.h:995
@ ERROR_REPLY
The device refused with CMD_ERROR_RESP; handle_error_response_() has recorded it.
Definition hub_core.h:996
@ FAILED
No usable reply (silence, or an unconfirmed acceptance of a request that needs a payload).
Definition hub_core.h:994
void schedule_heating_poll_(const std::string &device_id, uint32_t delay_ms)
Arm the heater's regular-cadence poll timer.
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:167
ManagementActions management_actions_
Owns rename, identify, force-open, scan_paired_devices, and other hub-level HA actions.
Definition hub_core.h:1280
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:316
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.
bool exchange_heating_(const std::string &device_id, const char *label, const IoFrame &request, IoFrame &response, uint8_t max_tries)
The exchange every heating frame shares: send on the command channel, record the outcome (exchange co...
virtual bool set_device_position(const std::string &device_id, uint8_t position)
Send a position command to a device.
virtual bool request_device_name(const std::string &device_id)
Request the stored device name from a device.
virtual void set_device_temperature_range(const std::string &device_id, float min_c, float max_c)
Set the temperature encoding range of a heater that carries a temperature in its main parameter (YAML...
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.
RainPollPolicy rain_poll_policy_
Schedule of the opt-in rain sensor poll.
Definition hub_core.h:1267
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.
bool enqueue_request_limitation(const std::string &device_id)
Enqueue REQUEST_LIMITATION, suppressing duplicates.
void enqueue_set_fan_position(const std::string &device_id, uint8_t position)
Enqueue SET_FAN_POSITION with an IO position (0-100; 0 = full heat, 100 = off).
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 defer(const std::string &device_id, uint32_t delay_ms, uint32_t now)
Pull the device's next poll forward to delay_ms after now. Never pushes it later.
bool first_error_reply(const std::string &device_id)
True the first time it is called for a device that answered the limitation read with an error reply,...
void on_poll_succeeded(const std::string &device_id)
Clear the unanswered-poll count.
bool on_poll_failed(const std::string &device_id)
Count one unanswered poll (saturating).
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).
RainSensorState rain_state_from_limitation_reply(const LimitationStatus &status)
Rain state from a decoded limitation reply: the one place that knows which reply fields decide.
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?
std::string describe_limitation_reply(const IoFrame &frame)
Render a CMD_LIMITATION_STATUS_RESP frame for a log line or probe report.
constexpr const char * TAG
Shared log tag for hub-level messages.
Definition log_helpers.h:32
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 log_component_capture(const RadioDriver *radio, const char *stage, const uint8_t *buf, uint8_t len, const IoFrame *frame=nullptr)
Log a frame at the "io_capture" tag with structured fields.
Definition log_helpers.h:97
std::string format_limitation_value(uint16_t value_raw)
Render a limitation value: a percentage, or the raw selector when it is not a position.
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.
bool device_takes_main_parameter_only(DeviceType type, uint8_t subtype)
Does this device take movement commands as the main parameter only?
bool decode_limitation_status(const IoFrame &f, LimitationStatus &out)
Decode a limitation reply (assumed layout, see LimitationStatus).
bool decode_heating_reply(const IoFrame &reply, HeatingFunction fn, float &value)
Decode the value a heater answered a register read or write with.
constexpr size_t HEATING_PAYLOAD_MAX_SIZE
Largest payload any function produces: a set of a 16-bit value (4-byte header + 2 value bytes).
RainSensorState
What the hub's latest minimum-limitation read says about a rain-protection limit.
@ UNKNOWN
No usable reading: never read, or the last reads went unanswered.
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_FAN_POSITION
set_fan_position call (heat level of an exterior heater as an IO position).
@ 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).
@ REQUEST_LIMITATION
request_device_limitation call (read the device's minimum limitation).
@ ONEWAY_ENROLL
1W add-controller (0x30) registering an identity.
@ ONEWAY_POSITION
1W numeric position sent as a controller identity.
@ FP9
Functional parameter 9, one byte.
@ FP16
Functional parameter 16, two bytes.
size_t encode_heating_payload(HeatingFunction fn, float value, uint8_t out[HEATING_PAYLOAD_MAX_SIZE])
Encode the function the heating_control action runs: a read for a read-only register,...
const char * rain_sensor_state_name(RainSensorState state)
Get a human-readable name for a RainSensorState.
HeatingFunction
Registers and functions of a 2W heater, one row of the descriptor table each.
const HeatingProfile * find_heating_profile(DeviceType type, uint8_t subtype)
Look up the heating profile for a (type, subtype).
DeviceCapabilityClass device_capability_class(DeviceType type)
Map a raw IO‑Homecontrol type to the closest ESPHome/Home Assistant entity family.
bool create_limitation_status_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, LimitationType type, bool low_power)
Build the limitation read (see the header for the evidence and the read-only guarantee).
CoverCommand
Named device commands for cover-type actuators.
@ FAVORITE
Move to stored favorite/"My" position.
@ VENT
Move to ventilation position (window-type devices).
@ FORCE_OPEN
Move to fully open at elevated priority; intended to bypass soft locks and environmental limits (conf...
bool create_execute_heating_level(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t level)
Build a heating-level execute frame (0x00). See proto_commands.h.
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.
@ PRIVATE_REGISTERS
CMD_WRITE_PRIVATE register reads and writes (proto_heating.h).
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_heating_main_parameter(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint16_t main_parameter)
Build a temperature / alias execute frame (0x00), main parameter only. See proto_commands....
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.
bool create_execute_heating_with_fp16(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint16_t main_parameter, uint16_t fp16)
Build a temperature / alias execute frame (0x00) carrying functional parameter 16....
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
bool device_uses_heating_levels(DeviceType type, uint8_t subtype)
Does a climate entity drive this device with heating levels?
bool effective_is_stopped(const IoDevice &dev)
Whether a consumer should treat the device as at rest, prediction first.
void note_moving_evidence(IoDevice &dev, uint32_t now_ms)
Record that a device is (believed to be) travelling right now.
bool device_uses_heating_registers(DeviceType type, uint8_t subtype)
Does a climate entity drive this device over the private register protocol?
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.
@ MINIMUM
Resulting minimum limit; rain protection raises it (a floor on the position value).
bool device_uses_heating_temperature(DeviceType type, uint8_t subtype)
Does a climate entity drive this device with a temperature or an alias in the main parameter?
@ CLIMATE
Climate device (heating/cooling).
@ HEATER_LEVEL
Heater with a heat level in the position field (exterior heating).
@ COVER
Position‑controlled cover (shutter/blind/awning).
bool create_execute_heating_with_mode_flags(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint16_t main_parameter, uint16_t mode_flags)
Build a temperature / alias execute frame (0x00) carrying functional parameter 9. See proto_commands....
size_t encode_heating_read(HeatingFunction fn, uint8_t out[HEATING_PAYLOAD_MAX_SIZE])
Encode a read ("get") of one register into a CMD_WRITE_PRIVATE (0x20) payload.
constexpr uint8_t HEATING_DIRECTION_GET
Payload byte at index 1 of a read ("get") request.
bool create_execute_main_parameter(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t position, bool force_open_priority)
Build a main-parameter-only position execute frame (0x00). See proto_commands.h.
std::string format_device_kind(DeviceType type, uint8_t subtype)
Human-readable description of a (type, subtype) for logs and reports.
bool device_supports_tilt(DeviceType type)
Does this device type support tilt (slat angle) control?
uint8_t serialize(const IoFrame &f, uint8_t *buf, uint8_t buf_size)
Serialize a parsed frame into a wire buffer (without CRC).
Command builders for the IO‑Homecontrol protocol.
The IO node-type and subtype table: names, entity class, platform and behaviour flags.
Heating profiles: what a climate card offers for each kind of heater, and how.
bool saw_challenge
True if a 0x3C was seen during this exchange.
A functional parameter and its value.
uint16_t value
Its value (one byte for FP9, two for FP16).
HeatingFpKind kind
Which parameter; NONE sends none.
const HeatingFunction * boot_registers
PRIVATE_REGISTERS: registers read once until they have been read.
size_t boot_register_count
Number of entries in boot_registers.
const HeatingFunction * poll_registers
PRIVATE_REGISTERS: registers every poll reads, in order.
size_t poll_register_count
Number of entries in poll_registers.
HeatingControlPath path
How the heater is commanded and read.
bool has(HeatingFunction fn) const
Has this register been read yet?
Everything one execute-family operation needs beyond its own guard and frame builder.
Definition hub_core.h:1034
const char * action
Verb/phrase for the "Sending ..." and rejection logs.
Definition hub_core.h:1035
bool settle_as_stop
Passed through to arm_execute_confirmation_poll_().
Definition hub_core.h:1036
Per-request knobs of exchange_and_record_().
Definition hub_core.h:1001
uint8_t max_tries
Transmit-attempt cap forwarded to send_and_receive_().
Definition hub_core.h:1004
uint32_t retry_after_fail_ms
If non-zero, drives the status-poll backoff and failure streaks.
Definition hub_core.h:1003
bool warn_on_no_response
Log a warning when no response arrives.
Definition hub_core.h:1002
Runtime state of a paired IO‑Homecontrol device.
float temperature_range_min_c
Lower end of the temperature encoding range of a heater that carries a temperature in its main parame...
HeatingReadings heating
What the hub has read from a heater over the private register protocol (room temperature,...
bool low_power
YAML-declared: this target is a low-power / duty-cycled receiver, so directed frames set CTRL1_LOW_PO...
uint8_t subtype
Device subtype (manufacturer‑specific).
float temperature_range_max_c
Upper end of that range (YAML max_temperature:).
uint8_t node_id[NODE_ID_SIZE]
Device's 3‑byte radio address.
RainSensorState rain_sensor
Rain state from the opt-in minimum-limitation poll.
DeviceType type
Device type (shutter, awning, etc.).
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.
One decoded CMD_LIMITATION_STATUS_RESP payload.
uint16_t value_raw
data[1..2], big-endian, position coding (STATUS_POS_MAX = 100 %).
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).