Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
hub_status.cpp
Go to the documentation of this file.
1#include "hub_internal.h"
2
3#include "hub_decisions.h"
4#include "proto_commands.h"
5
6#include <cinttypes>
7
8/// @file hub_status.cpp
9/// @brief Inbound status handling and passive receive-side state updates.
10/// @ingroup hioc_hub
11///
12/// This file owns the receive-side state path for the hub:
13/// - decode status-bearing frames into normalized device state,
14/// - decide when passive traffic should arm one-shot or tracked follow-up polls,
15/// - ACK authenticated device-initiated status updates.
16///
17/// @todo Validate the unsolicited CMD_STATUS_UPDATE path on hardware that actually emits
18/// device-initiated updates after pairing, including inbound authentication,
19/// three-channel ACK broadcast, and Home Assistant state publication without polling.
20///
21/// The goal of the split is to keep hub_core.cpp focused on lifecycle,
22/// device registry, and scheduling while leaving the protocol-specific receive
23/// interpretation in one place.
24
25namespace esphome {
26namespace home_io_control {
27
28namespace {
29
30constexpr uint8_t PRIVATE_RESPONSE_MIN_DATA_LEN = 6; ///< Minimum payload length for 0x04 position-bearing replies.
31 ///< Bytes 0–5 (stopped flag + target + current) are mandatory;
32 ///< byte 7 (settle hint) is optional and checked separately.
33constexpr uint8_t STATUS_UPDATE_MIN_DATA_LEN = 11; ///< Minimum payload length for 0x71 device-initiated updates.
34constexpr uint8_t GET_NAME_RESPONSE_MIN_DATA_LEN = 1; ///< Minimum payload length for 0x51 name-bearing replies.
35constexpr uint8_t GET_INFO2_RESPONSE_MIN_DATA_LEN = 12; ///< Minimum payload length for 0x57 type/subtype metadata.
36constexpr uint8_t ERROR_RESPONSE_MIN_DATA_LEN = 1; ///< Minimum payload length for 0xFE result-bearing replies.
37constexpr uint8_t EXTENDED_TILT_RESPONSE_MIN_DATA_LEN =
38 15; ///< Minimum payload length for tilt-capable extended status replies.
39constexpr uint8_t STATUS_STOPPED_FLAGS_OFFSET = 0; ///< Byte containing STATUS_STOPPED.
40constexpr uint8_t PRIVATE_RESPONSE_DELAY_HINT_OFFSET = 7; ///< Coarse follow-up delay hint byte in many 0x04 replies.
41// A 0x04 payload does not describe its own layout — which fields these offsets name depends on
42// the request that drew the reply, and only the caller knows that. A reply to a tilt EXECUTE puts
43// the tilt selector 0x20 at offset 4 and a 16-bit slat angle at 5..6, straddling the bytes a
44// reply to a status poll uses for the current position (4..5). Do not try to recover the
45// difference by sniffing the payload: 0x20 is also a perfectly legal current-position MSB (raw
46// 0x2000-0x20FF is 16.0%-16.5%), and a tilt ack is 8 bytes like any hint-carrying position reply,
47// so neither a `data[4] == STATUS_TILT_SELECTOR` test nor a length test can separate them — the
48// first would blank real readings from any cover resting near 16%. The request-derived
49// `trust_position` parameter on update_device_status_() is the discriminator, and the only
50// correct one. See tests/corpus/captures/issues/issue_60_tilt_execute_ack_tilt_block*.yaml.
51constexpr uint8_t PRIVATE_RESPONSE_TARGET_OFFSET = 2; ///< Target-position MSB offset in 0x04 replies.
52constexpr uint8_t PRIVATE_RESPONSE_CURRENT_OFFSET = 4; ///< Current-position MSB offset in 0x04 replies.
53constexpr uint8_t STATUS_UPDATE_TARGET_OFFSET = 5; ///< Target-position MSB offset in 0x71 updates.
54constexpr uint8_t STATUS_UPDATE_CURRENT_OFFSET = 7; ///< Current-position MSB offset in 0x71 updates.
55constexpr uint8_t GET_INFO2_TYPE_OFFSET = 10; ///< Packed device type byte in 0x57 replies.
56constexpr uint8_t GET_INFO2_TYPE_SUBTYPE_OFFSET = 11; ///< Packed type low bits plus subtype byte in 0x57 replies.
57constexpr uint8_t EXTENDED_TILT_SELECTOR_OFFSET = 12; ///< Selector byte announcing extended tilt payload.
58constexpr uint8_t EXTENDED_TILT_MSB_OFFSET = 13; ///< Tilt-position MSB within extended replies.
59constexpr uint8_t EXTENDED_TILT_LSB_OFFSET = 14; ///< Tilt-position LSB within extended replies.
60constexpr uint8_t PRIVATE_RESPONSE_HINT_UNUSED = 0xFF; ///< Value used by devices that do not expose a follow-up timer.
61constexpr uint8_t PRIVATE_RESPONSE_HINT_ZERO =
62 0x00; ///< Value treated as invalid or uninformative for follow-up timing.
63constexpr uint32_t PRIVATE_RESPONSE_HINT_SCALE_MS = 1000; ///< Private-response delay hint is expressed in seconds.
64constexpr uint32_t PRIVATE_RESPONSE_HINT_BIAS_MS =
65 1000; ///< Observed devices need an extra second beyond the hint value.
66
67/// @brief Decode the shared target/current position fields used by private response and status‑update frames.
68/// Different frame types use different byte offsets, but the normalization policy is identical once offsets known.
69/// @param dev Device record to update.
70/// @param frame IoFrame containing a status‑bearing command.
71/// @param target_offset Byte offset of target MSB within frame.data.
72/// @param current_offset Byte offset of current MSB within frame.data.
73/// @param allow_tilt_from_extended_response If true and frame is extended, decode tilt from the extended tilt bytes.
74void decode_status_fields(IoDevice &dev, const IoFrame &frame, uint8_t target_offset, uint8_t current_offset,
75 bool allow_tilt_from_extended_response) {
76 uint16_t const tgt = (frame.data[target_offset] << 8) | frame.data[target_offset + 1];
77 uint16_t const cur = (frame.data[current_offset] << 8) | frame.data[current_offset + 1];
78 decode_position_report(tgt, cur, dev.is_stopped, dev.target, dev.position);
80
81 if (allow_tilt_from_extended_response && device_supports_tilt(dev.type) &&
82 frame.data_len >= EXTENDED_TILT_RESPONSE_MIN_DATA_LEN &&
83 frame.data[EXTENDED_TILT_SELECTOR_OFFSET] == STATUS_TILT_SELECTOR) {
84 uint16_t const tilt_raw = (frame.data[EXTENDED_TILT_MSB_OFFSET] << 8) | frame.data[EXTENDED_TILT_LSB_OFFSET];
85 dev.tilt = decode_tilt_report(tilt_raw);
86 }
87}
88
89/// @brief Compute the delay before the next status poll for a private‑response device.
90/// @param dev Device record.
91/// @param frame The private response frame (may contain a coarse retry hint in byte 7).
92/// @param policy Policy used to look up the configured poll interval.
93/// @param id Device ID for policy lookup.
94/// @return Delay in milliseconds, or 0 if the device is stopped.
95uint32_t compute_private_response_delay_ms(const IoDevice &dev, const IoFrame &frame, const StatusPollPolicy &policy,
96 const std::string &id) {
97 if (dev.is_stopped)
98 return 0;
99
100 // Private responses carry a coarse follow‑up timer in byte 7 on many devices. Decode it here
101 // (0 = absent) and let settle_delay_ms() reconcile it with the configured interval and default.
102 // Some devices omit byte 7 entirely (data_len == 6); treat those as hint-absent.
103 uint32_t hint_delay_ms = 0;
104 if (frame.data_len > PRIVATE_RESPONSE_DELAY_HINT_OFFSET &&
105 frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] != PRIVATE_RESPONSE_HINT_UNUSED &&
106 frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] != PRIVATE_RESPONSE_HINT_ZERO) {
107 hint_delay_ms = (frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] * PRIVATE_RESPONSE_HINT_SCALE_MS) +
108 PRIVATE_RESPONSE_HINT_BIAS_MS;
109 }
110 // A private response is the shared reply to both polls (0x03) and commands (0x00); it carries no
111 // marker for STOP, so the STOP cap is applied by the command path, not here.
112 return settle_delay_ms(policy.get_interval(id), hint_delay_ms, /*cap_for_stop=*/false);
113}
114
115/// @brief Compute the delay before the next status poll for a device‑originated status update.
116/// @param dev Device record.
117/// @param policy Policy used to look up the configured poll interval.
118/// @param id Device ID for policy lookup.
119/// @return Delay in milliseconds for tracked polling; 0 if stopped.
120uint32_t compute_status_update_delay_ms(const IoDevice &dev, const StatusPollPolicy &policy, const std::string &id) {
121 if (dev.is_stopped)
122 return 0;
123 // Device-originated updates carry no follow-up hint and are never STOP replies.
124 return settle_delay_ms(policy.get_interval(id), /*hint_delay_ms=*/0, /*cap_for_stop=*/false);
125}
126
127/// @brief Apply a private-response frame to the device record.
128/// @param id Device ID for policy lookup.
129/// @param dev Device record to update.
130/// @param frame Private-response frame.
131/// @param policy Poll policy for scheduling follow-up polls.
132/// @param trust_position False to skip decoding target/current from `frame` — the immediate
133/// reply to our own just-sent CMD_EXECUTE has been observed (real hardware, see
134/// tests/corpus/captures/somfy_awning/execute_ack_reports_stale_target_*.yaml) echoing
135/// pre-command target/current values rather than the freshly-commanded target. `is_stopped` is
136/// still applied either way; the optimistic target already set by the caller (or the follow-up
137/// status poll a few seconds later) remains the source of truth for target/current in that case.
138void apply_private_response_status(const std::string &id, IoDevice &dev, const IoFrame &frame, StatusPollPolicy &policy,
139 bool trust_position = true) {
140 dev.is_stopped = (frame.data[STATUS_STOPPED_FLAGS_OFFSET] & STATUS_STOPPED) != 0;
141 dev.last_status = millis();
142 if (trust_position) {
143 decode_status_fields(dev, frame, PRIVATE_RESPONSE_TARGET_OFFSET, PRIVATE_RESPONSE_CURRENT_OFFSET, true);
144 } else {
146 }
147
148 if (dev.is_stopped || !policy.is_tracking_active(id, dev.last_status)) {
149 policy.clear(id);
150 return;
151 }
152
153 uint32_t const delay_ms = compute_private_response_delay_ms(dev, frame, policy, id);
154 const bool hint_present = frame.data_len > PRIVATE_RESPONSE_DELAY_HINT_OFFSET;
155 const uint8_t hint_byte =
156 hint_present ? frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] : PRIVATE_RESPONSE_HINT_UNUSED;
157 const bool has_hint =
158 hint_present && hint_byte != PRIVATE_RESPONSE_HINT_UNUSED && hint_byte != PRIVATE_RESPONSE_HINT_ZERO;
159 ESP_LOGD(
160 detail::TAG, "Device %s: next status poll in %" PRIu32 " ms (device hint=%s, configured interval=%" PRIu32 " ms)",
161 id.c_str(), delay_ms, has_hint ? std::to_string(hint_byte).append("s").c_str() : "none", policy.get_interval(id));
162 uint32_t const new_deadline = dev.last_status + delay_ms;
163 uint32_t const existing_deadline = policy.get_next_update(id);
164 // Don't push the deadline forward — only move it earlier. This prevents repeated command
165 // responses (e.g. multiple rapid STOP presses) from compounding the wait time.
166 policy.set_next_update(
167 id, (existing_deadline != 0 && existing_deadline < new_deadline) ? existing_deadline : new_deadline);
168}
169
170/// @brief Apply a device-originated status-update frame to the device record.
171/// @param id Device ID for policy lookup.
172/// @param dev Device record to update.
173/// @param frame Status-update frame.
174/// @param policy Poll policy for scheduling follow-up polls.
175void apply_unsolicited_status_update(const std::string &id, IoDevice &dev, const IoFrame &frame,
176 StatusPollPolicy &policy) {
177 dev.is_stopped = (frame.data[STATUS_STOPPED_FLAGS_OFFSET] & STATUS_STOPPED) != 0;
178 dev.last_status = millis();
179 decode_status_fields(dev, frame, STATUS_UPDATE_TARGET_OFFSET, STATUS_UPDATE_CURRENT_OFFSET, false);
180
181 if (dev.is_stopped || !policy.is_tracking_active(id, dev.last_status)) {
182 policy.clear(id);
183 return;
184 }
185
186 policy.set_next_update(id, dev.last_status + compute_status_update_delay_ms(dev, policy, id));
187}
188
189/// @brief Apply INFO2 metadata to the device record when YAML has not already declared it.
190/// @param dev Device record to update.
191/// @param frame INFO2 response frame.
192void apply_info2_response(IoDevice &dev, const IoFrame &frame) {
193 if (dev.type != DeviceType::UNKNOWN)
194 return;
195
196 dev.type = decode_packed_device_type(frame.data[GET_INFO2_TYPE_OFFSET], frame.data[GET_INFO2_TYPE_SUBTYPE_OFFSET]);
197 dev.subtype = decode_packed_device_subtype(frame.data[GET_INFO2_TYPE_SUBTYPE_OFFSET]);
198 if (default_inverted_for_type(dev.type))
199 dev.inverted = true;
200}
201
202/// @brief Apply a name response frame to the device record.
203/// @param dev Device record to update.
204/// @param frame Name response frame.
205void apply_name_response(IoDevice &dev, const IoFrame &frame) {
206 std::string const name = decode_device_name_payload(frame.data, frame.data_len);
207 memset(dev.name, 0, sizeof(dev.name));
208 if (!name.empty())
209 memcpy(dev.name, name.c_str(), name.length());
210}
211
212} // namespace
213
214void IOHomeControlComponent::begin_status_poll_tracking_(const std::string &device_id, uint32_t initial_delay_ms) {
215 if (this->get_device(device_id) == nullptr)
216 return;
217 this->poll_policy_.begin_tracking(device_id, initial_delay_ms, millis());
218}
219
220void IOHomeControlComponent::schedule_status_poll_(const std::string &device_id, uint32_t delay_ms) {
221 // The timeout name is per-device so repeated remote traffic resets the pending poll instead of
222 // stacking multiple delayed callbacks for the same actuator.
223 const std::string timeout_name = "remote_poll_" + device_id;
224 this->set_timeout(timeout_name.c_str(), delay_ms,
225 [this, device_id]() { this->queue_request_device_status(device_id); });
226}
227
228void IOHomeControlComponent::schedule_device_polls_(const std::vector<std::string> &device_ids, uint32_t delay_ms) {
229 for (const auto &device_id : device_ids) {
230 this->begin_status_poll_tracking_(device_id, 0);
231 this->schedule_status_poll_(device_id, delay_ms);
232 }
233}
234
235void IOHomeControlComponent::schedule_linked_remote_polls_(const std::string &remote_id, uint32_t delay_ms) {
236 const std::vector<std::string> *linked = this->registry_.linked_devices(remote_id);
237 if (linked == nullptr)
238 return;
239 this->schedule_device_polls_(*linked, delay_ms);
240}
241
243 const std::string &src_id) const {
244 std::vector<std::string> devices;
245 if (const std::vector<std::string> *id_linked = this->registry_.linked_devices(src_id)) {
246 devices = *id_linked;
247 }
249 if (const std::vector<std::string> *class_linked = this->registry_.linked_devices_for_class(info.target_type)) {
250 for (const auto &device_id : *class_linked) {
251 if (std::find(devices.begin(), devices.end(), device_id) == devices.end())
252 devices.push_back(device_id);
253 }
254 }
255 }
256 return devices;
257}
258
260 const std::vector<std::string> &device_ids) {
261 if (!info.has_intent)
262 return false;
263
264 const bool is_stop = info.main0 == POS_STOP;
265 const std::optional<float> target = is_stop ? std::nullopt : oneway_intent_to_target(info.main0, info.main1);
266
267 for (const auto &device_id : device_ids) {
268 const IoDevice *dev = this->registry_.get(device_id);
269 if (dev != nullptr && info.target_type != DeviceType::UNKNOWN && dev->type != DeviceType::UNKNOWN &&
270 dev->type != info.target_type) {
271 continue; // Type mismatch: still polled by schedule_device_polls_(), just not moved optimistically.
272 }
273 if (is_stop) {
274 this->registry_.clear_optimistic_target(device_id);
275 } else if (target.has_value()) {
276 this->registry_.apply_optimistic_target(device_id, *target);
277 }
278 }
279 return is_stop;
280}
281
283 const std::string &src_id) {
284 if (!info.has_intent)
285 return;
286 // All overheard 1W traffic is DEBUG-logged regardless (see log_1w_remote_frame()); the HA event
287 // additionally requires the sender to be on the `exposed_senders` allowlist, since 1W broadcasts
288 // carry no ownership marker and this radio may overhear a neighbor's remote (or sensor) as
289 // easily as the user's own. DEBUG-log the reason it did or didn't fire so a live log capture is
290 // enough to diagnose a misconfigured allowlist vs. a disconnected API.
291 if (!this->is_connected()) {
292 ESP_LOGD(detail::TAG, "1W sender %s has intent but the API is not connected, skipping %s", src_id.c_str(),
294 return;
295 }
296 if (!detail::is_exposed_sender(this->exposed_senders_, src_id)) {
297 ESP_LOGD(detail::TAG, "1W sender %s has intent but is not in exposed_senders, skipping %s", src_id.c_str(),
299 return;
300 }
301 ESP_LOGD(detail::TAG, "Firing %s for sender %s", detail::ONEWAY_SENDER_EVENT, src_id.c_str());
302 this->fire_homeassistant_event(detail::ONEWAY_SENDER_EVENT, detail::build_sender_event_data(info, linked));
303}
304
305void IOHomeControlComponent::update_device_status_(const IoFrame &frame, bool trust_position) {
306 const std::string id = node_id_to_string(frame.src);
307 IoDevice *device_ptr = this->registry_.get(id);
308 if (device_ptr == nullptr) {
309 detail::log_frame_issue(this, "rx", "unregistered_device", frame, frame_length(frame));
310 return;
311 }
312 IoDevice &dev = *device_ptr;
314
315 if (frame.cmd == CMD_PRIVATE_RESP) {
316 if (frame.data_len < PRIVATE_RESPONSE_MIN_DATA_LEN) {
317 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
318 return;
319 }
320
321 // CMD_PRIVATE_RESP (0x04) serves as the reply to both status polls (0x03) and execute
322 // commands (0x00). The position fields are shared across both response types, but the
323 // immediate reply to our own execute command is not necessarily trustworthy for them (see
324 // apply_private_response_status()'s trust_position parameter).
325 apply_private_response_status(id, dev, frame, this->poll_policy_, trust_position);
328 this->notify_device_update_(id);
329 return;
330 }
331
332 if (frame.cmd == CMD_STATUS_UPDATE) {
333 if (frame.data_len < STATUS_UPDATE_MIN_DATA_LEN) {
334 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
335 return;
336 }
337
338 // Status-update frames come from the device itself rather than from a direct controller poll.
339 // They use different offsets for the target/current fields and do not carry reliable tilt data.
340 apply_unsolicited_status_update(id, dev, frame, this->poll_policy_);
342
343 // The originator byte at data[1] tells us what caused the device to move.
344 // Log it so users can understand device-initiated movements (e.g., wind sensor, timer).
345 if (frame.data_len > 1) {
346 ESP_LOGD(detail::TAG, "Device %s: status update originator=%s(0x%02X)", id.c_str(),
347 originator_name(frame.data[1]), frame.data[1]);
348 }
349
350 detail::log_status_update(id, dev, " (status update)");
351 this->notify_device_update_(id);
352 return;
353 }
354
355 if (frame.cmd == CMD_GET_NAME_RESP) {
356 if (frame.data_len < GET_NAME_RESPONSE_MIN_DATA_LEN) {
357 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
358 return;
359 }
360
361 apply_name_response(dev, frame);
362 ESP_LOGI(detail::TAG, "Device %s: name=%s", id.c_str(), dev.name[0] == '\0' ? "" : dev.name);
363 this->notify_device_update_(id);
364 return;
365 }
366
367 if (frame.cmd == CMD_GET_INFO2_RESP) {
368 if (frame.data_len < GET_INFO2_RESPONSE_MIN_DATA_LEN) {
369 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
370 return;
371 }
372
373 // INFO2 is metadata, not movement state. Only learn type from radio if still UNKNOWN;
374 // YAML-declared type takes priority.
375 const bool type_was_unknown = dev.type == DeviceType::UNKNOWN;
376 apply_info2_response(dev, frame);
377 ESP_LOGI(detail::TAG, "Device %s: type=%s (%u) class=%s profile=%s subtype=%u", id.c_str(),
380 if (type_was_unknown && dev.type != DeviceType::UNKNOWN) {
381 ESP_LOGI(detail::TAG,
382 "Device %s: type learned at runtime, not declared in YAML — add `%s` to skip "
383 "re-learning it on every future boot",
384 id.c_str(), detail::describe_learned_device_type(dev.type).c_str());
385 }
386 return;
387 }
388
389 if (frame.cmd == CMD_ERROR_RESP) {
390 if (frame.data_len < ERROR_RESPONSE_MIN_DATA_LEN) {
391 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
392 return;
393 }
394
395 detail::record_command_result(dev, id, frame.data[0]);
396 this->notify_device_update_(id);
397 return;
398 }
399}
400
402 // A gap of a full quiet period or more since the last frame means the previous burst already
403 // released any deferred poll, so this frame starts a new burst window rather than extending the
404 // old one (which would make ONEWAY_POLL_DEFER_CAP_MS fire on the very next frame).
406 this->first_1w_activity_ms_ = now;
407 this->last_1w_activity_ms_ = now;
408}
409
411 IoFrame frame;
412 if (!parse(packet.data, packet.len, frame)) {
413 detail::log_component_capture(this->radio_, "parse_fail", packet.data, packet.len);
414 return;
415 }
416
417 detail::log_component_capture(this->radio_, "parse_ok", packet.data, packet.len, &frame);
418
419 // Exchange-internal frames (0x3C challenge request, 0x3D challenge response) are part of
420 // another controller's authenticated exchange. They carry no extractable status data for
421 // a passive observer — skip silently. They remain visible in io_capture (stage=parse_ok).
423 return;
424 }
425
426 // === Key-extraction responder ("Accept Foreign Pairing") ===
427 // Narrow, armed-only branches for the device-role pairing responder (pairing_responder.h);
428 // see hub_key_extraction.cpp. When disarmed these frames fall through unchanged to the normal
429 // handling below (0x28/0x2C/0x31/0x32 are not otherwise acted on by this hub, so that's a no-op).
430 if (this->try_handle_key_extraction_frame_(frame))
431 return;
432
433 // === 1W remote frame decode ===
434 // 1W remotes broadcast commands to a typed device-class address (e.g., "all awnings").
435 // Decode the frame content for diagnostic logging, then fall through to linked_remotes
436 // handling which may schedule a status poll for devices this remote controls.
437 if ((frame.ctrl0 & CTRL0_PROTOCOL_1W) != 0) {
438 const std::string src_id = node_id_to_string(frame.src);
439 const uint32_t now = millis();
440
441 // Any 1W frame means a remote is transmitting right now, duplicate or not — record it before
442 // the dedup check so loop() keeps background polls off the radio for the rest of the burst.
443 this->record_1w_activity_(now);
444
445 // Decode once and reuse for the dedup key, logging, and (when it carries a command intent) the
446 // sender HA event, so a physical remote press (or sensor trigger) can drive automations directly.
447 // The decode must happen *before* the dedup check: a move and a stop share the CMD_EXECUTE
448 // command byte and are told apart only by the decoded intent, which is part of the key.
449 const OneWayFrameInfo info = decode_1w_frame(frame);
450
451 // 1W remotes repeat each command 4× at 40ms intervals across channels, and a held button keeps
452 // resending. Collapse that into one logical press per remote+command+intent.
453 const decisions::OneWayDedupState incoming{src_id, frame.cmd, info.has_intent, info.main0, info.main1, now};
455 return;
456 this->last_1w_logged_ = incoming;
457
458 const std::vector<std::string> *linked = this->registry_.linked_devices(src_id);
459 detail::log_1w_remote_frame(info, linked);
460 this->maybe_fire_sender_event_(info, linked != nullptr && !linked->empty(), src_id);
461 // Id-linked devices plus, for a typed broadcast, class-linked devices — deduplicated so a
462 // device linked both ways is only touched once per press.
463 const std::vector<std::string> target_devices = this->resolve_1w_target_devices_(info, src_id);
464 const bool is_stop = this->apply_optimistic_linked_state_(info, target_devices);
465 this->schedule_device_polls_(target_devices, is_stop ? 0 : REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS);
466 return;
467 }
468
469 if (frame.cmd == CMD_STATUS_UPDATE && memcmp(frame.dst, this->node_id_, NODE_ID_SIZE) == 0) {
470 if (this->authenticate_request_(frame, packet.freq_hz)) {
471 IoFrame resp;
472 if (!create_status_update_resp(resp, this->node_id_, frame.src)) {
473 detail::log_frame_issue(this, "rx", "ack_build_failed", frame, packet.len);
474 return;
475 }
476 // Device-originated updates may arrive while the sender and receiver are not aligned on the
477 // same hop channel anymore. Broadcasting the ACK across all three IO-homecontrol channels
478 // matched the behavior of real controllers and made updates reliable in practice.
482 this->update_device_status_(frame);
483 } else {
484 detail::log_frame_issue(this, "rx", "auth_failed", frame, packet.len);
485 }
486 return;
487 }
488
489 if (frame.cmd == CMD_PRIVATE_RESP || frame.cmd == CMD_STATUS_UPDATE) {
490 // Passive receive mode can still observe replies/status from other exchanges (another
491 // controller sharing a device, or an attacker who knows the device's node ID -- node IDs
492 // travel in the clear, see README.md's "Reporting Unsupported Devices"). Nothing here proves
493 // the frame's source currently holds the system key, so its content is never applied -- see
494 // ADR 0022. State goes stale until this hub's own next authenticated poll corrects it.
495 detail::log_frame_issue(this, "rx", "unauthenticated_status_ignored", frame, packet.len);
496 return;
497 }
498
499 // Check if this frame targets one of our registered devices (e.g., a physical remote
500 // commanding a shutter we also control). If so, schedule a status poll after 2 seconds
501 // to pick up the resulting position change. The timeout name includes the device ID so
502 // repeated remote activity resets the timer rather than stacking redundant polls.
503 // The 2-second delay gives the device time to complete the exchange and start moving.
504 const std::string dst_id = node_id_to_string(frame.dst);
505 if (this->get_device(dst_id) != nullptr && memcmp(frame.src, this->node_id_, NODE_ID_SIZE) != 0) {
506 ESP_LOGD(detail::TAG, "rx remote_activity src=%s dst=%s cmd=%s(0x%02X), scheduling status poll",
507 node_id_to_string(frame.src).c_str(), dst_id.c_str(), command_name(frame.cmd), frame.cmd);
508 this->begin_status_poll_tracking_(dst_id, 0);
510 return;
511 }
512
513 // Check if the frame source is a linked remote using 2W protocol (e.g., a 2W controller
514 // whose commands target a device at an address we don't have registered). 1W remotes are
515 // already handled above via the CTRL0_PROTOCOL_1W check.
516 const std::string src_id = node_id_to_string(frame.src);
517 if (this->registry_.linked_devices(src_id) != nullptr) {
518 ESP_LOGD(detail::TAG, "rx remote_activity (linked) remote=%s cmd=%s(0x%02X), scheduling status poll",
519 src_id.c_str(), command_name(frame.cmd), frame.cmd);
520 this->schedule_linked_remote_polls_(src_id);
521 return;
522 }
523
524 detail::log_frame_issue(this, "rx", "unhandled_cmd", frame, packet.len);
525
526 // If the command is not in our known set AND the frame was addressed to our hub, it may be a
527 // protocol extension we should support — ask the user to report it. Frames merely overheard
528 // between other devices (not addressed to us) are still logged above at debug level, but do
529 // not warrant a warning: we are not a party to that exchange, so there is nothing to add.
530 const bool addressed_to_us = memcmp(frame.dst, this->node_id_, NODE_ID_SIZE) == 0;
531 if (addressed_to_us && std::strcmp(command_name(frame.cmd), "UNKNOWN_CMD") == 0) {
532 const std::string src_id = node_id_to_string(frame.src);
533 ESP_LOGW(detail::TAG,
534 "Received unknown command 0x%02X from %s. "
535 "If you see this repeatedly, please file a GitHub issue with this command ID, "
536 "your device model, and the log context so protocol support can be extended.",
537 frame.cmd, src_id.c_str());
538 }
539}
540
541} // namespace home_io_control
542} // namespace esphome
void maybe_fire_sender_event_(const OneWayFrameInfo &info, bool linked, const std::string &src_id)
Fire the sender HA event for a decoded 1W frame, if the sender is exposed.
uint32_t last_1w_activity_ms_
millis() of the most recent 1W frame of any kind, including ones dropped as duplicates — a repeat sti...
Definition hub_core.h:774
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.
virtual IoDevice * get_device(const std::string &device_id)
Retrieve a device by ID; returns nullptr if not found.
Definition hub_core.cpp:304
std::vector< std::string > resolve_1w_target_devices_(const OneWayFrameInfo &info, const std::string &src_id) const
Resolve the set of devices a 1W frame should affect: devices linked to the sending remote by node ID,...
void schedule_status_poll_(const std::string &device_id, uint32_t delay_ms)
Schedule a delayed status poll for a registered device using the Component timeout API.
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...
void schedule_device_polls_(const std::vector< std::string > &device_ids, uint32_t delay_ms)
Schedule status polls for a fixed list of devices (shared by the id-linked and class-linked 1W paths,...
void record_1w_activity_(uint32_t now)
Record that a 1W frame just arrived, updating last_1w_activity_ms_ and — when this frame starts a new...
bool transmit_frame_(const IoFrame &frame, uint32_t freq, uint16_t preamble)
Transmit a raw IoFrame on the current frequency with given preamble length.
Definition hub_core.cpp:259
void schedule_linked_remote_polls_(const std::string &remote_id, uint32_t delay_ms=REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS)
Schedule status polls for all devices associated with a linked remote.
void notify_device_update_(const std::string &id)
Fire all registered device update callbacks for the given device ID.
Definition hub_core.cpp:276
decisions::OneWayDedupState last_1w_logged_
Identity of the last processed 1W frame, for burst suppression; see decisions::is_duplicate_1w_frame(...
Definition hub_core.h:770
std::vector< std::string > exposed_senders_
1W sender node IDs (remotes or sensors) allowed to fire the sender HA event (add_exposed_sender).
Definition hub_core.h:752
void process_received_packet_(const RadioRxPacket &packet)
Parse a received frame, merge supported device state or metadata, and notify callbacks.
bool authenticate_request_(const IoFrame &request, uint32_t freq)
Handle an inbound authenticated command from a device (status updates, etc.).
Definition hub_core.cpp:272
bool try_handle_key_extraction_frame_(const IoFrame &frame)
Dispatch a frame to the key-extraction responder if it's one of its 0x28/0x2C/0x31/0x32 frames and th...
uint32_t first_1w_activity_ms_
millis() of the first 1W frame in the current burst.
Definition hub_core.h:779
bool apply_optimistic_linked_state_(const OneWayFrameInfo &info, const std::vector< std::string > &device_ids)
Apply optimistic target state to every device in device_ids, when the decoded frame carries a resolva...
Per-hub poll scheduling and failure-backoff policy.
Pure transition helpers for hub-owned exchange and pairing frame decisions.
Internal helpers shared by the hub implementation .cpp files.
bool is_duplicate_1w_frame(const OneWayDedupState &last, const OneWayDedupState &incoming, uint32_t window_ms)
Decide whether an incoming 1W frame repeats the previous one inside the burst window.
bool is_exchange_internal_command(uint8_t cmd)
Returns true for commands that are internal to an exchange handshake and carry no useful information ...
bool oneway_burst_started_fresh(uint32_t last_1w_activity_ms, uint32_t now, uint32_t quiet_ms)
Whether a 1W frame arriving at now starts a new burst rather than extending the current one — true if...
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.
std::string describe_learned_device_type(DeviceType type)
Build the YAML line to add once a device's type is learned at runtime.
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 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.
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.
void normalize_stopped_state(IoDevice &dev)
Normalize stopped state: some devices briefly report stopped before target/current converge.
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.
void log_frame_issue(IOHomeControlComponent *component, const char *direction, const char *reason, const IoFrame &frame, uint8_t len)
Log a frame‑level issue (unregistered endpoints, unsupported commands).
void clear_command_result(IoDevice &dev)
Clear a previously recorded CMD_ERROR_RESP result, if any.
void 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.
const char * device_operation_profile_name(DeviceType type)
Human‑readable operation profile name for a device type.
uint32_t settle_delay_ms(uint32_t interval_ms, uint32_t hint_delay_ms, bool cap_for_stop)
Resolve the follow-up settle-poll delay while a device may still be moving.
static constexpr uint32_t ONEWAY_QUIET_PERIOD_MS
Hold queued background polls back for this long after any 1W frame, so a poll the hub itself schedule...
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
static constexpr uint8_t CMD_ERROR_RESP
Error response to any command.
static constexpr uint8_t CMD_GET_NAME_RESP
Device name response.
@ UNKNOWN
Unknown/unspecified device.
static constexpr uint8_t CMD_STATUS_UPDATE
Device-initiated status update (needs auth).
bool default_inverted_for_type(DeviceType type)
Determine whether a device type has inverted position mapping by default.
std::optional< float > oneway_intent_to_target(uint8_t main0, uint8_t main1)
Resolve a 1W main-byte pair to an optimistic IO target position, if unambiguous.
static constexpr uint32_t FREQ_CH1
The protocol uses 3 frequency channels in the 868 MHz ISM band.
static constexpr uint32_t FREQ_CH3
Channel 3: 869.85 MHz (2W only).
static constexpr uint8_t CTRL0_PROTOCOL_1W
Bit 5: 1=OneWay protocol, 0=TwoWay protocol.
Definition proto_frame.h:42
const char * command_name(uint8_t cmd)
Get a human-readable name for any IO-Homecontrol command ID.
const char * device_type_name(DeviceType type)
Convert a DeviceType to a lowercase string identifier.
float decode_tilt_report(uint16_t tilt_raw)
Decode tilt angle from raw 16‑bit value.
OneWayFrameInfo decode_1w_frame(const IoFrame &frame)
Decode a parsed 1W frame into a structured OneWayFrameInfo.
static constexpr uint32_t REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS
Delay before polling after overheard remote traffic.
uint8_t frame_length(const IoFrame &f)
Get total frame length from ctrl0.
DeviceType decode_packed_device_type(uint8_t type_msb, uint8_t type_subtype)
Decode a protocol-packed device type from two metadata bytes.
bool parse(const uint8_t *buf, uint8_t buf_len, IoFrame &f)
Parse a wire buffer into a parsed IoFrame (validates length and CTRL0).
static constexpr uint8_t CMD_PRIVATE_RESP
Response to 0x00 and 0x03 (contains position data).
const char * device_capability_class_name(DeviceType type)
Get a human‑readable name for a capability class.
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
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.
std::string decode_device_name_payload(const uint8_t *data, uint8_t len)
Decode a device-name payload from IO-homecontrol's Latin-1 wire format into UTF-8.
uint8_t decode_packed_device_subtype(uint8_t type_subtype)
Decode a protocol-packed device subtype from the second metadata byte.
@ BROADCAST_TYPE
Broadcast to specific device type with non-standard suffix.
static constexpr uint8_t STATUS_TILT_SELECTOR
Extended status payload marker for tilt-capable devices.
static constexpr uint16_t SHORT_PREAMBLE
8 bytes for response/continuation frames
static constexpr uint8_t CMD_GET_INFO2_RESP
Device type/model response.
static constexpr uint8_t POS_STOP
Position values in the IO protocol.
const char * originator_name(uint8_t originator)
Get a human-readable name for a command originator byte.
void decode_position_report(uint16_t target_raw, uint16_t current_raw, bool is_stopped, float &target, float &position)
Decode target/current position values from a status frame.
bool device_supports_tilt(DeviceType type)
Does this device type support tilt (slat angle) control?
bool create_status_update_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a status-update acknowledgment (0x72).
static constexpr uint8_t STATUS_STOPPED
Status byte flags in CMD_PRIVATE_RESP and CMD_STATUS_UPDATE.
Command builders for the IO‑Homecontrol protocol.
Runtime state of a paired IO‑Homecontrol device.
char name[DEVICE_NAME_BUFFER_SIZE]
Cached UTF-8 device name decoded from Latin-1 wire payloads.
uint8_t subtype
Device subtype (manufacturer‑specific).
DeviceType type
Device type (shutter, awning, etc.).
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:71
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes).
Definition proto_frame.h:77
uint8_t ctrl0
Control byte 0: flags + length.
Definition proto_frame.h:72
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:75
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:74
uint8_t data_len
Actual length of data.
Definition proto_frame.h:78
Decoded representation of a 1W remote frame.
bool has_intent
True if originator/ACEI/intent fields were decoded.
DeviceType target_type
Target device class from broadcast address.
uint8_t main0
Raw first main byte (has_intent only); feeds oneway_intent_to_target().
uint8_t main1
Raw second main byte (has_intent only); feeds oneway_intent_to_target().
AddressClass address_class
Classification of the broadcast address.
Raw packet received from the radio.
uint8_t len
Length of packet in bytes.
uint32_t freq_hz
Frequency the packet was received on (Hz).
uint8_t data[RADIO_PACKET_BUFFER_SIZE]
Raw packet data buffer.
Key fields of the last processed 1W frame, used to collapse a remote's repeat burst.