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/// @ingroup hioc_hub
9///
10/// This file owns the receive-side state path for the hub:
11/// - decode status-bearing frames into normalized device state,
12/// - decide when passive traffic should arm one-shot or tracked follow-up polls,
13/// - ACK authenticated device-initiated status updates.
14///
15/// @todo Validate the unsolicited CMD_STATUS_UPDATE path on hardware that actually emits
16/// device-initiated updates after pairing, including inbound authentication,
17/// three-channel ACK broadcast, and Home Assistant state publication without polling.
18///
19/// The goal of the split is to keep hub_core.cpp focused on lifecycle,
20/// device registry, and scheduling while leaving the protocol-specific receive
21/// interpretation in one place.
22
23namespace esphome {
24namespace home_io_control {
25
26namespace {
27
28constexpr uint8_t PRIVATE_RESPONSE_MIN_DATA_LEN = 8; ///< Minimum payload length for 0x04 position-bearing replies.
29constexpr uint8_t STATUS_UPDATE_MIN_DATA_LEN = 11; ///< Minimum payload length for 0x71 device-initiated updates.
30constexpr uint8_t GET_NAME_RESPONSE_MIN_DATA_LEN = 1; ///< Minimum payload length for 0x51 name-bearing replies.
31constexpr uint8_t GET_INFO2_RESPONSE_MIN_DATA_LEN = 12; ///< Minimum payload length for 0x57 type/subtype metadata.
32constexpr uint8_t ERROR_RESPONSE_MIN_DATA_LEN = 1; ///< Minimum payload length for 0xFE result-bearing replies.
33constexpr uint8_t EXTENDED_TILT_RESPONSE_MIN_DATA_LEN =
34 15; ///< Minimum payload length for tilt-capable extended status replies.
35constexpr uint8_t STATUS_STOPPED_FLAGS_OFFSET = 0; ///< Byte containing STATUS_STOPPED.
36constexpr uint8_t PRIVATE_RESPONSE_DELAY_HINT_OFFSET = 7; ///< Coarse follow-up delay hint byte in many 0x04 replies.
37constexpr uint8_t PRIVATE_RESPONSE_TARGET_OFFSET = 2; ///< Target-position MSB offset in 0x04 replies.
38constexpr uint8_t PRIVATE_RESPONSE_CURRENT_OFFSET = 4; ///< Current-position MSB offset in 0x04 replies.
39constexpr uint8_t STATUS_UPDATE_TARGET_OFFSET = 5; ///< Target-position MSB offset in 0x71 updates.
40constexpr uint8_t STATUS_UPDATE_CURRENT_OFFSET = 7; ///< Current-position MSB offset in 0x71 updates.
41constexpr uint8_t GET_INFO2_TYPE_OFFSET = 10; ///< Packed device type byte in 0x57 replies.
42constexpr uint8_t GET_INFO2_TYPE_SUBTYPE_OFFSET = 11; ///< Packed type low bits plus subtype byte in 0x57 replies.
43constexpr uint8_t EXTENDED_TILT_SELECTOR_OFFSET = 12; ///< Selector byte announcing extended tilt payload.
44constexpr uint8_t EXTENDED_TILT_MSB_OFFSET = 13; ///< Tilt-position MSB within extended replies.
45constexpr uint8_t EXTENDED_TILT_LSB_OFFSET = 14; ///< Tilt-position LSB within extended replies.
46constexpr uint8_t PRIVATE_RESPONSE_HINT_UNUSED = 0xFF; ///< Value used by devices that do not expose a follow-up timer.
47constexpr uint8_t PRIVATE_RESPONSE_HINT_ZERO =
48 0x00; ///< Value treated as invalid or uninformative for follow-up timing.
49constexpr uint32_t PRIVATE_RESPONSE_HINT_SCALE_MS = 1000; ///< Private-response delay hint is expressed in seconds.
50constexpr uint32_t PRIVATE_RESPONSE_HINT_BIAS_MS =
51 1000; ///< Observed devices need an extra second beyond the hint value.
52constexpr uint32_t DEFAULT_SINGLE_FOLLOW_UP_POLL_DELAY_MS =
53 60000; ///< Legacy worst-case settle poll when neither a device hint nor an explicit interval is available.
54
55/// @brief Decode the shared target/current position fields used by private response and status‑update frames.
56/// Different frame types use different byte offsets, but the normalization policy is identical once offsets known.
57/// @param dev Device record to update.
58/// @param frame IoFrame containing a status‑bearing command.
59/// @param target_offset Byte offset of target MSB within frame.data.
60/// @param current_offset Byte offset of current MSB within frame.data.
61/// @param allow_tilt_from_extended_response If true and frame is extended, decode tilt from the extended tilt bytes.
62void decode_status_fields(IoDevice &dev, const IoFrame &frame, uint8_t target_offset, uint8_t current_offset,
63 bool allow_tilt_from_extended_response) {
64 uint16_t const tgt = (frame.data[target_offset] << 8) | frame.data[target_offset + 1];
65 uint16_t const cur = (frame.data[current_offset] << 8) | frame.data[current_offset + 1];
66 decode_position_report(tgt, cur, dev.is_stopped, dev.target, dev.position);
68
69 if (allow_tilt_from_extended_response && device_supports_tilt(dev.type) &&
70 frame.data_len >= EXTENDED_TILT_RESPONSE_MIN_DATA_LEN &&
71 frame.data[EXTENDED_TILT_SELECTOR_OFFSET] == STATUS_TILT_SELECTOR) {
72 uint16_t const tilt_raw = (frame.data[EXTENDED_TILT_MSB_OFFSET] << 8) | frame.data[EXTENDED_TILT_LSB_OFFSET];
73 dev.tilt = decode_tilt_report(tilt_raw);
74 }
75}
76
77/// @brief Compute the delay before the next status poll for a private‑response device.
78/// @param dev Device record.
79/// @param frame The private response frame (may contain a coarse retry hint in byte 7).
80/// @return Delay in milliseconds.
81uint32_t compute_private_response_delay_ms(const IoDevice &dev, const IoFrame &frame) {
82 if (dev.is_stopped)
83 return 0;
84
85 // Private responses carry a coarse follow‑up timer in byte 7 on many devices.
86 // Delay priority is: device hint when present, otherwise the configured interval, otherwise the
87 // legacy one-shot settle delay. When both a hint and an explicit interval exist, cap the next
88 // poll to the shorter of the two so YAML cannot stretch a device-reported settle window.
89 if (frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] != PRIVATE_RESPONSE_HINT_UNUSED &&
90 frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] != PRIVATE_RESPONSE_HINT_ZERO) {
91 uint32_t const hinted_delay_ms =
92 frame.data[PRIVATE_RESPONSE_DELAY_HINT_OFFSET] * PRIVATE_RESPONSE_HINT_SCALE_MS + PRIVATE_RESPONSE_HINT_BIAS_MS;
93 if (dev.status_poll_interval_ms == 0)
94 return hinted_delay_ms;
95 return hinted_delay_ms < dev.status_poll_interval_ms ? hinted_delay_ms : dev.status_poll_interval_ms;
96 }
97 return dev.status_poll_interval_ms != 0 ? dev.status_poll_interval_ms : DEFAULT_SINGLE_FOLLOW_UP_POLL_DELAY_MS;
98}
99
100/// @brief Compute the delay before the next status poll for a device‑originated status update.
101/// @param dev Device record.
102/// @return Delay in milliseconds for tracked polling; no-interval devices stop after the unsolicited update.
103uint32_t compute_status_update_delay_ms(const IoDevice &dev) {
104 return dev.is_stopped ? 0 : dev.status_poll_interval_ms;
105}
106
107/// @brief Apply a private-response frame to the device record.
108/// @param dev Device record to update.
109/// @param frame Private-response frame.
110void apply_private_response_status(IoDevice &dev, const IoFrame &frame) {
111 dev.is_stopped = (frame.data[STATUS_STOPPED_FLAGS_OFFSET] & STATUS_STOPPED) != 0;
112 dev.last_status = millis();
113 decode_status_fields(dev, frame, PRIVATE_RESPONSE_TARGET_OFFSET, PRIVATE_RESPONSE_CURRENT_OFFSET, true);
114
115 const bool tracked_polling_active = detail::status_poll_tracking_active(dev, dev.last_status);
116 if (dev.is_stopped || !tracked_polling_active) {
117 if (!dev.is_stopped && dev.single_follow_up_poll_pending) {
118 dev.next_update = dev.last_status + compute_private_response_delay_ms(dev, frame);
119 dev.single_follow_up_poll_pending = false;
120 return;
121 }
123 return;
124 }
125
126 dev.next_update = dev.last_status + compute_private_response_delay_ms(dev, frame);
127}
128
129/// @brief Apply a device-originated status-update frame to the device record.
130/// @param dev Device record to update.
131/// @param frame Status-update frame.
132void apply_unsolicited_status_update(IoDevice &dev, const IoFrame &frame) {
133 dev.is_stopped = (frame.data[STATUS_STOPPED_FLAGS_OFFSET] & STATUS_STOPPED) != 0;
134 dev.last_status = millis();
135 decode_status_fields(dev, frame, STATUS_UPDATE_TARGET_OFFSET, STATUS_UPDATE_CURRENT_OFFSET, false);
136
137 if (dev.is_stopped || !detail::status_poll_tracking_active(dev, dev.last_status)) {
139 return;
140 }
141
142 dev.next_update = dev.last_status + compute_status_update_delay_ms(dev);
143}
144
145/// @brief Apply INFO2 metadata to the device record when YAML has not already declared it.
146/// @param dev Device record to update.
147/// @param frame INFO2 response frame.
148void apply_info2_response(IoDevice &dev, const IoFrame &frame) {
149 if (dev.type != DeviceType::UNKNOWN)
150 return;
151
152 dev.type = decode_packed_device_type(frame.data[GET_INFO2_TYPE_OFFSET], frame.data[GET_INFO2_TYPE_SUBTYPE_OFFSET]);
153 dev.subtype = decode_packed_device_subtype(frame.data[GET_INFO2_TYPE_SUBTYPE_OFFSET]);
154 if (default_inverted_for_type(dev.type))
155 dev.inverted = true;
156}
157
158/// @brief Apply a name response frame to the device record.
159/// @param dev Device record to update.
160/// @param frame Name response frame.
161void apply_name_response(IoDevice &dev, const IoFrame &frame) {
162 std::string const name = decode_device_name_payload(frame.data, frame.data_len);
163 memset(dev.name, 0, sizeof(dev.name));
164 if (!name.empty())
165 memcpy(dev.name, name.c_str(), name.length());
166}
167
168} // namespace
169
170void IOHomeControlComponent::begin_status_poll_tracking_(const std::string &device_id, uint32_t initial_delay_ms) {
171 auto *dev = this->get_device(device_id);
172 if (dev == nullptr || dev->status_poll_interval_ms == 0)
173 return;
174
175 uint32_t const now = millis();
176 dev->poll_deadline = now + detail::MAX_TRACKED_STATUS_POLL_WINDOW_MS;
177 dev->next_update = initial_delay_ms == 0 ? 0 : now + initial_delay_ms;
178 dev->status_poll_failures = 0;
179 dev->auth_poll_failures = 0;
180}
181
182void IOHomeControlComponent::schedule_status_poll_(const std::string &device_id, uint32_t delay_ms) {
183 // The timeout name is per-device so repeated remote traffic resets the pending poll instead of
184 // stacking multiple delayed callbacks for the same actuator.
185 const std::string timeout_name = "remote_poll_" + device_id;
186 this->set_timeout(timeout_name.c_str(), delay_ms,
187 [this, device_id]() { this->queue_request_device_status(device_id); });
188}
189
191 const std::string id = node_id_to_string(frame.src);
192 auto it = this->devices_.find(id);
193 if (it == this->devices_.end()) {
194 detail::log_frame_issue(this, "rx", "unregistered_device", frame, frame_length(frame));
195 return;
196 }
197 IoDevice &dev = it->second;
198
199 if (frame.cmd == CMD_PRIVATE_RESP) {
200 if (frame.data_len < PRIVATE_RESPONSE_MIN_DATA_LEN) {
201 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
202 return;
203 }
204
205 // CMD_PRIVATE_RESP (0x04) serves as the reply to both status polls (0x03) and execute
206 // commands (0x00). The position fields below are shared across both response types, so
207 // normalize them once here before the entity layer decides how to present the state.
208 apply_private_response_status(dev, frame);
210 this->notify_device_update_(id);
211 return;
212 }
213
214 if (frame.cmd == CMD_STATUS_UPDATE) {
215 if (frame.data_len < STATUS_UPDATE_MIN_DATA_LEN) {
216 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
217 return;
218 }
219
220 // Status-update frames come from the device itself rather than from a direct controller poll.
221 // They use different offsets for the target/current fields and do not carry reliable tilt data.
222 apply_unsolicited_status_update(dev, frame);
223 detail::log_status_update(id, dev, " (status update)");
224 this->notify_device_update_(id);
225 return;
226 }
227
228 if (frame.cmd == CMD_GET_NAME_RESP) {
229 if (frame.data_len < GET_NAME_RESPONSE_MIN_DATA_LEN) {
230 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
231 return;
232 }
233
234 apply_name_response(dev, frame);
235 ESP_LOGI(detail::TAG, "Device %s: name=%s", id.c_str(), dev.name[0] == '\0' ? "" : dev.name);
236 this->notify_device_update_(id);
237 return;
238 }
239
240 if (frame.cmd == CMD_GET_INFO2_RESP) {
241 if (frame.data_len < GET_INFO2_RESPONSE_MIN_DATA_LEN) {
242 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
243 return;
244 }
245
246 // INFO2 is metadata, not movement state. Only learn type from radio if still UNKNOWN;
247 // YAML-declared type takes priority.
248 apply_info2_response(dev, frame);
249 ESP_LOGI(detail::TAG, "Device %s: type=%s (%u) class=%s profile=%s subtype=%u", id.c_str(),
252 return;
253 }
254
255 if (frame.cmd == CMD_ERROR_RESP) {
256 if (frame.data_len < ERROR_RESPONSE_MIN_DATA_LEN) {
257 detail::log_frame_issue(this, "rx", "unsupported_payload", frame, frame_length(frame));
258 return;
259 }
260
261 detail::log_command_result(id, frame.data[0]);
262 return;
263 }
264}
265
267 IoFrame frame;
268 if (!parse(packet.data, packet.len, frame)) {
269 detail::log_component_capture(this->radio_, "parse_fail", packet.data, packet.len);
270 return;
271 }
272
273 detail::log_component_capture(this->radio_, "parse_ok", packet.data, packet.len, &frame);
274
275 // Exchange-internal frames (0x3C challenge request, 0x3D challenge response) are part of
276 // another controller's authenticated exchange. They carry no extractable status data for
277 // a passive observer — skip silently. They remain visible in io_capture (stage=parse_ok).
279 return;
280 }
281
282 if (frame.cmd == CMD_STATUS_UPDATE && memcmp(frame.dst, this->node_id_, NODE_ID_SIZE) == 0) {
283 if (this->authenticate_request_(frame, packet.freq_hz)) {
284 IoFrame resp;
285 if (!create_status_update_resp(resp, this->node_id_, frame.src)) {
286 detail::log_frame_issue(this, "rx", "ack_build_failed", frame, packet.len);
287 return;
288 }
289 // Device-originated updates may arrive while the sender and receiver are not aligned on the
290 // same hop channel anymore. Broadcasting the ACK across all three IO-homecontrol channels
291 // matched the behavior of real controllers and made updates reliable in practice.
295 this->update_device_status_(frame);
296 } else {
297 detail::log_frame_issue(this, "rx", "auth_failed", frame, packet.len);
298 }
299 return;
300 }
301
302 if (frame.cmd == CMD_PRIVATE_RESP || frame.cmd == CMD_STATUS_UPDATE) {
303 // Passive receive mode can still observe replies/status from other exchanges. If a frame
304 // is status-bearing and not exchange-internal, try to merge it into known device state.
305 this->update_device_status_(frame);
306 return;
307 }
308
309 // Check if this frame targets one of our registered devices (e.g., a physical remote
310 // commanding a shutter we also control). If so, schedule a status poll after 2 seconds
311 // to pick up the resulting position change. The timeout name includes the device ID so
312 // repeated remote activity resets the timer rather than stacking redundant polls.
313 // The 2-second delay gives the device time to complete the exchange and start moving.
314 const std::string dst_id = node_id_to_string(frame.dst);
315 if (this->get_device(dst_id) != nullptr && memcmp(frame.src, this->node_id_, NODE_ID_SIZE) != 0) {
316 if (auto *dev = this->get_device(dst_id); dev != nullptr)
317 dev->single_follow_up_poll_pending = dev->status_poll_interval_ms == 0;
318 ESP_LOGD(detail::TAG, "rx remote_activity src=%s dst=%s cmd=0x%02X, scheduling status poll",
319 node_id_to_string(frame.src).c_str(), dst_id.c_str(), frame.cmd);
320 this->begin_status_poll_tracking_(dst_id, 0);
322 return;
323 }
324
325 // Check if the frame source is a linked remote (e.g., a 1W remote whose destination address
326 // differs from the device's 2W ID). When a linked remote is active, schedule status polls
327 // for all devices it controls.
328 const std::string src_id = node_id_to_string(frame.src);
329 auto remote_it = this->linked_remotes_.find(src_id);
330 if (remote_it != this->linked_remotes_.end()) {
331 for (const auto &device_id : remote_it->second) {
332 if (auto *dev = this->get_device(device_id); dev != nullptr)
333 dev->single_follow_up_poll_pending = dev->status_poll_interval_ms == 0;
334 ESP_LOGD(detail::TAG, "rx remote_activity (linked) remote=%s device=%s cmd=0x%02X, scheduling status poll",
335 src_id.c_str(), device_id.c_str(), frame.cmd);
336 this->begin_status_poll_tracking_(device_id, 0);
338 }
339 return;
340 }
341
342 detail::log_frame_issue(this, "rx", "unhandled_cmd", frame, packet.len);
343}
344
345} // namespace home_io_control
346} // namespace esphome
std::map< std::string, IoDevice > devices_
Definition hub_core.h:441
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:258
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:196
void update_device_status_(const IoFrame &frame)
Extract supported position or metadata info from a response frame and merge it into the 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:222
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:446
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.).
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).
void log_command_result(const std::string &id, uint8_t result, uint8_t request_cmd=0, bool include_request_cmd=false)
Log a decoded CMD_ERROR_RESP result with optional request-command context.
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:81
static constexpr uint8_t CMD_ERROR_RESP
Error response to any command.
static constexpr uint8_t CMD_GET_NAME_RESP
Device name response.
@ UNKNOWN
Unknown/unspecified device.
static constexpr uint8_t CMD_STATUS_UPDATE
Device-initiated status update (needs auth).
bool default_inverted_for_type(DeviceType type)
Determine whether a device type has inverted position mapping by default.
static constexpr uint32_t FREQ_CH1
The protocol uses 3 frequency channels in the 868 MHz ISM band.
Definition proto_frame.h:31
static constexpr uint32_t FREQ_CH3
Channel 3: 869.85 MHz (2W only).
Definition proto_frame.h:33
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:32
std::string node_id_to_string(const uint8_t id[NODE_ID_SIZE])
Format a 3‑byte node ID as a 6‑character uppercase hex string.
std::string decode_device_name_payload(const uint8_t *data, uint8_t len)
Decode a device-name payload from IO-homecontrol's Latin-1 wire format into UTF-8.
uint8_t decode_packed_device_subtype(uint8_t type_subtype)
Decode a protocol-packed device subtype from the second metadata byte.
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:41
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.
char name[DEVICE_NAME_BUFFER_SIZE]
Cached UTF-8 device name decoded from Latin-1 wire payloads.
uint8_t subtype
Device subtype (manufacturer‑specific).
DeviceType type
Device type (shutter, awning, etc.).
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes).
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.