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"
6#include "proto_dhw.h"
8
9#include <cmath>
10#include <algorithm>
11#include <cinttypes>
12#include <cstdio>
13#include <iterator>
14
15/// @file hub_operations.cpp
16/// @brief High-level command execution and queued operation dispatch.
17/// @ingroup hioc_hub
18///
19/// This file owns the outbound user-facing operations on the hub:
20/// - cover position and tilt commands,
21/// - explicit status requests,
22/// - light/switch semantic wrappers,
23/// - queued dispatch on the main loop.
24///
25/// Keeping these methods out of hub_core.cpp makes it easier to reason about the
26/// difference between lifecycle/polling logic and the explicit actions initiated
27/// by Home Assistant entities.
28
29namespace esphome {
30namespace home_io_control {
31
32namespace {
33
34/// Stack-buffer size for a pre-formatted execute action phrase such as
35/// "position=100%% tilt=100%%".
36constexpr size_t EXECUTE_ACTION_BUF_SIZE = 40;
37
38/// Wire-scale "fully open" position for a FORCE_OPEN. A normal actuator reads fully open as 0;
39/// an IoDevice::inverted actuator (a horizontal awning, say) reads it as 100.
40constexpr uint8_t FORCE_OPEN_WIRE_POSITION = 0;
41constexpr uint8_t FORCE_OPEN_WIRE_POSITION_INVERTED = 100;
42
43/// @brief Return the human-readable verb for a position-style command.
44/// @param dev Device receiving the command.
45/// @param position Requested execute position.
46/// @return Log-friendly action string such as "open", "turn on", or "lock".
47const char *position_command_action(const IoDevice &dev, uint8_t position) {
48 if (position == POS_STOP)
49 return "stop";
50
52 return "set position";
53
54 bool const active_state = position == BINARY_ENTITY_ON_POSITION;
55 switch (device_capability_class(dev.type)) {
59 return active_state ? "turn on" : "turn off";
61 return active_state ? "unlock" : "lock";
67 default:
68 return active_state ? "open" : "close";
69 }
70}
71
72/// @brief Return the effective profile label for a device's "Sending ... (profile=...)" logs.
73/// @param dev Device receiving the command.
74/// @return "dimmable_light" for a LIGHT-class device with device_feature::LEVELS (the YAML `dimmable:`
75/// choice, which the wire protocol has no signal for); device_operation_profile_name() for
76/// every other device.
77const char *operation_profile_name(const IoDevice &dev) {
78 if (has_feature(dev.features, device_feature::LEVELS) &&
80 return "dimmable_light";
81 return device_operation_profile_name(dev.type, dev.subtype);
82}
83
84/// @brief Return the accepted entity/profile label for rejected execute-position logs.
85/// @param dev Device the command was rejected for.
86/// @param position Requested execute position.
87/// @return Expected profile label for detail::log_rejected_operation(); names the missing feature
88/// when the device only lacks intermediate positions.
89const char *position_rejection_profile(const IoDevice &dev, uint8_t position) {
90 // A LIGHT-class device only reaches rejection for a genuinely out-of-range value or, without
91 // `dimmable:`, an intermediate one — the fix there isn't "needs cover_position", it's "needs a
92 // value in 0-100" (or `dimmable: true`).
95 return "0-100";
96 if (!has_feature(dev.features, device_feature::LEVELS) && position != POS_STOP &&
98 return "cover with intermediate positions (LEVELS feature)";
99 return detail::is_binary_entity_position(position) ? "cover_position or binary_on_off" : "cover_position";
100}
101
102/// @brief The queue-time capability guard for one family of queued operation.
103///
104/// Every `queue_*` method applies the same early-reject as its execute-time counterpart, for fast
105/// user feedback, with a "queued ..." rejection noun. This table is the one place those pairings
106/// are recorded, so a deliberate asymmetry is a visible row rather than an accident. The lone
107/// `queue_*` method with a guard that is NOT a row here is `queue_device_command`: it returns
108/// bool and uses the command name as its rejection noun, so it keeps its guard inline (see there).
109/// NOTE the asymmetry `queue_set_device_position` carries a COVER guard that `set_device_position`
110/// deliberately does *not* — light/switch/lock all funnel through `set_device_position`, so an
111/// entity-class guard there would break them; at queue time each entity has its own method.
112struct QueueGuard {
113 bool (*accepts)(const IoDevice &dev); ///< false → reject this operation for this device.
114 const char *rejection_noun; ///< e.g. "queued cover command".
115 const char *expected; ///< e.g. "cover entity".
116};
117
118constexpr QueueGuard QUEUE_GUARD_COVER{
120 "queued cover command", "cover entity"};
121constexpr QueueGuard QUEUE_GUARD_TILT{[](const IoDevice &d) { return detail::known_device_accepts_execute_tilt(d); },
122 "queued tilt command", "tilt-capable cover"};
123constexpr QueueGuard QUEUE_GUARD_POSITION_AND_TILT{
124 [](const IoDevice &d) { return detail::known_device_accepts_execute_tilt(d); }, "queued position+tilt command",
125 "tilt-capable cover"};
126constexpr QueueGuard QUEUE_GUARD_LIGHT{
128 "queued light command", "light entity"};
129constexpr QueueGuard QUEUE_GUARD_FAN{
131 "queued fan command", "exterior heating (fan entity)"};
132constexpr QueueGuard QUEUE_GUARD_LOCK{
134 "queued lock command", "lock entity"};
135constexpr QueueGuard QUEUE_GUARD_SWITCH{
137 "queued switch command", "switch entity"};
138constexpr QueueGuard QUEUE_GUARD_STATUS{
139 [](const IoDevice &d) { return detail::known_device_supports_status_requests(d); }, "queued status request",
140 "status-capable actuator"};
141
142/// @brief Apply one QueueGuard. Returns true (and logs) when the operation must be rejected.
143///
144/// Matches the historical guard exactly: an unregistered/unknown device (dev == nullptr) is *not*
145/// rejected here — it passes through so discovery and imported devices keep working.
146bool queue_guard_rejects(IOHomeControlComponent *hub, const std::string &device_id, const QueueGuard &guard) {
147 const IoDevice *dev = hub->get_device(device_id);
148 if (dev != nullptr && !guard.accepts(*dev)) {
149 detail::log_rejected_operation(device_id, *dev, guard.rejection_noun, guard.expected);
150 return true;
151 }
152 return false;
153}
154
155} // namespace
156
157void IOHomeControlComponent::arm_execute_confirmation_poll_(const std::string &device_id, bool for_stop) {
158 uint32_t const existing = this->poll_policy_.get_next_update(device_id);
159 uint32_t const delay_ms = settle_delay_ms(this->poll_policy_.get_interval(device_id), 0, for_stop);
160 this->begin_status_poll_tracking_(device_id, delay_ms);
161 if (existing != 0 && existing < millis() + delay_ms)
162 this->poll_policy_.set_next_update(device_id, existing);
163 if (for_stop)
164 this->poll_policy_.mark_stop_settle(device_id);
165}
166
167// A device event report (decisions::is_device_event_report(): a 0x70, or a 0x71 carrying a
168// subscribed event) from the very device we asked can arrive while we listen for the reply, and
169// classify_exchange_first_response() accepts any endpoint-matching frame as that reply. It answers
170// none of our requests, so it is recorded and never applied. The device is awake and transmitting,
171// so the request is asked once more rather than counted as a timeout (which would start the poll
172// backoff for a device that did answer, just with something else). An EXECUTE that is not safe to
173// repeat is not sent again: it ends as unanswered, like any lost exchange. The one event report that
174// does answer the request is a subscription's answer (decisions::is_subscription_answer()): it is the
175// reading the sensor polls ask for, so it is returned as the reply.
177 const decisions::AttemptRequest &attempts) {
178 ExchangeOutcome outcome = this->send_and_receive_(request, response, FREQ_CH2, attempts);
180 decisions::is_subscription_answer(request, response))
181 return outcome;
182 this->record_device_event_(response);
183 if (request.cmd == CMD_EXECUTE && !decisions::is_repeatable_execute(request))
185 outcome = this->send_and_receive_(request, response, FREQ_CH2, attempts);
187 decisions::is_subscription_answer(request, response))
188 return outcome;
189 this->record_device_event_(response);
191}
192
193// Run an authenticated request on the standard command channel and do the per-outcome bookkeeping
194// every caller shares; what the reply means is left to the caller.
196 const IoFrame &request,
197 IoFrame &response,
198 const RequestOptions &options) {
199 const bool warn_on_no_response = options.warn_on_no_response;
200 const uint32_t retry_after_fail_ms = options.retry_after_fail_ms;
201 const ExchangeOutcome outcome = this->exchange_past_device_event_(request, response, options.attempts);
202 // An unconfirmed acceptance means the device authenticated the request but never closed the
203 // exchange. Whether that counts as success depends entirely on what the request was *for*:
204 // - a command (CMD_EXECUTE) is treated as done. The device has it, and most devices that end
205 // this way report the result in their own status update a few seconds later. It is not proof
206 // the command was carried out, though, so the engine has already sent it once more where the
207 // silence was an anomaly (decisions::retry_after_unconfirmed_accept_is_safe()), and the
208 // confirmation poll corrects the state if the device did not act;
209 // - a status poll or a name read exists to obtain a payload. Getting none means the question
210 // went unanswered, so it stays a failure and takes the auth-shaped poll backoff: a quick
211 // retry the first time, a long one when it repeats (StatusPollPolicy::retry_delay_ms()); a
212 // poll of a moving device skips its first misses at the normal cadence first.
213 const bool unconfirmed_counts_as_success = request.cmd == CMD_EXECUTE;
214
215 // Recorded once, here, for every outcome — deliberately above the split below, because the rule
216 // is about the outcome and not about how this request chooses to classify it. The two branches
217 // disagree on whether an unconfirmed acceptance is a failure; they must not disagree on whether it
218 // happened. The unconfirmed count is the only record of the CMD_EXECUTE case, which reports
219 // success, and it is what separates "the device never heard us" from "it heard us and the reply
220 // was lost" in the branch that does count as a failure.
221 if (IoDevice *dev = this->registry_.get(device_id); dev != nullptr)
222 detail::record_exchange_outcome(*dev, request.cmd, outcome);
223
224 if (outcome == ExchangeOutcome::FAILED ||
225 (outcome == ExchangeOutcome::SUCCESS_UNCONFIRMED && !unconfirmed_counts_as_success)) {
226 const auto &dbg = this->exchange_engine_.get_debug();
227 if (IoDevice *dev = this->registry_.get(device_id); dev != nullptr) {
228 detail::record_exchange_timeout(*dev, dbg.tries);
229 // A 0x3C challenge seen during the exchange is itself a frame from this device: the exchange
230 // still failed (our challenge-response or the device's final reply was lost), but the device
231 // demonstrably transmitted. Stamp link health the same way the explicit-refusal branch does,
232 // or a device that challenges every attempt yet never closes the exchange ages exactly like
233 // a powered-off one.
234 if (dbg.saw_challenge)
236 this->notify_device_update_(device_id);
237 }
238 if (retry_after_fail_ms != 0) {
239 // A single-try poll of a device the hub believes is travelling lost one frame: it keeps the
240 // normal cadence before the backoff ladder starts (MOVING_POLL_MISSES_SKIPPED). A device that
241 // reported STOPPED off its target and awaits the confirming poll is at rest, not travelling,
242 // and a poll that already had several tries keeps the ladder.
243 const IoDevice *dev = this->registry_.get(device_id);
244 const bool device_moving = request.cmd == CMD_PRIVATE && options.attempts.is_progress_look() && dev != nullptr &&
245 !effective_is_stopped(*dev) && !detail::is_settling(*dev) &&
246 !device_has_no_motion(dev->type, dev->subtype);
247 this->schedule_background_poll_backoff_(device_id, dbg.saw_challenge, device_moving);
248 }
249 this->log_exchange_debug_(device_id.c_str());
250 if (warn_on_no_response) {
251 ESP_LOGW(detail::TAG, "Command 0x%02X failed for device %s: no valid response (stage=%s tries=%u)", request.cmd,
252 device_id.c_str(), dbg.stage, dbg.tries);
253 }
255 }
256
258 // The device challenged the request, so it received it; whether it also received our challenge
259 // answer — and therefore acted — is not observable from here (see the WAIT_FINAL_RESPONSE
260 // branch in ExchangeEngine::send_and_receive()). There is no frame to parse, and inventing a
261 // position from a request we only know was *challenged* would be worse than leaving the last
262 // known state alone — the device's own asynchronous status update supplies the real one, and
263 // that path authenticates now. Clear the failure streaks: this was not a failure.
264 //
265 // Log the snapshot anyway. This branch is the one exchange ending that prints nothing at all
266 // otherwise, so a device that routinely stops replying here leaves no trace to diagnose, and
267 // its final_rx_* fields (did the radio receive anything during the final wait?) are what
268 // separates a lost answer from a lost reply. Its cap_* fields describe the device's challenge,
269 // not that wait.
270 this->log_exchange_unconfirmed_debug_(device_id.c_str());
271 if (retry_after_fail_ms != 0)
272 this->poll_policy_.clear_failure_streaks(device_id);
273 if (IoDevice *dev = this->registry_.get(device_id); dev != nullptr) {
275 this->notify_device_update_(device_id);
276 }
278 }
279
280 if (response.cmd == CMD_ERROR_RESP) {
281 this->handle_error_response_(device_id, request, response, retry_after_fail_ms);
283 }
284
285 if (retry_after_fail_ms != 0)
286 this->poll_policy_.clear_failure_streaks(device_id);
288}
289
290// Execute an authenticated request and, on a reply, feed it back through the normal inbound status
291// parser so all state normalization stays in one place.
292bool IOHomeControlComponent::execute_request_and_update_(const std::string &device_id, const IoFrame &request,
293 bool warn_on_no_response, uint32_t retry_after_fail_ms,
294 const decisions::AttemptRequest &attempts) {
295 IoFrame response;
296 switch (
297 this->exchange_and_record_(device_id, request, response, {warn_on_no_response, retry_after_fail_ms, attempts})) {
300 return false;
302 return true;
304 break;
305 }
306
307 // The immediate reply to our own CMD_EXECUTE (position/tilt/stop/favorite/vent) is not
308 // trustworthy for target/current position on at least some devices — see
309 // update_device_status_()'s trust_position doc comment. Every other request we send
310 // (status poll, get name, ...) keeps trusting its reply as before.
311 this->update_device_status_(response, request.cmd != CMD_EXECUTE);
312 return true;
313}
314
315bool IOHomeControlComponent::handle_error_response_(const std::string &device_id, const IoFrame &request,
316 const IoFrame &response, uint32_t retry_after_fail_ms) {
317 IoDevice *dev = this->registry_.get(device_id);
318 // An explicit refusal is still a reply from the device, but no caller feeds it to
319 // update_device_status_(), so stamp link health here to keep update_link_health()'s "every frame
320 // from a registered device" contract.
321 if (dev != nullptr)
323 if (response.data_len == 0) {
324 detail::log_frame_issue(this, this->node_id_, "rx", "unsupported_payload", response, frame_length(response));
325 } else if (dev != nullptr) {
326 detail::record_command_result(*dev, device_id, response.data[0], request.cmd, true);
327 } else {
328 detail::log_command_result(device_id, response.data[0], request.cmd, true);
329 }
330 if (dev != nullptr)
331 this->notify_device_update_(device_id);
332 if (retry_after_fail_ms != 0)
334 return false;
335}
336
337bool IOHomeControlComponent::run_execute_operation_(const std::string &device_id, const ExecuteRequestSpec &spec,
338 const std::function<bool(const IoDevice &)> &accepts,
339 const char *rejection_profile,
340 const std::function<bool(IoFrame &, const IoDevice &)> &build) {
341 // Every false return means this command will not happen: an unregistered or not-yet-initialized
342 // device, a profile guard rejection, a builder failure, or an exchange that ended with no valid
343 // response or an explicit CMD_ERROR_RESP. In all of them the prediction the entity applied at
344 // control() time must be withdrawn, or the Home Assistant cover animates a movement that is not
345 // occurring — indefinitely, since only a frame from the device can settle it.
346 const bool accepted = this->try_execute_operation_(device_id, spec, accepts, rejection_profile, build);
347 if (accepted && spec.settle_as_stop) {
348 this->registry_.confirm_optimistic_stop(device_id);
349 // The moving evidence is left to the STOP's own ack (apply_private_response_status()): a motor
350 // that reports "stopped" clears it, one still winding down keeps it, because right after a STOP
351 // a VELUX SSL can still be in the travelling state that hears only the short preamble. An ack
352 // with no status to decode leaves the evidence as it was; a settle poll that draws nothing spends it.
353 } else if (accepted) {
354 // An accepted movement command means the receiver is travelling now, whether or not the reply
355 // carried a status to decode (ExchangeOutcome::SUCCESS_UNCONFIRMED has none). Stamped here,
356 // after the exchange, and not when the optimistic overlay is applied before it: see
357 // note_moving_evidence().
358 if (IoDevice *dev = this->registry_.get(device_id); dev != nullptr)
359 note_moving_evidence(*dev, millis());
360 } else {
361 this->registry_.rollback_optimistic(device_id, /*failed_stop=*/spec.settle_as_stop);
362 }
363 return accepted;
364}
365
366bool IOHomeControlComponent::try_execute_operation_(const std::string &device_id, const ExecuteRequestSpec &spec,
367 const std::function<bool(const IoDevice &)> &accepts,
368 const char *rejection_profile,
369 const std::function<bool(IoFrame &, const IoDevice &)> &build) {
370 auto *dev = this->get_device(device_id);
371 if (dev == nullptr || !this->initialized_)
372 return false;
373
374 // Once a device family is known, use the profile helpers to reject YAML/entity mismatches
375 // before they hit the radio path. Unknown types still pass through so discovery and imported
376 // devices keep working as before.
377 if (!accepts(*dev)) {
378 detail::log_rejected_operation(device_id, *dev, spec.action, rejection_profile);
379 return false;
380 }
381
382 this->begin_status_poll_tracking_(device_id, this->poll_policy_.get_interval(device_id));
383
384 ESP_LOGI(detail::TAG, "Sending %s to device %s (profile=%s)", spec.action, device_id.c_str(),
385 operation_profile_name(*dev));
386
387 IoFrame request;
388 if (!build(request, *dev)) {
389 this->poll_policy_.clear(device_id);
390 return false;
391 }
392 // A command whose copies have one effect takes the command plan (as many attempts as fit the
393 // budget to a mains device); a stored-position or toggle-like command keeps today's three.
397 if (!this->execute_request_and_update_(device_id, request, true, 0, attempts)) {
399 return false;
400 }
401 this->arm_execute_confirmation_poll_(device_id, spec.settle_as_stop);
402 return true;
403}
404
405bool IOHomeControlComponent::set_device_position(const std::string &device_id, uint8_t position) {
406 const auto *dev = this->get_device(device_id);
407 if (dev == nullptr)
408 return false;
409 // action and rejection profile depend on this device's class and the requested value, so
410 // resolve them here where dev is in scope; run_execute_operation_() re-checks dev/initialized_.
411 return this->run_execute_operation_(
412 device_id, {position_command_action(*dev, position), position == POS_STOP},
413 [position](const IoDevice &d) { return detail::known_device_accepts_execute_position(d, position); },
414 position_rejection_profile(*dev, position),
415 [this, position](IoFrame &request, const IoDevice &d) {
416 // MOTOR_DEFAULT sends the 6-byte frame a wall pad sends, which carries no speed profile.
417 if (!has_feature(d.features, device_feature::EXTENDED_POSITION_FRAME) ||
418 d.travel_speed == TravelSpeed::MOTOR_DEFAULT)
419 return create_execute_main_parameter(request, this->node_id_, d.node_id, d.low_power, position);
420 return create_execute_position(request, this->node_id_, d.node_id, d.low_power, position,
421 d.travel_speed == TravelSpeed::SILENT);
422 });
423}
424
425bool IOHomeControlComponent::execute_device_command_(const std::string &device_id, CoverCommand cmd) {
426 // A named position (favourite, ventilation) goes out only to a device whose features include its
427 // bit; a motor without it would answer PARAMETER_UNKNOWN (issue #163), so the command is refused
428 // here, through the same rejection and rollback path as a position.
429 const NamedPosition *named = find_named_position(cmd);
430 char expected[EXECUTE_ACTION_BUF_SIZE] = "cover entity";
431 if (named != nullptr)
432 snprintf(expected, sizeof(expected), "cover with %s position", named->name);
433 return this->run_execute_operation_(
434 device_id, {cover_command_name(cmd), cmd == CoverCommand::STOP},
435 [named](const IoDevice &d) {
437 (named == nullptr || device_accepts_main_parameter(d.features, named->main_parameter));
438 },
439 expected,
440 [this, cmd](IoFrame &request, const IoDevice &d) {
441 // FORCE_OPEN needs the device's own wire-scale "fully open" position (0, or 100 for an
442 // IoDevice::inverted device such as a horizontal awning) — create_execute_command() has no
443 // device access to resolve that, so it is built separately here where d is in scope. The
444 // silent profile is a functional parameter, so only a device with the extended frame gets it.
445 if (cmd != CoverCommand::FORCE_OPEN) {
446 return create_execute_command(request, this->node_id_, d.node_id, d.low_power, cmd,
447 d.travel_speed == TravelSpeed::SILENT &&
448 has_feature(d.features, device_feature::EXTENDED_POSITION_FRAME));
449 }
450 const uint8_t open_position = d.inverted ? FORCE_OPEN_WIRE_POSITION_INVERTED : FORCE_OPEN_WIRE_POSITION;
451 return has_feature(d.features, device_feature::EXTENDED_POSITION_FRAME)
452 ? create_force_open(request, this->node_id_, d.node_id, d.low_power, open_position)
453 : create_execute_main_parameter(request, this->node_id_, d.node_id, d.low_power, open_position,
454 /*force_open_priority=*/true);
455 });
456}
457
458bool IOHomeControlComponent::set_device_tilt(const std::string &device_id, uint8_t tilt_percent) {
459 char action[EXECUTE_ACTION_BUF_SIZE];
460 snprintf(action, sizeof(action), "tilt=%u%%", tilt_percent);
461 return this->run_execute_operation_(
462 device_id, {action, false}, [](const IoDevice &d) { return detail::known_device_accepts_execute_tilt(d); },
463 "tilt-capable cover",
464 [this, tilt_percent](IoFrame &request, const IoDevice &d) {
465 return create_execute_tilt(request, this->node_id_, d.node_id, d.low_power, tilt_percent);
466 });
467}
468
469bool IOHomeControlComponent::set_device_position_and_tilt(const std::string &device_id, uint8_t position,
470 uint8_t tilt_percent) {
471 char action[EXECUTE_ACTION_BUF_SIZE];
472 snprintf(action, sizeof(action), "position=%u%% tilt=%u%%", position, tilt_percent);
473 return this->run_execute_operation_(
474 device_id, {action, false}, [](const IoDevice &d) { return detail::known_device_accepts_execute_tilt(d); },
475 "tilt-capable cover",
476 [this, position, tilt_percent](IoFrame &request, const IoDevice &d) {
477 return create_execute_position_and_tilt(request, this->node_id_, d.node_id, d.low_power, position,
478 tilt_percent);
479 });
480}
481
482// A cover's status poll is a settling read (several attempts inside the exchange budget) when it
483// decides where the cover ended up: after an accepted STOP, confirming a stop off target, in the
484// ladder's retry band, or with no ladder behind it (a one-off poll, the opt-in status refresh).
485// Every other poll the scheduler owns only looks at a device that is probably still busy: one
486// attempt, the backoff ladder is its retry.
488 bool scheduler_managed, bool state_unknown) {
489 if (!scheduler_managed) {
491 return state_unknown ? attempts.with_state_unknown() : attempts;
492 }
493 // Asked before take_settling_read(), which consumes the mark.
494 const bool predicted_end = this->poll_policy_.is_predicted_end_read(device_id);
496 this->poll_policy_.get_status_poll_failures(device_id), this->poll_policy_.get_auth_poll_failures(device_id),
497 this->poll_policy_.take_settling_read(device_id)));
498 return predicted_end ? attempts.with_wake_preamble_first() : attempts;
499}
500
501// A heater keeps the count-based budget: a poll with no ladder behind it gets the full
502// EXCHANGE_RETRY_COUNT, a scheduler-owned one the ladder's band (decisions::scheduled_poll_max_tries()).
504 bool scheduler_managed) {
505 if (!scheduler_managed)
506 return EXCHANGE_RETRY_COUNT;
508 this->poll_policy_.get_auth_poll_failures(device_id));
509}
510
511bool IOHomeControlComponent::request_device_status(const std::string &device_id) {
512 auto *dev = this->get_device(device_id);
513 if (dev == nullptr || !this->initialized_)
514 return false;
515
517 detail::log_rejected_operation(device_id, *dev, "status request", "status-capable actuator");
518 return false;
519 }
520 // Consumed by every status read, so a catch-up mark never outlives the read it was meant for. It
521 // shapes only a cover's one-off read: a heater keeps its budget, and a device the follow-up window
522 // tracks keeps the ladder's plan (cover_poll_attempts_()).
523 const bool state_unknown = this->poll_policy_.take_state_unknown(device_id);
524
525 // A heater with private registers has no 0x03 status: its state is a handful of registers.
526 const HeatingProfile *heating = find_heating_profile(*dev);
527 if (heating != nullptr && heating->path == HeatingControlPath::PRIVATE_REGISTERS)
528 return this->poll_heating_device_(device_id);
529 // A water heater answers a status read for its target, and keeps the rest in family-0x00 registers.
530 if (has_feature(dev->features, device_feature::WATER_HEATER))
531 return this->poll_dhw_device_(device_id);
532
533 IoFrame request;
534 // Tilt-capable covers need the extended 0x03200100 status request so the response includes
535 // the reliable 16-byte tilt block. Other devices stay on the shorter generic request.
536 bool const request_ok = has_feature(dev->features, device_feature::TILT)
537 ? create_get_status_tilt(request, this->node_id_, dev->node_id, dev->low_power)
538 : create_get_status(request, this->node_id_, dev->node_id, dev->low_power);
539 if (!request_ok)
540 return false;
541 // A heating-level heater is polled on the heater cadence with the same status read; its failure
542 // ladder needs a tracking window, which a cover only has after a command.
543 if (heating != nullptr && !this->poll_policy_.is_tracking_active(device_id, millis()))
544 this->begin_status_poll_tracking_(device_id, 0);
545 const bool scheduler_managed = this->poll_policy_.is_tracking_active(device_id, millis());
546 const uint32_t retry_after_fail_ms = scheduler_managed ? STATUS_RETRY_AFTER_FAIL_MS : 0;
547 const decisions::AttemptRequest attempts =
548 heating != nullptr ? this->heater_poll_attempts_(device_id, scheduler_managed)
549 : this->cover_poll_attempts_(device_id, scheduler_managed, state_unknown);
550 const bool ok = this->execute_request_and_update_(device_id, request, false, retry_after_fail_ms, attempts);
551 if (heating != nullptr) {
552 // The regular cadence continues whatever this poll found, as for a register heater.
553 const uint32_t configured_ms = this->poll_policy_.get_interval(device_id);
554 this->schedule_heating_poll_(device_id, configured_ms != 0 ? configured_ms : HEATING_POLL_DEFAULT_INTERVAL_MS);
555 }
556 return ok;
557}
558
559namespace {
560
561/// Count one periodic poll that brought no reading. A poll the device left unanswered is asked
562/// again after PERIODIC_POLL_MISS_RETRY_MS first and counts only if the retry is unanswered too;
563/// any other miss (an error reply, an answer of another shape) counts at once.
564/// @return True while the last reading is no longer usable.
565bool count_poll_miss(PeriodicPollPolicy &policy, const std::string &device_id, bool unanswered, const char *label) {
566 if (!unanswered)
567 return policy.on_poll_failed(device_id);
568 const bool unusable = policy.on_poll_unanswered(device_id, millis());
569 if (policy.retry_armed(device_id)) {
570 ESP_LOGD(detail::TAG, "Device %s: %s poll unanswered, asking again in %" PRIu32 " s", device_id.c_str(), label,
571 PERIODIC_POLL_MISS_RETRY_MS / 1000);
572 }
573 return unusable;
574}
575
576} // namespace
577
578bool IOHomeControlComponent::request_device_limitation(const std::string &device_id) {
579 IoDevice *dev = this->registry_.get(device_id);
580 if (dev == nullptr || !this->initialized_)
581 return false;
582
583 // Not a failure and not a reading: a moving device is left alone (the same caution the diagnostic
584 // probe applies), and asking again soon is cheaper than waiting out a whole interval.
585 if (!effective_is_stopped(*dev)) {
586 ESP_LOGD(detail::TAG, "Device %s: rain sensor poll deferred, device is moving", device_id.c_str());
587 this->rain_poll_policy_.defer(device_id, PERIODIC_POLL_MOVING_RETRY_MS, millis());
588 return false;
589 }
590
591 IoFrame request;
593 return false;
594
595 // retry_after_fail_ms = 0 keeps the status-poll backoff ladder out of a rain poll's way; a miss
596 // here is counted by the rain schedule instead.
597 IoFrame response;
598 const RequestResult result =
599 this->exchange_and_record_(device_id, request, response, {false, 0, PERIODIC_POLL_MAX_TRIES});
600
601 // A reply consumed inside the blocking exchange never reaches the normal receive path.
602 dev = this->registry_.get(device_id);
604 std::string limit_text;
605 if (result == RequestResult::REPLY && dev != nullptr) {
606 // The device transmitted, whatever it said: stamp link health the way the status path does.
608 uint8_t raw[FRAME_MAX_SIZE] = {0};
609 const uint8_t raw_len = serialize(response, raw, sizeof(raw));
610 detail::log_component_capture(this->radio_, "limitation_rx", raw, raw_len, &response);
611 LimitationStatus status;
612 if (response.cmd == CMD_LIMITATION_STATUS_RESP && decode_limitation_status(response, status)) {
613 ESP_LOGD(detail::TAG, "Device %s: %s", device_id.c_str(), detail::describe_limitation_reply(response).c_str());
615 limit_text = detail::format_limitation_value(status.value_raw);
616 } else {
617 ESP_LOGD(detail::TAG, "Device %s: unexpected reply 0x%02X to the limitation read", device_id.c_str(),
618 response.cmd);
619 }
620 } else if (result == RequestResult::ERROR_REPLY && this->rain_poll_policy_.first_error_reply(device_id)) {
621 ESP_LOGW(detail::TAG, "Device %s answers the limitation read with an error; its rain sensor stays unknown",
622 device_id.c_str());
623 }
624
625 this->apply_rain_poll_result_(device_id, reading, limit_text, result == RequestResult::FAILED);
626 return reading != RainSensorState::UNKNOWN;
627}
628
629void IOHomeControlComponent::apply_rain_poll_result_(const std::string &device_id, RainSensorState reading,
630 const std::string &limit_text, bool unanswered) {
631 IoDevice *dev = this->registry_.get(device_id);
632 if (dev == nullptr)
633 return;
634 RainSensorState state = dev->rain_sensor;
635 if (reading != RainSensorState::UNKNOWN) {
636 this->rain_poll_policy_.on_poll_succeeded(device_id);
637 state = reading;
638 } else if (count_poll_miss(this->rain_poll_policy_, device_id, unanswered, "rain sensor")) {
639 state = RainSensorState::UNKNOWN; // the last reading is too old to keep showing
640 }
641 if (state != dev->rain_sensor) {
642 ESP_LOGI(detail::TAG, "Device %s: rain sensor %s -> %s%s%s%s", device_id.c_str(),
644 limit_text.empty() ? "" : " (minimum limit ", limit_text.c_str(), limit_text.empty() ? "" : ")");
645 dev->rain_sensor = state;
646 }
647 // Notify on every outcome: link health moved too, so the link sensors need the update.
648 this->notify_device_update_(device_id);
649}
650
651bool IOHomeControlComponent::request_device_sun_level(const std::string &device_id) {
652 return this->request_event_reading_(device_id, EventReading::SUN_LEVEL);
653}
654
657}
658
662
663namespace {
664
665/// The device record field a poll feeds: IoDevice::sun_level or IoDevice::battery_level.
666std::optional<uint16_t> &event_reading_field(IoDevice &dev, bool sun) {
667 return sun ? dev.sun_level : dev.battery_level;
668}
669
670/// The reading an answer carries, or nothing when it is not of the captured shape.
671std::optional<uint16_t> decode_event_reading(bool sun, const IoFrame &response) {
672 if (sun) {
673 uint16_t value = 0;
674 return decode_sun_level_event(response, value) ? std::optional<uint16_t>(value) : std::nullopt;
675 }
676 uint8_t level = 0;
677 return decode_battery_level_event(response, level) ? std::optional<uint16_t>(level) : std::nullopt;
678}
679
680/// A reading for the state-change log line: the number (with " %" for the battery) or "unknown".
681std::string format_event_reading(const std::optional<uint16_t> &reading, bool sun) {
682 if (!reading.has_value())
683 return "unknown";
684 return std::to_string(*reading) + (sun ? "" : " %");
685}
686
687} // namespace
688
689// The request is a subscription: the device answers it with the event's current value, and that
690// answer is the reading (ADR 0064). Every poll sends the subscription again; the device stores it
691// each time, which the RS100 Solar io has accepted repeatedly (issue #156).
692bool IOHomeControlComponent::request_event_reading_(const std::string &device_id, EventReading kind) {
693 IoDevice *dev = this->registry_.get(device_id);
694 if (dev == nullptr || !this->initialized_)
695 return false;
696 const bool sun = kind == EventReading::SUN_LEVEL;
697 const char *const label = sun ? "sun level" : "battery level";
698 PeriodicPollPolicy &policy = this->event_reading_policy_(kind);
699
700 // Same caution as the rain poll: a moving device is left alone and asked again soon.
701 if (!effective_is_stopped(*dev)) {
702 ESP_LOGD(detail::TAG, "Device %s: %s poll deferred, device is moving", device_id.c_str(), label);
703 policy.defer(device_id, PERIODIC_POLL_MOVING_RETRY_MS, millis());
704 return false;
705 }
706
707 IoFrame request;
708 if (!create_subscribe_event(request, this->node_id_, dev->node_id,
709 sun ? EVENT_LOCAL_SENSOR : EVENT_BATTERY_LEVEL_CHANGE, dev->low_power))
710 return false;
711
712 // retry_after_fail_ms = 0 keeps the status-poll backoff ladder out of the way; a miss is counted
713 // by this poll's own schedule instead.
714 IoFrame response;
715 const RequestResult result =
716 this->exchange_and_record_(device_id, request, response, {false, 0, PERIODIC_POLL_MAX_TRIES});
717
718 // A reply consumed inside the blocking exchange never reaches the normal receive path.
719 dev = this->registry_.get(device_id);
720 if (dev == nullptr)
721 return false;
722 std::optional<uint16_t> reading;
723 if (result == RequestResult::REPLY) {
724 // The device transmitted, whatever it said: stamp link health the way the status path does.
726 uint8_t raw[FRAME_MAX_SIZE] = {0};
727 const uint8_t raw_len = serialize(response, raw, sizeof(raw));
728 detail::log_component_capture(this->radio_, "event_reading_rx", raw, raw_len, &response);
729 reading = decode_event_reading(sun, response);
730 if (!reading.has_value() && policy.first_error_reply(device_id)) {
732 bytes_to_hex(response.data, response.data_len, hex, sizeof(hex));
733 ESP_LOGW(detail::TAG,
734 "Device %s answers the %s poll with cmd=0x%02X payload=%s, a shape not decoded; its %s "
735 "stays unknown",
736 device_id.c_str(), label, response.cmd, hex, label);
737 }
738 } else if (result == RequestResult::ERROR_REPLY && policy.first_error_reply(device_id)) {
739 ESP_LOGW(detail::TAG, "Device %s refuses the %s subscription; its %s stays unknown", device_id.c_str(), label,
740 label);
741 }
742
743 this->apply_event_reading_(device_id, kind, reading, result == RequestResult::FAILED);
744 return reading.has_value();
745}
746
747void IOHomeControlComponent::apply_event_reading_(const std::string &device_id, EventReading kind,
748 const std::optional<uint16_t> &reading, bool unanswered) {
749 IoDevice *dev = this->registry_.get(device_id);
750 if (dev == nullptr)
751 return;
752 const bool sun = kind == EventReading::SUN_LEVEL;
753 PeriodicPollPolicy &policy = this->event_reading_policy_(kind);
754 std::optional<uint16_t> &field = event_reading_field(*dev, sun);
755 const std::optional<uint16_t> before = field;
756 const char *const label = sun ? "sun level" : "battery level";
757 if (reading.has_value()) {
758 policy.on_poll_succeeded(device_id);
759 field = reading;
760 } else if (count_poll_miss(policy, device_id, unanswered, label)) {
761 field.reset(); // the last reading is too old to keep showing
762 }
763 if (field != before) {
764 ESP_LOGI(detail::TAG, "Device %s: %s %s -> %s", device_id.c_str(), label, format_event_reading(before, sun).c_str(),
765 format_event_reading(field, sun).c_str());
766 } else if (reading.has_value()) {
767 ESP_LOGD(detail::TAG, "Device %s: %s %s (unchanged)", device_id.c_str(), label,
768 format_event_reading(reading, sun).c_str());
769 }
770 // Notify on every outcome: link health moved too, so the link sensors need the update.
771 this->notify_device_update_(device_id);
772}
773
774bool IOHomeControlComponent::request_device_name(const std::string &device_id) {
775 auto *dev = this->get_device(device_id);
776 if (dev == nullptr || !this->initialized_)
777 return false;
778
779 IoFrame request;
780 if (!create_get_name(request, this->node_id_, dev->node_id, dev->low_power))
781 return false;
782 return this->execute_request_and_update_(device_id, request, false, 0);
783}
784
785bool IOHomeControlComponent::set_light_position(const std::string &device_id, uint8_t position) {
786 auto *dev = this->get_device(device_id);
787 if (dev == nullptr || !this->initialized_)
788 return false;
789
791 detail::log_rejected_operation(device_id, *dev, "light command", "light entity");
792 return false;
793 }
794
795 // Light entities reuse the controller's existing execute path — the same position encoding
796 // covers use, confirmed on real dimmable hardware (see the somfy_izymo_dimmer_* captures).
797 return this->set_device_position(device_id, position);
798}
799
800bool IOHomeControlComponent::set_fan_position(const std::string &device_id, uint8_t position) {
801 auto *dev = this->get_device(device_id);
802 if (dev == nullptr || !this->initialized_)
803 return false;
804
806 detail::log_rejected_operation(device_id, *dev, "fan command", "exterior heating (fan entity)");
807 return false;
808 }
809
810 // An exterior heater takes the same position execute as a dimmable light; the heat level is the
811 // IO position (0 = full heat), see platform_fan.cpp.
812 return this->set_device_position(device_id, position);
813}
814
815bool IOHomeControlComponent::set_light_state(const std::string &device_id, bool on) {
816 return this->set_light_position(device_id, on ? BINARY_ENTITY_ON_POSITION : BINARY_ENTITY_OFF_POSITION);
817}
818
819bool IOHomeControlComponent::set_switch_state(const std::string &device_id, bool on) {
820 auto *dev = this->get_device(device_id);
821 if (dev == nullptr || !this->initialized_)
822 return false;
823
825 detail::log_rejected_operation(device_id, *dev, "switch command", "switch entity");
826 return false;
827 }
828
829 // Switches share the same transport-level representation as binary lights.
830 return this->set_device_position(device_id, on ? BINARY_ENTITY_ON_POSITION : BINARY_ENTITY_OFF_POSITION);
831}
832
833bool IOHomeControlComponent::set_lock_state(const std::string &device_id, bool locked) {
834 auto *dev = this->get_device(device_id);
835 if (dev == nullptr || !this->initialized_)
836 return false;
837
839 detail::log_rejected_operation(device_id, *dev, "lock command", "lock entity");
840 return false;
841 }
842
843 // Lock entities currently reuse the protocol's proven binary execute encoding:
844 // unlock maps to 0 and lock maps to 100.
845 return this->set_device_position(device_id, locked ? BINARY_ENTITY_OFF_POSITION : BINARY_ENTITY_ON_POSITION);
846}
847
848bool IOHomeControlComponent::set_heating_level(const std::string &device_id, uint8_t level) {
849 // The same funnel as the other execute-family operations: gate, frame, exchange, and a settle poll
850 // that reads the level back (see arm_execute_confirmation_poll_()).
851 return this->run_execute_operation_(
852 device_id, {"heating level", false},
853 [](const IoDevice &d) { return device_uses_heating_levels(d.type, d.subtype); }, "heating-level climate device",
854 [this, level](IoFrame &request, const IoDevice &d) {
855 return create_execute_heating_level(request, this->node_id_, d.node_id, d.low_power, level);
856 });
857}
858
859bool IOHomeControlComponent::set_heating_temperature(const std::string &device_id, uint16_t main_parameter,
860 const HeatingFp &fp) {
861 return this->run_execute_operation_(
862 device_id, {"heating temperature", false},
863 [](const IoDevice &d) { return device_uses_heating_temperature(d.type, d.subtype); },
864 "temperature-controlled climate device",
865 [this, main_parameter, fp](IoFrame &request, const IoDevice &d) {
866 switch (fp.kind) {
868 return create_execute_heating_with_mode_flags(request, this->node_id_, d.node_id, d.low_power,
869 main_parameter, fp.value);
871 return create_execute_heating_with_fp16(request, this->node_id_, d.node_id, d.low_power, main_parameter,
872 fp.value);
874 break;
875 }
876 return create_execute_heating_main_parameter(request, this->node_id_, d.node_id, d.low_power, main_parameter);
877 });
878}
879
880void IOHomeControlComponent::set_device_temperature_range(const std::string &device_id, float min_c, float max_c) {
881 IoDevice *dev = this->registry_.get(device_id);
882 if (dev == nullptr)
883 return;
884 dev->temperature_range_min_c = min_c;
885 dev->temperature_range_max_c = max_c;
886}
887
888bool IOHomeControlComponent::exchange_heating_(const std::string &device_id, const char *label, const IoFrame &request,
889 IoFrame &response, uint8_t max_tries) {
890 // A read and a write of the same register share the label, so the log names which one it was: a
891 // read-back that fails after an acknowledged write must not read as a refused write.
892 const bool is_read = request.data_len > 1 && request.data[1] == HEATING_DIRECTION_GET;
893 const char *direction = is_read ? "read" : "write";
894 ESP_LOGI(detail::TAG, "Sending heating %s %s to device %s", direction, label, device_id.c_str());
895
896 const ExchangeOutcome outcome = this->send_and_receive_(request, response, FREQ_CH2, max_tries);
897 const bool got_reply = outcome == ExchangeOutcome::SUCCESS_WITH_RESPONSE;
898 const bool acknowledged = got_reply && response.cmd == CMD_WRITE_PRIVATE_ACK;
899
900 // Feed the device-agnostic link-health / exchange-failure companion sensors either way, so a
901 // climate device is never silently invisible to the Last Contact / Exchange Failures
902 // diagnostics. A CMD_ERROR_RESP is still a reply — record its result code on the device so the
903 // "Last Result Code" diagnostic surfaces it, exactly as the cover path does.
904 IoDevice *dev = this->get_device(device_id);
905 if (dev == nullptr)
906 return false;
907 // Same rule as execute_request_and_update_(): an unconfirmed acceptance is counted wherever it
908 // happens, or a climate device that authenticates and then goes silent stays invisible in the
909 // very diagnostic built to surface that, while a cover doing the same thing is counted.
910 detail::record_exchange_outcome(*dev, request.cmd, outcome);
911 if (outcome == ExchangeOutcome::FAILED) {
913 } else {
915 if (got_reply && response.cmd == CMD_ERROR_RESP && response.data_len > 0) {
916 detail::record_command_result(*dev, device_id, response.data[0], CMD_WRITE_PRIVATE, true);
917 } else if (acknowledged) {
919 }
920 }
921 this->notify_device_update_(device_id);
922
923 if (!acknowledged) {
924 this->log_exchange_debug_(device_id.c_str());
925 ESP_LOGW(detail::TAG, "Heating %s %s: no CMD_WRITE_PRIVATE_ACK from device %s", direction, label,
926 device_id.c_str());
927 return false;
928 }
929
930 // Logging the ack payload lets a field tester read back what the heater reports even for the
931 // registers nothing decodes (`power_on`, `midnight_sync`).
932 char ack_payload_hex[FRAME_LOG_HEX_BUFFER_SIZE];
933 bytes_to_hex(response.data, response.data_len, ack_payload_hex, sizeof(ack_payload_hex));
934 ESP_LOGD(detail::TAG, "heating %s %s ACK payload: %s", direction, label, ack_payload_hex);
935 return true;
936}
937
938bool IOHomeControlComponent::send_heating_command(const std::string &device_id, HeatingFunction fn, float value) {
939 auto *dev = this->get_device(device_id);
940 if (dev == nullptr || !this->initialized_)
941 return false;
942
943 // Capability gate: only via the predicate, never an inline device-type or vendor list.
944 if (!device_uses_heating_registers(dev->type, dev->subtype)) {
945 detail::log_rejected_operation(device_id, *dev, "heating command", "heater with private registers");
946 return false;
947 }
948
949 uint8_t payload[HEATING_PAYLOAD_MAX_SIZE];
950 const size_t payload_len = encode_heating_payload(fn, value, payload);
951 if (payload_len == 0) {
952 ESP_LOGW(detail::TAG, "Heating command %s for device %s rejected: value %.2f out of range",
953 heating_function_name(fn), device_id.c_str(), value);
954 return false;
955 }
956
957 IoFrame request;
958 if (!create_write_private(request, this->node_id_, dev->node_id, dev->low_power, payload, payload_len)) {
959 ESP_LOGW(detail::TAG, "Heating command %s for device %s: failed to build frame", heating_function_name(fn),
960 device_id.c_str());
961 return false;
962 }
963
964 IoFrame response;
965 // A write is something the user waits on: the full retry budget.
966 if (!this->exchange_heating_(device_id, heating_function_name(fn), request, response, EXCHANGE_RETRY_COUNT))
967 return false;
968 // The regulator answers the new setpoint or mode only after a while: poll the heater again soon, so
969 // heating / idle catches up without waiting a whole poll interval.
970 this->schedule_heating_poll_(device_id, HEATING_POST_WRITE_POLL_DELAY_MS);
971 return true;
972}
973
974bool IOHomeControlComponent::read_private_register_(const std::string &device_id, uint8_t reg_hi, uint8_t reg_lo,
975 const char *label, IoFrame &response, uint8_t max_tries) {
976 const IoDevice *dev = this->get_device(device_id);
977 if (dev == nullptr || !this->initialized_)
978 return false;
979
980 // A read only ever goes to a device that takes register reads at all.
981 if (!device_accepts_register_probe(dev->type, dev->subtype)) {
982 detail::log_rejected_operation(device_id, *dev, "register read", "heater with private registers");
983 return false;
984 }
985
986 uint8_t payload[HEATING_PAYLOAD_MAX_SIZE];
987 const size_t payload_len = encode_heating_register_read(reg_hi, reg_lo, payload);
988 IoFrame request;
989 if (!create_write_private(request, this->node_id_, dev->node_id, dev->low_power, payload, payload_len)) {
990 ESP_LOGW(detail::TAG, "Heating read %s for device %s: failed to build frame", label, device_id.c_str());
991 return false;
992 }
993
994 if (!this->exchange_heating_(device_id, label, request, response, max_tries))
995 return false;
996 if (!heating_reply_echoes_register(response, reg_hi, reg_lo)) {
997 char payload_hex[FRAME_LOG_HEX_BUFFER_SIZE];
998 bytes_to_hex(response.data, response.data_len, payload_hex, sizeof(payload_hex));
999 ESP_LOGD(detail::TAG, "heating %s reply from device %s is not for register 0x%02X%02X: %s", label,
1000 device_id.c_str(), reg_hi, reg_lo, payload_hex);
1001 return false;
1002 }
1003 return true;
1004}
1005
1006bool IOHomeControlComponent::read_heating_register(const std::string &device_id, HeatingFunction fn, float *value_out,
1007 uint8_t max_tries) {
1008 auto *dev = this->get_device(device_id);
1009 if (dev == nullptr || !this->initialized_)
1010 return false;
1011
1012 if (!device_uses_heating_registers(dev->type, dev->subtype)) {
1013 detail::log_rejected_operation(device_id, *dev, "heating read", "heater with private registers");
1014 return false;
1015 }
1016
1017 uint8_t register_low = 0;
1018 if (!heating_function_is_readable(fn) || !heating_function_register(fn, register_low)) {
1019 ESP_LOGW(detail::TAG, "Heating read %s for device %s rejected: not a readable register", heating_function_name(fn),
1020 device_id.c_str());
1021 return false;
1022 }
1023
1024 IoFrame response;
1025 if (!this->read_private_register_(device_id, HEATING_REGISTER_HIGH_BYTE, register_low, heating_function_name(fn),
1026 response, max_tries))
1027 return false;
1028
1029 float value = NAN;
1030 if (!decode_heating_reply(response, fn, value)) {
1031 char payload_hex[FRAME_LOG_HEX_BUFFER_SIZE];
1032 bytes_to_hex(response.data, response.data_len, payload_hex, sizeof(payload_hex));
1033 ESP_LOGD(detail::TAG, "heating %s reply from device %s carries no value: %s", heating_function_name(fn),
1034 device_id.c_str(), payload_hex);
1035 return false;
1036 }
1037
1038 // The exchange blocks and the registry may have been touched by a callback meanwhile, so the record
1039 // is looked up again.
1040 dev = this->get_device(device_id);
1041 if (dev == nullptr)
1042 return false;
1043 dev->heating.store(fn, value);
1044 // A reading that left the programme drops the temperature in force before anything shows it.
1045 if (const HeatingProfile *profile = find_heating_profile(*dev); profile != nullptr)
1046 clear_inactive_programme_reading(*profile, dev->heating);
1047 this->notify_device_update_(device_id);
1048 if (value_out != nullptr)
1049 *value_out = value;
1050 return true;
1051}
1052
1053void IOHomeControlComponent::check_heater_model_(const std::string &device_id, uint8_t max_tries) {
1054 IoDevice *dev = this->get_device(device_id);
1055 if (dev == nullptr || dev->model_checked || dev->model_check_attempts >= decisions::HEATER_MODEL_CHECK_MAX_RUNS ||
1056 !device_offers_models(dev->type, dev->subtype))
1057 return;
1058 dev->model_check_attempts++;
1059
1060 IoFrame name_reply;
1061 const bool name_read = this->read_private_register_(device_id, HEATING_REGISTER_HIGH_BYTE,
1062 HEATING_REGISTER_MODEL_NAME, "model_name", name_reply, max_tries);
1063
1064 // A towel dryer's poll has already read its mode register; any other heater is asked once here.
1065 dev = this->get_device(device_id);
1066 if (dev == nullptr)
1067 return;
1068 bool mode_answered = dev->heating.towel_mode >= 0;
1069 bool mode_has_value = mode_answered;
1070 uint8_t mode_register = 0;
1071 if (!mode_answered && heating_function_register(HeatingFunction::TOWEL_MODE, mode_register)) {
1072 IoFrame mode_reply;
1073 mode_answered = this->read_private_register_(device_id, HEATING_REGISTER_HIGH_BYTE, mode_register, "towel_mode",
1074 mode_reply, max_tries);
1075 mode_has_value = mode_answered && mode_reply.data_len > HEATING_REPLY_VALUE_OFFSET;
1076 }
1077 // Report only from a run in which both reads were answered; otherwise the next poll tries again.
1078 if (!name_read || !mode_answered)
1079 return;
1080 dev = this->get_device(device_id);
1081 if (dev == nullptr)
1082 return;
1083 dev->model_checked = true;
1084
1085 const std::string name = decisions::format_heater_model_name(name_reply.data + HEATING_REPLY_VALUE_OFFSET,
1087 ESP_LOGI(detail::TAG, "Device %s reports model name \"%s\"", device_id.c_str(),
1088 name.empty() ? "(none)" : name.c_str());
1089
1091 const char *towel_name = towel != nullptr ? towel->name : "towel_dryer";
1092 switch (decisions::classify_heater_model_hint(dev->model, mode_has_value)) {
1094 ESP_LOGW(detail::TAG,
1095 "Device %s is declared with io_model: %s, but it holds no towel-dryer mode (register 0x0136 is "
1096 "empty): it looks like a radiator. Remove io_model: from its climate entry",
1097 device_id.c_str(), towel_name);
1098 break;
1100 ESP_LOGW(detail::TAG,
1101 "Device %s holds a towel-dryer mode (register 0x0136): it looks like a towel dryer. Set "
1102 "io_model: %s on its climate entry for its modes, Boost and Drying",
1103 device_id.c_str(), towel_name);
1104 break;
1106 break;
1107 }
1108}
1109
1110void IOHomeControlComponent::poll_programme_period_(const std::string &device_id, const HeatingProfile &profile,
1111 uint8_t max_tries) {
1112 const IoDevice *dev = this->get_device(device_id);
1113 if (dev == nullptr || !programme_period_active(profile, dev->heating))
1114 return;
1115 const HeatingProgrammePeriod &period = *profile.programme_period;
1116 if (!this->read_heating_register(device_id, period.in_force_fn, nullptr, max_tries))
1117 return;
1118 // A temperature that fits neither period means the eco setback is not what the heater holds now (it was
1119 // changed on the display): forget it, so the boot registers read it again.
1120 IoDevice *updated = this->get_device(device_id);
1121 if (updated != nullptr && resolve_programme_period(profile, updated->heating).preset_index < 0)
1122 updated->heating.clear(period.setback_fn);
1123}
1124
1125bool IOHomeControlComponent::poll_heating_device_(const std::string &device_id) {
1126 const IoDevice *dev = this->get_device(device_id);
1127 if (dev == nullptr || !this->initialized_)
1128 return false;
1129 // The registers one poll reads come from the device's profile, so there is no per-device list here.
1130 const HeatingProfile *profile = find_heating_profile(*dev);
1131 if (profile == nullptr || profile->path != HeatingControlPath::PRIVATE_REGISTERS)
1132 return false;
1133
1134 // The failure ladder lives in StatusPollPolicy and only runs inside a tracking window. Open one
1135 // unless one is already running: begin_tracking() resets the failure counters, which would flatten
1136 // the ladder to its first step on every retry.
1137 if (!this->poll_policy_.is_tracking_active(device_id, millis()))
1138 this->begin_status_poll_tracking_(device_id, 0);
1139
1140 // A poll always runs inside a tracking window, so the ladder is its retry mechanism: the heater
1141 // try band (decisions::scheduled_poll_max_tries()), not a cover's settling plan.
1142 const uint8_t max_tries = decisions::scheduled_poll_max_tries(this->poll_policy_.get_status_poll_failures(device_id),
1143 this->poll_policy_.get_auth_poll_failures(device_id));
1144
1145 bool ok = true;
1146 size_t answered = 0;
1147 for (const HeatingFunction fn : profile->poll_registers) {
1148 if (!this->read_heating_register(device_id, fn, nullptr, max_tries)) {
1149 ok = false;
1150 break;
1151 }
1152 answered++;
1153 }
1154
1155 // The temperature in force is read only while the heater's own programme runs: outside it the register
1156 // holds no temperature, and the card needs no read.
1157 if (ok && profile->programme_period != nullptr)
1158 this->poll_programme_period_(device_id, *profile, max_tries);
1159
1160 // A part the heater may not have (a night light): read every poll once the core registers answered,
1161 // and a miss is not a poll failure (the next poll asks again).
1162 for (const HeatingFunction fn : profile->extra_poll_registers) {
1163 if (!ok)
1164 break;
1165 this->read_heating_register(device_id, fn, nullptr, max_tries);
1166 }
1167
1168 // Properties and settings that rarely change are read until they have been read once. A miss here
1169 // is not a poll failure; the next poll asks again.
1170 for (const HeatingFunction fn : profile->boot_registers) {
1171 if (!ok)
1172 break;
1173 dev = this->get_device(device_id);
1174 if (dev != nullptr && !dev->heating.has(fn))
1175 this->read_heating_register(device_id, fn, nullptr, max_tries);
1176 }
1177
1178 // Once the device has answered a read: the one-off model check. It runs even when a later register of
1179 // the poll failed, because a towel dryer declared on a radiator fails exactly there (its mode register
1180 // holds no value) and is what the check exists to point out.
1181 if (answered > 0)
1182 this->check_heater_model_(device_id, max_tries);
1183
1184 if (ok) {
1185 this->poll_policy_.clear(device_id);
1186 } else {
1188 }
1189 // The regular cadence continues whatever this poll found; a failed poll is additionally retried
1190 // sooner by the ladder above.
1191 const uint32_t configured_ms = this->poll_policy_.get_interval(device_id);
1192 this->schedule_heating_poll_(device_id, configured_ms != 0 ? configured_ms : HEATING_POLL_DEFAULT_INTERVAL_MS);
1193 return ok;
1194}
1195
1196// === Water heater (device_feature::WATER_HEATER) ===
1197
1198bool IOHomeControlComponent::read_dhw_register_(const std::string &device_id, uint8_t index, const char *label,
1199 IoFrame &response, uint8_t max_tries) {
1200 const IoDevice *dev = this->get_device(device_id);
1201 if (dev == nullptr || !this->initialized_)
1202 return false;
1203 // A family-0x00 read only ever goes to a water heater: a radiator's family is 0x01.
1204 if (!has_feature(dev->features, device_feature::WATER_HEATER)) {
1205 detail::log_rejected_operation(device_id, *dev, "hot water register read", "water heater");
1206 return false;
1207 }
1208 return this->read_private_register_(device_id, DHW_REGISTER_HIGH_BYTE, index, label, response, max_tries);
1209}
1210
1211namespace {
1212
1213/// The log label of a water heater register the poll reads.
1214const char *dhw_register_label(uint8_t index) {
1215 switch (index) {
1216 case DHW_REG_WATER_TEMPERATURE:
1217 return "dhw_water_temperature";
1218 case DHW_REG_MODE:
1219 return "dhw_mode";
1220 case DHW_REG_HEATING:
1221 return "dhw_heating";
1222 case DHW_REG_BOOST_DURATION:
1223 return "dhw_boost_duration";
1224 case DHW_REG_AWAY_DURATION:
1225 return "dhw_away_duration";
1226 case DHW_REG_ENERGY:
1227 return "dhw_energy";
1228 case DHW_REG_BRAND:
1229 return "dhw_brand";
1230 default:
1231 return "dhw_register";
1232 }
1233}
1234
1235/// Fold a boost or away duration register into the readings (kept and logged; the state is FP16, ADR 0081).
1236bool store_dhw_duration(const std::string &device_id, DhwReadings &readings, uint8_t index, const uint8_t *value,
1237 size_t len) {
1238 uint8_t duration = 0;
1239 if (!decode_dhw_duration(value, len, duration))
1240 return false;
1241 const bool boost = index == DHW_REG_BOOST_DURATION;
1242 (boost ? readings.boost_duration : readings.away_duration) = duration;
1243 ESP_LOGD(detail::TAG, "Device %s: hot water %s duration %u", device_id.c_str(), boost ? "boost" : "away",
1244 static_cast<unsigned>(duration));
1245 return true;
1246}
1247
1248/// Fold the brand letter into the readings, logging it the first time.
1249bool store_dhw_brand(const std::string &device_id, DhwReadings &readings, const uint8_t *value, size_t len) {
1250 if (len < 1)
1251 return false;
1252 if (readings.brand < 0) {
1253 // One letter per brand; logged once so a field report says which make answered.
1254 const char letter = (value[0] >= ' ' && value[0] <= '~') ? static_cast<char>(value[0]) : '?';
1255 ESP_LOGI(detail::TAG, "Device %s reports hot water brand '%c' (0x%02X)", device_id.c_str(), letter, value[0]);
1256 }
1257 readings.brand = value[0];
1258 return true;
1259}
1260
1261/// Fold one decoded water heater register into the readings.
1262/// @return false when the value is not one the decoder takes (the reading is left as it was).
1263bool store_dhw_reading(const std::string &device_id, DhwReadings &readings, uint8_t index, const uint8_t *value,
1264 size_t len) {
1265 switch (index) {
1266 case DHW_REG_WATER_TEMPERATURE:
1267 return decode_dhw_water_temperature(value, len, readings.water_c);
1268 case DHW_REG_MODE: {
1269 DhwMode mode = DhwMode::AUTO;
1270 if (!decode_dhw_mode(value, len, mode))
1271 return false;
1272 readings.mode = static_cast<int16_t>(mode);
1273 return true;
1274 }
1275 case DHW_REG_HEATING: {
1276 DhwHeatingState state;
1277 if (!decode_dhw_heating_state(value, len, state))
1278 return false;
1279 readings.heating = state.heating ? 1 : 0;
1280 readings.element = state.element ? 1 : 0;
1281 readings.heat_pump = state.heat_pump ? 1 : 0;
1282 return true;
1283 }
1284 case DHW_REG_BOOST_DURATION:
1285 case DHW_REG_AWAY_DURATION:
1286 return store_dhw_duration(device_id, readings, index, value, len);
1287 case DHW_REG_ENERGY: {
1288 uint32_t wh = 0;
1289 if (!decode_dhw_energy_wh(value, len, wh))
1290 return false;
1291 readings.energy_wh = wh;
1292 return true;
1293 }
1294 case DHW_REG_BRAND:
1295 return store_dhw_brand(device_id, readings, value, len);
1296 default:
1297 return false;
1298 }
1299}
1300
1301/// The registers every water heater poll reads, after the status read, in this order.
1302constexpr uint8_t DHW_POLL_REGISTERS[] = {DHW_REG_WATER_TEMPERATURE, DHW_REG_MODE, DHW_REG_HEATING,
1303 DHW_REG_BOOST_DURATION, DHW_REG_AWAY_DURATION, DHW_REG_ENERGY};
1304
1305} // namespace
1306
1307bool IOHomeControlComponent::read_dhw_reading_(const std::string &device_id, uint8_t index, uint8_t max_tries) {
1308 IoFrame response;
1309 const char *label = dhw_register_label(index);
1310 if (!this->read_dhw_register_(device_id, index, label, response, max_tries))
1311 return false;
1312 const uint8_t *value = nullptr;
1313 size_t len = 0;
1314 // read_private_register_() has checked the echo, so this only locates the value bytes.
1315 if (!decode_dhw_register_reply(response, index, value, len))
1316 return false;
1317 // The exchange blocks and the registry may have been touched by a callback meanwhile.
1318 IoDevice *dev = this->get_device(device_id);
1319 if (dev == nullptr)
1320 return false;
1321 if (!store_dhw_reading(device_id, dev->dhw, index, value, len)) {
1322 char payload_hex[FRAME_LOG_HEX_BUFFER_SIZE];
1323 bytes_to_hex(response.data, response.data_len, payload_hex, sizeof(payload_hex));
1324 ESP_LOGD(detail::TAG, "%s reply from device %s carries no value this codec takes: %s", label, device_id.c_str(),
1325 payload_hex);
1326 return true;
1327 }
1328 this->notify_device_update_(device_id);
1329 return true;
1330}
1331
1332bool IOHomeControlComponent::read_dhw_status_(const std::string &device_id, uint8_t max_tries) {
1333 const IoDevice *dev = this->get_device(device_id);
1334 if (dev == nullptr)
1335 return false;
1336 // The main parameter plus FP15 and FP16 in one exchange. The default FP1-FP7 read is not used: the heater
1337 // holds none of them and answers it with result code 5. The reply goes through update_device_status_(),
1338 // whose water heater branch stores the target, the capabilities and the boost and away state.
1339 IoFrame request;
1340 return create_status_mp_fp_read(request, this->node_id_, dev->node_id, dev->low_power, DHW_STATUS_FPI1,
1341 DHW_STATUS_FPI2) &&
1342 this->execute_request_and_update_(device_id, request, false, 0, max_tries);
1343}
1344
1345bool IOHomeControlComponent::poll_dhw_device_(const std::string &device_id) {
1346 const IoDevice *dev = this->get_device(device_id);
1347 if (dev == nullptr || !this->initialized_ || !has_feature(dev->features, device_feature::WATER_HEATER))
1348 return false;
1349
1350 // The same ladder and try band as a heater poll (poll_heating_device_()).
1351 if (!this->poll_policy_.is_tracking_active(device_id, millis()))
1352 this->begin_status_poll_tracking_(device_id, 0);
1353 const uint8_t max_tries = decisions::scheduled_poll_max_tries(this->poll_policy_.get_status_poll_failures(device_id),
1354 this->poll_policy_.get_auth_poll_failures(device_id));
1355
1356 bool ok = this->read_dhw_status_(device_id, max_tries);
1357
1358 for (size_t i = 0; ok && i < sizeof(DHW_POLL_REGISTERS); i++)
1359 ok = this->read_dhw_reading_(device_id, DHW_POLL_REGISTERS[i], max_tries);
1360
1361 // The brand never changes: read until it has been read once. A miss is not a poll failure.
1362 dev = this->get_device(device_id);
1363 if (ok && dev != nullptr && dev->dhw.brand < 0)
1364 this->read_dhw_reading_(device_id, DHW_REG_BRAND, max_tries);
1365
1366 if (ok) {
1367 this->poll_policy_.clear(device_id);
1368 } else {
1370 }
1371 const uint32_t configured_ms = this->poll_policy_.get_interval(device_id);
1372 this->schedule_heating_poll_(device_id, configured_ms != 0 ? configured_ms : HEATING_POLL_DEFAULT_INTERVAL_MS);
1373 return ok;
1374}
1375
1376bool IOHomeControlComponent::set_water_heater_target(const std::string &device_id, float celsius) {
1377 uint16_t main_parameter = 0;
1378 if (!encode_dhw_target(celsius, main_parameter)) {
1379 ESP_LOGW(detail::TAG, "Hot water target %.1f C for device %s rejected: outside %.0f-%.0f C",
1380 static_cast<double>(celsius), device_id.c_str(), static_cast<double>(DHW_TARGET_MIN_C),
1381 static_cast<double>(DHW_TARGET_MAX_C));
1382 return false;
1383 }
1384 // The settle poll (arm_execute_confirmation_poll_()) reads the target back.
1385 return this->execute_dhw_(device_id, "hot water target", [this, main_parameter](IoFrame &request, const IoDevice &d) {
1386 return create_execute_heating_main_parameter(request, this->node_id_, d.node_id, d.low_power, main_parameter);
1387 });
1388}
1389
1390bool IOHomeControlComponent::execute_dhw_(const std::string &device_id, const char *action,
1391 const std::function<bool(IoFrame &, const IoDevice &)> &build) {
1392 // try_execute_operation_() and not run_execute_operation_(): a water heater never moves, so the
1393 // optimistic-overlay rollback and the moving evidence of a cover command do not apply.
1394 const bool accepted = this->try_execute_operation_(
1395 device_id, {action, false},
1396 [](const IoDevice &d) { return has_feature(d.features, device_feature::WATER_HEATER); }, "water heater", build);
1397 // The heating state follows a change only after a while, as on a heater after a write.
1398 if (accepted)
1399 this->schedule_heating_poll_(device_id, HEATING_POST_WRITE_POLL_DELAY_MS);
1400 return accepted;
1401}
1402
1403const IoDevice *IOHomeControlComponent::water_heater_for_write_(const std::string &device_id, const char *operation) {
1404 const IoDevice *dev = this->get_device(device_id);
1405 if (dev == nullptr || !this->initialized_)
1406 return nullptr;
1407 if (!has_feature(dev->features, device_feature::WATER_HEATER)) {
1408 detail::log_rejected_operation(device_id, *dev, operation, "water heater");
1409 return nullptr;
1410 }
1411 return dev;
1412}
1413
1414bool IOHomeControlComponent::write_dhw_register_(const std::string &device_id, const char *operation, uint8_t index,
1415 const uint8_t *payload, size_t payload_len) {
1416 const IoDevice *dev = this->water_heater_for_write_(device_id, operation);
1417 if (dev == nullptr)
1418 return false;
1419
1420 IoFrame request;
1421 if (!create_write_private(request, this->node_id_, dev->node_id, dev->low_power, payload, payload_len)) {
1422 ESP_LOGW(detail::TAG, "%s for device %s: failed to build frame", operation, device_id.c_str());
1423 return false;
1424 }
1425
1426 IoFrame response;
1427 // A write is something the user waits on: the full retry budget.
1428 if (!this->exchange_heating_(device_id, dhw_register_label(index), request, response, EXCHANGE_RETRY_COUNT))
1429 return false;
1430 // A heater that keeps the value echoes it in the ack; one that ignores the write acks something else
1431 // (issue #198), so the ack is logged for a field report.
1432 const uint8_t *ack_value = nullptr;
1433 size_t ack_len = 0;
1434 if (decode_dhw_register_reply(response, index, ack_value, ack_len)) {
1435 char ack_hex[FRAME_LOG_HEX_BUFFER_SIZE];
1436 bytes_to_hex(ack_value, ack_len, ack_hex, sizeof(ack_hex));
1437 ESP_LOGI(detail::TAG, "Device %s: %s write acknowledged with value [%s]", device_id.c_str(), operation, ack_hex);
1438 }
1439 // The entity shows what the heater holds, not what was sent; the heating state follows later.
1440 this->read_dhw_reading_(device_id, index, EXCHANGE_RETRY_COUNT);
1441 this->schedule_heating_poll_(device_id, HEATING_POST_WRITE_POLL_DELAY_MS);
1442 return true;
1443}
1444
1445bool IOHomeControlComponent::set_water_heater_mode(const std::string &device_id, DhwMode mode) {
1446 uint8_t payload[HEATING_PAYLOAD_MAX_SIZE];
1447 const size_t payload_len = encode_dhw_mode_write(static_cast<uint8_t>(mode), payload);
1448 if (payload_len == 0) {
1449 ESP_LOGW(detail::TAG, "Hot water mode %u for device %s rejected: not a mode the heater reports",
1450 static_cast<unsigned>(mode), device_id.c_str());
1451 return false;
1452 }
1453 if (!this->write_dhw_register_(device_id, "hot water mode", DHW_REG_MODE, payload, payload_len))
1454 return false;
1455 // The heater chooses its own target in AUTO and holds its manual one otherwise, so the target changes
1456 // with the mode: read it now rather than at the next poll. A miss here does not undo the write.
1457 this->read_dhw_status_(device_id, EXCHANGE_RETRY_COUNT);
1458 return true;
1459}
1460
1461namespace {
1462
1463/// Log names of the two features the operating-mode word switches; their wire facts are
1464/// dhw_operating_feature_wire()'s.
1465struct DhwOperatingFeatureNames {
1466 const char *feature; ///< The feature and its EXECUTE.
1467 const char *duration; ///< Its duration write.
1468};
1469
1470/// One row per DhwOperatingFeature, in its order.
1471constexpr DhwOperatingFeatureNames DHW_OPERATING_FEATURE_NAMES[] = {
1472 {"hot water boost", "hot water boost duration"},
1473 {"hot water away", "hot water away duration"},
1474};
1475static_assert(std::size(DHW_OPERATING_FEATURE_NAMES) == static_cast<size_t>(DhwOperatingFeature::AWAY) + 1,
1476 "one row per DhwOperatingFeature");
1477
1478} // namespace
1479
1480bool IOHomeControlComponent::set_dhw_operating_mode_(const std::string &device_id, DhwOperatingFeature which, bool on) {
1481 const DhwOperatingFeatureNames &names = DHW_OPERATING_FEATURE_NAMES[static_cast<size_t>(which)];
1483 const IoDevice *dev = this->water_heater_for_write_(device_id, names.feature);
1484 if (dev == nullptr)
1485 return false;
1486 // Once FP15 has been read the heater says what it supports; before that the write is allowed to try.
1487 if (dev->dhw.capabilities >= 0 && (static_cast<uint16_t>(dev->dhw.capabilities) & wire.capability) == 0) {
1488 ESP_LOGW(detail::TAG, "%s for device %s refused: the heater does not report it as supported (FP15 0x%04X)",
1489 names.feature, device_id.c_str(), static_cast<unsigned>(dev->dhw.capabilities));
1490 return false;
1491 }
1492
1493 if (on) {
1494 // The duration goes first (ADR 0081). The heater has been seen to ignore it, and a missed ack is not
1495 // fatal either: the FP16 EXECUTE below is what switches the mode.
1496 uint8_t payload[HEATING_PAYLOAD_MAX_SIZE];
1497 const size_t payload_len = encode_dhw_duration_write(which, payload);
1498 if (!this->write_dhw_register_(device_id, names.duration, wire.duration_register, payload, payload_len)) {
1499 ESP_LOGW(detail::TAG, "Device %s: %s write not acknowledged, sending the mode anyway", device_id.c_str(),
1500 names.duration);
1501 }
1502 }
1503
1504 const uint16_t word = on ? wire.on_word : DHW_OPERATING_MODE_OFF;
1505 // The off word ends boost and away alike.
1506 const bool accepted =
1507 this->execute_dhw_(device_id, on ? names.feature : "hot water boost and away off",
1508 [this, word](IoFrame &request, const IoDevice &d) {
1509 return create_execute_heating_with_fp16(request, this->node_id_, d.node_id, d.low_power,
1510 MAIN_PARAMETER_KEEP, word);
1511 });
1512 if (!accepted)
1513 return false;
1514 // The entity shows what FP16 reports, never the command: read it at once. A miss does not undo the write,
1515 // and the poll execute_dhw_() moved reads it again.
1516 this->read_dhw_status_(device_id, EXCHANGE_RETRY_COUNT);
1517 return true;
1518}
1519
1520bool IOHomeControlComponent::set_water_heater_boost(const std::string &device_id, bool on) {
1521 return this->set_dhw_operating_mode_(device_id, DhwOperatingFeature::BOOST, on);
1522}
1523
1524bool IOHomeControlComponent::set_water_heater_away(const std::string &device_id, bool on) {
1525 return this->set_dhw_operating_mode_(device_id, DhwOperatingFeature::AWAY, on);
1526}
1527
1528void IOHomeControlComponent::queue_set_device_position(const std::string &device_id, uint8_t position) {
1529 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_COVER)) {
1530 // control() applies the optimistic prediction before calling this method, so a queue-time
1531 // guard rejection is a command that will not happen and must withdraw it, exactly as
1532 // run_execute_operation_() does for a dispatch-time failure. No-op when nothing was predicted.
1533 this->registry_.rollback_optimistic(device_id);
1534 return;
1535 }
1536
1537 // Pre-scan for a pending SET_TILT so we can log its value if coalescing happens.
1538 uint8_t pending_tilt = 0;
1539 for (const auto &op : this->op_queue_) {
1540 if (op.type == PendingOperationType::SET_TILT && op.device_id == device_id) {
1541 pending_tilt = op.position; // SET_TILT stores tilt in op.position
1542 break;
1543 }
1544 }
1545 if (this->op_queue_.enqueue_set_position(device_id, position)) {
1546 ESP_LOGI(detail::TAG,
1547 "Coalesced SET_POSITION (pos=%u) + pending SET_TILT (tilt=%u) → SET_POSITION_AND_TILT for "
1548 "device %s",
1549 position, pending_tilt, device_id.c_str());
1550 }
1551}
1552
1553bool IOHomeControlComponent::queue_device_command(const std::string &device_id, CoverCommand cmd) {
1554 const IoDevice *dev = this->get_device(device_id);
1555 // Every false return below is a command that will not happen; control() (STOP →
1556 // apply_optimistic_stop) already predicted, so withdraw it — see queue_set_device_position().
1557 const bool is_stop = cmd == CoverCommand::STOP;
1558 if (!this->initialized_ || dev == nullptr) {
1559 this->registry_.rollback_optimistic(device_id, /*failed_stop=*/is_stop);
1560 return false;
1561 }
1562 // Same COVER guard as QUEUE_GUARD_COVER, kept inline here: this method returns bool and its
1563 // rejection noun is the specific command name rather than a fixed "queued cover command".
1565 detail::log_rejected_operation(device_id, *dev, cover_command_name(cmd), "cover entity");
1566 this->registry_.rollback_optimistic(device_id, /*failed_stop=*/is_stop);
1567 return false;
1568 }
1569 this->op_queue_.enqueue_device_command(device_id, cmd);
1570 return true;
1571}
1572
1573void IOHomeControlComponent::queue_set_device_tilt(const std::string &device_id, uint8_t tilt_percent) {
1574 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_TILT)) {
1575 // Withdraw the entity's optimistic prediction — see queue_set_device_position().
1576 this->registry_.rollback_optimistic(device_id);
1577 return;
1578 }
1579
1580 // Pre-scan for a pending SET_POSITION so we can log its value if coalescing happens.
1581 uint8_t pending_pos = 0;
1582 for (const auto &op : this->op_queue_) {
1583 if (op.type == PendingOperationType::SET_POSITION && op.device_id == device_id) {
1584 pending_pos = op.position;
1585 break;
1586 }
1587 }
1588 if (this->op_queue_.enqueue_set_tilt(device_id, tilt_percent)) {
1589 ESP_LOGI(detail::TAG,
1590 "Coalesced pending SET_POSITION (pos=%u) + SET_TILT (tilt=%u) → "
1591 "SET_POSITION_AND_TILT for device %s",
1592 pending_pos, tilt_percent, device_id.c_str());
1593 }
1594}
1595
1596void IOHomeControlComponent::queue_set_device_position_and_tilt(const std::string &device_id, uint8_t position,
1597 uint8_t tilt_percent) {
1598 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_POSITION_AND_TILT)) {
1599 // Withdraw the entity's optimistic prediction — see queue_set_device_position().
1600 this->registry_.rollback_optimistic(device_id);
1601 return;
1602 }
1603 this->op_queue_.enqueue_set_position_and_tilt(device_id, position, tilt_percent);
1604}
1605
1607 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_STATUS))
1608 return;
1609 // Keep at most one pending status poll per device. Without this, an overdue next_update can add
1610 // the same poll on every main-loop iteration until the first queued request is finally processed.
1611 this->op_queue_.enqueue_request_status(device_id);
1612}
1613
1615 if (this->get_device(device_id) == nullptr)
1616 return;
1617 this->op_queue_.enqueue_request_limitation(device_id);
1618}
1619
1621 if (this->get_device(device_id) == nullptr)
1622 return;
1623 this->op_queue_.enqueue_request_sun_level(device_id);
1624}
1625
1627 if (this->get_device(device_id) == nullptr)
1628 return;
1630}
1631
1632void IOHomeControlComponent::queue_request_device_name(const std::string &device_id) {
1633 if (this->get_device(device_id) == nullptr)
1634 return;
1635 this->op_queue_.enqueue_request_name(device_id);
1636}
1637
1638/// Queue a discovery-and-pair request with elevated priority.
1639///
1640/// Flushes any pending status/name poll operations (which would consume time
1641/// during the device's limited pairing window) and pushes discovery to the
1642/// front of the queue. Duplicate requests are suppressed.
1644 if (this->last_pairing_end_ms_ != 0 && millis() - this->last_pairing_end_ms_ < PAIRING_STALE_PRESS_MS) {
1645 ESP_LOGW(detail::TAG, "Discover & Pair pressed while the previous attempt was running: ignored. Put the device "
1646 "back into pairing mode and press again.");
1647 return;
1648 }
1650}
1651
1653 return detail::describe_last_commander(dev, this->node_id_);
1654}
1655
1657 if (this->busy_) {
1658 // Same event/log path a rejected API call gets (ManagementActions::resolve_device_() and
1659 // friends) -- a busy press must not go silent, since it's the one press outcome the API
1660 // action can never produce (an automation-triggered press is the only way to reach this
1661 // guard at all, see the doc comment on the declaration).
1663 result.action = "scan_paired_devices";
1664 result.message = "a radio exchange was already in progress; press the button again once it finishes";
1665 this->management_actions_.publish_result(result);
1666 return;
1667 }
1669}
1670
1671void IOHomeControlComponent::queue_set_light_position(const std::string &device_id, uint8_t position) {
1672 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_LIGHT))
1673 return;
1674 this->op_queue_.enqueue_set_light_position(device_id, position);
1675}
1676
1677void IOHomeControlComponent::queue_set_fan_position(const std::string &device_id, uint8_t position) {
1678 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_FAN))
1679 return;
1680 this->op_queue_.enqueue_set_fan_position(device_id, position);
1681}
1682
1683void IOHomeControlComponent::queue_set_light_state(const std::string &device_id, bool on) {
1684 this->queue_set_light_position(device_id, on ? BINARY_ENTITY_ON_POSITION : BINARY_ENTITY_OFF_POSITION);
1685}
1686
1687void IOHomeControlComponent::queue_set_lock_state(const std::string &device_id, bool locked) {
1688 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_LOCK))
1689 return;
1690 this->op_queue_.enqueue_set_lock_state(device_id, locked);
1691}
1692
1693void IOHomeControlComponent::queue_set_switch_state(const std::string &device_id, bool on) {
1694 if (queue_guard_rejects(this, device_id, QUEUE_GUARD_SWITCH))
1695 return;
1696 this->op_queue_.enqueue_set_switch_state(device_id, on);
1697}
1698
1699// === 1W transmit ===
1700
1701void IOHomeControlComponent::execute_oneway_command_(const std::string &controller_id, CoverCommand cmd) {
1702 this->execute_oneway_([&] { this->oneway_transmitter_.send_command(controller_id, cmd); });
1703}
1704
1705void IOHomeControlComponent::execute_oneway_position_(const std::string &controller_id, uint8_t position) {
1706 this->execute_oneway_([&] { this->oneway_transmitter_.send_position(controller_id, position); });
1707}
1708
1709void IOHomeControlComponent::execute_oneway_enroll_(const std::string &controller_id) {
1710 this->execute_oneway_([&] { this->oneway_transmitter_.send_enrollment(controller_id); });
1711}
1712
1713void IOHomeControlComponent::execute_oneway_unenroll_(const std::string &controller_id) {
1714 this->execute_oneway_([&] { this->oneway_transmitter_.send_unenrollment(controller_id); });
1715}
1716
1718 if (this->busy_ || this->op_queue_.empty())
1719 return;
1720
1721 // Pop before dispatch so any handler that re-queues follow-up work sees the queue in its
1722 // post-consumption state and cannot accidentally execute the same operation twice.
1723 auto opt = this->op_queue_.pop();
1724 if (!opt.has_value())
1725 return;
1726 const PendingOperation &operation = *opt;
1727
1728 switch (operation.type) {
1730 this->set_device_position(operation.device_id, operation.position);
1731 break;
1733 this->set_device_tilt(operation.device_id, operation.position);
1734 break;
1736 this->set_device_position_and_tilt(operation.device_id, operation.position, operation.tilt);
1737 break;
1739 this->execute_device_command_(operation.device_id, operation.command);
1740 break;
1742 // operation.position already carries the target IO position (0-100) regardless of whether
1743 // it was enqueued via queue_set_light_state() (binary extremes) or
1744 // queue_set_light_position() (dimmable) — see enqueue_set_light_state()'s thin-wrapper doc.
1745 this->set_light_position(operation.device_id, operation.position);
1746 break;
1748 this->set_fan_position(operation.device_id, operation.position);
1749 break;
1751 this->set_lock_state(operation.device_id, operation.position == BINARY_ENTITY_OFF_POSITION);
1752 break;
1754 this->set_switch_state(operation.device_id, operation.position == BINARY_ENTITY_ON_POSITION);
1755 break;
1757 // device_id carries the controller-identity handle for 1W ops — see PendingOperation.
1758 this->execute_oneway_command_(operation.device_id, operation.command);
1759 break;
1761 this->execute_oneway_position_(operation.device_id, operation.position);
1762 break;
1764 this->execute_oneway_enroll_(operation.device_id);
1765 break;
1767 this->execute_oneway_unenroll_(operation.device_id);
1768 break;
1770 this->request_device_status(operation.device_id);
1771 break;
1773 this->request_device_name(operation.device_id);
1774 break;
1776 this->request_device_limitation(operation.device_id);
1777 break;
1779 this->request_device_sun_level(operation.device_id);
1780 break;
1782 this->request_device_battery_level(operation.device_id);
1783 break;
1785 this->discover_and_pair();
1786 break;
1787 }
1788 // Every operation blocks on the radio, so a press that started during it was not heard. While a
1789 // burst of linked-remote activity is open, this moves its catch-up read out (ADR 0080).
1791}
1792
1793} // namespace home_io_control
1794} // 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.
void schedule_background_poll_backoff_(const std::string &device_id, bool auth_like, bool device_moving=false)
Apply backoff after a failed background status poll and log the result.
Definition hub_core.cpp:378
EventReading
The two subscription-answer sensor polls (ADR 0064).
Definition hub_core.h:1157
@ SUN_LEVEL
EVENT_LOCAL_SENSOR, IoDevice::sun_level.
Definition hub_core.h:1158
@ BATTERY_LEVEL
EVENT_BATTERY_LEVEL_CHANGE, IoDevice::battery_level.
Definition hub_core.h:1159
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.
bool poll_dhw_device_(const std::string &device_id)
Poll a water heater: the status read for its target, capabilities and boost and away state (read_dhw_...
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.
virtual bool set_water_heater_mode(const std::string &device_id, DhwMode mode)
Set a water heater's operating mode: a write of register 0x0006 (encode_dhw_mode_write()).
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...
virtual void queue_request_device_sun_level(const std::string &device_id)
Queue an async sun level poll; executed in loop() as a background operation.
bool execute_dhw_(const std::string &device_id, const char *action, const std::function< bool(IoFrame &, const IoDevice &)> &build)
Send one water heater EXECUTE (target or operating mode) through try_execute_operation_(),...
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:1308
bool request_event_reading_(const std::string &device_id, EventReading kind)
Shared body of request_device_sun_level() and request_device_battery_level(): defer while the device ...
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 set_dhw_operating_mode_(const std::string &device_id, DhwOperatingFeature which, bool on)
Switch a water heater's boost or away through the operating-mode word (ADR 0081).
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:402
virtual void queue_request_device_status(const std::string &device_id)
Queue an async status request; returns immediately, executed in loop().
bool read_dhw_reading_(const std::string &device_id, uint8_t index, uint8_t max_tries)
Read one family-0x00 register of a water heater and fold its value into IoDevice::dhw.
PeriodicPollPolicy battery_level_poll_policy_
Schedule of the opt-in battery level poll.
Definition hub_core.h:1622
TuningConfig tuning_
Runtime tuning overrides.
Definition hub_core.h:1604
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:1479
void process_pending_operation_()
Pop next pending operation from the queue and execute it (set position, request status,...
bool read_private_register_(const std::string &device_id, uint8_t reg_hi, uint8_t reg_lo, const char *label, IoFrame &response, uint8_t max_tries)
Read one private register raw: build the read request, run exchange_heating_() and check that the rep...
const IoDevice * water_heater_for_write_(const std::string &device_id, const char *operation)
The water heater device_id names, or nullptr when it is unknown, the hub is not set up yet,...
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).
decisions::AttemptRequest cover_poll_attempts_(const std::string &device_id, bool scheduler_managed, bool state_unknown=false)
Attempts for a cover's status read: its read kind (a settling read or a look at a busy device,...
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 bool request_device_battery_level(const std::string &device_id)
Subscribe the device to EVENT_BATTERY_LEVEL_CHANGE and publish the level its answer carries.
virtual void queue_request_device_name(const std::string &device_id)
Queue an async device-name request; returns immediately, executed in loop().
ExchangeOutcome send_and_receive_(const IoFrame &request, IoFrame &response, uint32_t freq, const decisions::AttemptRequest &attempts=EXCHANGE_RETRY_COUNT)
Main request/response exchange with retry and automatic authentication.
Definition hub_core.cpp:313
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:1602
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.
PeriodicPollPolicy & event_reading_policy_(EventReading kind)
The schedule of kind's poll.
void poll_programme_period_(const std::string &device_id, const HeatingProfile &profile, uint8_t max_tries)
Read the temperature in force of a heater's programme, when the programme runs.
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:1476
ExchangeOutcome exchange_past_device_event_(const IoFrame &request, IoFrame &response, const decisions::AttemptRequest &attempts)
send_and_receive_() on the command channel, except that a device event report (decisions::is_device_e...
virtual bool set_water_heater_boost(const std::string &device_id, bool on)
Start or end a water heater's boost: a duration write of register 0x0000, then an EXECUTE with the op...
void record_device_event_(const IoFrame &frame)
Record a device event report, or a frame from a registered device that nothing else here handles: log...
OneWayTransmitter oneway_transmitter_
Owns the 1W controller identities, their rolling-sequence counters and the transmit burst.
Definition hub_core.h:1641
decisions::AttemptRequest heater_poll_attempts_(const std::string &device_id, bool scheduler_managed)
Attempts for a heater's status read: the count-based budget heaters keep (decisions::scheduled_poll_m...
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, PARTIAL, PEDESTRIAN, FORCE_OPEN); returns immedia...
virtual void queue_request_device_battery_level(const std::string &device_id)
Queue an async battery level poll; executed in loop() as a background operation.
virtual bool set_water_heater_target(const std::string &device_id, float celsius)
Set a water heater's target temperature.
virtual bool discover_and_pair()
Discover and pair a device that is in pairing mode.
void apply_event_reading_(const std::string &device_id, EventReading kind, const std::optional< uint16_t > &reading, bool unanswered)
Fold one poll outcome into the device record: a reading replaces the value and clears the miss count;...
bool poll_heating_device_(const std::string &device_id)
Poll a heater: read its profile's poll registers (stopping at the first failure), then the programme'...
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:1634
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:1195
@ REPLY
The device answered; the caller interprets the reply frame.
Definition hub_core.h:1199
@ UNCONFIRMED
A CMD_EXECUTE the device accepted but never closed; counted as success, no reply frame.
Definition hub_core.h:1197
@ ERROR_REPLY
The device refused with CMD_ERROR_RESP; handle_error_response_() has recorded it.
Definition hub_core.h:1198
@ FAILED
No usable reply (silence, or an unconfirmed acceptance of a request that needs a payload).
Definition hub_core.h:1196
void schedule_heating_poll_(const std::string &device_id, uint32_t delay_ms)
Arm the heater's regular-cadence poll timer.
PeriodicPollPolicy rain_poll_policy_
Schedule of the opt-in rain sensor poll.
Definition hub_core.h:1620
virtual bool request_device_sun_level(const std::string &device_id)
Subscribe the device to EVENT_LOCAL_SENSOR and publish the sun value its answer carries.
virtual bool set_light_state(const std::string &device_id, bool on)
Semantic binary helper for light entities.
bool write_dhw_register_(const std::string &device_id, const char *operation, uint8_t index, const uint8_t *payload, size_t payload_len)
Write one family-0x00 register of a water heater, then read it back.
void apply_rain_poll_result_(const std::string &device_id, RainSensorState reading, const std::string &limit_text, bool unanswered)
Fold one rain poll outcome into the device's rain sensor state and tell subscribers.
esphome::home_io_control::ManagementActionResult ManagementActionResult
Result payload used by hub-level management actions such as rename.
Definition hub_core.h:167
bool read_dhw_register_(const std::string &device_id, uint8_t index, const char *label, IoFrame &response, uint8_t max_tries)
Read one family-0x00 register of a water heater raw (read_private_register_() gated on device_feature...
ManagementActions management_actions_
Owns rename, identify, force-open, scan_paired_devices, and other hub-level HA actions.
Definition hub_core.h:1636
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:339
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 set_water_heater_away(const std::string &device_id, bool on)
Start or end a water heater's away (holiday) mode: a duration write of register 0x0005 (255,...
bool read_dhw_status_(const std::string &device_id, uint8_t max_tries)
Read a water heater's target, capabilities and boost and away state: the status read DHW_STATUS_FPI1 ...
virtual bool request_device_name(const std::string &device_id)
Request the stored device name from a device.
void check_heater_model_(const std::string &device_id, uint8_t max_tries)
The one-off model check of a heater kind that has model rows (device_offers_models()).
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_request_and_update_(const std::string &device_id, const IoFrame &request, bool warn_on_no_response, uint32_t retry_after_fail_ms=0, const decisions::AttemptRequest &attempts=EXCHANGE_RETRY_COUNT)
Shared request/response helper for high-level operations: exchange_and_record_() plus update_device_s...
bool execute_device_command_(const std::string &device_id, CoverCommand cmd)
Execute a named device command (STOP, FAVORITE, VENT, PARTIAL, PEDESTRIAN, FORCE_OPEN) via the authen...
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.
PeriodicPollPolicy sun_poll_policy_
Schedule of the opt-in sun level poll.
Definition hub_core.h:1621
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_sun_level(const std::string &device_id)
Enqueue REQUEST_SUN_LEVEL, 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.
bool enqueue_request_battery_level(const std::string &device_id)
Enqueue REQUEST_BATTERY_LEVEL, suppressing duplicates.
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.
Per-device periodic poll schedule, miss retry and miss counter for one poll kind.
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.
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 failed poll (saturating), with no retry: for a poll the device answered with something that...
bool first_error_reply(const std::string &device_id)
True the first time it is called for a device that answered the poll's request with an error reply,...
bool retry_armed(const std::string &device_id) const
True while a retry armed by on_poll_unanswered() has not been popped yet.
bool on_poll_unanswered(const std::string &device_id, uint32_t now)
Handle a poll the device left unanswered.
void mark_stop_settle(const std::string &device_id)
Mark the device's next poll as the one reading where an accepted STOP left it, so it is a settling re...
void on_exchange_ended(uint32_t now, uint32_t delay_ms)
An exchange ended at now.
bool take_state_unknown(const std::string &device_id)
Consume the mark set by mark_state_unknown(): true once, for the status read dispatched after it.
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 (and skipped moving misses) 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.
bool is_predicted_end_read(const std::string &device_id) const
True when the next poll is the predicted-end read (the mark take_settling_read() consumes).
Pure transition helpers for hub-owned exchange and pairing frame decisions.
Helpers shared only by the hub's own implementation files (hub_*.cpp).
bool is_repeatable_execute(const IoFrame &request)
True for a CMD_EXECUTE that has the same effect sent twice as sent once.
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.
bool is_repeat_safe_command(const IoFrame &request)
Whether a request may be sent again to a device that said nothing, without a second action.
std::string format_heater_model_name(const uint8_t *bytes, size_t len)
Render a heater's model-name register (0x0119, ASCII) for a log line.
bool is_device_event_report(const IoFrame &frame)
Returns true for a frame that reports a device event rather than a status: a CMD_SET_CONFIG1_RESP,...
ReadKind scheduled_poll_read_kind(uint8_t status_poll_failures, uint8_t auth_poll_failures, bool settles_a_position)
What a cover's status poll is for, from where it sits and what it settles.
bool is_subscription_answer(const IoFrame &request, const IoFrame &response)
Returns true when response is the answer to the subscription request: the request is a CMD_SUBSCRIBE_...
@ LOOKS_LIKE_TOWEL_DRYER
Declared as no towel dryer, but the towel-dryer mode register holds a value.
@ LOOKS_LIKE_RADIATOR
Declared a towel dryer, but the towel-dryer mode register holds no value.
@ NONE
The answer fits the declaration: nothing to say.
HeaterModelHint classify_heater_model_hint(DeviceModel declared, bool towel_mode_has_value)
Compare a heater's declared model with whether it answered the towel-dryer mode register (0x0136) wit...
@ SETTLING
A read that decides where a device ended up: as many attempts as fit the budget.
@ COMMAND
A request that is harmless to repeat (is_repeat_safe_command()): to a mains device as many attempts a...
uint8_t scheduled_poll_max_tries(uint8_t status_poll_failures, uint8_t auth_poll_failures)
Transmit-attempt budget for a scheduler-owned status poll of a heater, by ladder position.
constexpr uint8_t HEATER_MODEL_CHECK_MAX_RUNS
Runs of the one-off heater model check before the hub gives up on it for this boot.
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
void log_frame_issue(IOHomeControlComponent *component, const uint8_t *own_node_id, const char *direction, const char *reason, const IoFrame &frame, uint8_t len)
Log a frame‑level issue (unregistered endpoints, unsupported commands).
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.
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.
bool is_settling(const IoDevice &dev)
True while a device that reported stopped off its target waits for the confirming poll.
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 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...
void clear_inactive_programme_reading(const HeatingProfile &profile, HeatingReadings &readings)
Drop the temperature in force from the readings when the programme does not run.
bool create_get_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a get-name request (0x50).
constexpr size_t HEATING_REPLY_VALUE_OFFSET
Offset of the first value byte in a register reply: the four header bytes it echoes.
void bytes_to_hex(const uint8_t *data, uint8_t len, char *out, size_t out_size)
Definition log_frame.h:23
bool decode_dhw_mode(const uint8_t *value, size_t len, DhwMode &mode)
Operating mode from the value of register 0x0006.
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 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).
@ REQUEST_BATTERY_LEVEL
request_device_battery_level call (subscribe to EVENT_BATTERY_LEVEL_CHANGE, read the answer).
@ 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.
@ REQUEST_SUN_LEVEL
request_device_sun_level call (subscribe to EVENT_LOCAL_SENSOR, read the answer).
@ 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.
DhwMode
Operating mode as register 0x0006 holds it.
Definition proto_dhw.h:145
@ AUTO
The heater's automatic mode (its own schedule and target).
Definition proto_dhw.h:146
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 HeatingProfile * find_heating_profile(DeviceType type, uint8_t subtype, DeviceModel model)
Look up the heating profile for a (type, subtype, model).
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.
@ TOWEL_MODE
Register 0x0136, read/write: a towel dryer's mode (TowelDryerMode).
constexpr uint8_t HEATING_REGISTER_HIGH_BYTE
Payload byte at index 2: the high byte of the 16-bit register number, constant 0x01.
bool heating_function_is_readable(HeatingFunction fn)
Whether the register behind a function can be read.
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.
@ FORCE_OPEN
Move to fully open at elevated priority; intended to bypass soft locks and environmental limits (conf...
bool decode_dhw_heating_state(const uint8_t *value, size_t len, DhwHeatingState &state)
What is heating, from the value of register 0x0021.
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.
@ TOWEL_DRYER
Towel dryer under the electrical heater with an adjustable setpoint (52/1).
bool decode_sun_level_event(const IoFrame &frame, uint16_t &out)
Decode the sun value of an EVENT_LOCAL_SENSOR report.
bool programme_period_active(const HeatingProfile &profile, const HeatingReadings &readings)
Does the heater's own programme run right now, with no timed run on top of it?
DhwOperatingFeature
The two features the operating-mode word switches; dhw_operating_feature_wire() holds their wire fact...
Definition proto_dhw.h:115
const DeviceTypeInfo * find_device_model_info(DeviceType type, uint8_t subtype, DeviceModel model)
Look up the model row with exactly this (type, subtype, model), without falling back.
const NamedPosition * find_named_position(CoverCommand command)
The named position a command sends.
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", ...).
const char * device_operation_profile_name(DeviceType type, uint8_t subtype)
Human‑readable operation profile name for a device kind.
bool encode_dhw_target(float celsius, uint16_t &main_parameter)
Encode a target temperature as a main parameter over DHW_TARGET_MIN_C..DHW_TARGET_MAX_C.
Definition proto_dhw.cpp:97
constexpr bool has_feature(DeviceFeatures set, DeviceFeatures bit)
True when every bit of bit is set in set.
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 heating_reply_echoes_register(const IoFrame &reply, uint8_t reg_hi, uint8_t reg_lo)
Is this frame a register reply that echoes a request for this register?
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 or a named position (NAMED_POSITIONS).
bool decode_dhw_water_temperature(const uint8_t *value, size_t len, float &celsius)
Water temperature from the value of register 0x0017: signed 16-bit big-endian tenths.
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:20
bool device_offers_models(DeviceType type, uint8_t subtype)
Does this exact (type, subtype) have model rows below it?
bool device_uses_heating_levels(DeviceType type, uint8_t subtype)
Does a climate entity drive this device with heating levels?
bool decode_dhw_energy_wh(const uint8_t *value, size_t len, uint32_t &wh)
Energy since installation from the value of register 0x0004: unsigned 32-bit big-endian,...
bool effective_is_stopped(const IoDevice &dev)
Whether a consumer should treat the device as at rest, prediction first.
bool create_status_mp_fp_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t fpi1, uint8_t fpi2)
Build the KLF-style status read of the main parameter plus functional parameters (see the header for ...
bool create_subscribe_event(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint16_t event_id, bool low_power)
Build the subscribe request (see the header for the allowlist).
constexpr uint8_t HEATING_REGISTER_MODEL_NAME
Register 0x0119: the heater's model name, ASCII, not terminated ("ADELIS 2 DIGIT" on an Atlantic Adel...
size_t encode_dhw_duration_write(DhwOperatingFeature which, uint8_t out[HEATING_PAYLOAD_MAX_SIZE])
Encode the duration write a start of which sends before its FP16 EXECUTE: 0C 61 00 00 01 for boost (D...
Definition proto_dhw.cpp:92
void note_moving_evidence(IoDevice &dev, uint32_t now_ms)
Record that a device is (believed to be) travelling right now.
bool decode_dhw_register_reply(const IoFrame &reply, uint8_t index, const uint8_t *&value, size_t &len)
The value bytes of a reply to a family-0x00 register read.
size_t encode_heating_register_read(uint8_t reg_hi, uint8_t reg_lo, uint8_t out[HEATING_PAYLOAD_MAX_SIZE])
Encode a read ("get") of an arbitrary register into a CMD_WRITE_PRIVATE (0x20) payload.
uint32_t settle_delay_ms(uint32_t interval_ms, uint32_t remaining_delay_ms, bool cap_for_stop)
Resolve the follow-up settle-poll delay while a device may still be moving.
bool device_uses_heating_registers(DeviceType type, uint8_t subtype)
Does a climate entity drive this device over the private register protocol?
size_t encode_dhw_mode_write(uint8_t mode, uint8_t out[HEATING_PAYLOAD_MAX_SIZE])
Encode a write of the operating mode: 0C 61 00 06 <mode>.
Definition proto_dhw.cpp:82
const DhwOperatingFeatureWire & dhw_operating_feature_wire(DhwOperatingFeature which)
The wire facts of which: the one place each feature's FP15 bit, FP16 field and duration live.
Definition proto_dhw.cpp:88
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.
ProgrammePeriodState resolve_programme_period(const HeatingProfile &profile, const HeatingReadings &readings)
Which period of its programme a heater is in, and the temperature it heats to.
@ 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?
bool device_has_no_motion(DeviceType type, uint8_t subtype)
Does this device never move, so a status reply must not start the movement settle loop?
@ 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 decode_battery_level_event(const IoFrame &frame, uint8_t &out)
Decode the level of an EVENT_BATTERY_LEVEL_CHANGE report as a percentage.
bool device_accepts_register_probe(DeviceType type, uint8_t subtype)
May the diagnostic register-read probes (register_00, register_01) be sent to this device?
@ SILENT
Profile 0x05, the manufacturer app's silent operation: slower than the motor's own.
@ MOTOR_DEFAULT
The 6-byte frame with no profile: the motor's own speed, as its wall pad moves it.
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....
bool heating_function_register(HeatingFunction fn, uint8_t &register_low)
Low byte of the 0x01xx register behind a function.
constexpr uint8_t HEATING_DIRECTION_GET
Payload byte at index 1 of a read ("get") request.
constexpr bool device_accepts_main_parameter(DeviceFeatures features, uint16_t main_parameter)
True when a device with these features accepts this main-parameter value in an EXECUTE.
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.
bool decode_dhw_duration(const uint8_t *value, size_t len, uint8_t &duration)
A duration from the value of register 0x0000 or 0x0005: one byte.
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.
Pure codec for a domestic hot water heater: its family-0x00 registers and its target temperature.
Heating profiles: what a climate card offers for each kind of heater, and how.
One row of the node-type / subtype table.
const char * name
Type row: lowercase identifier ("roller_shutter").
What register 0x0021 says is heating.
Definition proto_dhw.h:152
bool heating
Byte 0: the heater is heating at all (heat pump, element or both).
Definition proto_dhw.h:153
The wire facts of one DhwOperatingFeature: what FP15, FP16 and the duration registers say about it.
Definition proto_dhw.h:121
What the hub has read from a domestic hot water heater.
Definition proto_dhw.h:162
int16_t brand
Brand letter (register 0x0007); -1 until read.
Definition proto_dhw.h:176
int32_t capabilities
FP15 operating-mode capabilities (DHW_CAPABILITY_*); -1 until read.
Definition proto_dhw.h:170
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.
std::span< const HeatingFunction > boot_registers
PRIVATE_REGISTERS: registers read once until they have been read.
std::span< const HeatingFunction > poll_registers
PRIVATE_REGISTERS: registers every poll reads, in order.
const HeatingProgrammePeriod * programme_period
PRIVATE_REGISTERS: how the heater's own programme names the comfort or eco period it is in; nullptr f...
HeatingControlPath path
How the heater is commanded and read.
std::span< const HeatingFunction > extra_poll_registers
PRIVATE_REGISTERS: registers every poll reads after poll_registers, for a part the heater may not hav...
How a heater's own weekly programme shows which period runs: the registers that tell,...
HeatingFunction in_force_fn
Register of the temperature in force.
HeatingFunction setback_fn
Register of the eco setback, a distance below the comfort setpoint.
bool has(HeatingFunction fn) const
Has this register been read yet?
bool clear(HeatingFunction fn)
Forget a register's value: it reads as "never read" again.
int16_t towel_mode
TowelDryerMode value; -1 until read.
Everything one execute-family operation needs beyond its own guard and frame builder.
Definition hub_core.h:1249
const char * action
Verb/phrase for the "Sending ..." and rejection logs.
Definition hub_core.h:1250
bool settle_as_stop
Passed through to arm_execute_confirmation_poll_().
Definition hub_core.h:1251
Per-request knobs of exchange_and_record_().
Definition hub_core.h:1203
uint32_t retry_after_fail_ms
If non-zero, drives the status-poll backoff and failure streaks.
Definition hub_core.h:1205
decisions::AttemptRequest attempts
Attempt cap or read kind forwarded to send_and_receive_().
Definition hub_core.h:1206
bool warn_on_no_response
Log a warning when no response arrives.
Definition hub_core.h:1204
Runtime state of a paired IO‑Homecontrol device.
uint8_t model_check_attempts
Runs of the hub's one-off model check (a heater kind with model rows: model name read,...
float temperature_range_min_c
Lower end of the temperature encoding range of a heater that carries a temperature in its main parame...
DeviceFeatures features
What this device can do, resolved by resolve_device_features().
HeatingReadings heating
What the hub has read from a heater over the private register protocol (room temperature,...
std::optional< uint16_t > sun_level
Raw sun value from the opt-in EVENT_LOCAL_SENSOR poll.
bool low_power
YAML-declared: this target is a low-power / duty-cycled receiver, so directed frames set CTRL1_LOW_PO...
std::optional< uint16_t > battery_level
Battery level in percent (0-100) from the opt-in EVENT_BATTERY_LEVEL_CHANGE poll (decode_battery_leve...
uint8_t subtype
Device subtype (manufacturer‑specific).
DeviceModel model
YAML-declared product model below the subtype (io_model:); never on the wire.
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.
DhwReadings dhw
What the hub has read from a water heater (device_feature::WATER_HEATER): family-0x00 registers and t...
RainSensorState rain_sensor
Rain state from the opt-in minimum-limitation poll.
bool model_checked
The model check completed: both of its reads were answered.
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.
One named position: its feature bit, its main-parameter value and the command that sends it.
uint16_t main_parameter
Value sent as the EXECUTE main parameter.
const char * name
cover_command_name() of the command ("FAVORITE", "VENT", "PEDESTRIAN", "PARTIAL").
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).
int8_t preset_index
Index into the profile's presets of the period, or -1 when the temperature in force fits neither peri...
uint32_t remote_activity_catchup_poll_ms
Read every linked device this long after the last exchange of a burst of linked-remote activity; 0 = ...
What a caller asks of one exchange's attempts: a plain cap on tries (today's shape),...
bool is_progress_look() const
True for a look at a device that is probably still busy: one attempt, the ladder is the retry (ReadKi...
static AttemptRequest of_kind(ReadKind read_kind)
AttemptRequest with_wake_preamble_first() const
Mark the read as aimed at the moment the receiver has just come to rest: to a low-power target every ...