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#include "proto_dhw.h"
7
8#include <algorithm>
9#include <cinttypes>
10#include <optional>
11
12/// @file hub_status.cpp
13/// @brief Inbound status handling and passive receive-side state updates.
14/// @ingroup hioc_hub
15///
16/// This file owns the receive-side state path for the hub:
17/// - decode status-bearing frames into normalized device state,
18/// - decide when passive traffic should arm one-shot or tracked follow-up polls,
19/// - ACK authenticated device-initiated status updates.
20///
21/// @todo Validate the unsolicited CMD_STATUS_UPDATE path on hardware that actually emits
22/// device-initiated updates after pairing, including inbound authentication,
23/// three-channel ACK broadcast, and Home Assistant state publication without polling.
24///
25/// The goal of the split is to keep hub_core.cpp focused on lifecycle,
26/// device registry, and scheduling while leaving the protocol-specific receive
27/// interpretation in one place.
28
29namespace esphome {
30namespace home_io_control {
31
32namespace {
33
34/// Minimum payload length for 0x04 position-bearing replies: bytes 0-5 (stopped flag, target,
35/// current) are mandatory; bytes 6-7 (remaining time) are optional and checked separately.
36constexpr uint8_t PRIVATE_RESPONSE_MIN_DATA_LEN = 6;
37constexpr uint8_t STATUS_UPDATE_MIN_DATA_LEN = 11; ///< Minimum payload length for a 0x71 position update.
38constexpr uint8_t GET_NAME_RESPONSE_MIN_DATA_LEN = 1; ///< Minimum payload length for 0x51 name-bearing replies.
39constexpr uint8_t GET_INFO2_RESPONSE_MIN_DATA_LEN = 12; ///< Minimum payload length for 0x57 type/subtype metadata.
40constexpr uint8_t ERROR_RESPONSE_MIN_DATA_LEN = 1; ///< Minimum payload length for 0xFE result-bearing replies.
41constexpr uint8_t EXTENDED_TILT_RESPONSE_MIN_DATA_LEN =
42 15; ///< Minimum payload length for tilt-capable extended status replies.
43constexpr uint8_t STATUS_STOPPED_FLAGS_OFFSET = 0; ///< Byte holding the execution state (and STATUS_STOPPED).
44constexpr uint8_t STATUS_BATTERY_OFFSET = 1; ///< Byte holding STATUS_BATTERY_MASK, the same in 0x04 and 0x71 payloads.
45constexpr uint8_t PRIVATE_RESPONSE_REMAINING_TIME_OFFSET =
46 6; ///< MSB of the 16-bit remaining travel time (seconds) in a status reply (0x04 to a 0x03 poll).
47// A 0x04 payload does not describe its own layout — which fields these offsets name depends on
48// the request that drew the reply, and only the caller knows that. A reply to a tilt EXECUTE puts
49// the tilt selector 0x20 at offset 4 and a 16-bit slat angle at 5..6, straddling the bytes a
50// reply to a status poll uses for the current position (4..5). Do not try to recover the
51// difference by sniffing the payload: 0x20 is also a perfectly legal current-position MSB (raw
52// 0x2000-0x20FF is 16.0%-16.5%), and a tilt ack is 8 bytes like a status reply carrying the remaining time,
53// so neither a `data[4] == STATUS_TILT_SELECTOR` test nor a length test can separate them — the
54// first would blank real readings from any cover resting near 16%. The request-derived
55// `trust_position` parameter on update_device_status_() is the discriminator, and the only
56// correct one. See tests/corpus/captures/exchange/tilt_cover_exchange_ack_tilt_block*.yaml.
57constexpr uint16_t MAIN_PARAMETER_LOW_BYTE_MASK = 0xFF; ///< Low byte of a 16-bit main parameter.
58constexpr uint8_t PRIVATE_RESPONSE_TARGET_OFFSET = 2; ///< Target-position MSB offset in 0x04 replies.
59constexpr uint8_t PRIVATE_RESPONSE_CURRENT_OFFSET = 4; ///< Current-position MSB offset in 0x04 replies.
60constexpr uint8_t STATUS_UPDATE_TARGET_OFFSET = 5; ///< Target-position MSB offset in 0x71 updates.
61constexpr uint8_t STATUS_UPDATE_CURRENT_OFFSET = 7; ///< Current-position MSB offset in 0x71 updates.
62// The 0x71 position fields sit inside the EVENT_STATUS_CHANGE event data (decode_status_update_event()).
63static_assert(STATUS_UPDATE_TARGET_OFFSET == STATUS_UPDATE_EVENT_DATA_OFFSET,
64 "the target position is the first byte of the status-change event data");
66 "the last-command record is part of the status-change event data");
67constexpr uint8_t GET_INFO2_TYPE_OFFSET = 10; ///< Packed device type byte in 0x57 replies.
68constexpr uint8_t GET_INFO2_TYPE_SUBTYPE_OFFSET = 11; ///< Packed type low bits plus subtype byte in 0x57 replies.
69constexpr uint8_t EXTENDED_TILT_SELECTOR_OFFSET = 12; ///< Selector byte announcing extended tilt payload.
70constexpr uint8_t EXTENDED_TILT_MSB_OFFSET = 13; ///< Tilt-position MSB within extended replies.
71constexpr uint8_t EXTENDED_TILT_LSB_OFFSET = 14; ///< Tilt-position LSB within extended replies.
72constexpr uint8_t PRIVATE_RESPONSE_REMAINING_TIME_MIN_DATA_LEN =
73 8; ///< A status reply shorter than this (a 6-byte VELUX reply) carries no remaining time.
74constexpr uint16_t PRIVATE_RESPONSE_REMAINING_TIME_NONE = 0xFFFF; ///< All bits set: no remaining time provided.
75constexpr uint32_t PRIVATE_RESPONSE_REMAINING_TIME_SCALE_MS = 1000; ///< The remaining time counts in seconds.
76constexpr uint32_t PRIVATE_RESPONSE_REMAINING_TIME_BIAS_MS =
77 1000; ///< Observed covers come to rest up to about a second after the count runs out (issue #178).
78
79/// @brief Turn a decoded report's STOPPED flag into the hub's movement state.
80///
81/// Runs on every trusted report (a reply to a status poll, a device-originated status update).
82/// `dev.is_stopped` holds the report's own flag on entry and the verdict on return; see
83/// decisions::classify_stopped_report() for the rule. A cover that comes to rest off its target
84/// reads moving for one more poll, then stopped, instead of moving for good.
85/// @param id Device ID, for the log line.
86/// @param dev Device record, with this report's target and position already decoded.
87/// @param follow_up_possible A tracked follow-up poll will run.
88/// @return The verdict, so the caller can mark the confirming poll.
89decisions::StoppedReport settle_stopped_report(const std::string &id, IoDevice &dev, bool follow_up_possible) {
91 in.reported_stopped = dev.is_stopped;
92 in.target = dev.target;
93 in.position = dev.position;
94 in.candidate = dev.stop_candidate;
95 in.follow_up_possible = follow_up_possible;
96 in.has_motion = !device_has_no_motion(dev.type, dev.subtype);
99 ESP_LOGD(detail::TAG, "Device %s: reports stopped at %.2f%%, target %.2f%%: confirming with one more poll",
100 id.c_str(), static_cast<double>(dev.position), static_cast<double>(dev.target));
101 } else if (verdict == decisions::StoppedReport::STOPPED_CONFIRMED && follow_up_possible) {
102 // Only inside a tracked poll chain, where it ends the settle: an untracked poll of a device
103 // resting at the same place confirms on every report and would log each time.
104 ESP_LOGD(detail::TAG, "Device %s: stopped at %.2f%% again, target %.2f%%: at rest off target", id.c_str(),
105 static_cast<double>(dev.position), static_cast<double>(dev.target));
107 ESP_LOGD(detail::TAG,
108 "Device %s: reports stopped at %.2f%%, target %.2f%%, no poll to confirm by: taken as at rest", id.c_str(),
109 static_cast<double>(dev.position), static_cast<double>(dev.target));
110 }
112 dev.stop_candidate.target = dev.target;
113 dev.stop_candidate.position = dev.position;
114 } else {
115 dev.stop_candidate.clear();
116 }
117 dev.is_stopped = decisions::stopped_report_at_rest(verdict);
118 return verdict;
119}
120
121/// @brief Decode the shared target/current position fields used by private response and status‑update frames.
122/// Different frame types use different byte offsets, but the normalization policy is identical once offsets known.
123/// @param dev Device record to update.
124/// @param frame IoFrame containing a status‑bearing command.
125/// @param target_offset Byte offset of target MSB within frame.data.
126/// @param current_offset Byte offset of current MSB within frame.data.
127/// @param allow_tilt_from_extended_response If true and frame is extended, decode tilt from the extended tilt bytes.
128/// @param id Device ID, for the settle log line.
129/// @param policy Poll policy: a tracked device has a follow-up poll (see settle_stopped_report()), and
130/// the poll confirming a stop off target is marked on it as a settling read.
131void decode_status_fields(IoDevice &dev, const IoFrame &frame, uint8_t target_offset, uint8_t current_offset,
132 bool allow_tilt_from_extended_response, const std::string &id, StatusPollPolicy &policy) {
133 uint16_t const tgt = (frame.data[target_offset] << 8) | frame.data[target_offset + 1];
134 uint16_t const cur = (frame.data[current_offset] << 8) | frame.data[current_offset + 1];
135 decode_position_report(tgt, cur, dev.is_stopped, dev.target, dev.position);
136 // After a first stop off target the next poll decides whether the cover really rests there: worth
137 // several attempts. Any other verdict drops a mark an earlier report left, so a cover that moves
138 // again before that poll is looked at with a progress read.
139 policy.set_confirm_stopped(id, settle_stopped_report(id, dev, policy.is_tracking_active(id, dev.last_status)) ==
141 // A decoded position is the observation this prediction existed to stand in for.
142 dev.optimistic.clear_position();
143
144 if (allow_tilt_from_extended_response && has_feature(dev.features, device_feature::TILT) &&
145 frame.data_len >= EXTENDED_TILT_RESPONSE_MIN_DATA_LEN &&
146 frame.data[EXTENDED_TILT_SELECTOR_OFFSET] == STATUS_TILT_SELECTOR) {
147 uint16_t const tilt_raw = (frame.data[EXTENDED_TILT_MSB_OFFSET] << 8) | frame.data[EXTENDED_TILT_LSB_OFFSET];
148 dev.tilt = decode_tilt_report(tilt_raw);
149 dev.optimistic.clear_tilt();
150 }
151}
152
153/// @brief Keep the wake belief's moving evidence in step with a status the device just reported.
154/// @param dev Device the status came from.
155/// @param moving True when the status says the device is travelling.
156/// @param now_ms When the status was received.
157void track_motion_evidence(IoDevice &dev, bool moving, uint32_t now_ms) {
158 if (moving) {
159 note_moving_evidence(dev, now_ms);
160 } else {
162 }
163}
164
165/// @brief Decode the remaining travel time of a status reply.
166///
167/// A 0x04 answering a status poll carries the device's remaining travel time in seconds at bytes
168/// 6-7 (big-endian), after status, functional-parameter indicator, target and current position:
169/// the order of the "main info" status answer in the VELUX KLF 200 API.
170/// The 0x04 answering a command (EXECUTE) has a request-dependent layout: there those bytes hold
171/// something else (`06 00` from a Somfy Izymo, a tilt block's `8D 00`), so they are read only when
172/// the reply's position is trusted, i.e. on a status-poll reply.
173/// @param frame The private response frame.
174/// @param trust_position True for a status-poll reply (see apply_private_response_status()).
175/// @return The remaining time in seconds, or nullopt when the reply carries none.
176std::optional<uint16_t> decode_remaining_time_s(const IoFrame &frame, bool trust_position) {
177 if (!trust_position || frame.data_len < PRIVATE_RESPONSE_REMAINING_TIME_MIN_DATA_LEN)
178 return std::nullopt;
179 const uint16_t remaining = (frame.data[PRIVATE_RESPONSE_REMAINING_TIME_OFFSET] << 8) |
180 frame.data[PRIVATE_RESPONSE_REMAINING_TIME_OFFSET + 1];
181 if (remaining == PRIVATE_RESPONSE_REMAINING_TIME_NONE)
182 return std::nullopt;
183 return remaining;
184}
185
186/// @brief Compute the delay before the next status poll for a private‑response device.
187/// @param dev Device record.
188/// @param remaining_s Usable remaining travel time in seconds (nullopt when there is none).
189/// @param policy Policy used to look up the configured poll interval.
190/// @param id Device ID for policy lookup.
191/// @return Delay in milliseconds, or 0 if the device is stopped.
192uint32_t compute_private_response_delay_ms(const IoDevice &dev, std::optional<uint16_t> remaining_s,
193 const StatusPollPolicy &policy, const std::string &id) {
194 if (effective_is_stopped(dev))
195 return 0;
196 // The remaining time plus a second is when the device should be at rest; settle_delay_ms()
197 // reconciles it with the configured interval and the default (it can only shorten them).
198 const uint32_t remaining_delay_ms =
199 remaining_s.has_value()
200 ? (*remaining_s * PRIVATE_RESPONSE_REMAINING_TIME_SCALE_MS) + PRIVATE_RESPONSE_REMAINING_TIME_BIAS_MS
201 : 0;
202 // A private response is the shared reply to both polls (0x03) and commands (0x00); it carries no
203 // marker for STOP, so the STOP cap is applied by the command path, not here.
204 return settle_delay_ms(policy.get_interval(id), remaining_delay_ms, /*cap_for_stop=*/false);
205}
206
207/// @brief Compute the delay before the next status poll for a device‑originated status update.
208/// @param dev Device record.
209/// @param policy Policy used to look up the configured poll interval.
210/// @param id Device ID for policy lookup.
211/// @return Delay in milliseconds for tracked polling; 0 if stopped.
212uint32_t compute_status_update_delay_ms(const IoDevice &dev, const StatusPollPolicy &policy, const std::string &id) {
213 if (effective_is_stopped(dev))
214 return 0;
215 // Device-originated updates carry no remaining time and are never STOP replies.
216 return settle_delay_ms(policy.get_interval(id), /*remaining_delay_ms=*/0, /*cap_for_stop=*/false);
217}
218
219/// @brief Read a status reply's byte 0: record the device's execution state and say whether it is at rest.
220///
221/// The execution state decides (decisions::reads_stopped_by_status_byte()): waiting for its turn,
222/// waiting for power and executing are moving; unable to execute, error and done are at rest. A
223/// heater has no motion, so a heating-level heater and an exterior heater are always at rest whatever
224/// their byte says (device_has_no_motion()): the movement settle loop (follow-up polls while
225/// "moving") must never run for them, and what that byte holds on a heater is unconfirmed; they keep
226/// no execution state. A byte with no defined state (bits [2:0] hold 6 or 7) falls back on
227/// STATUS_STOPPED and is logged. A change of state is logged with the raw byte.
228/// \todo The waiting states are experimental: only a main-parameter read of a queued shutter has
229/// captured WAITING_FOR_POWER so far, not an ordinary status reply.
230/// @param id Device ID for the log lines.
231/// @param dev Device the reply came from; its `execution_state` is updated.
232/// @param flags The reply's byte 0.
233/// @return true when the device is at rest.
234bool read_status_byte(const std::string &id, IoDevice &dev, uint8_t flags) {
235 if (device_has_no_motion(dev.type, dev.subtype))
236 return true;
237 const ExecutionState state = execution_state_of(flags);
238 if (state == ExecutionState::UNDEFINED) {
239 ESP_LOGW(detail::TAG, "Device %s: status byte 0x%02X has no defined execution state: read by its stopped bit",
240 id.c_str(), flags);
241 } else if (state != dev.execution_state) {
242 ESP_LOGD(detail::TAG, "Device %s: execution state %s (status byte 0x%02X)", id.c_str(), execution_state_name(state),
243 flags);
244 }
245 dev.execution_state = state;
247}
248
249/// @brief Decide whether a report that reads as moving schedules a follow-up poll.
250///
251/// A moving report opens the follow-up window when none is open, for device kinds that report
252/// motion through status; a report that finds the window expired closes it and schedules nothing.
253/// @param id Device ID for policy lookup and the log line.
254/// @param dev Device record whose last_status stamps the report.
255/// @param policy Poll policy owning the window.
256/// @return True when the caller schedules the next poll.
257bool follow_moving_report(const std::string &id, const IoDevice &dev, StatusPollPolicy &policy) {
258 const MovingReportAction action =
259 policy.on_moving_report(id, dev.last_status, detail::known_device_reports_motion_through_status(dev));
260 if (action == MovingReportAction::OPEN)
261 ESP_LOGD(detail::TAG, "Device %s: reports moving with no poll planned: following it", id.c_str());
262 return action == MovingReportAction::FOLLOW || action == MovingReportAction::OPEN;
263}
264
265/// @brief Apply a private-response frame to the device record.
266/// @param id Device ID for policy lookup.
267/// @param dev Device record to update.
268/// @param frame Private-response frame.
269/// @param policy Poll policy for scheduling follow-up polls.
270/// @param trust_position False to skip decoding target/current from `frame` — the immediate
271/// reply to our own just-sent CMD_EXECUTE has been observed (real hardware, see
272/// tests/corpus/captures/exchange/somfy_awning_exchange_ack_reports_stale_target_*.yaml) echoing
273/// pre-command target/current values rather than the freshly-commanded target. `is_stopped` is
274/// still applied either way; the optimistic target already set by the caller (or the follow-up
275/// status poll a few seconds later) remains the source of truth for target/current in that case.
276void apply_private_response_status(const std::string &id, IoDevice &dev, const IoFrame &frame, StatusPollPolicy &policy,
277 bool trust_position = true) {
278 const bool reported_stopped = read_status_byte(id, dev, frame.data[STATUS_STOPPED_FLAGS_OFFSET]);
279 dev.is_stopped = reported_stopped;
280 dev.last_status = millis();
281 if (trust_position) {
282 decode_status_fields(dev, frame, PRIVATE_RESPONSE_TARGET_OFFSET, PRIVATE_RESPONSE_CURRENT_OFFSET, true, id, policy);
284 // An ack's own position bytes are not decoded, so it is held against the last trusted report.
285 // When that report is a stop the hub already accepted off target, the gap is not movement. The
286 // candidate stays: the first poll after the command compares targets, so a report from before
287 // a command to a new target can never confirm one after it.
289 }
290 // The wake belief follows the device's own flag, not the hub's verdict. On the execute-ack path
291 // (trust_position == false) dev.target/position are stale, so the normalized is_stopped can read
292 // "moving" for a device that just reported stopped. It matters for the ack to a STOP: a VELUX SSL
293 // still reports "moving" while it winds down, and in that state it ignores the wake-up preamble,
294 // so the settle poll after the STOP must lead short. A move's ack is overridden anyway:
295 // run_execute_operation_() stamps the evidence once the move is accepted, after this runs. On a
296 // trusted report a settling device has said "stopped": at rest a VELUX SSL hears only the wake-up
297 // preamble, so the confirming poll must lead with it (issue #151).
298 track_motion_evidence(dev, !reported_stopped, dev.last_status);
299
300 // A zero remaining time means "about to stop" only in a report that says moving, from a device
301 // that has sent a count before: a counting cover reports 0 in its last second of travel (issue
302 // #178), while a device that has never sent a count may simply not provide one, and would
303 // otherwise be polled every second. A "stopped" report keeps the default delay, which is what a
304 // STOPPED off target needs before its confirming poll (decisions::classify_stopped_report()).
305 std::optional<uint16_t> remaining_s = decode_remaining_time_s(frame, trust_position);
306 if (remaining_s.has_value() && *remaining_s != 0)
307 dev.reports_remaining_time = true;
308 if (remaining_s.has_value() && *remaining_s == 0 && !dev.reports_remaining_time)
309 remaining_s.reset();
310 // A report that says stopped schedules from the default delay, never from a remaining time: it is
311 // either at rest or the first stop off target, whose confirming poll keeps the delay the verdict
312 // assumes (decisions::classify_stopped_report(), "known bound"), whatever count the reply carries.
313 if (reported_stopped)
314 remaining_s.reset();
315 // A moving receiver that counts is moving until its count has run out: a silent exchange before
316 // then does not spend the wake belief's evidence (ADR 0077).
317 if (trust_position && remaining_s.has_value()) {
319 dev, dev.last_status + (*remaining_s * PRIVATE_RESPONSE_REMAINING_TIME_SCALE_MS) + PREDICTED_END_MARGIN_MS);
320 }
321
322 if (effective_is_stopped(dev)) {
323 policy.clear(id);
324 return;
325 }
326 if (!follow_moving_report(id, dev, policy))
327 return;
328 // A moving device that counts down is read once more, with the settling plan, at the end of its
329 // count (a count of 0 included); the progress read below keeps its own cadence until then.
330 if (trust_position)
331 policy.on_trusted_report(id, remaining_s, dev.last_status);
332
333 // A count that stopped falling backs the progress reads off (StatusPollPolicy::frozen_count_delay_ms());
334 // a cadence that is longer already stays.
335 uint32_t const delay_ms =
336 std::max(compute_private_response_delay_ms(dev, remaining_s, policy, id), policy.frozen_count_delay_ms(id));
337 ESP_LOGD(detail::TAG,
338 "Device %s: next status poll in %" PRIu32 " ms (remaining time=%s, configured interval=%" PRIu32 " ms)",
339 id.c_str(), delay_ms, remaining_s.has_value() ? std::to_string(*remaining_s).append("s").c_str() : "none",
340 policy.get_interval(id));
341 uint32_t const new_deadline = dev.last_status + delay_ms;
342 uint32_t const existing_deadline = policy.get_next_update(id);
343 // Don't push the deadline forward — only move it earlier. This prevents repeated command
344 // responses (e.g. multiple rapid STOP presses) from compounding the wait time.
345 policy.set_next_update(
346 id, (existing_deadline != 0 && existing_deadline < new_deadline) ? existing_deadline : new_deadline);
347}
348
349/// @brief Apply a device-originated status-update frame to the device record.
350/// @param id Device ID for policy lookup.
351/// @param dev Device record to update.
352/// @param frame Status-update frame.
353/// @param policy Poll policy for scheduling follow-up polls.
354void apply_unsolicited_status_update(const std::string &id, IoDevice &dev, const IoFrame &frame,
355 StatusPollPolicy &policy) {
356 const bool reported_stopped = read_status_byte(id, dev, frame.data[STATUS_STOPPED_FLAGS_OFFSET]);
357 dev.is_stopped = reported_stopped;
358 dev.last_status = millis();
359 decode_status_fields(dev, frame, STATUS_UPDATE_TARGET_OFFSET, STATUS_UPDATE_CURRENT_OFFSET, false, id, policy);
360 // The device's own flag, as on the poll path (apply_private_response_status()).
361 track_motion_evidence(dev, !reported_stopped, dev.last_status);
362
363 if (effective_is_stopped(dev)) {
364 policy.clear(id);
365 return;
366 }
367 if (!follow_moving_report(id, dev, policy))
368 return;
369
370 policy.set_next_update(id, dev.last_status + compute_status_update_delay_ms(dev, policy, id));
371}
372
373/// @brief Record what a heater commanded with an EXECUTE reports as its current main parameter.
374///
375/// A heating-level heater reports a level; a temperature heater reports an alias (kept in
376/// HeatingReadings::level) or a plain temperature (HeatingReadings::setpoint_c, decoded over the
377/// device's encoding range, which also clears a stale alias).
378/// @param id Device ID for the log line.
379/// @param dev Device record to update; other devices are left alone.
380/// @param frame A reply to a status poll (the layout of a reply to an EXECUTE follows the request).
381void apply_heating_main_parameter(const std::string &id, IoDevice &dev, const IoFrame &frame) {
382 const HeatingProfile *profile = find_heating_profile(dev);
383 if (profile == nullptr || profile->path == HeatingControlPath::PRIVATE_REGISTERS)
384 return;
385 const auto current = static_cast<uint16_t>((frame.data[PRIVATE_RESPONSE_CURRENT_OFFSET] << BITS_PER_BYTE) |
386 frame.data[PRIVATE_RESPONSE_CURRENT_OFFSET + 1]);
387 dev.heating.main_parameter = current;
388 if ((current >> BITS_PER_BYTE) == profile->mp_prefix) {
389 const auto wire = static_cast<int16_t>(current & MAIN_PARAMETER_LOW_BYTE_MASK);
390 if (dev.heating.level != wire)
391 ESP_LOGI(detail::TAG, "Device %s: heating value 0x%02X", id.c_str(), static_cast<unsigned>(wire));
392 dev.heating.level = wire;
393 return;
394 }
395 float celsius = 0.0F;
396 if (profile->path == HeatingControlPath::TEMPERATURE_EXECUTE &&
397 decode_temperature_main_parameter(current, dev.temperature_range_min_c, dev.temperature_range_max_c, celsius)) {
398 dev.heating.setpoint_c = celsius;
399 dev.heating.level = -1;
400 return;
401 }
402 ESP_LOGD(detail::TAG, "Device %s: status main parameter 0x%04X is not a heating value", id.c_str(), current);
403}
404
405/// @brief Apply a water heater's status reply: its main parameter is the target temperature; the operating-mode
406/// read (FP15, FP16) adds its capabilities and the boost and away state.
407///
408/// A water heater has no position, no movement and no battery: the cover parts of the status path (the
409/// stopped flag, target/current position, the follow-up window, the moving evidence, the last-command
410/// record) must not touch it, so it never reaches them. Only a reply to a status read carries the target;
411/// the ack of an EXECUTE echoes the request and is not decoded.
412/// @param id Device ID for the log line.
413/// @param dev Device record to update.
414/// @param frame A CMD_PRIVATE_RESP from the heater.
415/// @param trust_position True for a reply to a status read, false for the ack of an EXECUTE.
416void apply_dhw_status_reply(const std::string &id, IoDevice &dev, const IoFrame &frame, bool trust_position) {
417 if (!trust_position)
418 return;
419 DhwOperatingStatus status;
420 if (decode_dhw_operating_status(frame, status)) {
421 if (dev.dhw.boost != (status.boost ? 1 : 0) || dev.dhw.away != (status.away ? 1 : 0)) {
422 ESP_LOGI(detail::TAG, "Device %s: hot water boost %s, away %s (FP15 0x%04X)", id.c_str(),
423 status.boost ? "on" : "off", status.away ? "on" : "off", static_cast<unsigned>(status.capabilities));
424 }
425 dev.dhw.capabilities = status.capabilities;
426 dev.dhw.boost = status.boost ? 1 : 0;
427 dev.dhw.away = status.away ? 1 : 0;
428 }
429 float celsius = NAN;
430 if (decode_dhw_status_target(frame, celsius)) {
431 if (dev.dhw.target_c != celsius)
432 ESP_LOGI(detail::TAG, "Device %s: hot water target %.1f C", id.c_str(), static_cast<double>(celsius));
433 dev.dhw.target_c = celsius;
434 return;
435 }
436 // An alias or an out-of-range value is not a target temperature: it is logged and the target shows unknown.
437 ESP_LOGD(detail::TAG, "Device %s: status main parameter 0x%02X%02X is not a hot water target", id.c_str(),
438 frame.data[DHW_STATUS_MAIN_PARAMETER_OFFSET], frame.data[DHW_STATUS_MAIN_PARAMETER_OFFSET + 1]);
439 dev.dhw.target_c = NAN;
440}
441
442/// @brief Record the coarse battery level a status payload carries, logging a change.
443/// @param id Device ID for the log line.
444/// @param dev Device record to update.
445/// @param frame A status poll reply or a device-originated status update (both put it at data[1]).
446void apply_battery_state(const std::string &id, IoDevice &dev, const IoFrame &frame) {
447 const BatteryState reported = decode_battery_state(frame.data[STATUS_BATTERY_OFFSET]);
448 if (reported == dev.battery)
449 return;
450 // A battery level changes rarely, so a change is worth an INFO line; mains devices stay at
451 // NOT_REPORTED and never log.
452 ESP_LOGI(detail::TAG, "Device %s: battery %s -> %s", id.c_str(), battery_state_name(dev.battery),
453 battery_state_name(reported));
454 dev.battery = reported;
455}
456
457/// @brief Apply INFO2 metadata to the device record when YAML has not already declared it.
458/// @param dev Device record to update.
459/// @param frame INFO2 response frame.
460void apply_info2_response(IoDevice &dev, const IoFrame &frame) {
461 if (dev.type != DeviceType::UNKNOWN)
462 return;
463
464 dev.type = decode_packed_device_type(frame.data[GET_INFO2_TYPE_OFFSET], frame.data[GET_INFO2_TYPE_SUBTYPE_OFFSET]);
465 dev.subtype = decode_packed_device_subtype(frame.data[GET_INFO2_TYPE_SUBTYPE_OFFSET]);
467}
468
469/// @brief Apply a name response frame to the device record.
470/// @param dev Device record to update.
471/// @param frame Name response frame.
472void apply_name_response(IoDevice &dev, const IoFrame &frame) {
473 std::string const name = decode_device_name_payload(frame.data, frame.data_len);
474 memset(dev.name, 0, sizeof(dev.name));
475 if (!name.empty())
476 memcpy(dev.name, name.c_str(), name.length());
477}
478
479} // namespace
480
481void IOHomeControlComponent::begin_status_poll_tracking_(const std::string &device_id, uint32_t initial_delay_ms) {
482 if (this->get_device(device_id) == nullptr)
483 return;
484 this->poll_policy_.begin_tracking(device_id, initial_delay_ms, millis());
485}
486
488 while (const auto ended = this->poll_policy_.pop_ended_device()) {
489 IoDevice *dev = this->registry_.get(*ended);
490 if (dev == nullptr)
491 continue;
492 if (detail::is_settling(*dev)) {
493 // The device's own last word was "stopped"; nothing is left to confirm it.
494 ESP_LOGD(detail::TAG, "Device %s: no poll left to confirm its stop at %.2f%%, target %.2f%%: taken as at rest",
495 ended->c_str(), static_cast<double>(dev->position), static_cast<double>(dev->target));
497 // The device was last seen (or commanded) moving and has not reported a stop: no report came,
498 // or the last one still read moving after the window ran out. With no poll left the cover would
499 // show movement for good. Position and target stay as last reported.
500 if (dev->position == UNKNOWN_POSITION) {
501 ESP_LOGI(detail::TAG, "Device %s: no stop reported since its move; shown at rest at an unknown position",
502 ended->c_str());
503 } else {
504 ESP_LOGI(detail::TAG, "Device %s: no stop reported since its move; shown at rest at %.0f%%", ended->c_str(),
505 static_cast<double>(dev->position));
506 }
508 // A resting device needs the wake-up preamble again, so the next request must not assume the
509 // device is still awake from the movement.
511 // Its last word (waiting, running) is no longer news: an Active Issue it raised is withdrawn.
513 } else {
514 continue;
515 }
516 dev->is_stopped = true;
517 detail::log_status_update(*ended, *dev);
518 this->notify_device_update_(*ended);
519 }
520}
521
522void IOHomeControlComponent::schedule_status_poll_(const std::string &device_id, uint32_t delay_ms) {
523 // Keyed by the device's node address (decisions::remote_poll_timer_id()), not by a per-device
524 // name string: Component::set_timeout(const char*, ...) stores the caller's pointer rather than
525 // copying it, so a name built from `device_id` here would dangle the moment this function
526 // returned. The numeric id is still per-device, so repeated remote traffic resets the pending
527 // poll instead of stacking multiple delayed callbacks for the same actuator.
528 uint8_t node_id[NODE_ID_SIZE];
529 if (!hex_to_bytes(device_id, node_id, NODE_ID_SIZE))
530 return; // malformed device_id: every caller passes one already validated by the registry
531 this->set_timeout(decisions::remote_poll_timer_id(node_id), delay_ms, [this, device_id]() {
532 this->poll_policy_.on_remote_poll_ran(device_id);
533 this->queue_request_device_status(device_id);
534 });
535}
536
537void IOHomeControlComponent::schedule_heating_poll_(const std::string &device_id, uint32_t delay_ms) {
538 uint8_t node_id[NODE_ID_SIZE];
539 if (!hex_to_bytes(device_id, node_id, NODE_ID_SIZE))
540 return; // malformed device_id: the caller passes one already validated by the registry
541 this->set_timeout(decisions::heating_poll_timer_id(node_id), delay_ms,
542 [this, device_id]() { this->queue_request_device_status(device_id); });
543}
544
545void IOHomeControlComponent::schedule_device_polls_(const std::vector<std::string> &device_ids, uint32_t delay_ms) {
546 for (const auto &device_id : device_ids) {
547 this->begin_status_poll_tracking_(device_id, 0);
548 this->schedule_status_poll_(device_id, delay_ms);
549 }
550}
551
553 const std::string &controller, uint8_t cmd) {
554 const IoDevice *dev = this->get_device(device_id);
555 if (dev == nullptr)
556 return;
557 const uint32_t now = millis();
558 const char *what = kind == ForeignFrameKind::DEVICE_CHALLENGE_TO_OTHER ? "challenge" : command_name(cmd);
559 switch (this->poll_policy_.on_foreign_frame(device_id, kind, controller, cmd, now, dev->last_status)) {
561 ESP_LOGD(detail::TAG, "rx remote_activity device=%s controller=%s %s(0x%02X): scheduling status poll",
562 device_id.c_str(), controller.c_str(), what, cmd);
563 this->begin_status_poll_tracking_(device_id, 0);
564 this->schedule_status_poll_(device_id, REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS);
565 break;
567 ESP_LOGD(detail::TAG, "rx remote_activity device=%s controller=%s %s(0x%02X): poll pending, pushed out",
568 device_id.c_str(), controller.c_str(), what, cmd);
569 this->schedule_status_poll_(device_id, REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS);
570 break;
572 ESP_LOGD(detail::TAG, "rx remote_activity device=%s controller=%s %s(0x%02X): %s, no poll", device_id.c_str(),
573 controller.c_str(), what, cmd,
575 ? "answers a heard read"
576 : "a read, and the device was read in the last 10 min");
577 break;
578 }
579}
580
582 // A registered device challenging another controller: a write whose request frame was missed
583 // looks exactly like this, so the policy decides whether it warrants a poll.
584 if (frame.cmd != CMD_CHALLENGE_REQ || memcmp(frame.dst, this->node_id_, NODE_ID_SIZE) == 0)
585 return;
586 const std::string challenger = node_id_to_string(frame.src);
587 if (this->get_device(challenger) == nullptr)
588 return;
590 frame.cmd);
591}
592
594 // Another controller's frame to a registered device (e.g. a gateway commanding a shutter we also
595 // control). Our own frames are never foreign.
596 const std::string dst_id = node_id_to_string(frame.dst);
597 if (this->get_device(dst_id) != nullptr && memcmp(frame.src, this->node_id_, NODE_ID_SIZE) != 0) {
599 return true;
600 }
601
602 // A linked 2W controller whose commands target a device at an address we don't have registered.
603 // 1W remotes are handled before this, via the CTRL0_PROTOCOL_1W check.
604 const std::string src_id = node_id_to_string(frame.src);
605 const std::vector<std::string> *linked = this->registry_.linked_devices(src_id);
606 if (linked == nullptr)
607 return false;
608 // Copied: the helper may call back into code that touches the registry.
609 const std::vector<std::string> devices = *linked;
610 for (const auto &device_id : devices)
611 this->act_on_foreign_frame_(device_id, ForeignFrameKind::REQUEST_TO_DEVICE, src_id, frame.cmd);
612 return true;
613}
614
616 const std::string &src_id) const {
617 std::vector<std::string> devices;
618 if (const std::vector<std::string> *id_linked = this->registry_.linked_devices(src_id)) {
619 devices = *id_linked;
620 }
622 if (const std::vector<std::string> *class_linked = this->registry_.linked_devices_for_class(info.target_type)) {
623 for (const auto &device_id : *class_linked) {
624 if (std::find(devices.begin(), devices.end(), device_id) == devices.end())
625 devices.push_back(device_id);
626 }
627 }
628 }
629 return devices;
630}
631
632void IOHomeControlComponent::follow_linked_remote_press_(const OneWayFrameInfo &info, const std::string &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 : this->tuning_.remote_activity_poll_delay_ms);
638 // The polls just scheduled make the radio deaf to a press that starts while they run, so a
639 // catch-up read of every linked device follows the burst when it is switched on (ADR 0080).
640 if (!target_devices.empty() && this->tuning_.remote_activity_catchup_poll_ms != 0)
642}
643
645 const std::vector<std::string> &device_ids) {
646 if (!info.has_intent)
647 return false;
648
649 const bool is_stop = info.main0 == POS_STOP;
650 const std::optional<float> target = is_stop ? std::nullopt : oneway_intent_to_target(info.main0, info.main1);
651
652 for (const auto &device_id : device_ids) {
653 IoDevice *dev = this->registry_.get(device_id);
654 if (dev != nullptr && info.target_type != DeviceType::UNKNOWN && dev->type != DeviceType::UNKNOWN &&
655 dev->type != info.target_type) {
656 continue; // Type mismatch: still polled by schedule_device_polls_(), just not moved optimistically.
657 }
658 // Evidence first: the overlay calls below notify entity callbacks, and `dev` is not touched after
659 // them.
660 if (is_stop) {
661 if (dev != nullptr)
663 this->registry_.apply_optimistic_stop(device_id);
664 } else if (target.has_value()) {
665 // Unlike our own commands, this movement was really started by a remote the device heard, so
666 // it is evidence of travel even where the optimistic overlay is disabled.
667 if (dev != nullptr)
668 note_moving_evidence(*dev, millis());
669 this->registry_.apply_optimistic_target(device_id, *target);
670 }
671 }
672 return is_stop;
673}
674
676 const std::string &src_id) {
677 if (!info.has_intent)
678 return;
679 // All overheard 1W traffic is DEBUG-logged regardless (see log_1w_remote_frame()); the HA event
680 // additionally requires the sender to be on the `exposed_senders` allowlist, since 1W broadcasts
681 // carry no ownership marker and this radio may overhear a neighbor's remote (or sensor) as
682 // easily as the user's own. DEBUG-log the reason it did or didn't fire so a live log capture is
683 // enough to diagnose a misconfigured allowlist vs. a disconnected API.
684 if (!this->is_connected()) {
685 ESP_LOGD(detail::TAG, "1W sender %s has intent but the API is not connected, skipping %s", src_id.c_str(),
687 return;
688 }
689 if (!detail::is_exposed_sender(this->exposed_senders_, src_id)) {
690 ESP_LOGD(detail::TAG, "1W sender %s has intent but is not in exposed_senders, skipping %s", src_id.c_str(),
692 return;
693 }
694 ESP_LOGD(detail::TAG, "Firing %s for sender %s", detail::ONEWAY_SENDER_EVENT, src_id.c_str());
695 this->fire_homeassistant_event(detail::ONEWAY_SENDER_EVENT, detail::build_sender_event_data(info, linked));
696}
697
698void IOHomeControlComponent::apply_status_update_(const std::string &id, IoDevice &dev, const IoFrame &frame) {
699 // A 0x71 carrying another event than EVENT_STATUS_CHANGE is a subscribed report (EVENT_LOCAL_SENSOR,
700 // tests/corpus/captures/probe/somfy_rs100_probe_subscribe_event_sx1262.yaml), not a position
701 // update: its event data does not hold the fields decoded below.
703 this->record_device_event_(frame);
704 return;
705 }
706 if (frame.data_len < STATUS_UPDATE_MIN_DATA_LEN) {
707 detail::log_frame_issue(this, this->node_id_, "rx", "unsupported_payload", frame, frame_length(frame));
708 return;
709 }
710 // A water heater has not been seen sending a status update, and its fields are a cover's: none of
711 // them is applied to it. Logged so a capture of the first one is not lost.
712 if (has_feature(dev.features, device_feature::WATER_HEATER)) {
713 detail::log_frame_issue(this, this->node_id_, "rx", "unsupported_payload", frame, frame_length(frame));
714 return;
715 }
716
717 // Status-update frames come from the device itself rather than from a direct controller poll.
718 // They use different offsets for the target/current fields and do not carry reliable tilt data.
719 const float predicted_target = dev.optimistic.target;
720 apply_unsolicited_status_update(id, dev, frame, this->poll_policy_);
721 this->note_status_reported_(id);
724 detail::update_rain_limitation(id, dev, predicted_target, record.valid, millis());
725 apply_battery_state(id, dev, frame);
727
728 // What caused the device to move (wind sensor, timer, a remote). Empty when the payload is
729 // too short to carry the byte — a 11-14 byte 0x71 is still applied for its position fields,
730 // it just has no originator to report. This reads the same data[14] byte the last-command
731 // record above just decoded, but through a separate, older, unguarded accessor kept for its
732 // own pinned test (StatusUpdateOriginatorIsAtOffset14AndDecodePathUndisturbed) — it can render
733 // a byte here that the "Last Command Source" sensor leaves empty, on the one payload shape
734 // that differs between them: an all-zero commander (which apply_last_command_record() above
735 // treats as "no record", see decode_last_command_record()'s doc comment) paired with a
736 // populated originator byte. No capture has shown that combination in practice.
737 const std::string originator = detail::describe_status_update_originator(frame);
738 if (!originator.empty())
739 ESP_LOGD(detail::TAG, "Device %s: status update originator=%s", id.c_str(), originator.c_str());
740
741 detail::log_status_update(id, dev, " (status update)");
742 this->notify_device_update_(id);
743}
744
745void IOHomeControlComponent::update_device_status_(const IoFrame &frame, bool trust_position) {
746 const std::string id = node_id_to_string(frame.src);
747 IoDevice *device_ptr = this->registry_.get(id);
748 if (device_ptr == nullptr) {
749 detail::log_frame_issue(this, this->node_id_, "rx", "unregistered_device", frame, frame_length(frame));
750 return;
751 }
752 IoDevice &dev = *device_ptr;
754
755 if (frame.cmd == CMD_PRIVATE_RESP) {
756 if (frame.data_len < PRIVATE_RESPONSE_MIN_DATA_LEN) {
757 detail::log_frame_issue(this, this->node_id_, "rx", "unsupported_payload", frame, frame_length(frame));
758 return;
759 }
760
761 // A water heater's reply carries its target and nothing a cover has; it never enters the cover path.
762 if (has_feature(dev.features, device_feature::WATER_HEATER)) {
763 apply_dhw_status_reply(id, dev, frame, trust_position);
765 this->notify_device_update_(id);
766 return;
767 }
768
769 // CMD_PRIVATE_RESP (0x04) serves as the reply to both status polls (0x03) and execute
770 // commands (0x00). The position fields are shared across both response types, but the
771 // immediate reply to our own execute command is not necessarily trustworthy for them (see
772 // apply_private_response_status()'s trust_position parameter).
773 // Decoding a position clears the optimistic prediction, so capture it first: a clamped
774 // command is the disagreement between that prediction and what the device then reports.
775 const float predicted_target = dev.optimistic.target;
776 apply_private_response_status(id, dev, frame, this->poll_policy_, trust_position);
777 // The device names, in its own status payload, the controller that last commanded it. Skipped
778 // on an execute ack (trust_position == false): that reply's payload layout is request-derived
779 // rather than self-describing (see the offset comment at the top of this file), and our own
780 // ack is not a report of the *last* command anyway — the settle poll a few seconds later is.
781 if (trust_position) {
784 detail::update_rain_limitation(id, dev, predicted_target, record.valid, millis());
785 // Same reason as the record above: only a reply to a status poll is a status report.
786 apply_battery_state(id, dev, frame);
787 apply_heating_main_parameter(id, dev, frame);
788 this->note_status_reported_(id);
789 }
792 this->notify_device_update_(id);
793 return;
794 }
795
796 if (frame.cmd == CMD_STATUS_UPDATE) {
797 this->apply_status_update_(id, dev, frame);
798 return;
799 }
800
801 if (frame.cmd == CMD_GET_NAME_RESP) {
802 if (frame.data_len < GET_NAME_RESPONSE_MIN_DATA_LEN) {
803 detail::log_frame_issue(this, this->node_id_, "rx", "unsupported_payload", frame, frame_length(frame));
804 return;
805 }
806
807 apply_name_response(dev, frame);
808 ESP_LOGI(detail::TAG, "Device %s: name=%s", id.c_str(), dev.name[0] == '\0' ? "" : dev.name);
809 this->notify_device_update_(id);
810 return;
811 }
812
813 if (frame.cmd == CMD_GET_INFO2_RESP) {
814 if (frame.data_len < GET_INFO2_RESPONSE_MIN_DATA_LEN) {
815 detail::log_frame_issue(this, this->node_id_, "rx", "unsupported_payload", frame, frame_length(frame));
816 return;
817 }
818
819 // INFO2 is metadata, not movement state. Only learn type from radio if still UNKNOWN;
820 // YAML-declared type takes priority.
821 const bool type_was_unknown = dev.type == DeviceType::UNKNOWN;
822 apply_info2_response(dev, frame);
823 ESP_LOGI(detail::TAG, "Device %s: type=%s (%u) class=%s profile=%s subtype=%u", id.c_str(),
824 device_type_name(dev.type), static_cast<unsigned>(dev.type), device_capability_class_name(dev.type),
826 if (type_was_unknown && dev.type != DeviceType::UNKNOWN) {
827 ESP_LOGI(detail::TAG,
828 "Device %s: type learned at runtime, not declared in YAML — add `%s` to skip "
829 "re-learning it on every future boot",
830 id.c_str(), detail::describe_learned_device_type(dev.type).c_str());
831 }
832 return;
833 }
834
835 if (frame.cmd == CMD_ERROR_RESP) {
836 if (frame.data_len < ERROR_RESPONSE_MIN_DATA_LEN) {
837 detail::log_frame_issue(this, this->node_id_, "rx", "unsupported_payload", frame, frame_length(frame));
838 return;
839 }
840
841 detail::record_command_result(dev, id, frame.data[0]);
842 this->notify_device_update_(id);
843 return;
844 }
845}
846
848 // A gap of a full quiet period or more since the last frame means the previous burst already
849 // released any deferred poll, so this frame starts a new burst window rather than extending the
850 // old one (which would make ONEWAY_POLL_DEFER_CAP_MS fire on the very next frame).
851 if (decisions::oneway_burst_started_fresh(this->last_1w_activity_ms_, now, ONEWAY_QUIET_PERIOD_MS))
852 this->first_1w_activity_ms_ = now;
853 this->last_1w_activity_ms_ = now;
854}
855
857 if (!decisions::is_one_way_pairing_gesture((frame.ctrl0 & CTRL0_PROTOCOL_1W) != 0, frame.dst, frame.cmd))
858 return;
859 memcpy(this->recent_oneway_pairing_sighting_.src, frame.src, NODE_ID_SIZE);
860 memcpy(this->recent_oneway_pairing_sighting_.dst, frame.dst, NODE_ID_SIZE);
864}
865
866/// @brief Log an overheard limitation request/reply decoded at DEBUG; any other frame is ignored.
867static void log_overheard_limitation_read(const IoFrame &frame) {
868 if (frame.cmd == CMD_LIMITATION_STATUS_REQ) {
869 ESP_LOGD(detail::TAG, "rx %s", detail::describe_limitation_request(frame).c_str());
870 } else if (frame.cmd == CMD_LIMITATION_STATUS_RESP) {
871 ESP_LOGD(detail::TAG, "rx %s", detail::describe_limitation_reply(frame).c_str());
872 }
873}
874
875namespace {
876constexpr size_t DEVICE_EVENT_HEAD_BUFFER_SIZE = 48; ///< "#<n> t=<s> ": two uint32 values and punctuation.
877constexpr size_t DEVICE_EVENT_CMD_BUFFER_SIZE = 8; ///< " 0xNN" plus the terminator.
878constexpr uint32_t DEVICE_EVENT_MS_PER_SECOND = 1000;
879} // namespace
880
881bool IOHomeControlComponent::record_if_device_event_(const IoFrame &frame, bool reached_unhandled_fallback) {
882 if (this->get_device(node_id_to_string(frame.src)) == nullptr)
883 return false;
884 // A device event report (decisions::is_device_event_report()) is always recorded; any other frame
885 // only with probes on, once nothing else here claimed it, so a report of an unexpected shape is
886 // not lost.
887 if (!decisions::is_device_event_report(frame) && !(reached_unhandled_fallback && this->diagnostic_probes_enabled_))
888 return false;
889 this->record_device_event_(frame);
890 return true;
891}
892
894 return this->record_if_device_event_(reply, /*reached_unhandled_fallback=*/false);
895}
896
898 std::string payload;
899 char byte_hex[4];
900 for (uint8_t i = 0; i < frame.data_len; i++) {
901 snprintf(byte_hex, sizeof(byte_hex), i == 0 ? "%02X" : " %02X", frame.data[i]);
902 payload += byte_hex;
903 }
904 const std::string src_id = node_id_to_string(frame.src);
905 const std::string dst_id = node_id_to_string(frame.dst);
906 this->device_event_count_++;
907 if (this->diagnostic_probes_enabled_) {
908 ESP_LOGI(detail::TAG, "Device event from %s: cmd=0x%02X dst=%s payload=%s", src_id.c_str(), frame.cmd,
909 dst_id.c_str(), payload.c_str());
910 } else {
911 ESP_LOGD(detail::TAG, "Device event from %s: cmd=0x%02X dst=%s payload=%s", src_id.c_str(), frame.cmd,
912 dst_id.c_str(), payload.c_str());
913 }
914 char head[DEVICE_EVENT_HEAD_BUFFER_SIZE];
915 snprintf(head, sizeof(head), "#%" PRIu32 " t=%" PRIu32 " ", this->device_event_count_,
916 millis() / DEVICE_EVENT_MS_PER_SECOND);
917 char cmd_hex[DEVICE_EVENT_CMD_BUFFER_SIZE];
918 snprintf(cmd_hex, sizeof(cmd_hex), " 0x%02X", frame.cmd);
919 this->last_device_event_ = std::string(head) + src_id + cmd_hex + (payload.empty() ? "" : " ") + payload;
920 if (this->device_event_callback_)
922}
923
925 IoFrame frame;
926 if (!parse(packet.data, packet.len, frame)) {
927 detail::log_component_capture(this->radio_, "parse_fail", packet.data, packet.len);
928 return;
929 }
930
931 detail::log_component_capture(this->radio_, "parse_ok", packet.data, packet.len, &frame);
932
933 // === Key-extraction responder ("Accept Foreign Pairing") ===
934 // Runs before the exchange-internal drop below: a hub-issued 0x3C challenging our own 0x37 is
935 // addressed to us and is ours to answer (key_extraction_responder.cpp). Self-gated on armed + our
936 // throwaway ID, so the disarmed path (and every command other than 0x28/0x2C/0x31/0x32/0x36/0x3C)
937 // is bit-for-bit unchanged by this ordering — try_handle_frame() returns false immediately
938 // whenever it doesn't apply, and this reorder can only affect frames where BOTH this call and
939 // the is_exchange_internal_command() check below would otherwise fire, i.e. only 0x3C/0x3D (that
940 // predicate's entire domain, hub_decisions.h).
941 if (this->key_extraction_.try_handle_frame(frame, packet.freq_hz))
942 return;
943
944 // Exchange-internal frames (0x3C challenge request, 0x3D challenge response) belonging to
945 // *another* controller's authenticated exchange carry no extractable status data for a passive
946 // observer — skip silently. They remain visible in io_capture (stage=parse_ok).
947 // A registered device's challenge to another controller may still warrant a poll of our own
948 // (note_foreign_challenge_()); its content is never applied.
950 this->note_foreign_challenge_(frame);
951 return;
952 }
953
954 // === 1W remote frame decode ===
955 // 1W remotes broadcast commands to a typed device-class address (e.g., "all awnings").
956 // Decode the frame content for diagnostic logging, then fall through to linked_remotes
957 // handling which may schedule a status poll for devices this remote controls.
958 if ((frame.ctrl0 & CTRL0_PROTOCOL_1W) != 0) {
959 const std::string src_id = node_id_to_string(frame.src);
960 const uint32_t now = millis();
961
962 // Any 1W frame means a remote is transmitting right now, duplicate or not — record it before
963 // the dedup check so loop() keeps background polls off the radio for the rest of the burst.
964 this->record_1w_activity_(now);
965 // Remember a pairing-gesture sighting the same way, before dedup, so a repeated gesture frame
966 // still refreshes the timestamp (issue #27/#65) — see record_oneway_pairing_gesture_()'s doc
967 // comment for why the discovery telemetry window alone isn't enough to catch this.
968 this->record_oneway_pairing_gesture_(frame, now);
969
970 // Decode once and reuse for the dedup key, logging, and (when it carries a command intent) the
971 // sender HA event, so a physical remote press (or sensor trigger) can drive automations directly.
972 // The decode must happen *before* the dedup check: a move and a stop share the CMD_EXECUTE
973 // command byte and are told apart only by the decoded intent, which is part of the key.
974 const OneWayFrameInfo info = decode_1w_frame(frame);
975
976 // Opt-in, receive-only 1W key adoption (oneway_key_adoption.cpp). Both calls sit after
977 // the parse and before the dedup check so an add-controller broadcast is seen even when its
978 // repeats would collapse into one logical press. Both self-gate on armed, so the disarmed
979 // path is unchanged; they observe the frame rather than consuming it, and execution always
980 // continues into the normal logging path below. Order matters only in that the class
981 // observation must be recorded before an adoption can consume it.
983 this->oneway_key_adoption_.try_adopt(frame);
984
985 // 1W remotes repeat each command 4× at 40ms intervals across channels, and a held button keeps
986 // resending. Collapse that into one logical press per remote+command+intent (plus destination for
987 // intent-less frames, so each class of a multi-class sweep is kept).
988 decisions::OneWayDedupState incoming{src_id, frame.cmd, info.has_intent, info.main0, info.main1, now};
989 memcpy(incoming.dst, frame.dst, NODE_ID_SIZE);
991 return;
992 this->last_1w_logged_ = incoming;
993
994 const std::vector<std::string> *linked = this->registry_.linked_devices(src_id);
995 detail::log_1w_remote_frame(info, linked);
996 this->maybe_fire_sender_event_(info, linked != nullptr && !linked->empty(), src_id);
997 this->follow_linked_remote_press_(info, src_id);
998 return;
999 }
1000
1001 // Limitation reads between other parties (a KLF 200 polling a window) are only rendered, so a
1002 // passive monitor's log shows what they asked and answered. Nothing is applied -- the sender's
1003 // key is unproven (ADR 0022) -- and the frame continues into the normal handling below.
1005
1006 if (frame.cmd == CMD_STATUS_UPDATE && memcmp(frame.dst, this->node_id_, NODE_ID_SIZE) == 0) {
1007 if (this->authenticate_request_(frame, packet.freq_hz)) {
1008 IoFrame resp;
1009 if (!create_status_update_resp(resp, this->node_id_, frame.src)) {
1010 detail::log_frame_issue(this, this->node_id_, "rx", "ack_build_failed", frame, packet.len);
1011 return;
1012 }
1013 // Device-originated updates may arrive while the sender and receiver are not aligned on the
1014 // same hop channel anymore. Broadcasting the ACK across all three IO-homecontrol channels
1015 // matched the behavior of real controllers and made updates reliable in practice. The channel
1016 // the exchange ran on goes first: the device is listening there, and in #163 a motor whose
1017 // exchange ran on CH3 got its acknowledgement last and re-sent its challenge answer once.
1018 const uint16_t ack_preamble = this->radio_->response_preamble();
1019 const auto ack_order = decisions::status_update_ack_channel_order(packet.freq_hz);
1020 this->transmit_frame_(resp, ack_order[0], ack_preamble);
1021 this->transmit_frame_(resp, ack_order[1], ack_preamble);
1022 this->transmit_frame_(resp, ack_order[2], ack_preamble);
1023 this->update_device_status_(frame);
1024 } else {
1025 detail::log_frame_issue(this, this->node_id_, "rx", "auth_failed", frame, packet.len);
1026 }
1027 return;
1028 }
1029
1030 if (frame.cmd == CMD_PRIVATE_RESP || frame.cmd == CMD_STATUS_UPDATE) {
1031 // An event report from a registered device to another controller is recorded like a 0x70 below:
1032 // recording only, nothing is transmitted or applied.
1033 if (this->record_if_device_event_(frame, /*reached_unhandled_fallback=*/false))
1034 return;
1035 // Passive receive mode can still observe replies/status from other exchanges (another
1036 // controller sharing a device, or an attacker who knows the device's node ID -- node IDs
1037 // travel in the clear, see README.md's "Reporting Unsupported Devices"). Nothing here proves
1038 // the frame's source currently holds the system key, so its content is never applied -- see
1039 // ADR 0022. State goes stale until this hub's own next authenticated poll corrects it.
1040 detail::log_frame_issue(this, this->node_id_, "rx", "unauthenticated_status_ignored", frame, packet.len);
1041 return;
1042 }
1043
1044 // A device's 0x70 is recorded whoever it is addressed to, before act_on_overheard_request_() can
1045 // treat it as someone else's traffic. Recording only: nothing is transmitted, no state is touched.
1046 if (this->record_if_device_event_(frame, /*reached_unhandled_fallback=*/false))
1047 return;
1048
1049 // A frame of another controller to one of our registered devices, or from a linked 2W
1050 // controller: act_on_overheard_request_() decides whether it warrants a poll of our own.
1051 if (this->act_on_overheard_request_(frame))
1052 return;
1053
1054 detail::log_frame_issue(this, this->node_id_, "rx", "unhandled_cmd", frame, packet.len);
1055
1056 this->record_if_device_event_(frame, /*reached_unhandled_fallback=*/true);
1057
1058 // If the command is not in our known set AND the frame was addressed to our hub, it may be a
1059 // protocol extension we should support — ask the user to report it. Frames merely overheard
1060 // between other devices (not addressed to us) are still logged above at debug level, but do
1061 // not warrant a warning: we are not a party to that exchange, so there is nothing to add.
1062 const bool addressed_to_us = memcmp(frame.dst, this->node_id_, NODE_ID_SIZE) == 0;
1063 if (addressed_to_us && std::strcmp(command_name(frame.cmd), "UNKNOWN_CMD") == 0) {
1064 const std::string src_id = node_id_to_string(frame.src);
1065 ESP_LOGW(detail::TAG,
1066 "Received unknown command 0x%02X from %s. "
1067 "If you see this repeatedly, please file a GitHub issue with this command ID, "
1068 "your device model, and the log context so protocol support can be extended.",
1069 frame.cmd, src_id.c_str());
1070 }
1071}
1072
1073} // namespace home_io_control
1074} // 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:1670
uint32_t device_event_count_
Device events recorded since boot, the last one's text and its subscriber; see record_device_event_()...
Definition hub_core.h:1612
bool record_probe_reply_event(const IoFrame &reply)
Record a diagnostic probe's reply on the "Last Device Event" sensor when it is a device event report ...
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:402
void follow_linked_remote_press_(const OneWayFrameInfo &info, const std::string &src_id)
React to a deduplicated 1W press from a remote: apply the optimistic state to the devices it controls...
void note_status_reported_(const std::string &device_id)
The device just reported its status: move its status refresh (if it has one) a whole interval out.
Definition hub_core.cpp:374
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()),...
TuningConfig tuning_
Runtime tuning overrides.
Definition hub_core.h:1604
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.
bool diagnostic_probes_enabled_
Whether diagnostic probes (DiagnosticProbes::probe_device()/probe_sweep()) are enabled.
Definition hub_core.h:1618
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:1633
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_device_event_(const IoFrame &frame)
Record a device event report, or a frame from a registered device that nothing else here handles: log...
void settle_ended_status_tracking_()
Settle every device whose polling window ran out (StatusPollPolicy::pop_ended_device()).
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...
void act_on_foreign_frame_(const std::string &device_id, ForeignFrameKind kind, const std::string &controller, uint8_t cmd)
Decide, and act on, what a frame of another controller means for one registered device: a heard write...
void apply_status_update_(const std::string &id, IoDevice &dev, const IoFrame &frame)
Handle an authenticated CMD_STATUS_UPDATE from a registered device: a device event report (decisions:...
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:308
void schedule_heating_poll_(const std::string &device_id, uint32_t delay_ms)
Arm the heater's regular-cadence poll timer.
OnewayKeyAdoption oneway_key_adoption_
Opt-in, receive-only 1W controller-key adoption listener (oneway_key_adoption.cpp).
Definition hub_core.h:1646
void notify_device_update_(const std::string &id)
Fire all registered device update callbacks for the given device ID.
Definition hub_core.cpp:339
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:1666
KeyExtractionResponder key_extraction_
Device-role responder for the "Recover System Key" feature (key_extraction_responder....
Definition hub_core.h:1652
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:1608
bool act_on_overheard_request_(const IoFrame &frame)
Hand another controller's frame to act_on_foreign_frame_(): per the registered device it is addressed...
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:335
uint32_t first_1w_activity_ms_
millis() of the first 1W frame in the current burst.
Definition hub_core.h:1675
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...
void note_foreign_challenge_(const IoFrame &frame)
Hand a registered device's challenge (0x3C) to another controller to act_on_foreign_frame_().
bool record_if_device_event_(const IoFrame &frame, bool reached_unhandled_fallback)
Record frame as a device event when it qualifies: its source is a registered device and it is either ...
bool try_handle_frame(const IoFrame &frame, uint32_t rx_freq_hz=0)
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.
ForeignAction on_foreign_frame(const std::string &device_id, ForeignFrameKind kind, const std::string &controller, uint8_t cmd, uint32_t now, uint32_t last_status_ms)
Decide what an overheard frame of another controller means for this device.
void on_remote_poll_ran(const std::string &device_id)
The poll armed by foreign traffic ran (or was dropped): nothing is pending any more.
void on_linked_remote_activity()
A linked remote was heard: open a burst, or keep the open one.
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.
std::optional< std::string > pop_ended_device()
Return and consume a device whose polling window ended without the device being reported at rest: the...
Pure transition helpers for hub-owned exchange and pairing frame decisions.
Helpers shared only by the hub's own implementation files (hub_*.cpp).
bool stopped_report_at_rest(StoppedReport report)
True when a verdict means the device is at rest.
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 ...
StoppedReport classify_stopped_report(const StoppedReportInput &in)
Decide whether a "stopped" report means at rest.
bool stopped_report_keeps_candidate(StoppedReport report)
True when a verdict leaves an off-target report to remember for the next one.
bool is_device_event_report(const IoFrame &frame)
Returns true for a frame that reports a device event rather than a status: a CMD_SET_CONFIG1_RESP,...
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_...
std::array< uint32_t, 3 > status_update_ack_channel_order(uint32_t receive_freq_hz)
The order in which a 0x72 status-update acknowledgement is sent on the three channels: the channel th...
StoppedReport
What the hub makes of one status report's movement state.
@ SETTLING
Stopped off its target for the first time: treated as moving until the next report confirms it.
@ STOPPED_UNCONFIRMABLE
Stopped off its target and no follow-up poll will run, so the device's own flag stands.
@ STOPPED_CONFIRMED
Stopped off its target, as in the remembered report: at rest there.
bool reads_stopped_by_status_byte(uint8_t status_byte)
Does byte 0 of a status reply say the device is at rest?
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
void log_frame_issue(IOHomeControlComponent *component, const uint8_t *own_node_id, const char *direction, const char *reason, const IoFrame &frame, uint8_t len)
Log a frame‑level issue (unregistered endpoints, unsupported commands).
std::string describe_learned_device_type(DeviceType type)
Build the YAML line to add once a device's type is learned at runtime.
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.
bool stop_candidate_matches_observation(const IoDevice &dev)
True when the remembered off-target stop is the device's last trusted report.
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.
bool is_settling(const IoDevice &dev)
True while a device that reported stopped off its target waits for the confirming poll.
bool known_device_reports_motion_through_status(const IoDevice &dev)
Does the device report its movement through status replies?
void normalize_stopped_state(IoDevice &dev)
Normalize the stopped flag of an execute ack against the last observed target and position.
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 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.
ExecutionState execution_state_of(uint8_t status_byte)
Read the execution state out of byte 0 of a status reply.
const HeatingProfile * find_heating_profile(DeviceType type, uint8_t subtype, DeviceModel model)
Look up the heating profile for a (type, subtype, model).
ForeignFrameKind
What an overheard frame of another controller is.
@ REQUEST_TO_DEVICE
Another controller's command to a registered device.
@ DEVICE_CHALLENGE_TO_OTHER
A registered device's challenge (0x3C) addressed to another controller.
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
void resolve_device_features(IoDevice &dev)
Resolve a device's capability bits and inversion from its kind and its YAML overrides.
const char * execution_state_name(ExecutionState state)
Get a human-readable name for an ExecutionState.
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.
const char * device_operation_profile_name(DeviceType type, uint8_t subtype)
Human‑readable operation profile name for a device kind.
constexpr bool has_feature(DeviceFeatures set, DeviceFeatures bit)
True when every bit of bit is set in set.
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 decode_dhw_operating_status(const IoFrame &reply, DhwOperatingStatus &status)
Capabilities and operating-mode state from the reply to the status read DHW_STATUS_FPI1 / DHW_STATUS_...
bool parse(const uint8_t *buf, uint8_t buf_len, IoFrame &f)
Parse a wire buffer into a parsed IoFrame (validates length and CTRL0).
constexpr uint8_t STATUS_UPDATE_EVENT_DATA_OFFSET
First event-data byte.
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.
ExecutionState
What a device says it is doing, from bits [2:0] of byte 0 of a status reply (CMD_PRIVATE_RESP,...
@ UNDEFINED
Bits [2:0] hold 6 or 7, which no source defines.
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.
void note_moving_until(IoDevice &dev, uint32_t until_ms)
Record when the travel the device is reporting should be over, from its own remaining time.
uint8_t decode_packed_device_subtype(uint8_t type_subtype)
Decode a protocol-packed device subtype from the second metadata byte.
uint32_t settle_delay_ms(uint32_t interval_ms, uint32_t remaining_delay_ms, bool cap_for_stop)
Resolve the follow-up settle-poll delay while a device may still be moving.
@ 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?
MovingReportAction
What the hub does with a status report that reads as moving.
@ FOLLOW
A window is open: schedule the next poll as usual.
@ OPEN
No window: one was opened now; schedule the next poll as usual.
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.
bool decode_dhw_status_target(const IoFrame &reply, float &celsius)
Target temperature from a status reply (CMD_PRIVATE_RESP) of a domestic hot water heater.
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 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.
@ PUSH_PENDING
A poll armed by foreign traffic is pending: move its timer out, change nothing else.
@ ARM
Schedule a poll REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS from now and begin tracking.
@ IGNORE
Nothing: no poll, no tracking change.
Command builders for the IO‑Homecontrol protocol.
Pure codec for a domestic hot water heater: its family-0x00 registers and its target temperature.
Heating profiles: what a climate card offers for each kind of heater, and how.
The operating-mode part of the status read DHW_STATUS_FPI1 / DHW_STATUS_FPI2 (its main parameter,...
Definition proto_dhw.h:136
Runtime state of a paired IO‑Homecontrol device.
float target
Target position the device is moving toward.
DeviceFeatures features
What this device can do, resolved by resolve_device_features().
ExecutionState execution_state
Execution state of the last status report that carried one; UNDEFINED until then, and for a device th...
uint32_t last_status
millis() timestamp of last received status.
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.
float position
Current position: 0=open, 100=closed, or UNKNOWN_POSITION.
uint8_t subtype
Device subtype (manufacturer‑specific).
DeviceType type
Device type (shutter, awning, etc.).
bool is_stopped
True if device is not moving.
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.
void clear_position()
Withdraw the position prediction; call when a decoded position observation supersedes it.
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.
uint32_t remote_activity_poll_delay_ms
Delay from a linked-remote press (not a STOP) to the first status poll of its devices.
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.
One decoded status report, plus what the hub remembers and whether it will poll again.
bool reported_stopped
The report says at rest (decisions::reads_stopped_by_status_byte()).