Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
hub_internal.h
Go to the documentation of this file.
1#pragma once
2
3/// @file hub_internal.h
4/// @brief Helpers shared only by the hub's own implementation files (`hub_*.cpp`).
5/// @ingroup hioc_hub
6///
7/// Collaborators and entities include log_helpers.h / entity_helpers.h instead; `make
8/// include-graph` enforces this. Keeping these helpers here leaves hub_core.h focused on the
9/// public component shape and the member-function declarations.
10
11#include "hub_core.h"
12#include "entity_helpers.h"
13#include "log_frame.h"
14#include "log_helpers.h"
15#include "proto_codecs.h"
16#include "proto_device_types.h"
17
18#include "esphome/core/log.h"
19
20#include <algorithm>
21#include <array>
22#include <cstdio>
23#include <cstring>
24#include <map>
25#include <string>
26#include <vector>
27
28namespace esphome {
29namespace home_io_control {
30namespace detail {
31
32// ============================================================================
33// Shared constants
34// ============================================================================
35
36/// Suppress a repeated 1W log/poll for the same remote *and the same intent* within this window.
37/// Wide on purpose: it collapses both the 4×/40ms reliability burst and a held button into one
38/// logical press. A *different* intent from the same remote (a stop after a move) is not a
39/// duplicate and passes through immediately — see decisions::is_duplicate_1w_frame().
40inline constexpr uint32_t ONEWAY_DEDUP_WINDOW_MS = 2000;
41
42// ============================================================================
43// Capability and entity-profile helpers
44// ============================================================================
45
46/// @brief Is the given position value an on/off binary encoding?
47/// @param position Position value to test.
48/// @return true if position equals BINARY_ENTITY_ON_POSITION or BINARY_ENTITY_OFF_POSITION.
49inline bool is_binary_entity_position(uint8_t position) {
50 return position == BINARY_ENTITY_ON_POSITION || position == BINARY_ENTITY_OFF_POSITION;
51}
52
53/// @brief Does the device's type match the expected HA entity class?
54/// UNKNOWN devices always match to keep imported/discovered devices working.
55/// @param dev IoDevice to check.
56/// @param expected Desired capability class (COVER, LIGHT, SWITCH, etc.).
57/// @return true if device type matches or is UNKNOWN.
59 return dev.type == DeviceType::UNKNOWN || device_capability_class(dev.type) == expected;
60}
61
62/// @brief Does the device support status requests?
63/// UNKNOWN devices pass through.
64/// @param dev IoDevice to check.
65/// @return true if device type supports status requests or is UNKNOWN.
67 // A heater is polled over its register protocol rather than with the 0x03 status request, but it
68 // is polled all the same (IOHomeControlComponent::poll_heating_device_()); so is a water heater
69 // (IOHomeControlComponent::poll_dhw_device_()).
72 has_feature(dev.features, device_feature::WATER_HEATER);
73}
74
75/// @brief Does the device report its movement through status replies?
76/// Covers, lights, switches and locks do: their 0x04 reply says whether they are travelling. A
77/// heater never moves (device_has_no_motion()), whether it takes a heat level in the position field
78/// or is driven over its register protocol. A device not yet typed passes.
79/// @param dev IoDevice to check.
80/// @return true if the device takes execute positions and can move, or is UNKNOWN.
85
86/// @brief Can this device accept an execute (position) command?
87/// The device's entity class must have an EXECUTE position path (cover, light, switch, lock, heater
88/// level; a device not yet typed passes), and the main parameter the position maps to must be one
89/// its features accept: open, close and stop always, a percent only with LEVELS
90/// (device_accepts_main_parameter()). A light's LEVELS come from its `dimmable:` key, a switch or
91/// lock never has them, so they stay binary.
92/// @param dev IoDevice to check.
93/// @param position Position value being sent (percent, or POS_STOP).
94/// @return true if operation is appropriate for this device.
95inline bool known_device_accepts_execute_position(const IoDevice &dev, uint8_t position) {
97 return false;
99}
100
101/// @brief Can this device accept a tilt command?
102/// @param dev IoDevice to check.
103/// @return true only if the device has device_feature::TILT.
105 return has_feature(dev.features, device_feature::TILT);
106}
107
108// ============================================================================
109// Logging helpers
110// ============================================================================
111
112/// @brief Log a rejected operation with capability mismatch details.
113/// @param device_id Device ID string.
114/// @param dev IoDevice that rejected the command.
115/// @param operation Human‑readable operation name (e.g., "set position").
116/// @param expected Expected capability class or profile name.
117inline void log_rejected_operation(const std::string &device_id, const IoDevice &dev, const char *operation,
118 const char *expected) {
119 ESP_LOGW(TAG, "Rejecting %s for device %s: type=%s (%u) class=%s profile=%s expected=%s", operation,
120 device_id.c_str(), device_type_name(dev.type), static_cast<unsigned>(dev.type),
122}
123
124/// @brief Log a frame‑level issue (unregistered endpoints, unsupported commands).
125///
126/// Logs on FRAME_TAG so the lines can be filtered on their own. The level follows
127/// decisions::frame_issue_is_warning(): WARN when the frame is addressed to a registered device or to
128/// this hub, DEBUG for overheard traffic. Reasons that are real problems (auth_failed, ack_build_failed,
129/// unsupported_payload) only arise behind a dst == own node ID check or on replies to our own requests,
130/// so they stay at WARN without an override.
131/// @param component Pointer to the component (for device lookup).
132/// @param own_node_id The hub's 3-byte node ID.
133/// @param direction "tx" or "rx".
134/// @param reason Short issue label (e.g., "unregistered_device").
135/// @param frame Parsed frame.
136/// @param len Serialized length.
137inline void log_frame_issue(IOHomeControlComponent *component, const uint8_t *own_node_id, const char *direction,
138 const char *reason, const IoFrame &frame, uint8_t len) {
139 const std::string src_id = node_id_to_string(frame.src);
140 const std::string dst_id = node_id_to_string(frame.dst);
141 const bool src_registered = component->get_device(src_id) != nullptr;
142 const bool dst_registered = component->get_device(dst_id) != nullptr;
143 const bool dst_is_us = std::memcmp(frame.dst, own_node_id, NODE_ID_SIZE) == 0;
144
145 if (decisions::frame_issue_is_warning(dst_registered, dst_is_us)) {
146 ESP_LOGW(FRAME_TAG, "%s issue=%s cmd=%s(0x%02X) src=%s%s dst=%s%s len=%u data_len=%u", direction, reason,
147 command_name(frame.cmd), frame.cmd, src_id.c_str(), src_registered ? " (registered)" : "", dst_id.c_str(),
148 dst_registered ? " (registered)" : "", len, frame.data_len);
149 return;
150 }
151
152 ESP_LOGD(FRAME_TAG, "%s issue=%s cmd=%s(0x%02X) src=%s%s dst=%s len=%u data_len=%u", direction, reason,
153 command_name(frame.cmd), frame.cmd, src_id.c_str(), src_registered ? " (registered)" : "", dst_id.c_str(),
154 len, frame.data_len);
155}
156
157// ============================================================================
158// 1W remote frame decode
159// ============================================================================
160
161/// @brief Log an already-decoded 1W remote frame at DEBUG level.
162///
163/// Formats a concise DEBUG log line showing remote ID, destination class, command intent, and
164/// priority. When the remote is linked to devices, appends the linked device IDs. Takes the
165/// already-decoded OneWayFrameInfo so callers that also build a HA event (see
166/// build_sender_event_data()) decode the frame once, not twice.
167/// @param info Already-decoded 1W frame info (see decode_1w_frame()).
168/// @param linked_devices Optional pointer to device IDs this remote is linked to.
169inline void log_1w_remote_frame(const OneWayFrameInfo &info, const std::vector<std::string> *linked_devices = nullptr) {
170 const std::string src_id = node_id_to_string(info.src);
171
172 // Resolve the broadcast target label: "all" for BROADCAST_ALL, otherwise the device type name.
173 // Shared with the TX path (OneWayTransmitter::send_burst()) via oneway_target_label() so the two
174 // cannot render the same address differently.
175 //
176 // Rendered as `dst-class=`, never "targets X": this is the class encoded in the frame's
177 // destination, not a statement about the device the remote drives. A Somfy remote sends every
178 // press twice, once to the device-class broadcast and once to the all-devices broadcast,
179 // whatever the channel actually controls — so an awning channel logs a light-class frame. Worded
180 // as "targets light" it read as a claim about the device, and cost an issue reporter two rounds
181 // chasing a channel mix-up that never happened.
182 const char *target_label = oneway_target_label(info);
183
184 // Build optional suffix showing linked devices.
185 std::string suffix;
186 if (linked_devices != nullptr && !linked_devices->empty()) {
187 suffix = " (linked →";
188 for (const auto &dev_id : *linked_devices) {
189 suffix += ' ';
190 suffix += dev_id;
191 }
192 suffix += ')';
193 }
194
195 if (info.has_intent) {
196 ESP_LOGD(TAG, "rx 1W remote %s dst-class=%s: %s(0x%02X) %s originator=%s priority=%s%s", src_id.c_str(),
197 target_label, command_name(info.cmd), info.cmd, info.intent, originator_name(info.originator),
198 acei_level_name(info.acei_level), suffix.c_str());
199 return;
200 }
201
202 ESP_LOGD(TAG, "rx 1W remote %s dst-class=%s: %s(0x%02X) data_len=%u%s", src_id.c_str(), target_label,
203 command_name(info.cmd), info.cmd, info.data_len, suffix.c_str());
204}
205
206/// @brief Home Assistant event fired when a decoded 1W frame carries a command intent from an
207/// exposed sender (a physical remote button press, or a wind/rain sensor's triggered command).
208inline constexpr const char *ONEWAY_SENDER_EVENT = "esphome.home_io_control_sender_event";
209
210/// @brief Whether a 1W sender is on the `exposed_senders` allowlist for the sender HA event.
211///
212/// "Sender" covers both remotes and wind/rain sensors — they use the identical 1W broadcast
213/// mechanism and differ only in the `originator` byte inside the payload, not in addressing.
214/// Overheard 1W traffic is always DEBUG-logged regardless of this check (see
215/// log_1w_remote_frame()); this only gates whether the event reaches Home Assistant. Deliberately
216/// separate from `linked_devices` — a sender can be event-enabled without controlling any
217/// registered device (e.g. to trigger an HA automation with no matching cover/light/switch), or
218/// vice versa.
219/// @param exposed_senders Configured allowlist (`exposed_senders` YAML key, empty by default).
220/// @param sender_id Node ID of the 1W sender that sent the frame.
221/// @return true if the sender is in the allowlist.
222inline bool is_exposed_sender(const std::vector<std::string> &exposed_senders, const std::string &sender_id) {
223 return std::find(exposed_senders.begin(), exposed_senders.end(), sender_id) != exposed_senders.end();
224}
225
226/// Buffer size for describe_learned_device_type()'s hex fallback: "io_device_type: 0xXX" plus margin.
227inline constexpr size_t LEARNED_DEVICE_TYPE_HEX_BUFFER_SIZE = 24;
228
229/// @brief Build the YAML line to add once a device's type is learned at runtime.
230///
231/// Logged when `io_device_type` was left unset in YAML and an INFO2 response just resolved it
232/// for the first time this boot (ADR 0018: nothing persists, so this repeats on every reboot
233/// until the user copies the line in). Reuses yaml_device_type_name() so the exact syntax always
234/// matches what the pairing snippet and the Python schema accept.
235/// @param type The now-known device type. Must not be DeviceType::UNKNOWN.
236/// @return The YAML line to add, e.g. `io_device_type: "interior_venetian_blind"` or `io_device_type: 0x11`.
237inline std::string describe_learned_device_type(DeviceType type) {
238 const char *name = yaml_device_type_name(type);
239 if (name != nullptr)
240 return std::string("io_device_type: \"") + name + "\"";
241 std::array<char, LEARNED_DEVICE_TYPE_HEX_BUFFER_SIZE> buffer{};
242 std::snprintf(buffer.data(), buffer.size(), "io_device_type: 0x%02X", static_cast<unsigned>(type));
243 return std::string(buffer.data());
244}
245
246/// @brief Build the Home Assistant event data map for a decoded 1W sender frame.
247///
248/// Only meaningful when `info.has_intent` is true (the caller gates emission on that); the
249/// `intent` field is only populated by decode_1w_frame() in that case.
250/// @param info Already-decoded 1W frame info (see decode_1w_frame()).
251/// @param linked True if this sender is linked to at least one registered device.
252/// @return Event data map ready for fire_homeassistant_event().
253inline std::map<std::string, std::string> build_sender_event_data(const OneWayFrameInfo &info, bool linked) {
254 return {
255 {"remote_id", node_id_to_string(info.src)},
256 {"target_class", address_class_name(info.address_class)},
257 {"target_type", device_type_name(info.target_type)},
258 {"cmd", format_name_and_hex(command_name(info.cmd), info.cmd)},
259 {"intent", info.intent},
260 {"originator", originator_name(info.originator)},
261 {"acei_level", acei_level_name(info.acei_level)},
262 {"linked", linked ? "true" : "false"},
263 };
264}
265
266// ============================================================================
267// Status normalization helpers
268// ============================================================================
269
270/// @brief Normalize the stopped flag of an execute ack against the last observed target and position.
271///
272/// Used only on the execute-ack path (`trust_position = false`), whose own position bytes are not
273/// decoded: a "stopped" ack is read as moving while the last *trusted* target and position still
274/// disagree. Trusted reports go through decisions::classify_stopped_report() instead, which
275/// accepts a cover resting off its target once a second report agrees.
276///
277/// Deliberately reads and writes observed fields only — never effective_*(). On the execute-ack
278/// path `dev.target` does not hold the commanded value, so this function does not use it to flip
279/// `is_stopped` back to false — effective_is_stopped() owns that.
280/// @param dev Device record to update (may clear is_stopped if positions differ).
282 if (dev.is_stopped && dev.target != UNKNOWN_POSITION && dev.position != UNKNOWN_POSITION &&
284 dev.is_stopped = false;
285 }
286}
287
288/// @brief Update per-device link-health stats from the radio's last capture.
289///
290/// Called for every frame whose `src` is a registered device, regardless of command type or
291/// whether that command's own payload was well-formed — any such frame is real evidence the
292/// device is reachable and at this signal strength. The call sites cover every way such a frame
293/// arrives: update_device_status_() (inbound status path), exchange_and_record_()'s
294/// explicit-refusal branch (a CMD_ERROR_RESP reply to our own request, which returns before
295/// reaching the status path), and its failure branch when a 0x3C challenge was seen (the device
296/// transmitted, even though the exchange did not complete). Always stamps `last_seen_ms`; only
297/// touches the RSSI fields when `radio` is non-null and its last capture is valid (real drivers
298/// always populate a valid capture before a frame is handed off, but tests calling this path
299/// directly without a radio, or without exercising RX through it, must not crash or fabricate
300/// an RSSI).
301/// @param dev Device that sent the frame.
302/// @param radio Radio driver to read the last capture from; may be nullptr.
303inline void update_link_health(IoDevice &dev, RadioDriver *radio) {
304 dev.last_seen_ms = millis();
305 if (radio == nullptr)
306 return;
307
308 const RadioCaptureInfo &capture = radio->get_last_capture();
309 if (!capture.valid)
310 return;
311
312 dev.last_rssi_dbm = capture.rssi_dbm;
313 if (dev.rssi_ema_scaled == RSSI_UNKNOWN_DBM) {
314 // First sample: seed the EMA directly instead of blending from 0.
315 dev.rssi_ema_scaled = static_cast<int16_t>(capture.rssi_dbm * RSSI_EMA_SCALE);
316 return;
317 }
318 // Integer EMA kept in fixed point: `S += x − round(S/N)` blends each sample at weight 1/N
319 // while S stays scaled by N, so sub-dBm contributions accumulate instead of truncating to
320 // zero — a whole-dBm EMA would stall as soon as |sample − EMA| < N and never converge.
321 dev.rssi_ema_scaled =
322 static_cast<int16_t>(dev.rssi_ema_scaled + capture.rssi_dbm - rssi_scaled_to_dbm(dev.rssi_ema_scaled));
323}
324
325/// @brief Record that an outbound exchange to this device timed out (no valid response).
326///
327/// Called once per failed exchange from exchange_and_record_()'s "no valid response"
328/// branch — the single place every device-directed exchange already reads
329/// ExchangeEngine::DebugInfo. Both counters saturate at UINT16_MAX instead of wrapping,
330/// matching PairingTelemetry's counters.
331/// @param dev Device the failed exchange was addressed to.
332/// @param tries Number of attempts the failed exchange made (`DebugInfo::tries`, 1-based).
333inline void record_exchange_timeout(IoDevice &dev, uint8_t tries) {
334 if (dev.exchange_timeout_count < UINT16_MAX)
337 static_cast<uint16_t>(std::min<uint32_t>(static_cast<uint32_t>(dev.exchange_attempt_count) + tries, UINT16_MAX));
338}
339
340/// @brief Record what an outbound exchange's ending says about the device, whichever way the caller
341/// then classifies the outcome.
342///
343/// - `ExchangeOutcome::SUCCESS_UNCONFIRMED` (the device answered the request with a challenge, we
344/// answered that, and the closing reply never arrived) counts in exchange_unconfirmed_count. A
345/// CMD_EXECUTE treats that outcome as success and records no failure, so this counter is the only
346/// place it is visible to the diagnostics at all. Saturates at UINT16_MAX instead of wrapping,
347/// like the counters above.
348/// - A CMD_EXECUTE that got a reply (`ExchangeOutcome::SUCCESS_WITH_RESPONSE`, a status or an error
349/// alike, since either one closes the exchange) marks the device as one that confirms, which is
350/// what lets a later unconfirmed EXECUTE to it be sent again.
351/// @param dev Device the exchange was addressed to.
352/// @param request_cmd Command byte of the request.
353/// @param outcome How the exchange ended.
354inline void record_exchange_outcome(IoDevice &dev, uint8_t request_cmd, ExchangeOutcome outcome) {
355 if (outcome == ExchangeOutcome::SUCCESS_UNCONFIRMED && dev.exchange_unconfirmed_count < UINT16_MAX)
357 if (outcome == ExchangeOutcome::SUCCESS_WITH_RESPONSE && request_cmd == CMD_EXECUTE)
358 dev.confirms_execute = true;
359}
360
361/// @brief Store a decoded record on the device, if it is valid.
362///
363/// A short or unpopulated payload leaves whatever was last learned in place rather than clearing
364/// it: the record is inherently last-writer-wins and only refreshes when something commands the
365/// device, so a stale value is the honest answer and a blanked one is not.
366inline void apply_last_command_record(IoDevice &dev, const LastCommandRecord &record) {
367 if (!record.valid)
368 return;
369 memcpy(dev.last_commander, record.commander, NODE_ID_SIZE);
371 dev.has_last_command = true;
372}
373
374/// @brief Refresh the derived rain-limitation flag from a status reply the device just sent.
375///
376/// Call after the reply's position and last-command record are applied. Stamps the rain-evidence
377/// memory when the device names the rain sensor, drops it once a stopped device sits where the
378/// hub had predicted (the clamp is over), then derives `limited_by_rain` through
379/// decisions::rain_limitation_rule(). Logs every change at DEBUG with its inputs: a clamped
380/// command's last-command record has not been observed in the field, so these lines are how field
381/// reports settle which evidence path a real window takes.
382/// @param id Device ID, for the log line.
383/// @param dev Device the reply came from.
384/// @param predicted_target `dev.optimistic.target` as it stood before the reply was decoded
385/// (decoding clears it), or UNKNOWN_POSITION.
386/// @param record_in_reply True when *this* reply carried a well-formed last-command record. The
387/// record on the device is last-writer-wins and persists across replies, so only a reply
388/// that carried it may refresh the rain-evidence memory; a shorter reply must not keep
389/// stale evidence alive.
390/// @param now_ms When the reply was received.
391inline void update_rain_limitation(const std::string &id, IoDevice &dev, float predicted_target, bool record_in_reply,
392 uint32_t now_ms) {
393 const bool evidence_recent =
394 dev.last_rain_evidence_ms != 0 && (now_ms - dev.last_rain_evidence_ms) <= RAIN_EVIDENCE_HOLD_MS;
395 const bool names_rain = dev.has_last_command && dev.last_command_originator == ORIGINATOR_RAIN_SENSOR;
396 if (names_rain && record_in_reply) {
397 dev.last_rain_evidence_ms = now_ms;
398 } else if (dev.is_stopped && has_reached_target_position(predicted_target, dev.target)) {
399 dev.last_rain_evidence_ms = 0;
400 }
401
404 predicted_target, dev.target, dev.is_stopped});
405 const bool limited = rule != decisions::RainLimitationRule::NONE;
406 if (limited != dev.limited_by_rain) {
407 ESP_LOGD(TAG, "Device %s: rain limitation %s (rule=%s originator=0x%02X predicted=%.0f%% observed=%.0f%% %s)",
408 id.c_str(), limited ? "set" : "cleared", decisions::rain_limitation_rule_name(rule),
409 dev.last_command_originator, static_cast<double>(predicted_target), static_cast<double>(dev.target),
410 dev.is_stopped ? "stopped" : "moving");
411 }
412 dev.limited_by_rain = limited;
413}
414
415/// @brief Describe a 0x71 status update's Command Originator as "name(0xXX)".
416///
417/// Pure rather than inlined into the log line it feeds, so the offset is testable: ESP_LOG* is a
418/// no-op stub in host tests, which makes the rendered line itself unobservable.
419/// @param frame A CMD_STATUS_UPDATE frame.
420/// @return "name(0xXX)", e.g. "user_remote(0x01)", or an empty string when the payload is too
421/// short to carry the byte. Shorter frames still carry usable position data — the branch
422/// gate is STATUS_UPDATE_MIN_DATA_LEN (11) — they just have no originator to report.
423inline std::string describe_status_update_originator(const IoFrame &frame) {
425 return {};
427}
428
429/// @brief Describe the hub's live optimistic predictions where they disagree with the observation.
430///
431/// Pure rather than inlined into log_status_update() so it is testable (ESP_LOG* is a no-op stub in
432/// host tests). log_status_update() runs from the inbound frame handlers and reports what the device
433/// said; a hub-side prediction must never be substituted into those observed fields, so it is
434/// appended as a clearly-labelled annotation instead. Only terms that actually differ are rendered —
435/// a prediction that merely confirms the observation adds nothing.
436/// @param dev Device record to read.
437/// @return e.g. " [predicted: target=100% stopped]", or an empty string when no prediction stands
438/// or every prediction agrees with what the device reported.
439inline std::string describe_prediction(const IoDevice &dev) {
440 const bool target_differs = effective_target(dev) != dev.target;
441 const bool motion_differs = effective_is_stopped(dev) != dev.is_stopped;
442 if (!target_differs && !motion_differs)
443 return {};
444 std::string out = " [predicted:";
445 if (target_differs)
446 out += " target=" + format_position(effective_target(dev));
447 if (motion_differs)
448 out += effective_is_stopped(dev) ? " stopped" : " moving";
449 out += "]";
450 return out;
451}
452
453/// @brief Describe the device's battery level for the status line.
454///
455/// Pure for the same reason as describe_prediction(). Rendered whenever the device reports a level,
456/// and also as "not_reported" when the YAML asked for battery entities (`battery: true`), so a
457/// device that reports none says so on every status line instead of only once.
458/// @param dev Device record to read.
459/// @return e.g. " battery=full", or an empty string for a device without battery entities that
460/// reports no level.
461inline std::string describe_battery(const IoDevice &dev) {
463 return {};
464 return std::string(" battery=") + battery_state_name(dev.battery);
465}
466
467/// @brief True while a device that reported stopped off its target waits for the confirming poll.
468///
469/// The hub handles it as moving until decisions::classify_stopped_report() confirms the stop.
470/// @param dev Device record to read.
471inline bool is_settling(const IoDevice &dev) { return !dev.is_stopped && dev.stop_candidate.armed(); }
472
473/// @brief True when the remembered off-target stop is the device's last trusted report.
474///
475/// Then the gap between `dev.target` and `dev.position` is a rest the hub has seen reported, not a
476/// movement, and normalize_stopped_state() must not read it as one.
477/// @param dev Device record to read.
482
483/// @brief Name the device's observed movement state for the status line.
484///
485/// "settling" is a device that reported stopped off its target and waits for the confirming poll
486/// (decisions::classify_stopped_report()); it is still handled as moving.
487/// @param dev Device record to read.
488/// @return "stopped", "settling" or "moving".
489inline const char *describe_motion(const IoDevice &dev) {
490 if (dev.is_stopped)
491 return "stopped";
492 return is_settling(dev) ? "settling" : "moving";
493}
494
495/// @brief Log a concise status‑update line used by inbound handlers.
496///
497/// The position/target/motion fields report what the device observed — never a hub prediction. A
498/// diverging live prediction is appended by describe_prediction(), clearly labelled, not merged in.
499/// @param id Device ID.
500/// @param dev Current device state.
501/// @param suffix Optional suffix added after the state string (e.g., " (status update)").
502inline void log_status_update(const std::string &id, const IoDevice &dev, const char *suffix = "") {
503 ESP_LOGI(TAG, "Device %s: position=%s target=%s %s%s%s%s", id.c_str(), format_position(dev.position).c_str(),
504 format_position(dev.target).c_str(), describe_motion(dev), describe_battery(dev).c_str(),
505 describe_prediction(dev).c_str(), suffix);
506}
507
508/// @brief Log a decoded CMD_ERROR_RESP result with optional request-command context.
509/// @param id Device ID.
510/// @param result Result byte from CMD_ERROR_RESP data[0].
511/// @param request_cmd Original outbound request command when known.
512/// @param include_request_cmd True to include request_cmd in the log line.
513inline void log_command_result(const std::string &id, uint8_t result, uint8_t request_cmd = 0,
514 bool include_request_cmd = false) {
515 const char *kind = is_limitation_result(result) ? "limitation" : "error";
516 if (include_request_cmd) {
517 ESP_LOGW(TAG, "Device %s: %s (0x%02X) returned %s result=0x%02X %s (%s)", id.c_str(), command_name(request_cmd),
518 request_cmd, kind, result, command_result_name(result), command_result_description(result));
519 return;
520 }
521
522 ESP_LOGW(TAG, "Device %s: explicit %s result=0x%02X %s (%s)", id.c_str(), kind, result, command_result_name(result),
524}
525
526/// @brief Store a decoded CMD_ERROR_RESP result on the device and log it.
527///
528/// Single place both CMD_ERROR_RESP call sites (the unsolicited status path and the reply to
529/// our own EXECUTE) route through, so the store-and-log policy cannot drift between them. Does
530/// not notify subscribers itself — callers already call notify_device_update_() once per
531/// handled frame; call it after this.
532/// @param dev Device that returned the result.
533/// @param id Device ID (for the log line).
534/// @param result Result byte from CMD_ERROR_RESP data[0].
535/// @param request_cmd Original outbound request command when known.
536/// @param include_request_cmd True to include request_cmd context in the log line.
537inline void record_command_result(IoDevice &dev, const std::string &id, uint8_t result, uint8_t request_cmd = 0,
538 bool include_request_cmd = false) {
539 dev.last_result_code = result;
540 dev.last_result_at_ms = millis();
541 log_command_result(id, result, request_cmd, include_request_cmd);
542}
543
544/// @brief Clear a previously recorded CMD_ERROR_RESP result, if any.
545///
546/// A stale limitation reason (e.g. a rain lockout from an hour ago) is worse than none once the
547/// device has since replied normally, so every successful status/command reply for a device
548/// clears it. Called from the CMD_PRIVATE_RESP and CMD_STATUS_UPDATE branches of
549/// update_device_status_() — not from CMD_GET_NAME_RESP/CMD_GET_INFO2_RESP, which are metadata
550/// lookups unrelated to whether the device's last movement command succeeded. Like
551/// record_command_result(), does not notify subscribers itself — both existing call sites clear
552/// before their own notify_device_update_() call, which is what actually publishes this change.
553/// @param dev Device to clear.
555 dev.last_result_code = 0;
556 dev.last_result_at_ms = 0;
557}
558
559} // namespace detail
560} // namespace home_io_control
561} // namespace esphome
The main IO-Homecontrol component.
Definition hub_core.h:100
virtual IoDevice * get_device(const std::string &device_id)
Retrieve a device by ID; returns nullptr if not found.
Definition hub_core.cpp:402
Abstract radio driver for IO-Homecontrol.
const RadioCaptureInfo & get_last_capture() const
Get the most recent radio capture info.
Conversions and renderers shared by the hub and its Home Assistant entities.
IO-Homecontrol ESPHome component — protocol controller.
Shared frame logging helpers for IO-Homecontrol.
Hub-layer log tag and log/format helpers shared by the hub and its collaborators.
const char * rain_limitation_rule_name(RainLimitationRule rule)
Name of a rain-limitation rule for log lines.
RainLimitationRule rain_limitation_rule(const RainLimitationInput &in)
Decide which rain-limitation evidence, if any, a status reply provides.
bool frame_issue_is_warning(bool dst_registered, bool dst_is_us)
Whether a frame issue (a frame the hub did not act on) is logged at WARN rather than DEBUG.
RainLimitationRule
Which evidence path labelled a device as limited by rain.
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 format_name_and_hex(const char *name, uint8_t value)
Format a name/value pair as "name(0xXX)", e.g. "execute(0x00)".
Definition log_helpers.h:45
std::map< std::string, std::string > build_sender_event_data(const OneWayFrameInfo &info, bool linked)
Build the Home Assistant event data map for a decoded 1W sender frame.
constexpr const char * TAG
Shared log tag for hub-level messages.
Definition log_helpers.h:32
std::string describe_battery(const IoDevice &dev)
Describe the device's battery level for the status line.
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).
std::string describe_learned_device_type(DeviceType type)
Build the YAML line to add once a device's type is learned at runtime.
bool known_device_supports_status_requests(const IoDevice &dev)
Does the device support status requests?
void apply_last_command_record(IoDevice &dev, const LastCommandRecord &record)
Store a decoded record on the device, if it is valid.
void log_1w_remote_frame(const OneWayFrameInfo &info, const std::vector< std::string > *linked_devices=nullptr)
Log an already-decoded 1W remote frame at DEBUG level.
void record_exchange_timeout(IoDevice &dev, uint8_t tries)
Record that an outbound exchange to this device timed out (no valid response).
bool stop_candidate_matches_observation(const IoDevice &dev)
True when the remembered off-target stop is the device's last trusted report.
const char * describe_motion(const IoDevice &dev)
Name the device's observed movement state for the status line.
std::string describe_status_update_originator(const IoFrame &frame)
Describe a 0x71 status update's Command Originator as "name(0xXX)".
bool is_exposed_sender(const std::vector< std::string > &exposed_senders, const std::string &sender_id)
Whether a 1W sender is on the exposed_senders allowlist for the sender HA event.
constexpr const char * ONEWAY_SENDER_EVENT
Home Assistant event fired when a decoded 1W frame carries a command intent from an exposed sender (a...
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.
constexpr const char * FRAME_TAG
Log tag of the "received a frame we did not act on" lines (log_frame_issue()), separate from TAG so a...
Definition log_helpers.h:35
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 known_device_reports_motion_through_status(const IoDevice &dev)
Does the device report its movement through status replies?
void normalize_stopped_state(IoDevice &dev)
Normalize the stopped flag of an execute ack against the last observed target and position.
bool is_binary_entity_position(uint8_t position)
Is the given position value an on/off binary encoding?
constexpr uint32_t ONEWAY_DEDUP_WINDOW_MS
Suppress a repeated 1W log/poll for the same remote and the same intent within this window.
void log_status_update(const std::string &id, const IoDevice &dev, const char *suffix="")
Log a concise status‑update line used by inbound handlers.
constexpr size_t LEARNED_DEVICE_TYPE_HEX_BUFFER_SIZE
Buffer size for describe_learned_device_type()'s hex fallback: "io_device_type: 0xXX" plus margin.
std::string describe_prediction(const IoDevice &dev)
Describe the hub's live optimistic predictions where they disagree with the observation.
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.
std::string format_originator(uint8_t originator)
Render a Command Originator byte as "name(0xXX)", e.g.
void update_rain_limitation(const std::string &id, IoDevice &dev, float predicted_target, bool record_in_reply, uint32_t now_ms)
Refresh the derived rain-limitation flag from a status reply the device just sent.
DeviceCapabilityClass device_capability_class(DeviceType type)
Map a raw IO‑Homecontrol type to the closest ESPHome/Home Assistant entity family.
float effective_target(const IoDevice &dev)
The main-position target a consumer should act on: the prediction when one stands,...
const char * oneway_target_label(const OneWayFrameInfo &info)
The destination label a decoded 1W frame renders as in a log line.
const char * command_name(uint8_t cmd)
Get a human-readable name for any IO-Homecontrol command ID.
int16_t rssi_scaled_to_dbm(int16_t scaled)
Convert an rssi_ema_scaled fixed-point value to whole dBm (round half away from zero).
const char * device_type_name(DeviceType type)
Convert a DeviceType to a lowercase string identifier.
DeviceType
IO-Homecontrol node types.
@ UNKNOWN
Unknown/unspecified device.
constexpr uint8_t STATUS_UPDATE_ORIGINATOR_OFFSET
Offset of the Command Originator byte in a CMD_STATUS_UPDATE (0x71) payload.
std::string format_position(float pos)
Format a position float as a human‑readable string (e.g.
Definition hub_core.h:1685
const char * device_operation_profile_name(DeviceType type, uint8_t subtype)
Human‑readable operation profile name for a device kind.
constexpr bool has_feature(DeviceFeatures set, DeviceFeatures bit)
True when every bit of bit is set in set.
const char * command_result_description(uint8_t result)
Return a human-readable explanation for a CMD_ERROR_RESP result code.
bool device_supports_climate_control(DeviceType type, uint8_t subtype)
Can a climate entity drive this device?
@ NOT_REPORTED
The device does not report a battery (mains powered), or no status was read yet.
const char * command_result_name(uint8_t result)
Return a stable symbolic name for a CMD_ERROR_RESP result code.
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.
constexpr uint16_t position_main_parameter(uint8_t position)
The main parameter set_device_position() sends for a percent position or the POS_STOP sentinel.
const char * device_capability_class_name(DeviceType type)
Get a human‑readable name for a capability class.
bool effective_is_stopped(const IoDevice &dev)
Whether a consumer should treat the device as at rest, prediction first.
const char * acei_level_name(uint8_t level)
Get a human-readable name for an ACEI priority level (0–7).
std::string node_id_to_string(const uint8_t id[NODE_ID_SIZE])
Format a 3‑byte node ID as a 6‑character uppercase hex string.
const char * address_class_name(AddressClass address_class)
Get a human-readable name for an address classification.
bool device_supports_status_requests(DeviceType type)
Does this device type support status request commands (0x03)?
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?
bool has_reached_target_position(float target, float position)
Has the device reached its target within tolerance?
const char * yaml_device_type_name(DeviceType type)
Return the YAML-friendly device-type name for types exposed in the Python schema.
DeviceCapabilityClass
High‑level capability class derived from DeviceType.
bool device_takes_execute_position(DeviceType type)
Does this device type take position EXECUTEs (a percent, on / off, or stop in the main parameter)?
const char * battery_state_name(BatteryState state)
Get the name a battery level is published under.
bool is_limitation_result(uint8_t result)
Check whether a result code represents an environmental or control limitation.
const char * originator_name(uint8_t originator)
Get a human-readable name for a command originator byte.
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.
Device-name, address-classification and 1W-frame codecs.
The IO node-type and subtype table: names, entity class, platform and behaviour flags.
Runtime state of a paired IO‑Homecontrol device.
uint32_t last_result_at_ms
millis() timestamp of last_result_code, 0 when none recorded.
float target
Target position the device is moving toward.
uint16_t exchange_attempt_count
Cumulative attempts (ExchangeEngine::DebugInfo::tries, 1-based per exchange) across those timed-out e...
DeviceFeatures features
What this device can do, resolved by resolve_device_features().
uint16_t exchange_unconfirmed_count
Cumulative count of exchanges this device authenticated and then never closed: it answered with a 0x3...
uint8_t last_result_code
Last CMD_ERROR_RESP result byte (0 = none recorded).
uint8_t last_commander[NODE_ID_SIZE]
Node ID of the controller that last commanded this device, as reported verbatim by the device in its ...
uint8_t last_command_originator
That command's Command Originator byte (ORIGINATOR_* in proto_constants.h).
int16_t last_rssi_dbm
Most recent raw RSSI sample (dBm), or RSSI_UNKNOWN_DBM.
bool limited_by_rain
Derived from observed status replies, never from a prediction (decisions::is_rain_limited(); ADR 0030...
float position
Current position: 0=open, 100=closed, or UNKNOWN_POSITION.
uint8_t subtype
Device subtype (manufacturer‑specific).
uint32_t last_seen_ms
millis() of the last frame received from this device (any command), 0 = never.
bool battery_entities
YAML-declared (battery: true): the battery entities exist, so the status log line names the level eve...
int16_t rssi_ema_scaled
Smoothed RSSI as fixed point in 1/RSSI_EMA_SCALE dBm — read through device_rssi_ema_dbm(),...
bool has_last_command
True once a status reply carried a well-formed last-command record.
uint32_t last_rain_evidence_ms
millis() of the last status reply that named the rain sensor as the last commander; 0 = never.
DeviceType type
Device type (shutter, awning, etc.).
uint16_t exchange_timeout_count
Cumulative count of outbound exchanges to this device with no valid response (see detail::record_exch...
BatteryState battery
Coarse battery level from byte 1 of the device's own status: a reply to a status poll or an authentic...
bool is_stopped
True if device is not moving.
bool confirms_execute
True once this device has answered a CMD_EXECUTE with a reply that closed the exchange (a status,...
StopCandidate stop_candidate
Last off-target "stopped" report, to confirm the next one.
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 src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:97
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:96
uint8_t data_len
Actual length of data.
One decoded last-command record.
uint8_t commander[NODE_ID_SIZE]
Controller that last commanded the device.
bool valid
False when the payload was too short, or the record was unpopulated.
uint8_t originator
That command's Command Originator (ORIGINATOR_*).
Decoded representation of a 1W remote frame.
bool has_intent
True if originator/ACEI/intent fields were decoded.
uint8_t originator
Command originator byte (e.g., ORIGINATOR_USER_REMOTE).
DeviceType target_type
Target device class from broadcast address.
uint8_t acei_level
ACEI priority level (0–7).
uint8_t cmd
Command ID (e.g., CMD_EXECUTE, CMD_ACTIVATE_MODE).
AddressClass address_class
Classification of the broadcast address.
char intent[ONEWAY_INTENT_BUFFER_SIZE]
Human-readable command intent (e.g., "CLOSE").
uint8_t src[NODE_ID_SIZE]
Remote source node ID (3 bytes).
uint8_t data_len
Raw data length (for commands without decoded intent).
Diagnostic capture from a radio operation.
int16_t rssi_dbm
Received signal strength (dBm).
float position
Current position it reported with it.
bool armed() const
True when a report is remembered.
float target
Target the device reported with the off-target "stopped".