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"
3
4#include "hub_decisions.h"
5#include "proto_commands.h"
6
7#include <cinttypes>
8
9/// @file hub_status.cpp
10/// @brief Inbound status handling and passive receive-side state updates.
11/// @ingroup hioc_hub
12///
13/// This file owns the receive-side state path for the hub:
14/// - decode status-bearing frames into normalized device state,
15/// - decide when passive traffic should arm one-shot or tracked follow-up polls,
16/// - ACK authenticated device-initiated status updates.
17///
18/// @todo Validate the unsolicited CMD_STATUS_UPDATE path on hardware that actually emits
19/// device-initiated updates after pairing, including inbound authentication,
20/// three-channel ACK broadcast, and Home Assistant state publication without polling.
21///
22/// The goal of the split is to keep hub_core.cpp focused on lifecycle,
23/// device registry, and scheduling while leaving the protocol-specific receive
24/// interpretation in one place.
25
26namespace esphome {
27namespace home_io_control {
28
29namespace {
30
31constexpr uint8_t PRIVATE_RESPONSE_MIN_DATA_LEN = 6; ///< Minimum payload length for 0x04 position-bearing replies.
32 ///< Bytes 0–5 (stopped flag + target + current) are mandatory;
33 ///< byte 7 (settle hint) is optional and checked separately.
34constexpr uint8_t STATUS_UPDATE_MIN_DATA_LEN = 11; ///< Minimum payload length for 0x71 device-initiated updates.
35constexpr uint8_t GET_NAME_RESPONSE_MIN_DATA_LEN = 1; ///< Minimum payload length for 0x51 name-bearing replies.
36constexpr uint8_t GET_INFO2_RESPONSE_MIN_DATA_LEN = 12; ///< Minimum payload length for 0x57 type/subtype metadata.
37constexpr uint8_t ERROR_RESPONSE_MIN_DATA_LEN = 1; ///< Minimum payload length for 0xFE result-bearing replies.
38constexpr uint8_t EXTENDED_TILT_RESPONSE_MIN_DATA_LEN =
39 15; ///< Minimum payload length for tilt-capable extended status replies.
40constexpr uint8_t STATUS_STOPPED_FLAGS_OFFSET = 0; ///< Byte containing STATUS_STOPPED.
41constexpr uint8_t STATUS_BATTERY_OFFSET = 1; ///< Byte holding STATUS_BATTERY_MASK, the same in 0x04 and 0x71 payloads.
42constexpr uint8_t PRIVATE_RESPONSE_DELAY_HINT_OFFSET = 7; ///< Coarse follow-up delay hint byte in many 0x04 replies.
43// A 0x04 payload does not describe its own layout — which fields these offsets name depends on
44// the request that drew the reply, and only the caller knows that. A reply to a tilt EXECUTE puts
45// the tilt selector 0x20 at offset 4 and a 16-bit slat angle at 5..6, straddling the bytes a
46// reply to a status poll uses for the current position (4..5). Do not try to recover the
47// difference by sniffing the payload: 0x20 is also a perfectly legal current-position MSB (raw
48// 0x2000-0x20FF is 16.0%-16.5%), and a tilt ack is 8 bytes like any hint-carrying position reply,
49// so neither a `data[4] == STATUS_TILT_SELECTOR` test nor a length test can separate them — the
50// first would blank real readings from any cover resting near 16%. The request-derived
51// `trust_position` parameter on update_device_status_() is the discriminator, and the only
52// correct one. See tests/corpus/captures/exchange/tilt_cover_exchange_ack_tilt_block*.yaml.
53constexpr uint16_t MAIN_PARAMETER_LOW_BYTE_MASK = 0xFF; ///< Low byte of a 16-bit main parameter.
54constexpr uint8_t PRIVATE_RESPONSE_TARGET_OFFSET = 2; ///< Target-position MSB offset in 0x04 replies.
55constexpr uint8_t PRIVATE_RESPONSE_CURRENT_OFFSET = 4; ///< Current-position MSB offset in 0x04 replies.
56constexpr uint8_t STATUS_UPDATE_TARGET_OFFSET = 5; ///< Target-position MSB offset in 0x71 updates.
57constexpr uint8_t STATUS_UPDATE_CURRENT_OFFSET = 7; ///< Current-position MSB offset in 0x71 updates.
58constexpr uint8_t GET_INFO2_TYPE_OFFSET = 10; ///< Packed device type byte in 0x57 replies.
59constexpr uint8_t GET_INFO2_TYPE_SUBTYPE_OFFSET = 11; ///< Packed type low bits plus subtype byte in 0x57 replies.
60constexpr uint8_t EXTENDED_TILT_SELECTOR_OFFSET = 12; ///< Selector byte announcing extended tilt payload.
61constexpr uint8_t EXTENDED_TILT_MSB_OFFSET = 13; ///< Tilt-position MSB within extended replies.
62constexpr uint8_t EXTENDED_TILT_LSB_OFFSET = 14; ///< Tilt-position LSB within extended replies.
63constexpr uint8_t PRIVATE_RESPONSE_HINT_UNUSED = 0xFF; ///< Value used by devices that do not expose a follow-up timer.
64constexpr uint8_t PRIVATE_RESPONSE_HINT_ZERO =
65 0x00; ///< Value treated as invalid or uninformative for follow-up timing.
66constexpr uint32_t PRIVATE_RESPONSE_HINT_SCALE_MS = 1000; ///< Private-response delay hint is expressed in seconds.
67constexpr uint32_t PRIVATE_RESPONSE_HINT_BIAS_MS =
68 1000; ///< Observed devices need an extra second beyond the hint value.
69
70/// @brief Decode the shared target/current position fields used by private response and status‑update frames.
71/// Different frame types use different byte offsets, but the normalization policy is identical once offsets known.
72/// @param dev Device record to update.
73/// @param frame IoFrame containing a status‑bearing command.
74/// @param target_offset Byte offset of target MSB within frame.data.
75/// @param current_offset Byte offset of current MSB within frame.data.
76/// @param allow_tilt_from_extended_response If true and frame is extended, decode tilt from the extended tilt bytes.
77void decode_status_fields(IoDevice &dev, const IoFrame &frame, uint8_t target_offset, uint8_t current_offset,
78 bool allow_tilt_from_extended_response) {
79 uint16_t const tgt = (frame.data[target_offset] << 8) | frame.data[target_offset + 1];
80 uint16_t const cur = (frame.data[current_offset] << 8) | frame.data[current_offset + 1];
81 decode_position_report(tgt, cur, dev.is_stopped, dev.target, dev.position);
83 // A decoded position is the observation this prediction existed to stand in for.
84 dev.optimistic.clear_position();
85
86 if (allow_tilt_from_extended_response && device_supports_tilt(dev.type) &&
87 frame.data_len >= EXTENDED_TILT_RESPONSE_MIN_DATA_LEN &&
88 frame.data[EXTENDED_TILT_SELECTOR_OFFSET] == STATUS_TILT_SELECTOR) {
89 uint16_t const tilt_raw = (frame.data[EXTENDED_TILT_MSB_OFFSET] << 8) | frame.data[EXTENDED_TILT_LSB_OFFSET];
90 dev.tilt = decode_tilt_report(tilt_raw);
91 dev.optimistic.clear_tilt();
92 }
93}
94
95/// @brief Keep the wake belief's moving evidence in step with a status the device just reported.
96/// @param dev Device the status came from.
97/// @param moving True when the status says the device is travelling.
98/// @param now_ms When the status was received.
99void track_motion_evidence(IoDevice &dev, bool moving, uint32_t now_ms) {
100 if (moving) {
101 note_moving_evidence(dev, now_ms);
102 } else {
104 }
105}
106
107/// @brief Compute the delay before the next status poll for a private‑response device.
108/// @param dev Device record.
109/// @param frame The private response frame (may contain a coarse retry hint in byte 7).
110/// @param policy Policy used to look up the configured poll interval.
111/// @param id Device ID for policy lookup.
112/// @return Delay in milliseconds, or 0 if the device is stopped.
113uint32_t compute_private_response_delay_ms(const IoDevice &dev, const IoFrame &frame, const StatusPollPolicy &policy,
114 const std::string &id) {
115 if (effective_is_stopped(dev))
116 return 0;
117
118 // Private responses carry a coarse follow‑up timer in byte 7 on many devices. Decode it here
119 // (0 = absent) and let settle_delay_ms() reconcile it with the configured interval and default.
120 // Some devices omit byte 7 entirely (data_len == 6); treat those as hint-absent.
121 uint32_t hint_delay_ms = 0;
122 if (frame.data_len > PRIVATE_RESPONSE_DELAY_HINT_OFFSET &&
123 frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] != PRIVATE_RESPONSE_HINT_UNUSED &&
124 frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] != PRIVATE_RESPONSE_HINT_ZERO) {
125 hint_delay_ms = (frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] * PRIVATE_RESPONSE_HINT_SCALE_MS) +
126 PRIVATE_RESPONSE_HINT_BIAS_MS;
127 }
128 // A private response is the shared reply to both polls (0x03) and commands (0x00); it carries no
129 // marker for STOP, so the STOP cap is applied by the command path, not here.
130 return settle_delay_ms(policy.get_interval(id), hint_delay_ms, /*cap_for_stop=*/false);
131}
132
133/// @brief Compute the delay before the next status poll for a device‑originated status update.
134/// @param dev Device record.
135/// @param policy Policy used to look up the configured poll interval.
136/// @param id Device ID for policy lookup.
137/// @return Delay in milliseconds for tracked polling; 0 if stopped.
138uint32_t compute_status_update_delay_ms(const IoDevice &dev, const StatusPollPolicy &policy, const std::string &id) {
139 if (effective_is_stopped(dev))
140 return 0;
141 // Device-originated updates carry no follow-up hint and are never STOP replies.
142 return settle_delay_ms(policy.get_interval(id), /*hint_delay_ms=*/0, /*cap_for_stop=*/false);
143}
144
145/// @brief Does a status reply's flags byte say the device is at rest?
146///
147/// A heater has no motion, so a heating-level heater and an exterior heater are always at rest
148/// whatever their flags byte says (device_has_no_motion()): the movement settle loop (follow-up
149/// polls while "moving") must never run for them, and what that byte holds on a heater is
150/// unconfirmed. Every other device reports it in STATUS_STOPPED.
151/// @param dev Device the reply came from.
152/// @param flags The reply's stopped-flags byte.
153/// @return true when the device is at rest.
154bool reports_stopped(const IoDevice &dev, uint8_t flags) {
155 return device_has_no_motion(dev.type, dev.subtype) || (flags & STATUS_STOPPED) != 0;
156}
157
158/// @brief Apply a private-response frame to the device record.
159/// @param id Device ID for policy lookup.
160/// @param dev Device record to update.
161/// @param frame Private-response frame.
162/// @param policy Poll policy for scheduling follow-up polls.
163/// @param trust_position False to skip decoding target/current from `frame` — the immediate
164/// reply to our own just-sent CMD_EXECUTE has been observed (real hardware, see
165/// tests/corpus/captures/exchange/somfy_awning_exchange_ack_reports_stale_target_*.yaml) echoing
166/// pre-command target/current values rather than the freshly-commanded target. `is_stopped` is
167/// still applied either way; the optimistic target already set by the caller (or the follow-up
168/// status poll a few seconds later) remains the source of truth for target/current in that case.
169void apply_private_response_status(const std::string &id, IoDevice &dev, const IoFrame &frame, StatusPollPolicy &policy,
170 bool trust_position = true) {
171 const bool reported_stopped = reports_stopped(dev, frame.data[STATUS_STOPPED_FLAGS_OFFSET]);
172 dev.is_stopped = reported_stopped;
173 dev.last_status = millis();
174 if (trust_position) {
175 decode_status_fields(dev, frame, PRIVATE_RESPONSE_TARGET_OFFSET, PRIVATE_RESPONSE_CURRENT_OFFSET, true);
176 } else {
178 }
179 // On the execute-ack path (trust_position == false) dev.target/position are stale, so the
180 // normalized is_stopped can read "moving" for a device that just reported stopped. The evidence
181 // therefore follows the device's own flag there. It matters for the ack to a STOP: a VELUX SSL
182 // still reports "moving" while it winds down, and in that state it ignores the wake-up preamble,
183 // so the settle poll after the STOP must lead short. A move's ack is overridden anyway:
184 // run_execute_operation_() stamps the evidence once the move is accepted, after this runs.
185 track_motion_evidence(dev, trust_position ? !dev.is_stopped : !reported_stopped, dev.last_status);
186
187 if (effective_is_stopped(dev) || !policy.is_tracking_active(id, dev.last_status)) {
188 policy.clear(id);
189 return;
190 }
191
192 uint32_t const delay_ms = compute_private_response_delay_ms(dev, frame, policy, id);
193 const bool hint_present = frame.data_len > PRIVATE_RESPONSE_DELAY_HINT_OFFSET;
194 const uint8_t hint_byte =
195 hint_present ? frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] : PRIVATE_RESPONSE_HINT_UNUSED;
196 const bool has_hint =
197 hint_present && hint_byte != PRIVATE_RESPONSE_HINT_UNUSED && hint_byte != PRIVATE_RESPONSE_HINT_ZERO;
198 ESP_LOGD(
199 detail::TAG, "Device %s: next status poll in %" PRIu32 " ms (device hint=%s, configured interval=%" PRIu32 " ms)",
200 id.c_str(), delay_ms, has_hint ? std::to_string(hint_byte).append("s").c_str() : "none", policy.get_interval(id));
201 uint32_t const new_deadline = dev.last_status + delay_ms;
202 uint32_t const existing_deadline = policy.get_next_update(id);
203 // Don't push the deadline forward — only move it earlier. This prevents repeated command
204 // responses (e.g. multiple rapid STOP presses) from compounding the wait time.
205 policy.set_next_update(
206 id, (existing_deadline != 0 && existing_deadline < new_deadline) ? existing_deadline : new_deadline);
207}
208
209/// @brief Apply a device-originated status-update frame to the device record.
210/// @param id Device ID for policy lookup.
211/// @param dev Device record to update.
212/// @param frame Status-update frame.
213/// @param policy Poll policy for scheduling follow-up polls.
214void apply_unsolicited_status_update(const std::string &id, IoDevice &dev, const IoFrame &frame,
215 StatusPollPolicy &policy) {
216 dev.is_stopped = reports_stopped(dev, frame.data[STATUS_STOPPED_FLAGS_OFFSET]);
217 dev.last_status = millis();
218 decode_status_fields(dev, frame, STATUS_UPDATE_TARGET_OFFSET, STATUS_UPDATE_CURRENT_OFFSET, false);
219 track_motion_evidence(dev, !dev.is_stopped, dev.last_status);
220
221 if (effective_is_stopped(dev) || !policy.is_tracking_active(id, dev.last_status)) {
222 policy.clear(id);
223 return;
224 }
225
226 policy.set_next_update(id, dev.last_status + compute_status_update_delay_ms(dev, policy, id));
227}
228
229/// @brief Record what a heater commanded with an EXECUTE reports as its current main parameter.
230///
231/// A heating-level heater reports a level; a temperature heater reports an alias (kept in
232/// HeatingReadings::level) or a plain temperature (HeatingReadings::setpoint_c, decoded over the
233/// device's encoding range, which also clears a stale alias).
234/// @param id Device ID for the log line.
235/// @param dev Device record to update; other devices are left alone.
236/// @param frame A reply to a status poll (the layout of a reply to an EXECUTE follows the request).
237void apply_heating_main_parameter(const std::string &id, IoDevice &dev, const IoFrame &frame) {
238 const HeatingProfile *profile = find_heating_profile(dev.type, dev.subtype);
239 if (profile == nullptr || profile->path == HeatingControlPath::PRIVATE_REGISTERS)
240 return;
241 const auto current = static_cast<uint16_t>((frame.data[PRIVATE_RESPONSE_CURRENT_OFFSET] << BITS_PER_BYTE) |
242 frame.data[PRIVATE_RESPONSE_CURRENT_OFFSET + 1]);
243 dev.heating.main_parameter = current;
244 if ((current >> BITS_PER_BYTE) == profile->mp_prefix) {
245 const auto wire = static_cast<int16_t>(current & MAIN_PARAMETER_LOW_BYTE_MASK);
246 if (dev.heating.level != wire)
247 ESP_LOGI(detail::TAG, "Device %s: heating value 0x%02X", id.c_str(), static_cast<unsigned>(wire));
248 dev.heating.level = wire;
249 return;
250 }
251 float celsius = 0.0F;
252 if (profile->path == HeatingControlPath::TEMPERATURE_EXECUTE &&
253 decode_temperature_main_parameter(current, dev.temperature_range_min_c, dev.temperature_range_max_c, celsius)) {
254 dev.heating.setpoint_c = celsius;
255 dev.heating.level = -1;
256 return;
257 }
258 ESP_LOGD(detail::TAG, "Device %s: status main parameter 0x%04X is not a heating value", id.c_str(), current);
259}
260
261/// @brief Record the coarse battery level a status payload carries, logging a change.
262/// @param id Device ID for the log line.
263/// @param dev Device record to update.
264/// @param frame A status poll reply or a device-originated status update (both put it at data[1]).
265void apply_battery_state(const std::string &id, IoDevice &dev, const IoFrame &frame) {
266 const BatteryState reported = decode_battery_state(frame.data[STATUS_BATTERY_OFFSET]);
267 if (reported == dev.battery)
268 return;
269 // A battery level changes rarely, so a change is worth an INFO line; mains devices stay at
270 // NOT_REPORTED and never log.
271 ESP_LOGI(detail::TAG, "Device %s: battery %s -> %s", id.c_str(), battery_state_name(dev.battery),
272 battery_state_name(reported));
273 dev.battery = reported;
274}
275
276/// @brief Apply INFO2 metadata to the device record when YAML has not already declared it.
277/// @param dev Device record to update.
278/// @param frame INFO2 response frame.
279void apply_info2_response(IoDevice &dev, const IoFrame &frame) {
280 if (dev.type != DeviceType::UNKNOWN)
281 return;
282
283 dev.type = decode_packed_device_type(frame.data[GET_INFO2_TYPE_OFFSET], frame.data[GET_INFO2_TYPE_SUBTYPE_OFFSET]);
284 dev.subtype = decode_packed_device_subtype(frame.data[GET_INFO2_TYPE_SUBTYPE_OFFSET]);
285 if (default_inverted_for_type(dev.type))
286 dev.inverted = true;
287}
288
289/// @brief Apply a name response frame to the device record.
290/// @param dev Device record to update.
291/// @param frame Name response frame.
292void apply_name_response(IoDevice &dev, const IoFrame &frame) {
293 std::string const name = decode_device_name_payload(frame.data, frame.data_len);
294 memset(dev.name, 0, sizeof(dev.name));
295 if (!name.empty())
296 memcpy(dev.name, name.c_str(), name.length());
297}
298
299} // namespace
300
301void IOHomeControlComponent::begin_status_poll_tracking_(const std::string &device_id, uint32_t initial_delay_ms) {
302 if (this->get_device(device_id) == nullptr)
303 return;
304 this->poll_policy_.begin_tracking(device_id, initial_delay_ms, millis());
305}
306
307void IOHomeControlComponent::schedule_status_poll_(const std::string &device_id, uint32_t delay_ms) {
308 // Keyed by the device's node address (decisions::remote_poll_timer_id()), not by a per-device
309 // name string: Component::set_timeout(const char*, ...) stores the caller's pointer rather than
310 // copying it, so a name built from `device_id` here would dangle the moment this function
311 // returned. The numeric id is still per-device, so repeated remote traffic resets the pending
312 // poll instead of stacking multiple delayed callbacks for the same actuator.
313 uint8_t node_id[NODE_ID_SIZE];
314 if (!hex_to_bytes(device_id, node_id, NODE_ID_SIZE))
315 return; // malformed device_id: every caller passes one already validated by the registry
316 this->set_timeout(decisions::remote_poll_timer_id(node_id), delay_ms,
317 [this, device_id]() { this->queue_request_device_status(device_id); });
318}
319
320void IOHomeControlComponent::schedule_heating_poll_(const std::string &device_id, uint32_t delay_ms) {
321 uint8_t node_id[NODE_ID_SIZE];
322 if (!hex_to_bytes(device_id, node_id, NODE_ID_SIZE))
323 return; // malformed device_id: the caller passes one already validated by the registry
324 this->set_timeout(decisions::heating_poll_timer_id(node_id), delay_ms,
325 [this, device_id]() { this->queue_request_device_status(device_id); });
326}
327
328void IOHomeControlComponent::schedule_device_polls_(const std::vector<std::string> &device_ids, uint32_t delay_ms) {
329 for (const auto &device_id : device_ids) {
330 this->begin_status_poll_tracking_(device_id, 0);
331 this->schedule_status_poll_(device_id, delay_ms);
332 }
333}
334
335void IOHomeControlComponent::schedule_linked_remote_polls_(const std::string &remote_id, uint32_t delay_ms) {
336 const std::vector<std::string> *linked = this->registry_.linked_devices(remote_id);
337 if (linked == nullptr)
338 return;
339 this->schedule_device_polls_(*linked, delay_ms);
340}
341
343 const std::string &src_id) const {
344 std::vector<std::string> devices;
345 if (const std::vector<std::string> *id_linked = this->registry_.linked_devices(src_id)) {
346 devices = *id_linked;
347 }
349 if (const std::vector<std::string> *class_linked = this->registry_.linked_devices_for_class(info.target_type)) {
350 for (const auto &device_id : *class_linked) {
351 if (std::find(devices.begin(), devices.end(), device_id) == devices.end())
352 devices.push_back(device_id);
353 }
354 }
355 }
356 return devices;
357}
358
360 const std::vector<std::string> &device_ids) {
361 if (!info.has_intent)
362 return false;
363
364 const bool is_stop = info.main0 == POS_STOP;
365 const std::optional<float> target = is_stop ? std::nullopt : oneway_intent_to_target(info.main0, info.main1);
366
367 for (const auto &device_id : device_ids) {
368 IoDevice *dev = this->registry_.get(device_id);
369 if (dev != nullptr && info.target_type != DeviceType::UNKNOWN && dev->type != DeviceType::UNKNOWN &&
370 dev->type != info.target_type) {
371 continue; // Type mismatch: still polled by schedule_device_polls_(), just not moved optimistically.
372 }
373 // Evidence first: the overlay calls below notify entity callbacks, and `dev` is not touched after
374 // them.
375 if (is_stop) {
376 if (dev != nullptr)
378 this->registry_.apply_optimistic_stop(device_id);
379 } else if (target.has_value()) {
380 // Unlike our own commands, this movement was really started by a remote the device heard, so
381 // it is evidence of travel even where the optimistic overlay is disabled.
382 if (dev != nullptr)
383 note_moving_evidence(*dev, millis());
384 this->registry_.apply_optimistic_target(device_id, *target);
385 }
386 }
387 return is_stop;
388}
389
391 const std::string &src_id) {
392 if (!info.has_intent)
393 return;
394 // All overheard 1W traffic is DEBUG-logged regardless (see log_1w_remote_frame()); the HA event
395 // additionally requires the sender to be on the `exposed_senders` allowlist, since 1W broadcasts
396 // carry no ownership marker and this radio may overhear a neighbor's remote (or sensor) as
397 // easily as the user's own. DEBUG-log the reason it did or didn't fire so a live log capture is
398 // enough to diagnose a misconfigured allowlist vs. a disconnected API.
399 if (!this->is_connected()) {
400 ESP_LOGD(detail::TAG, "1W sender %s has intent but the API is not connected, skipping %s", src_id.c_str(),
402 return;
403 }
404 if (!detail::is_exposed_sender(this->exposed_senders_, src_id)) {
405 ESP_LOGD(detail::TAG, "1W sender %s has intent but is not in exposed_senders, skipping %s", src_id.c_str(),
407 return;
408 }
409 ESP_LOGD(detail::TAG, "Firing %s for sender %s", detail::ONEWAY_SENDER_EVENT, src_id.c_str());
410 this->fire_homeassistant_event(detail::ONEWAY_SENDER_EVENT, detail::build_sender_event_data(info, linked));
411}
412
413void IOHomeControlComponent::update_device_status_(const IoFrame &frame, bool trust_position) {
414 const std::string id = node_id_to_string(frame.src);
415 IoDevice *device_ptr = this->registry_.get(id);
416 if (device_ptr == nullptr) {
417 detail::log_frame_issue(this, "rx", "unregistered_device", frame, frame_length(frame));
418 return;
419 }
420 IoDevice &dev = *device_ptr;
422
423 if (frame.cmd == CMD_PRIVATE_RESP) {
424 if (frame.data_len < PRIVATE_RESPONSE_MIN_DATA_LEN) {
425 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
426 return;
427 }
428
429 // CMD_PRIVATE_RESP (0x04) serves as the reply to both status polls (0x03) and execute
430 // commands (0x00). The position fields are shared across both response types, but the
431 // immediate reply to our own execute command is not necessarily trustworthy for them (see
432 // apply_private_response_status()'s trust_position parameter).
433 // Decoding a position clears the optimistic prediction, so capture it first: a clamped
434 // command is the disagreement between that prediction and what the device then reports.
435 const float predicted_target = dev.optimistic.target;
436 apply_private_response_status(id, dev, frame, this->poll_policy_, trust_position);
437 // The device names, in its own status payload, the controller that last commanded it. Skipped
438 // on an execute ack (trust_position == false): that reply's payload layout is request-derived
439 // rather than self-describing (see the offset comment at the top of this file), and our own
440 // ack is not a report of the *last* command anyway — the settle poll a few seconds later is.
441 if (trust_position) {
444 detail::update_rain_limitation(id, dev, predicted_target, record.valid, millis());
445 // Same reason as the record above: only a reply to a status poll is a status report.
446 apply_battery_state(id, dev, frame);
447 apply_heating_main_parameter(id, dev, frame);
448 }
451 this->notify_device_update_(id);
452 return;
453 }
454
455 if (frame.cmd == CMD_STATUS_UPDATE) {
456 if (frame.data_len < STATUS_UPDATE_MIN_DATA_LEN) {
457 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
458 return;
459 }
460
461 // Status-update frames come from the device itself rather than from a direct controller poll.
462 // They use different offsets for the target/current fields and do not carry reliable tilt data.
463 const float predicted_target = dev.optimistic.target;
464 apply_unsolicited_status_update(id, dev, frame, this->poll_policy_);
467 detail::update_rain_limitation(id, dev, predicted_target, record.valid, millis());
468 apply_battery_state(id, dev, frame);
470
471 // What caused the device to move (wind sensor, timer, a remote). Empty when the payload is
472 // too short to carry the byte — a 11-14 byte 0x71 is still applied for its position fields,
473 // it just has no originator to report. This reads the same data[14] byte the last-command
474 // record above just decoded, but through a separate, older, unguarded accessor kept for its
475 // own pinned test (StatusUpdateOriginatorIsAtOffset14AndDecodePathUndisturbed) — it can render
476 // a byte here that the "Last Command Source" sensor leaves empty, on the one payload shape
477 // that differs between them: an all-zero commander (which apply_last_command_record() above
478 // treats as "no record", see decode_last_command_record()'s doc comment) paired with a
479 // populated originator byte. No capture has shown that combination in practice.
480 const std::string originator = detail::describe_status_update_originator(frame);
481 if (!originator.empty())
482 ESP_LOGD(detail::TAG, "Device %s: status update originator=%s", id.c_str(), originator.c_str());
483
484 detail::log_status_update(id, dev, " (status update)");
485 this->notify_device_update_(id);
486 return;
487 }
488
489 if (frame.cmd == CMD_GET_NAME_RESP) {
490 if (frame.data_len < GET_NAME_RESPONSE_MIN_DATA_LEN) {
491 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
492 return;
493 }
494
495 apply_name_response(dev, frame);
496 ESP_LOGI(detail::TAG, "Device %s: name=%s", id.c_str(), dev.name[0] == '\0' ? "" : dev.name);
497 this->notify_device_update_(id);
498 return;
499 }
500
501 if (frame.cmd == CMD_GET_INFO2_RESP) {
502 if (frame.data_len < GET_INFO2_RESPONSE_MIN_DATA_LEN) {
503 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
504 return;
505 }
506
507 // INFO2 is metadata, not movement state. Only learn type from radio if still UNKNOWN;
508 // YAML-declared type takes priority.
509 const bool type_was_unknown = dev.type == DeviceType::UNKNOWN;
510 apply_info2_response(dev, frame);
511 ESP_LOGI(detail::TAG, "Device %s: type=%s (%u) class=%s profile=%s subtype=%u", id.c_str(),
512 device_type_name(dev.type), static_cast<unsigned>(dev.type), device_capability_class_name(dev.type),
514 if (type_was_unknown && dev.type != DeviceType::UNKNOWN) {
515 ESP_LOGI(detail::TAG,
516 "Device %s: type learned at runtime, not declared in YAML — add `%s` to skip "
517 "re-learning it on every future boot",
518 id.c_str(), detail::describe_learned_device_type(dev.type).c_str());
519 }
520 return;
521 }
522
523 if (frame.cmd == CMD_ERROR_RESP) {
524 if (frame.data_len < ERROR_RESPONSE_MIN_DATA_LEN) {
525 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
526 return;
527 }
528
529 detail::record_command_result(dev, id, frame.data[0]);
530 this->notify_device_update_(id);
531 return;
532 }
533}
534
536 // A gap of a full quiet period or more since the last frame means the previous burst already
537 // released any deferred poll, so this frame starts a new burst window rather than extending the
538 // old one (which would make ONEWAY_POLL_DEFER_CAP_MS fire on the very next frame).
539 if (decisions::oneway_burst_started_fresh(this->last_1w_activity_ms_, now, ONEWAY_QUIET_PERIOD_MS))
540 this->first_1w_activity_ms_ = now;
541 this->last_1w_activity_ms_ = now;
542}
543
545 if (!decisions::is_one_way_pairing_gesture((frame.ctrl0 & CTRL0_PROTOCOL_1W) != 0, frame.dst, frame.cmd))
546 return;
547 memcpy(this->recent_oneway_pairing_sighting_.src, frame.src, NODE_ID_SIZE);
548 memcpy(this->recent_oneway_pairing_sighting_.dst, frame.dst, NODE_ID_SIZE);
552}
553
554/// @brief Log an overheard limitation request/reply decoded at DEBUG; any other frame is ignored.
555static void log_overheard_limitation_read(const IoFrame &frame) {
556 if (frame.cmd == CMD_LIMITATION_STATUS_REQ) {
557 ESP_LOGD(detail::TAG, "rx %s", detail::describe_limitation_request(frame).c_str());
558 } else if (frame.cmd == CMD_LIMITATION_STATUS_RESP) {
559 ESP_LOGD(detail::TAG, "rx %s", detail::describe_limitation_reply(frame).c_str());
560 }
561}
562
564 IoFrame frame;
565 if (!parse(packet.data, packet.len, frame)) {
566 detail::log_component_capture(this->radio_, "parse_fail", packet.data, packet.len);
567 return;
568 }
569
570 detail::log_component_capture(this->radio_, "parse_ok", packet.data, packet.len, &frame);
571
572 // === Key-extraction responder ("Accept Foreign Pairing") ===
573 // Runs before the exchange-internal drop below: a hub-issued 0x3C challenging our own 0x37 is
574 // addressed to us and is ours to answer (key_extraction_responder.cpp). Self-gated on armed + our
575 // throwaway ID, so the disarmed path (and every command other than 0x28/0x2C/0x31/0x32/0x36/0x3C)
576 // is bit-for-bit unchanged by this ordering — try_handle_frame() returns false immediately
577 // whenever it doesn't apply, and this reorder can only affect frames where BOTH this call and
578 // the is_exchange_internal_command() check below would otherwise fire, i.e. only 0x3C/0x3D (that
579 // predicate's entire domain, hub_decisions.h).
580 if (this->key_extraction_.try_handle_frame(frame))
581 return;
582
583 // Exchange-internal frames (0x3C challenge request, 0x3D challenge response) belonging to
584 // *another* controller's authenticated exchange carry no extractable status data for a passive
585 // observer — skip silently. They remain visible in io_capture (stage=parse_ok).
587 return;
588 }
589
590 // === 1W remote frame decode ===
591 // 1W remotes broadcast commands to a typed device-class address (e.g., "all awnings").
592 // Decode the frame content for diagnostic logging, then fall through to linked_remotes
593 // handling which may schedule a status poll for devices this remote controls.
594 if ((frame.ctrl0 & CTRL0_PROTOCOL_1W) != 0) {
595 const std::string src_id = node_id_to_string(frame.src);
596 const uint32_t now = millis();
597
598 // Any 1W frame means a remote is transmitting right now, duplicate or not — record it before
599 // the dedup check so loop() keeps background polls off the radio for the rest of the burst.
600 this->record_1w_activity_(now);
601 // Remember a pairing-gesture sighting the same way, before dedup, so a repeated gesture frame
602 // still refreshes the timestamp (issue #27/#65) — see record_oneway_pairing_gesture_()'s doc
603 // comment for why the discovery telemetry window alone isn't enough to catch this.
604 this->record_oneway_pairing_gesture_(frame, now);
605
606 // Decode once and reuse for the dedup key, logging, and (when it carries a command intent) the
607 // sender HA event, so a physical remote press (or sensor trigger) can drive automations directly.
608 // The decode must happen *before* the dedup check: a move and a stop share the CMD_EXECUTE
609 // command byte and are told apart only by the decoded intent, which is part of the key.
610 const OneWayFrameInfo info = decode_1w_frame(frame);
611
612 // Opt-in, receive-only 1W key adoption (oneway_key_adoption.cpp). Both calls sit after
613 // the parse and before the dedup check so an add-controller broadcast is seen even when its
614 // repeats would collapse into one logical press. Both self-gate on armed, so the disarmed
615 // path is unchanged; they observe the frame rather than consuming it, and execution always
616 // continues into the normal logging path below. Order matters only in that the class
617 // observation must be recorded before an adoption can consume it.
619 this->oneway_key_adoption_.try_adopt(frame);
620
621 // 1W remotes repeat each command 4× at 40ms intervals across channels, and a held button keeps
622 // resending. Collapse that into one logical press per remote+command+intent (plus destination for
623 // intent-less frames, so each class of a multi-class sweep is kept).
624 decisions::OneWayDedupState incoming{src_id, frame.cmd, info.has_intent, info.main0, info.main1, now};
625 memcpy(incoming.dst, frame.dst, NODE_ID_SIZE);
627 return;
628 this->last_1w_logged_ = incoming;
629
630 const std::vector<std::string> *linked = this->registry_.linked_devices(src_id);
631 detail::log_1w_remote_frame(info, linked);
632 this->maybe_fire_sender_event_(info, linked != nullptr && !linked->empty(), src_id);
633 // Id-linked devices plus, for a typed broadcast, class-linked devices — deduplicated so a
634 // device linked both ways is only touched once per press.
635 const std::vector<std::string> target_devices = this->resolve_1w_target_devices_(info, src_id);
636 const bool is_stop = this->apply_optimistic_linked_state_(info, target_devices);
637 this->schedule_device_polls_(target_devices, is_stop ? 0 : REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS);
638 return;
639 }
640
641 // Limitation reads between other parties (a KLF 200 polling a window) are only rendered, so a
642 // passive monitor's log shows what they asked and answered. Nothing is applied -- the sender's
643 // key is unproven (ADR 0022) -- and the frame continues into the normal handling below.
645
646 if (frame.cmd == CMD_STATUS_UPDATE && memcmp(frame.dst, this->node_id_, NODE_ID_SIZE) == 0) {
647 if (this->authenticate_request_(frame, packet.freq_hz)) {
648 IoFrame resp;
649 if (!create_status_update_resp(resp, this->node_id_, frame.src)) {
650 detail::log_frame_issue(this, "rx", "ack_build_failed", frame, packet.len);
651 return;
652 }
653 // Device-originated updates may arrive while the sender and receiver are not aligned on the
654 // same hop channel anymore. Broadcasting the ACK across all three IO-homecontrol channels
655 // matched the behavior of real controllers and made updates reliable in practice.
656 const uint16_t ack_preamble = this->radio_->response_preamble();
657 this->transmit_frame_(resp, FREQ_CH1, ack_preamble);
658 this->transmit_frame_(resp, FREQ_CH2, ack_preamble);
659 this->transmit_frame_(resp, FREQ_CH3, ack_preamble);
660 this->update_device_status_(frame);
661 } else {
662 detail::log_frame_issue(this, "rx", "auth_failed", frame, packet.len);
663 }
664 return;
665 }
666
667 if (frame.cmd == CMD_PRIVATE_RESP || frame.cmd == CMD_STATUS_UPDATE) {
668 // Passive receive mode can still observe replies/status from other exchanges (another
669 // controller sharing a device, or an attacker who knows the device's node ID -- node IDs
670 // travel in the clear, see README.md's "Reporting Unsupported Devices"). Nothing here proves
671 // the frame's source currently holds the system key, so its content is never applied -- see
672 // ADR 0022. State goes stale until this hub's own next authenticated poll corrects it.
673 detail::log_frame_issue(this, "rx", "unauthenticated_status_ignored", frame, packet.len);
674 return;
675 }
676
677 // Check if this frame targets one of our registered devices (e.g., a physical remote
678 // commanding a shutter we also control). If so, schedule a status poll after 2 seconds
679 // to pick up the resulting position change. The timeout name includes the device ID so
680 // repeated remote activity resets the timer rather than stacking redundant polls.
681 // The 2-second delay gives the device time to complete the exchange and start moving.
682 const std::string dst_id = node_id_to_string(frame.dst);
683 if (this->get_device(dst_id) != nullptr && memcmp(frame.src, this->node_id_, NODE_ID_SIZE) != 0) {
684 ESP_LOGD(detail::TAG, "rx remote_activity src=%s dst=%s cmd=%s(0x%02X), scheduling status poll",
685 node_id_to_string(frame.src).c_str(), dst_id.c_str(), command_name(frame.cmd), frame.cmd);
686 this->begin_status_poll_tracking_(dst_id, 0);
687 this->schedule_status_poll_(dst_id, REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS);
688 return;
689 }
690
691 // Check if the frame source is a linked remote using 2W protocol (e.g., a 2W controller
692 // whose commands target a device at an address we don't have registered). 1W remotes are
693 // already handled above via the CTRL0_PROTOCOL_1W check.
694 const std::string src_id = node_id_to_string(frame.src);
695 if (this->registry_.linked_devices(src_id) != nullptr) {
696 ESP_LOGD(detail::TAG, "rx remote_activity (linked) remote=%s cmd=%s(0x%02X), scheduling status poll",
697 src_id.c_str(), command_name(frame.cmd), frame.cmd);
698 this->schedule_linked_remote_polls_(src_id);
699 return;
700 }
701
702 detail::log_frame_issue(this, "rx", "unhandled_cmd", frame, packet.len);
703
704 // If the command is not in our known set AND the frame was addressed to our hub, it may be a
705 // protocol extension we should support — ask the user to report it. Frames merely overheard
706 // between other devices (not addressed to us) are still logged above at debug level, but do
707 // not warrant a warning: we are not a party to that exchange, so there is nothing to add.
708 const bool addressed_to_us = memcmp(frame.dst, this->node_id_, NODE_ID_SIZE) == 0;
709 if (addressed_to_us && std::strcmp(command_name(frame.cmd), "UNKNOWN_CMD") == 0) {
710 const std::string src_id = node_id_to_string(frame.src);
711 ESP_LOGW(detail::TAG,
712 "Received unknown command 0x%02X from %s. "
713 "If you see this repeatedly, please file a GitHub issue with this command ID, "
714 "your device model, and the log context so protocol support can be extended.",
715 frame.cmd, src_id.c_str());
716 }
717}
718
719} // namespace home_io_control
720} // namespace esphome
const std::vector< std::string > * linked_devices(const std::string &remote_id) const
Retrieve the list of device IDs linked to a remote.
const std::vector< std::string > * linked_devices_for_class(DeviceType type) const
Retrieve the list of device IDs linked to a device class.
bool apply_optimistic_target(const std::string &device_id, float target_io_position)
Set an optimistic target position ahead of a confirming poll/response, and notify.
bool apply_optimistic_stop(const std::string &device_id, bool restorable=false)
Predict that a device has stopped (e.g.
IoDevice * get(const std::string &device_id)
Retrieve a registered device by ID.
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:1314
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:350
virtual void queue_request_device_status(const std::string &device_id)
Queue an async status request; returns immediately, executed in loop().
void record_oneway_pairing_gesture_(const IoFrame &frame, uint32_t now)
If frame matches a 1W remote's pairing gesture (decisions::is_one_way_pairing_gesture()),...
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...
RecentOneWayPairingSighting recent_oneway_pairing_sighting_
Most recent 1W pairing-gesture frame seen on the hub's normal passive RX path (e.g.
Definition hub_core.h:1277
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 went out on the radio — ours or someone else's — updating last_1w_activit...
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:298
void schedule_heating_poll_(const std::string &device_id, uint32_t delay_ms)
Arm the heater's regular-cadence poll timer.
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.
OnewayKeyAdoption oneway_key_adoption_
Opt-in, receive-only 1W controller-key adoption listener (oneway_key_adoption.cpp).
Definition hub_core.h:1290
void notify_device_update_(const std::string &id)
Fire all registered device update callbacks for the given device ID.
Definition hub_core.cpp:316
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:1310
KeyExtractionResponder key_extraction_
Device-role responder for the "Recover System Key" feature (key_extraction_responder....
Definition hub_core.h:1296
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:1259
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:312
uint32_t first_1w_activity_ms_
millis() of the first 1W frame in the current burst.
Definition hub_core.h:1319
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...
bool try_handle_frame(const IoFrame &frame)
Dispatch a frame to the responder if it's one of its 0x28/0x2C/0x31/0x32/0x36/0x3C frames and the res...
void try_adopt(const IoFrame &frame)
Decode an inbound CMD_ONEWAY_ADD_CONTROLLER (0x30) while armed, report the result,...
void record_observed_class(const OneWayFrameInfo &info)
Remember the most recent 1W target device class observed from info.src, for the adoption report's io_...
virtual uint16_t response_preamble() const
Return the preamble length for response/continuation frames.
const RadioCaptureInfo & get_last_capture() const
Get the most recent radio capture info.
Per-hub poll scheduling and failure-backoff policy.
void begin_tracking(const std::string &device_id, uint32_t initial_delay_ms, uint32_t now)
Begin bounded follow-up polling after a command or remote activity.
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_one_way_pairing_gesture(bool oneway, const uint8_t dst[NODE_ID_SIZE], uint8_t cmd)
True if a frame's shape matches a 1W remote's pairing gesture (issue #27/#65): CTRL0 1W bit set,...
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...
uint32_t heating_poll_timer_id(const uint8_t node_id[NODE_ID_SIZE])
Numeric set_timeout() id for a heater's regular-cadence poll timer (IOHomeControlComponent::schedule_...
uint32_t remote_poll_timer_id(const uint8_t node_id[NODE_ID_SIZE])
Numeric set_timeout() id for a device's remote-activity poll timer (hub_status.cpp's schedule_status_...
std::string describe_limitation_reply(const IoFrame &frame)
Render a CMD_LIMITATION_STATUS_RESP frame for a log line or probe report.
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_learned_device_type(DeviceType type)
Build the YAML line to add once a device's type is learned at runtime.
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 log_component_capture(const RadioDriver *radio, const char *stage, const uint8_t *buf, uint8_t len, const IoFrame *frame=nullptr)
Log a frame at the "io_capture" tag with structured fields.
Definition log_helpers.h:97
std::string 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.
std::string describe_limitation_request(const IoFrame &frame)
Render a CMD_LIMITATION_STATUS_REQ frame for a log line.
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.
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.
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.
const HeatingProfile * find_heating_profile(DeviceType type, uint8_t subtype)
Look up the heating profile for a (type, subtype).
bool default_inverted_for_type(DeviceType type)
Determine whether a device type has inverted position mapping by default.
bool decode_temperature_main_parameter(uint16_t main_parameter, float min_c, float max_c, float &celsius)
Decode a main parameter as a temperature over an encoding range (the inverse of encode_temperature_ma...
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.
constexpr uint8_t STATUS_UPDATE_LAST_COMMAND_OFFSET
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.
@ UNKNOWN
Unknown/unspecified device.
@ TEMPERATURE_EXECUTE
Standard EXECUTE with a temperature or an alias as the main parameter; read with 0x03.
@ PRIVATE_REGISTERS
CMD_WRITE_PRIVATE register reads and writes (proto_heating.h).
LastCommandRecord decode_last_command_record(const IoFrame &frame, uint8_t base)
Decode the last-command record at base from a status-bearing payload.
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.
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 the 10-bit node type from two packed metadata bytes.
BatteryState
A device's coarse battery level, as it reports it in byte 1 of its status payload.
bool parse(const uint8_t *buf, uint8_t buf_len, IoFrame &f)
Parse a wire buffer into a parsed IoFrame (validates length and CTRL0).
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.
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.
static void log_overheard_limitation_read(const IoFrame &frame)
Log an overheard limitation request/reply decoded at DEBUG; any other frame is ignored.
void note_moving_evidence(IoDevice &dev, uint32_t now_ms)
Record that a device is (believed to be) travelling right now.
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.
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?
BatteryState decode_battery_state(uint8_t status_byte)
Decode the coarse battery level from byte 1 of a status payload (CMD_PRIVATE_RESP to a status poll,...
const char * battery_state_name(BatteryState state)
Get the name a battery level is published under.
constexpr uint8_t PRIVATE_RESPONSE_LAST_COMMAND_OFFSET
Offset of the last-command record within each status-bearing payload.
void clear_moving_evidence(IoDevice &dev)
Forget that a device was travelling: it reported itself stopped (including in the ack to a STOP),...
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).
bool hex_to_bytes(const std::string &hex, uint8_t *out, uint8_t len)
Convert a hex string (e.g., "123ABC") to a byte array.
Command builders for the IO‑Homecontrol protocol.
Heating profiles: what a climate card offers for each kind of heater, and how.
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.
OptimisticState optimistic
Hub-side predictions; see OptimisticState. Never observation.
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:93
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:99
uint8_t ctrl0
Control byte 0: flags + length.
Definition proto_frame.h:94
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.
bool valid
False when the payload was too short, or the record was unpopulated.
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.
float target
Predicted main-position target, or UNKNOWN_POSITION.
int16_t rssi_dbm
Received signal strength (dBm).
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.
uint8_t dst[NODE_ID_SIZE]
Destination node ID (the 1W broadcast address, in practice).
uint32_t seen_ms
millis() the frame was seen; 0 if none seen since boot.
int16_t rssi
RSSI in dBm at the time it was overheard.
Key fields of the last processed 1W frame, used to collapse a remote's repeat burst.
uint8_t dst[NODE_ID_SIZE]
Destination address; compared only when has_intent is false.