Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
hub_status.cpp
Go to the documentation of this file.
1#include "hub_internal.h"
2
3#include "hub_decisions.h"
4#include "proto_commands.h"
5
6/// @file hub_status.cpp
7/// @brief Inbound status handling and passive receive-side state updates.
8///
9/// This file owns the receive-side state path for the hub:
10/// - decode status-bearing frames into normalized device state,
11/// - decide when passive traffic should arm one-shot or tracked follow-up polls,
12/// - ACK authenticated device-initiated status updates.
13///
14/// The goal of the split is to keep hub_core.cpp focused on lifecycle,
15/// device registry, and scheduling while leaving the protocol-specific receive
16/// interpretation in one place.
17
18namespace esphome {
19namespace home_io_control {
20
21namespace {
22
23constexpr uint8_t PRIVATE_RESPONSE_MIN_DATA_LEN = 8; ///< Minimum payload length for 0x04 position-bearing replies.
24constexpr uint8_t STATUS_UPDATE_MIN_DATA_LEN = 11; ///< Minimum payload length for 0x71 device-initiated updates.
25constexpr uint8_t GET_INFO2_RESPONSE_MIN_DATA_LEN = 12; ///< Minimum payload length for 0x57 type/subtype metadata.
26constexpr uint8_t EXTENDED_TILT_RESPONSE_MIN_DATA_LEN =
27 15; ///< Minimum payload length for tilt-capable extended status replies.
28constexpr uint8_t STATUS_STOPPED_FLAGS_OFFSET = 0; ///< Byte containing STATUS_STOPPED.
29constexpr uint8_t PRIVATE_RESPONSE_DELAY_HINT_OFFSET = 7; ///< Coarse follow-up delay hint byte in many 0x04 replies.
30constexpr uint8_t PRIVATE_RESPONSE_TARGET_OFFSET = 2; ///< Target-position MSB offset in 0x04 replies.
31constexpr uint8_t PRIVATE_RESPONSE_CURRENT_OFFSET = 4; ///< Current-position MSB offset in 0x04 replies.
32constexpr uint8_t STATUS_UPDATE_TARGET_OFFSET = 5; ///< Target-position MSB offset in 0x71 updates.
33constexpr uint8_t STATUS_UPDATE_CURRENT_OFFSET = 7; ///< Current-position MSB offset in 0x71 updates.
34constexpr uint8_t GET_INFO2_TYPE_OFFSET = 10; ///< Packed device type byte in 0x57 replies.
35constexpr uint8_t GET_INFO2_TYPE_SUBTYPE_OFFSET = 11; ///< Packed type low bits plus subtype byte in 0x57 replies.
36constexpr uint8_t EXTENDED_TILT_SELECTOR_OFFSET = 12; ///< Selector byte announcing extended tilt payload.
37constexpr uint8_t EXTENDED_TILT_MSB_OFFSET = 13; ///< Tilt-position MSB within extended replies.
38constexpr uint8_t EXTENDED_TILT_LSB_OFFSET = 14; ///< Tilt-position LSB within extended replies.
39constexpr uint8_t PRIVATE_RESPONSE_HINT_UNUSED = 0xFF; ///< Value used by devices that do not expose a follow-up timer.
40constexpr uint8_t PRIVATE_RESPONSE_HINT_ZERO =
41 0x00; ///< Value treated as invalid or uninformative for follow-up timing.
42constexpr uint32_t PRIVATE_RESPONSE_HINT_SCALE_MS = 1000; ///< Private-response delay hint is expressed in seconds.
43constexpr uint32_t PRIVATE_RESPONSE_HINT_BIAS_MS =
44 1000; ///< Observed devices need an extra second beyond the hint value.
45constexpr uint32_t DEFAULT_SINGLE_FOLLOW_UP_POLL_DELAY_MS =
46 60000; ///< Legacy worst-case settle poll when neither a device hint nor an explicit interval is available.
47
48/// @brief Decode the shared target/current position fields used by private response and status‑update frames.
49/// Different frame types use different byte offsets, but the normalization policy is identical once offsets known.
50/// @param dev Device record to update.
51/// @param frame IoFrame containing a status‑bearing command.
52/// @param target_offset Byte offset of target MSB within frame.data.
53/// @param current_offset Byte offset of current MSB within frame.data.
54/// @param allow_tilt_from_extended_response If true and frame is extended, decode tilt from the extended tilt bytes.
55void decode_status_fields(IoDevice &dev, const IoFrame &frame, uint8_t target_offset, uint8_t current_offset,
56 bool allow_tilt_from_extended_response) {
57 uint16_t const tgt = (frame.data[target_offset] << 8) | frame.data[target_offset + 1];
58 uint16_t const cur = (frame.data[current_offset] << 8) | frame.data[current_offset + 1];
59 decode_position_report(tgt, cur, dev.is_stopped, dev.target, dev.position);
61
62 if (allow_tilt_from_extended_response && device_supports_tilt(dev.type) &&
63 frame.data_len >= EXTENDED_TILT_RESPONSE_MIN_DATA_LEN &&
64 frame.data[EXTENDED_TILT_SELECTOR_OFFSET] == STATUS_TILT_SELECTOR) {
65 uint16_t const tilt_raw = (frame.data[EXTENDED_TILT_MSB_OFFSET] << 8) | frame.data[EXTENDED_TILT_LSB_OFFSET];
66 dev.tilt = decode_tilt_report(tilt_raw);
67 }
68}
69
70/// @brief Compute the delay before the next status poll for a private‑response device.
71/// @param dev Device record.
72/// @param frame The private response frame (may contain a coarse retry hint in byte 7).
73/// @return Delay in milliseconds.
74uint32_t compute_private_response_delay_ms(const IoDevice &dev, const IoFrame &frame) {
75 if (dev.is_stopped)
76 return 0;
77
78 // Private responses carry a coarse follow‑up timer in byte 7 on many devices.
79 // Delay priority is: device hint when present, otherwise the configured interval, otherwise the
80 // legacy one-shot settle delay. When both a hint and an explicit interval exist, cap the next
81 // poll to the shorter of the two so YAML cannot stretch a device-reported settle window.
82 if (frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] != PRIVATE_RESPONSE_HINT_UNUSED &&
83 frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] != PRIVATE_RESPONSE_HINT_ZERO) {
84 uint32_t const hinted_delay_ms =
85 frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] * PRIVATE_RESPONSE_HINT_SCALE_MS + PRIVATE_RESPONSE_HINT_BIAS_MS;
86 if (dev.status_poll_interval_ms == 0)
87 return hinted_delay_ms;
88 return hinted_delay_ms < dev.status_poll_interval_ms ? hinted_delay_ms : dev.status_poll_interval_ms;
89 }
90 return dev.status_poll_interval_ms != 0 ? dev.status_poll_interval_ms : DEFAULT_SINGLE_FOLLOW_UP_POLL_DELAY_MS;
91}
92
93/// @brief Compute the delay before the next status poll for a device‑originated status update.
94/// @param dev Device record.
95/// @return Delay in milliseconds for tracked polling; no-interval devices stop after the unsolicited update.
96uint32_t compute_status_update_delay_ms(const IoDevice &dev) {
97 return dev.is_stopped ? 0 : dev.status_poll_interval_ms;
98}
99
100/// @brief Apply a private-response frame to the device record.
101/// @param dev Device record to update.
102/// @param frame Private-response frame.
103void apply_private_response_status(IoDevice &dev, const IoFrame &frame) {
104 dev.is_stopped = (frame.data[STATUS_STOPPED_FLAGS_OFFSET] & STATUS_STOPPED) != 0;
105 dev.last_status = millis();
106 decode_status_fields(dev, frame, PRIVATE_RESPONSE_TARGET_OFFSET, PRIVATE_RESPONSE_CURRENT_OFFSET, true);
107
108 const bool tracked_polling_active = detail::status_poll_tracking_active(dev, dev.last_status);
109 if (dev.is_stopped || !tracked_polling_active) {
110 if (!dev.is_stopped && dev.single_follow_up_poll_pending) {
111 dev.next_update = dev.last_status + compute_private_response_delay_ms(dev, frame);
112 dev.single_follow_up_poll_pending = false;
113 return;
114 }
116 return;
117 }
118
119 dev.next_update = dev.last_status + compute_private_response_delay_ms(dev, frame);
120}
121
122/// @brief Apply a device-originated status-update frame to the device record.
123/// @param dev Device record to update.
124/// @param frame Status-update frame.
125void apply_unsolicited_status_update(IoDevice &dev, const IoFrame &frame) {
126 dev.is_stopped = (frame.data[STATUS_STOPPED_FLAGS_OFFSET] & STATUS_STOPPED) != 0;
127 dev.last_status = millis();
128 decode_status_fields(dev, frame, STATUS_UPDATE_TARGET_OFFSET, STATUS_UPDATE_CURRENT_OFFSET, false);
129
130 if (dev.is_stopped || !detail::status_poll_tracking_active(dev, dev.last_status)) {
132 return;
133 }
134
135 dev.next_update = dev.last_status + compute_status_update_delay_ms(dev);
136}
137
138/// @brief Apply INFO2 metadata to the device record when YAML has not already declared it.
139/// @param dev Device record to update.
140/// @param frame INFO2 response frame.
141void apply_info2_response(IoDevice &dev, const IoFrame &frame) {
142 if (dev.type != DeviceType::UNKNOWN)
143 return;
144
145 dev.type = decode_packed_device_type(frame.data[GET_INFO2_TYPE_OFFSET], frame.data[GET_INFO2_TYPE_SUBTYPE_OFFSET]);
146 dev.subtype = decode_packed_device_subtype(frame.data[GET_INFO2_TYPE_SUBTYPE_OFFSET]);
147 if (default_inverted_for_type(dev.type))
148 dev.inverted = true;
149}
150
151} // namespace
152
153void IOHomeControlComponent::begin_status_poll_tracking_(const std::string &device_id, uint32_t initial_delay_ms) {
154 auto *dev = this->get_device(device_id);
155 if (dev == nullptr || dev->status_poll_interval_ms == 0)
156 return;
157
158 uint32_t const now = millis();
159 dev->poll_deadline = now + detail::MAX_TRACKED_STATUS_POLL_WINDOW_MS;
160 dev->next_update = initial_delay_ms == 0 ? 0 : now + initial_delay_ms;
161 dev->status_poll_failures = 0;
162 dev->auth_poll_failures = 0;
163}
164
165void IOHomeControlComponent::schedule_status_poll_(const std::string &device_id, uint32_t delay_ms) {
166 // The timeout name is per-device so repeated remote traffic resets the pending poll instead of
167 // stacking multiple delayed callbacks for the same actuator.
168 const std::string timeout_name = "remote_poll_" + device_id;
169 this->set_timeout(timeout_name.c_str(), delay_ms,
170 [this, device_id]() { this->queue_request_device_status(device_id); });
171}
172
174 const std::string id = node_id_to_string(frame.src);
175 auto it = this->devices_.find(id);
176 if (it == this->devices_.end()) {
177 detail::log_frame_issue(this, "rx", "unregistered_device", frame, frame_length(frame));
178 return;
179 }
180 IoDevice &dev = it->second;
181
182 if (frame.cmd == CMD_PRIVATE_RESP) {
183 if (frame.data_len < PRIVATE_RESPONSE_MIN_DATA_LEN) {
184 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
185 return;
186 }
187
188 // CMD_PRIVATE_RESP (0x04) serves as the reply to both status polls (0x03) and execute
189 // commands (0x00). The position fields below are shared across both response types, so
190 // normalize them once here before the entity layer decides how to present the state.
191 apply_private_response_status(dev, frame);
193 this->notify_device_update_(id);
194 return;
195 }
196
197 if (frame.cmd == CMD_STATUS_UPDATE) {
198 if (frame.data_len < STATUS_UPDATE_MIN_DATA_LEN) {
199 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
200 return;
201 }
202
203 // Status-update frames come from the device itself rather than from a direct controller poll.
204 // They use different offsets for the target/current fields and do not carry reliable tilt data.
205 apply_unsolicited_status_update(dev, frame);
206 detail::log_status_update(id, dev, " (status update)");
207 this->notify_device_update_(id);
208 return;
209 }
210
211 if (frame.cmd == CMD_GET_INFO2_RESP) {
212 if (frame.data_len < GET_INFO2_RESPONSE_MIN_DATA_LEN) {
213 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
214 return;
215 }
216
217 // INFO2 is metadata, not movement state. Only learn type from radio if still UNKNOWN;
218 // YAML-declared type takes priority.
219 apply_info2_response(dev, frame);
220 ESP_LOGI(detail::TAG, "Device %s: type=%s (%u) class=%s profile=%s subtype=%u", id.c_str(),
223 return;
224 }
225
226 if (frame.cmd == CMD_PRIVATE_RESP || frame.cmd == CMD_STATUS_UPDATE || frame.cmd == CMD_GET_INFO2_RESP) {
227 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
228 }
229}
230
232 IoFrame frame;
233 if (!parse(packet.data, packet.len, frame)) {
234 detail::log_component_capture(this->radio_, "parse_fail", packet.data, packet.len);
235 return;
236 }
237
238 detail::log_component_capture(this->radio_, "parse_ok", packet.data, packet.len, &frame);
239
240 // Exchange-internal frames (0x3C challenge request, 0x3D challenge response) are part of
241 // another controller's authenticated exchange. They carry no extractable status data for
242 // a passive observer — skip silently. They remain visible in io_capture (stage=parse_ok).
244 return;
245 }
246
247 if (frame.cmd == CMD_STATUS_UPDATE && memcmp(frame.dst, this->node_id_, NODE_ID_SIZE) == 0) {
248 if (this->authenticate_request_(frame, packet.freq_hz)) {
249 IoFrame resp;
250 if (!create_status_update_resp(resp, this->node_id_, frame.src)) {
251 detail::log_frame_issue(this, "rx", "ack_build_failed", frame, packet.len);
252 return;
253 }
254 // Device-originated updates may arrive while the sender and receiver are not aligned on the
255 // same hop channel anymore. Broadcasting the ACK across all three IO-homecontrol channels
256 // matched the behavior of real controllers and made updates reliable in practice.
260 this->update_device_status_(frame);
261 } else {
262 detail::log_frame_issue(this, "rx", "auth_failed", frame, packet.len);
263 }
264 return;
265 }
266
267 if (frame.cmd == CMD_PRIVATE_RESP || frame.cmd == CMD_STATUS_UPDATE) {
268 // Passive receive mode can still observe replies/status from other exchanges. If a frame
269 // is status-bearing and not exchange-internal, try to merge it into known device state.
270 this->update_device_status_(frame);
271 return;
272 }
273
274 // Check if this frame targets one of our registered devices (e.g., a physical remote
275 // commanding a shutter we also control). If so, schedule a status poll after 2 seconds
276 // to pick up the resulting position change. The timeout name includes the device ID so
277 // repeated remote activity resets the timer rather than stacking redundant polls.
278 // The 2-second delay gives the device time to complete the exchange and start moving.
279 const std::string dst_id = node_id_to_string(frame.dst);
280 if (this->get_device(dst_id) != nullptr && memcmp(frame.src, this->node_id_, NODE_ID_SIZE) != 0) {
281 if (auto *dev = this->get_device(dst_id); dev != nullptr)
282 dev->single_follow_up_poll_pending = dev->status_poll_interval_ms == 0;
283 ESP_LOGD(detail::TAG, "rx remote_activity src=%s dst=%s cmd=0x%02X, scheduling status poll",
284 node_id_to_string(frame.src).c_str(), dst_id.c_str(), frame.cmd);
285 this->begin_status_poll_tracking_(dst_id, 0);
287 return;
288 }
289
290 // Check if the frame source is a linked remote (e.g., a 1W remote whose destination address
291 // differs from the device's 2W ID). When a linked remote is active, schedule status polls
292 // for all devices it controls.
293 const std::string src_id = node_id_to_string(frame.src);
294 auto remote_it = this->linked_remotes_.find(src_id);
295 if (remote_it != this->linked_remotes_.end()) {
296 for (const auto &device_id : remote_it->second) {
297 if (auto *dev = this->get_device(device_id); dev != nullptr)
298 dev->single_follow_up_poll_pending = dev->status_poll_interval_ms == 0;
299 ESP_LOGD(detail::TAG, "rx remote_activity (linked) remote=%s device=%s cmd=0x%02X, scheduling status poll",
300 src_id.c_str(), device_id.c_str(), frame.cmd);
301 this->begin_status_poll_tracking_(device_id, 0);
303 }
304 return;
305 }
306
307 detail::log_frame_issue(this, "rx", "unhandled_cmd", frame, packet.len);
308}
309
310} // namespace home_io_control
311} // namespace esphome
std::map< std::string, IoDevice > devices_
Definition hub_core.h:383
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:256
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 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:194
void update_device_status_(const IoFrame &frame)
Extract position/status info from a status or status-update frame and merge into device record.
void notify_device_update_(const std::string &id)
Fire all registered device update callbacks for the given device ID.
Definition hub_core.cpp:220
std::map< std::string, std::vector< std::string > > linked_remotes_
Maps remote node IDs to lists of device IDs they control.
Definition hub_core.h:388
void process_received_packet_(const RadioRxPacket &packet)
Parse received packet, update device state if it's a status frame, and notify covers.
bool authenticate_request_(const IoFrame &request, uint32_t freq)
Handle an inbound authenticated command from a device (status updates, etc.).
Pure transition helpers for hub-owned exchange and pairing frame decisions.
Internal helpers shared by the hub implementation .cpp files.
bool is_exchange_internal_command(uint8_t cmd)
Returns true for commands that are internal to an exchange handshake and carry no useful information ...
constexpr const char * TAG
Shared log tag for hub-level messages.
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.
void normalize_stopped_state(IoDevice &dev)
Normalize stopped state: some devices briefly report stopped before target/current converge.
void log_status_update(const std::string &id, const IoDevice &dev, const char *suffix="")
Log a concise status‑update line used by inbound handlers.
constexpr uint32_t REMOTE_ACTIVITY_STATUS_POLL_DELAY_MS
Delay before polling after overheard remote traffic.
constexpr uint32_t MAX_TRACKED_STATUS_POLL_WINDOW_MS
Hard stop for follow-up polling after a command or remote activity.
void clear_status_poll_tracking(IoDevice &dev)
Clear all bounded follow-up polling state for a device.
bool status_poll_tracking_active(const IoDevice &dev, uint32_t now)
Check whether a device remains inside its bounded follow-up polling window.
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).
const char * device_operation_profile_name(DeviceType type)
Human‑readable operation profile name for a device type.
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_frame.h:80
@ UNKNOWN
Unknown/unspecified device.
static constexpr uint8_t CMD_STATUS_UPDATE
Device-initiated status update (needs auth).
bool default_inverted_for_type(DeviceType type)
Determine whether a device type has inverted position mapping by default.
static constexpr uint32_t FREQ_CH1
The protocol uses 3 frequency channels in the 868 MHz ISM band.
Definition proto_frame.h:30
static constexpr uint32_t FREQ_CH3
Channel 3: 869.85 MHz (2W only).
Definition proto_frame.h:32
const char * device_type_name(DeviceType type)
Convert a DeviceType to a lowercase string identifier.
float decode_tilt_report(uint16_t tilt_raw)
Decode tilt angle from raw 16‑bit value.
uint8_t frame_length(const IoFrame &f)
Get total frame length from ctrl0.
DeviceType decode_packed_device_type(uint8_t type_msb, uint8_t type_subtype)
Decode a protocol-packed device type from two metadata bytes.
bool parse(const uint8_t *buf, uint8_t buf_len, IoFrame &f)
Parse a wire buffer into a parsed IoFrame (validates length and CTRL0).
static constexpr uint8_t CMD_PRIVATE_RESP
Response to 0x00 and 0x03 (contains position data).
const char * device_capability_class_name(DeviceType type)
Get a human‑readable name for a capability class.
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
Definition proto_frame.h:31
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.
uint8_t decode_packed_device_subtype(uint8_t type_subtype)
Decode a protocol-packed device subtype from the second metadata byte.
static constexpr uint8_t STATUS_TILT_SELECTOR
Extended status payload marker for tilt-capable devices.
static constexpr uint16_t SHORT_PREAMBLE
8 bytes for response/continuation frames
Definition proto_frame.h:40
static constexpr uint8_t CMD_GET_INFO2_RESP
Device type/model response.
void decode_position_report(uint16_t target_raw, uint16_t current_raw, bool is_stopped, float &target, float &position)
Decode target/current position values from a status frame.
bool device_supports_tilt(DeviceType type)
Does this device type support tilt (slat angle) control?
bool create_status_update_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a status-update acknowledgment (0x72).
static constexpr uint8_t STATUS_STOPPED
Status byte flags in CMD_PRIVATE_RESP and CMD_STATUS_UPDATE.
Command builders for the IO‑Homecontrol protocol.
Runtime state of a paired IO‑Homecontrol device.
uint8_t subtype
Device subtype (manufacturer‑specific).
DeviceType type
Device type (shutter, awning, etc.).
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
uint8_t data_len
Actual length of data.
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.