Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
proto_codecs.cpp
Go to the documentation of this file.
1/// @file proto_codecs.cpp
2/// @brief Device-name, address-classification and 1W-frame codec implementations.
3/// @ingroup hioc_protocol
4
5#include "proto_codecs.h"
6#include "proto_constants.h"
7#include "proto_crypto.h"
8#include "proto_frame.h"
9
10#include <cctype>
11#include <cstdio>
12#include <cstring>
13#include <string>
14
15namespace esphome {
16namespace home_io_control {
17
18namespace {
19
20constexpr uint8_t UTF8_SINGLE_BYTE_MAX = 0x80;
21constexpr uint8_t UTF8_TWO_BYTE_LEAD_BASE = 0xC0;
22constexpr uint8_t UTF8_CONTINUATION_BASE = 0x80;
23constexpr uint8_t UTF8_CONTINUATION_MASK = 0x3F;
24constexpr uint8_t UTF8_TWO_BYTE_SHIFT = 6;
25constexpr uint8_t NAME_PADDING_NUL = 0x00;
26constexpr uint8_t NAME_PADDING_SPACE = 0x20;
27constexpr uint8_t UTF8_TWO_BYTE_MASK = 0xE0;
28constexpr uint8_t UTF8_TWO_BYTE_PREFIX = 0xC0;
29constexpr uint8_t UTF8_THREE_BYTE_MASK = 0xF0;
30constexpr uint8_t UTF8_THREE_BYTE_PREFIX = 0xE0;
31constexpr uint8_t UTF8_FOUR_BYTE_MASK = 0xF8;
32constexpr uint8_t UTF8_FOUR_BYTE_PREFIX = 0xF0;
33constexpr uint8_t UTF8_CONTINUATION_PREFIX_MASK = 0xC0;
34constexpr uint8_t UTF8_CONTINUATION_PREFIX = 0x80;
35constexpr uint8_t UTF8_TWO_BYTE_VALUE_MASK = 0x1F;
36constexpr uint8_t ASCII_MAX = 0x7F;
37constexpr uint8_t DISCOVERY_TIMESTAMP_MSB_SHIFT = 8; ///< Shift for the timestamp field's big-endian MSB.
38
39// CMD_ONEWAY_ADD_CONTROLLER (0x30) declared-payload layout: enc_key[16] + man_id[1] + data[1] +
40// sequence[2] = 20 bytes. Offsets are into `frame.data`, never into the out-of-length MAC trailer
41// (`frame.mac`, see IoFrame::has_mac) which decode_1w_add_controller() reads separately.
42constexpr uint8_t ONEWAY_ADD_CONTROLLER_ENC_KEY_OFFSET = 0;
43constexpr uint8_t ONEWAY_ADD_CONTROLLER_MANUFACTURER_OFFSET = AES_KEY_SIZE; // 16
44constexpr uint8_t ONEWAY_ADD_CONTROLLER_SEQUENCE_OFFSET = AES_KEY_SIZE + 2; // 18: man_id(1) + data(1) skipped
45constexpr uint8_t ONEWAY_ADD_CONTROLLER_PAYLOAD_SIZE = AES_KEY_SIZE + 4; // 20: enc_key+man_id+data+sequence
46constexpr uint8_t ONEWAY_ADD_CONTROLLER_SEQUENCE_MSB_SHIFT = 8; ///< Shift for the sequence field's big-endian MSB.
47// The only span CMD_ONEWAY_ADD_CONTROLLER authenticates (create_1w_hmac()'s @warning): cmd byte
48// followed by the 16 encrypted-key bytes, NOT the whole declared payload.
49constexpr uint8_t ONEWAY_ADD_CONTROLLER_MAC_SPAN_SIZE = 1 + AES_KEY_SIZE; // 17
50
51std::string latin1_to_utf8(const uint8_t *data, size_t len) {
52 std::string result;
53 result.reserve(len * 2);
54
55 for (size_t index = 0; index < len; index++) {
56 uint8_t const byte = data[index];
57 if (byte < UTF8_SINGLE_BYTE_MAX) {
58 if (result.length() + 1 >= DEVICE_NAME_BUFFER_SIZE)
59 break;
60 result.push_back(static_cast<char>(byte));
61 continue;
62 }
63
64 if (result.length() + 2 >= DEVICE_NAME_BUFFER_SIZE)
65 break;
66
67 result.push_back(static_cast<char>(UTF8_TWO_BYTE_LEAD_BASE | (byte >> UTF8_TWO_BYTE_SHIFT)));
68 result.push_back(static_cast<char>(UTF8_CONTINUATION_BASE | (byte & UTF8_CONTINUATION_MASK)));
69 }
70
71 return result;
72}
73
74} // namespace
75
76std::string trim_ascii_whitespace(const std::string &value) {
77 size_t begin = 0;
78 while (begin < value.length() && std::isspace(static_cast<unsigned char>(value[begin])) != 0)
79 begin++;
80
81 size_t end = value.length();
82 while (end > begin && std::isspace(static_cast<unsigned char>(value[end - 1])) != 0)
83 end--;
84
85 return value.substr(begin, end - begin);
86}
87
88std::string decode_device_name_payload(const uint8_t *data, uint8_t len) {
89 if (data == nullptr || len == 0)
90 return {};
91
92 const uint8_t begin = data[0] > NAME_PADDING_SPACE ? 0 : 1;
93 if (begin >= len)
94 return {};
95
96 size_t raw_len = len - begin;
97 while (raw_len > 0 &&
98 (data[begin + raw_len - 1] == NAME_PADDING_NUL || data[begin + raw_len - 1] == NAME_PADDING_SPACE))
99 raw_len--;
100
101 if (raw_len == 0)
102 return {};
103
104 return latin1_to_utf8(data + begin, raw_len);
105}
106
108 uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE],
109 std::string &normalized_name) {
110 if (payload == nullptr)
112
113 std::memset(payload, 0, DEVICE_NAME_WRITE_PAYLOAD_SIZE);
114 normalized_name.clear();
115
116 const std::string trimmed_name = trim_ascii_whitespace(name);
117 if (trimmed_name.empty())
119
120 uint8_t latin1_len = 0;
121 for (size_t index = 0; index < trimmed_name.length();) {
122 const auto byte = static_cast<uint8_t>(trimmed_name[index]);
123 uint16_t codepoint = 0;
124 size_t advance = 1;
125
126 if (byte <= ASCII_MAX) {
127 codepoint = byte;
128 } else if ((byte & UTF8_TWO_BYTE_MASK) == UTF8_TWO_BYTE_PREFIX) {
129 if (index + 1 >= trimmed_name.length())
131
132 const auto continuation = static_cast<uint8_t>(trimmed_name[index + 1]);
133 if ((continuation & UTF8_CONTINUATION_PREFIX_MASK) != UTF8_CONTINUATION_PREFIX)
135
136 codepoint = static_cast<uint16_t>(((byte & UTF8_TWO_BYTE_VALUE_MASK) << UTF8_TWO_BYTE_SHIFT) |
137 (continuation & UTF8_CONTINUATION_MASK));
138 if (codepoint < UTF8_SINGLE_BYTE_MAX)
140 advance = 2;
141 } else if ((byte & UTF8_THREE_BYTE_MASK) == UTF8_THREE_BYTE_PREFIX ||
142 (byte & UTF8_FOUR_BYTE_MASK) == UTF8_FOUR_BYTE_PREFIX) {
144 } else {
146 }
147
148 if (codepoint > LATIN1_CODEPOINT_MAX)
150
151 if (latin1_len >= DEVICE_NAME_WRITE_CHAR_LIMIT)
153
154 payload[latin1_len++] = static_cast<uint8_t>(codepoint);
155 index += advance;
156 }
157
158 normalized_name = latin1_to_utf8(payload, latin1_len);
160}
161
163 switch (error) {
165 return "NONE";
167 return "EMPTY";
169 return "TOO_LONG";
171 return "INVALID_UTF8";
173 return "UNSUPPORTED_CHAR";
174 default:
175 return "UNKNOWN_DEVICE_NAME_VALIDATION_ERROR";
176 }
177}
178
180 switch (error) {
182 return "name accepted";
184 return "device name must not be empty";
186 return "device name exceeds the 15-character write limit";
188 return "device name must be valid UTF-8";
190 return "device name contains characters outside Latin-1";
191 default:
192 return "unknown device-name validation error";
193 }
194}
195
196AddressClass classify_address(const uint8_t addr[NODE_ID_SIZE]) {
197 if (addr[0] != 0x00)
199
200 uint8_t const suffix = addr[2] & ADDRESS_SUFFIX_MASK;
201 bool const has_type_bits = (addr[1] != 0) || ((addr[2] & 0xC0) != 0);
202
203 // Discovery suffix (0x3B) takes priority — typed discovery (e.g., 00 01 3B) is still DISCOVERY.
204 if (suffix == ADDRESS_SUFFIX_DISCOVERY)
206
207 // When type bits are present with broadcast suffix (0x3F), this is a typed broadcast
208 // addressing all devices of a specific type (e.g., 00 01 BF = "all light devices").
209 // Only 00 00 3F (no type bits) is the true "all device types" broadcast.
210 if (has_type_bits)
212 if (suffix == ADDRESS_SUFFIX_BROADCAST)
214 if (addr[1] == 0 && addr[2] == 0)
216
218}
219
220const char *address_class_name(AddressClass address_class) {
221 switch (address_class) {
223 return "unicast";
225 return "broadcast_all";
227 return "broadcast_type";
229 return "discovery";
231 default:
232 return "unknown_broadcast";
233 }
234}
235
236DeviceType broadcast_target_type(const uint8_t addr[NODE_ID_SIZE]) {
237 if (addr[0] != 0x00)
238 return DeviceType::UNKNOWN;
239
240 // Device type is encoded in bits [9:2] of the combined bytes 1–2:
241 // type = (addr[1] << 2) | (addr[2] >> 6)
242 uint16_t const type_raw =
243 (static_cast<uint16_t>(addr[1]) << DEVICE_TYPE_LOW_BITS_SHIFT) | (addr[2] >> DEVICE_TYPE_HIGH_BITS_SHIFT);
244
245 if (!is_known_device_type(type_raw))
246 return DeviceType::UNKNOWN;
247
248 return static_cast<DeviceType>(type_raw);
249}
250
251DeviceType discovery_target_type(const uint8_t addr[NODE_ID_SIZE]) {
253 return DeviceType::UNKNOWN;
254 return broadcast_target_type(addr);
255}
256
257uint8_t discovery_slot_count(const uint8_t addr[NODE_ID_SIZE]) {
258 switch (addr[2] & ADDRESS_SUFFIX_MASK) {
259 case ADDRESS_SUFFIX_DISCOVERY_64:
260 return DISCOVERY_SLOTS_64;
261 case ADDRESS_SUFFIX_DISCOVERY_128:
262 return DISCOVERY_SLOTS_128;
263 case ADDRESS_SUFFIX_DISCOVERY_255:
264 return DISCOVERY_SLOTS_255;
265 default: // ADDRESS_SUFFIX_DISCOVERY and anything unrecognised: the smallest window
266 return DISCOVERY_SLOTS_32;
267 }
268}
269
270void encode_broadcast_address(DeviceType type, uint8_t out[NODE_ID_SIZE]) {
271 // Both bytes together carry the ten type bits, so a 16-bit enumerator is encoded exactly; bits
272 // above the wire field are dropped explicitly rather than spilling into out[1].
273 const auto type_raw = static_cast<uint16_t>(static_cast<uint16_t>(type) & DEVICE_TYPE_WIRE_MASK);
274 out[0] = 0;
275 out[1] = static_cast<uint8_t>(type_raw >> DEVICE_TYPE_LOW_BITS_SHIFT);
276 out[2] = static_cast<uint8_t>((type_raw << DEVICE_TYPE_HIGH_BITS_SHIFT) | DEVICE_SUBTYPE_MASK);
277}
278
279void encode_discovery_address(DeviceType type, uint8_t out[NODE_ID_SIZE]) {
280 encode_broadcast_address(type, out);
281 // Only the subtype field differs from the broadcast form.
282 out[2] = static_cast<uint8_t>((out[2] & ~DEVICE_SUBTYPE_MASK) | ADDRESS_SUFFIX_DISCOVERY);
283}
284
285void decode_1w_main_intent(uint8_t main0, uint8_t main1, char *out, size_t out_size) {
286 if (out_size == 0)
287 return;
288 // Special command codes (same wire values as 2W).
289 if (main0 == POS_STOP) {
290 snprintf(out, out_size, "STOP");
291 return;
292 }
293 if (main0 == POS_FAVORITE) {
294 if (main1 == POS_VENT_MODIFIER) {
295 snprintf(out, out_size, "VENT");
296 } else {
297 snprintf(out, out_size, "FAVORITE");
298 }
299 return;
300 }
301 if (main0 == POS_UNKNOWN) {
302 snprintf(out, out_size, "UNCHANGED");
303 return;
304 }
305 if (main0 == POS_FORCE_OPEN) {
306 // Note: 0x64 (100) is also the wire value for position 50% (50*2=100). The protocol
307 // uses the same byte value for both. In practice, physical remotes rarely send numeric
308 // 50% positions — they use open/close/stop/favorite. FORCE_OPEN is the more likely
309 // interpretation for diagnostic decode of overheard 1W traffic.
310 snprintf(out, out_size, "FORCE_OPEN");
311 return;
312 }
313 if (main0 == POS_SECURED_TARGET) {
314 snprintf(out, out_size, "SECURED_TARGET");
315 return;
316 }
317 if (main0 == POS_DEFAULT) {
318 snprintf(out, out_size, "DEFAULT");
319 return;
320 }
321 // Numeric position: wire value is position_percent * POSITION_WIRE_SCALE (0=open, 200=closed).
322 if (main0 <= POSITION_WIRE_MAX) {
323 uint8_t const percent = main0 / POSITION_WIRE_SCALE;
324 if (percent == 0) {
325 snprintf(out, out_size, "OPEN");
326 // NOLINTNEXTLINE(cppcoreguidelines-avoid-magic-numbers,readability-magic-numbers)
327 } else if (percent == 100) {
328 snprintf(out, out_size, "CLOSE");
329 } else {
330 snprintf(out, out_size, "position %u%%", percent);
331 }
332 return;
333 }
334 // Unknown special code.
335 snprintf(out, out_size, "0x%02X", main0);
336}
337
338std::optional<float> oneway_intent_to_target(uint8_t main0, uint8_t main1) {
339 (void) main1;
340 // Special codes with no settled position (or explicitly "stop") never resolve to a target;
341 // mirrors decode_1w_main_intent()'s branch order so the two stay in agreement.
342 if (main0 == POS_STOP || main0 == POS_FAVORITE || main0 == POS_UNKNOWN || main0 == POS_FORCE_OPEN ||
343 main0 == POS_SECURED_TARGET || main0 == POS_DEFAULT) {
344 return std::nullopt;
345 }
346 // Wire value is position_percent * POSITION_WIRE_SCALE (0=open, 200=closed); divide as an
347 // integer first, matching decode_1w_main_intent()'s percent computation, then convert to float.
348 if (main0 <= POSITION_WIRE_MAX) {
349 uint8_t const percent = main0 / POSITION_WIRE_SCALE;
350 return static_cast<float>(percent);
351 }
352 return std::nullopt;
353}
354
355/// @brief Minimum data bytes for decode of execute/activate‑mode intent fields.
356static constexpr uint8_t ONEWAY_EXECUTE_MIN_DATA_LEN = 4; // originator(1) + ACEI(1) + main[2].
357
359 OneWayFrameInfo info{};
360 memcpy(info.src, frame.src, NODE_ID_SIZE);
361 info.address_class = classify_address(frame.dst);
363 info.cmd = frame.cmd;
364 info.data_len = frame.data_len;
365
366 // CMD 0x00 (execute) and 0x01 (activate mode) share the same initial payload layout:
367 // originator(1) + ACEI(1) + main[2]. CMD 0x20 (write private) has a different layout
368 // (register-based) and is not decoded here.
369 if ((frame.cmd == CMD_EXECUTE || frame.cmd == CMD_ACTIVATE_MODE) && frame.data_len >= ONEWAY_EXECUTE_MIN_DATA_LEN) {
370 info.has_intent = true;
371 info.originator = frame.data[0];
372 info.acei_level = (frame.data[1] & ACEI_LEVEL_MASK) >> ACEI_LEVEL_SHIFT;
373 info.main0 = frame.data[2];
374 info.main1 = frame.data[3];
375 decode_1w_main_intent(frame.data[2], frame.data[3], info.intent, sizeof(info.intent));
376 }
377
378 return info;
379}
380
381const char *oneway_target_label(const OneWayFrameInfo &info) {
383}
384
385DiscoveryResponseInfo decode_discovery_response(const IoFrame &frame, IoDevice &device, std::string &device_id) {
387
388 memcpy(device.node_id, frame.src, NODE_ID_SIZE);
389 info.metadata_complete = frame.data_len >= DEVICE_METADATA_SIZE;
390 if (info.metadata_complete) {
391 device.type = decode_packed_device_type(frame.data[0], frame.data[1]);
392 device.subtype = decode_packed_device_subtype(frame.data[1]);
393 } else {
394 device.type = DeviceType::UNKNOWN;
395 device.subtype = 0;
396 }
397 device.position = UNKNOWN_POSITION;
398 device.target = UNKNOWN_POSITION;
399 device.is_stopped = true;
400 device_id = node_id_to_string(device.node_id);
401
402 info.has_extended = frame.data_len >= DISCOVERY_RESP_FULL_SIZE;
403 if (frame.data_len > DISCOVERY_RESP_MANUFACTURER_OFFSET) {
404 info.manufacturer = frame.data[DISCOVERY_RESP_MANUFACTURER_OFFSET];
405 }
406 if (frame.data_len > DISCOVERY_RESP_BACKBONE_OFFSET + NODE_ID_SIZE - 1) {
407 memcpy(info.backbone, &frame.data[DISCOVERY_RESP_BACKBONE_OFFSET], NODE_ID_SIZE);
408 }
409 if (frame.data_len > DISCOVERY_RESP_FLAGS_OFFSET) {
410 info.flags = frame.data[DISCOVERY_RESP_FLAGS_OFFSET];
411 }
412 if (frame.data_len > DISCOVERY_RESP_TIMESTAMP_OFFSET + 1) {
413 info.timestamp =
414 static_cast<uint16_t>((frame.data[DISCOVERY_RESP_TIMESTAMP_OFFSET] << DISCOVERY_TIMESTAMP_MSB_SHIFT) |
415 frame.data[DISCOVERY_RESP_TIMESTAMP_OFFSET + 1]);
416 }
417
418 return info;
419}
420
421// ============================================================================
422// 1W Add-Controller Key Adoption (CMD 0x30)
423// ============================================================================
424
426 out = OneWayAdoptedKey{};
427
428 // Validate before decrypting -- reject rather than decrypt garbage. Order matches how a reader
429 // would narrow down "what kind of frame is this" (protocol bit, then command, then shape).
430 if ((frame.ctrl0 & CTRL0_PROTOCOL_1W) == 0)
432 if (frame.cmd != CMD_ONEWAY_ADD_CONTROLLER)
434 if (frame.data_len != ONEWAY_ADD_CONTROLLER_PAYLOAD_SIZE)
436
437 const uint8_t *enc_key = &frame.data[ONEWAY_ADD_CONTROLLER_ENC_KEY_OFFSET];
438 // Self-inverse wrap (crypto::crypt_1w_key()'s @warning) -- no direction flag: this same call
439 // decrypts the overheard ciphertext back to the plaintext network key.
440 if (!crypto::crypt_1w_key(frame.src, enc_key, out.system_key))
442
443 out.manufacturer = frame.data[ONEWAY_ADD_CONTROLLER_MANUFACTURER_OFFSET];
444 memcpy(out.sender_node, frame.src, NODE_ID_SIZE);
445 out.sequence = static_cast<uint16_t>(
446 (frame.data[ONEWAY_ADD_CONTROLLER_SEQUENCE_OFFSET] << ONEWAY_ADD_CONTROLLER_SEQUENCE_MSB_SHIFT) |
447 frame.data[ONEWAY_ADD_CONTROLLER_SEQUENCE_OFFSET + 1]);
448
449 // A frame with no MAC is not an error (real remotes send the 29-byte form without it) --
450 // report NOT_PRESENT and stop; there is nothing to verify.
451 if (!frame.has_mac) {
454 }
455
456 // MAC span is command-specific: cmd + enc_key (17 bytes), NOT the whole declared payload --
457 // see create_1w_hmac()'s @warning. Verified under the *recovered* key: a match is strong
458 // evidence the decryption above landed on the correct key, before any device is ever commanded.
459 uint8_t mac_span[ONEWAY_ADD_CONTROLLER_MAC_SPAN_SIZE];
460 mac_span[0] = frame.cmd;
461 memcpy(&mac_span[1], enc_key, AES_KEY_SIZE);
462
463 uint8_t expected_mac[HMAC_SIZE];
464 if (!crypto::create_1w_hmac(mac_span, sizeof(mac_span), out.sequence, out.system_key, expected_mac)) {
467 }
468
469 // Constant-time comparison, matching crypto::verify_hmac()'s convention.
470 uint8_t diff = 0;
471 for (uint8_t i = 0; i < HMAC_SIZE; i++)
472 diff |= static_cast<uint8_t>(frame.mac[i] ^ expected_mac[i]);
474
476}
477
479 LastCommandRecord record;
480 if (frame.data_len < base + NODE_ID_SIZE + 1)
481 return record;
482 memcpy(record.commander, &frame.data[base], NODE_ID_SIZE);
483 if (record.commander[0] == 0 && record.commander[1] == 0 && record.commander[2] == 0)
484 return record;
485 record.originator = frame.data[base + NODE_ID_SIZE];
486 record.valid = true;
487 return record;
488}
489
491 if (frame.cmd != CMD_STATUS_UPDATE || frame.data_len < STATUS_UPDATE_EVENT_DATA_OFFSET)
492 return false;
493 const uint8_t data_len = frame.data[STATUS_UPDATE_EVENT_LENGTH_OFFSET];
494 if (data_len != frame.data_len - STATUS_UPDATE_EVENT_DATA_OFFSET)
495 return false;
496 out.event_id = static_cast<uint16_t>((frame.data[STATUS_UPDATE_EVENT_ID_OFFSET] << BITS_PER_BYTE) |
499 out.data_len = data_len;
500 return true;
501}
502
503bool decode_sun_level_event(const IoFrame &frame, uint16_t &out) {
504 StatusUpdateEvent event;
505 if (!decode_status_update_event(frame, event) || event.event_id != EVENT_LOCAL_SENSOR ||
506 event.data_len != LOCAL_SENSOR_EVENT_DATA_LEN || frame.data[event.data_offset] != LOCAL_SENSOR_TYPE_SUN)
507 return false;
508 const uint8_t at = event.data_offset + LOCAL_SENSOR_VALUE_OFFSET;
509 out = static_cast<uint16_t>((frame.data[at] << BITS_PER_BYTE) | frame.data[at + 1]);
510 return true;
511}
512
513bool decode_battery_level_event(const IoFrame &frame, uint8_t &out) {
514 StatusUpdateEvent event;
515 if (!decode_status_update_event(frame, event) || event.event_id != EVENT_BATTERY_LEVEL_CHANGE ||
516 event.data_len != BATTERY_LEVEL_EVENT_DATA_LEN)
517 return false;
518 const uint8_t level = frame.data[event.data_offset];
519 if (level > BATTERY_LEVEL_MAX_PERCENT)
520 return false;
521 out = level;
522 return true;
523}
524
526 if (f.cmd != CMD_TRANSFER_OPEN_RESP || f.data_len != TRANSFER_OPEN_RESP_DATA_SIZE)
527 return false;
528 out.handle = f.data[0];
529 out.status = f.data[1];
530 out.length = static_cast<uint16_t>((f.data[2] << BITS_PER_BYTE) | f.data[3]);
531 return true;
532}
533
534uint16_t transfer_chunk_count(uint16_t length) {
535 return static_cast<uint16_t>((length + TRANSFER_BLOCK_SIZE - 1) / TRANSFER_BLOCK_SIZE);
536}
537
538bool decode_transfer_block(const IoFrame &f, uint8_t handle, uint16_t chunk, const uint8_t *&out, uint8_t &out_len) {
539 if (f.cmd != CMD_UNKNOWN4A_RESP || f.data_len < TRANSFER_READ_REQ_DATA_SIZE ||
540 f.data_len > TRANSFER_READ_REQ_DATA_SIZE + TRANSFER_BLOCK_SIZE)
541 return false;
542 // The first byte is compared on its low seven bits only: the top bit is not part of the handle.
543 constexpr uint8_t handle_mask = 0x7F;
544 constexpr uint16_t chunk_low_byte_mask = 0x00FF;
545 if ((f.data[0] & handle_mask) != (handle & handle_mask) ||
546 f.data[1] != static_cast<uint8_t>(chunk >> BITS_PER_BYTE) ||
547 f.data[2] != static_cast<uint8_t>(chunk & chunk_low_byte_mask))
548 return false;
549 out = f.data + TRANSFER_READ_REQ_DATA_SIZE;
550 out_len = static_cast<uint8_t>(f.data_len - TRANSFER_READ_REQ_DATA_SIZE);
551 return true;
552}
553
555 if (f.cmd != CMD_LIMITATION_STATUS_RESP || f.data_len != LIMITATION_RESP_DATA_SIZE)
556 return false;
557 out.parameter_id = f.data[0];
558 out.value_raw = static_cast<uint16_t>((f.data[1] << BITS_PER_BYTE) | f.data[2]);
559 out.originator = f.data[3];
560 out.time_raw = f.data[4];
561 return true;
562}
563
564bool decode_limitation_request(const IoFrame &f, LimitationType &type, uint8_t &parameter_id) {
565 if (f.cmd != CMD_LIMITATION_STATUS_REQ || f.data_len != LIMITATION_REQ_DATA_SIZE)
566 return false;
567 if (f.data[0] == static_cast<uint8_t>(LimitationType::MINIMUM)) {
569 } else if (f.data[0] == static_cast<uint8_t>(LimitationType::MAXIMUM)) {
571 } else {
572 return false;
573 }
574 parameter_id = f.data[1];
575 return true;
576}
577
578uint32_t limitation_time_seconds(uint8_t time_raw) {
579 if (time_raw == LIMITATION_TIME_UNLIMITED)
580 return UINT32_MAX;
581 if (time_raw > LIMITATION_TIME_MAX_COUNTED)
582 return 0;
583 return (static_cast<uint32_t>(time_raw) + 1) * LIMITATION_TIME_STEP_S;
584}
585
586} // namespace home_io_control
587} // namespace esphome
bool crypt_1w_key(const uint8_t node[NODE_ID_SIZE], const uint8_t in[AES_KEY_SIZE], uint8_t out[AES_KEY_SIZE])
Encrypt or decrypt a 1W controller key during add-controller key adoption (CMD 0x30).
bool create_1w_hmac(const uint8_t *data, uint8_t len, uint16_t sequence, const uint8_t controller_key[AES_KEY_SIZE], uint8_t hmac[HMAC_SIZE])
Create the 6-byte authenticator for a 1W frame.
constexpr uint8_t STATUS_UPDATE_EVENT_LENGTH_OFFSET
Number of event-data bytes that follow.
bool decode_limitation_status(const IoFrame &f, LimitationStatus &out)
Decode a limitation reply (assumed layout, see LimitationStatus).
uint8_t discovery_slot_count(const uint8_t addr[NODE_ID_SIZE])
Number of answer slots a discovery request opens.
@ VERIFIED
frame.has_mac was true and the MAC verified under the recovered key.
@ NOT_PRESENT
frame.has_mac was false; nothing to verify.
@ FAILED
frame.has_mac was true and the MAC did NOT verify under the recovered key.
bool decode_status_update_event(const IoFrame &frame, StatusUpdateEvent &out)
Decode the event header of a CMD_STATUS_UPDATE.
static constexpr uint8_t ONEWAY_EXECUTE_MIN_DATA_LEN
Minimum data bytes for decode of execute/activate‑mode intent fields.
void encode_broadcast_address(DeviceType type, uint8_t out[NODE_ID_SIZE])
Encode a device type into its typed-broadcast destination address — the exact inverse of broadcast_ta...
uint32_t limitation_time_seconds(uint8_t time_raw)
Remaining time of a limitation in seconds.
OneWayAddControllerDecodeError decode_1w_add_controller(const IoFrame &frame, OneWayAdoptedKey &out)
Decode a CMD_ONEWAY_ADD_CONTROLLER (0x30) frame into a recovered controller identity.
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.
const char * oneway_target_label(const OneWayFrameInfo &info)
The destination label a decoded 1W frame renders as in a log line.
bool decode_limitation_request(const IoFrame &f, LimitationType &type, uint8_t &parameter_id)
Decode a limitation request (assumed layout [selector] [parameter id] [00]).
bool decode_sun_level_event(const IoFrame &frame, uint16_t &out)
Decode the sun value of an EVENT_LOCAL_SENSOR report.
const char * device_type_name(DeviceType type)
Convert a DeviceType to a lowercase string identifier.
DeviceType
IO-Homecontrol node types.
@ UNKNOWN
Unknown/unspecified device.
LastCommandRecord decode_last_command_record(const IoFrame &frame, uint8_t base)
Decode the last-command record at base from a status-bearing payload.
OneWayAddControllerDecodeError
Decoding outcome for decode_1w_add_controller().
@ WRONG_COMMAND
frame.cmd is not CMD_ONEWAY_ADD_CONTROLLER.
@ NOT_ONEWAY
CTRL0_PROTOCOL_1W is not set — not a 1W frame at all.
@ BAD_LENGTH
Declared payload is not exactly the expected 20 bytes.
@ KEY_UNWRAP_FAILED
crypto::crypt_1w_key() itself reported failure.
OneWayFrameInfo decode_1w_frame(const IoFrame &frame)
Decode a parsed 1W frame into a structured OneWayFrameInfo.
void encode_discovery_address(DeviceType type, uint8_t out[NODE_ID_SIZE])
Encode a device type into its typed discovery address, the inverse of discovery_target_type().
DeviceType decode_packed_device_type(uint8_t type_msb, uint8_t type_subtype)
Decode the 10-bit node type from two packed metadata bytes.
const char * device_name_validation_error_name(DeviceNameValidationError error)
Return a stable symbolic name for a device-name validation result.
bool decode_transfer_open_reply(const IoFrame &f, TransferOpenReply &out)
Decode a transfer-open reply (layout verified against a VELUX KLR 200 copy session).
DeviceNameValidationError
Validation result for outbound device-name writes.
@ UNSUPPORTED_CHAR
Name contains characters outside Latin-1.
@ TOO_LONG
Name exceeds the 15-character write limit.
@ EMPTY
Name is empty after normalization.
@ INVALID_UTF8
Name contains malformed UTF-8 bytes.
std::string trim_ascii_whitespace(const std::string &value)
Trim leading and trailing ASCII whitespace from a string.
constexpr uint8_t STATUS_UPDATE_EVENT_DATA_OFFSET
First event-data byte.
constexpr uint8_t STATUS_UPDATE_EVENT_ID_OFFSET
Event id, big-endian, 2 bytes.
uint16_t transfer_chunk_count(uint16_t length)
Number of CMD_UNKNOWN4A_REQ chunks that cover length bytes: ceil(length / TRANSFER_BLOCK_SIZE).
DeviceNameValidationError encode_device_name_payload(const std::string &name, uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE], std::string &normalized_name)
Validate and encode a user-supplied UTF-8 device name into the fixed Latin-1 write payload.
bool is_known_device_type(uint16_t raw)
True when a raw 10-bit device-type value is a named DeviceType enumerator.
AddressClass classify_address(const uint8_t addr[NODE_ID_SIZE])
Classify an IO-Homecontrol 3-byte address.
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.
DeviceType broadcast_target_type(const uint8_t addr[NODE_ID_SIZE])
Extract the target device type from a typed broadcast address.
uint8_t decode_packed_device_subtype(uint8_t type_subtype)
Decode a protocol-packed device subtype from the second metadata byte.
AddressClass
Address classification categories for diagnostic purposes.
@ UNKNOWN_BROADCAST
Broadcast pattern that does not match known suffixes.
@ UNICAST
Normal device-to-device unicast address (first byte != 0x00).
@ BROADCAST_TYPE
Broadcast to specific device type with non-standard suffix.
@ BROADCAST_ALL
Broadcast to all devices of a type (address suffix 0x3F).
@ DISCOVERY
Discovery-related broadcast (address suffix 0x3B).
const char * address_class_name(AddressClass address_class)
Get a human-readable name for an address classification.
bool decode_transfer_block(const IoFrame &f, uint8_t handle, uint16_t chunk, const uint8_t *&out, uint8_t &out_len)
Decode one CMD_UNKNOWN4A_RESP data block.
LimitationType
On-air selector byte (request data[0]) choosing which resulting limit to read.
@ MINIMUM
Resulting minimum limit; rain protection raises it (a floor on the position value).
bool decode_battery_level_event(const IoFrame &frame, uint8_t &out)
Decode the level of an EVENT_BATTERY_LEVEL_CHANGE report as a percentage.
DiscoveryResponseInfo decode_discovery_response(const IoFrame &frame, IoDevice &device, std::string &device_id)
Decode a discovery-response payload (CMD_DISCOVER_RESP 0x29 or CMD_DISCOVER_SPE_RESP 0x2B — both carr...
const char * device_name_validation_error_description(DeviceNameValidationError error)
Return a human-readable explanation for a device-name validation result.
void decode_1w_main_intent(uint8_t main0, uint8_t main1, char *out, size_t out_size)
Decode the "main" position/command bytes from a 1W execute payload.
DeviceType discovery_target_type(const uint8_t addr[NODE_ID_SIZE])
Extract the device type a discovery request is restricted to.
Device-name, address-classification and 1W-frame codecs.
IO-Homecontrol command IDs, result codes and protocol enumerations.
Cryptographic helpers for the IO‑Homecontrol protocol.
IO-Homecontrol 2W frame container: control bytes, IoFrame and (de)serialization.
Extended discovery-response fields (manufacturer, Multi Information Byte, backbone address,...
bool has_extended
data_len >= DISCOVERY_RESP_FULL_SIZE (mfr/flags/timestamp present).
uint8_t backbone[NODE_ID_SIZE]
Backbone address as reported by the device.
bool metadata_complete
data_len >= DEVICE_METADATA_SIZE (type/subtype present).
uint8_t manufacturer
Raw manufacturer ID; name via manufacturer_name().
uint8_t flags
Multi Information Byte; decode with DISCOVERY_FLAGS_* masks.
uint16_t timestamp
Device timestamp field (advances between replies).
Runtime state of a paired IO‑Homecontrol device.
float target
Target position the device is moving toward.
float position
Current position: 0=open, 100=closed, or UNKNOWN_POSITION.
uint8_t subtype
Device subtype (manufacturer‑specific).
uint8_t node_id[NODE_ID_SIZE]
Device's 3‑byte radio address.
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 mac[HMAC_SIZE]
Out-of-length authenticator trailer; meaningful only when has_mac.
bool has_mac
True if this frame carries the mac trailer (see struct doc).
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.
uint8_t commander[NODE_ID_SIZE]
Controller that last commanded the device.
bool valid
False when the payload was too short, or the record was unpopulated.
uint8_t originator
That command's Command Originator (ORIGINATOR_*).
One decoded CMD_LIMITATION_STATUS_RESP payload.
uint8_t parameter_id
data[0]; LIMITATION_PARAM_MP for the main parameter.
uint8_t originator
data[3], Command Originator that imposed the limit (ORIGINATOR_*).
uint16_t value_raw
data[1..2], big-endian, position coding (STATUS_POS_MAX = 100 %).
uint8_t time_raw
data[4], remaining-time code; see limitation_time_seconds().
Recovered controller identity from a decoded CMD_ONEWAY_ADD_CONTROLLER (0x30) frame.
uint8_t manufacturer
Manufacturer ID byte from the payload (man_id).
OneWayMacStatus mac_status
MAC-verification outcome; see OneWayMacStatus.
uint8_t system_key[AES_KEY_SIZE]
Recovered network system key (crypto::crypt_1w_key() output).
uint8_t sender_node[NODE_ID_SIZE]
Sender's node address (frame.src) — the new identity's node.
uint16_t sequence
2-byte rolling sequence from the payload (big-endian on wire).
Decoded representation of a 1W remote frame.
bool has_intent
True if originator/ACEI/intent fields were decoded.
uint8_t originator
Command originator byte (e.g., ORIGINATOR_USER_REMOTE).
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 acei_level
ACEI priority level (0–7).
uint8_t main1
Raw second main byte (has_intent only); feeds oneway_intent_to_target().
uint8_t cmd
Command ID (e.g., CMD_EXECUTE, CMD_ACTIVATE_MODE).
AddressClass address_class
Classification of the broadcast address.
char intent[ONEWAY_INTENT_BUFFER_SIZE]
Human-readable command intent (e.g., "CLOSE").
uint8_t src[NODE_ID_SIZE]
Remote source node ID (3 bytes).
uint8_t data_len
Raw data length (for commands without decoded intent).
The event header of a well-formed CMD_STATUS_UPDATE payload.
uint8_t data_len
Number of event-data bytes; equals the payload length minus the header.
uint8_t data_offset
Payload offset of the first event-data byte (STATUS_UPDATE_EVENT_DATA_OFFSET).
uint16_t event_id
EVENT_STATUS_CHANGE for a position update, any other id for a device event report.
One decoded CMD_TRANSFER_OPEN_RESP payload.
uint8_t status
data[1]; TRANSFER_STATUS_ACCEPTED / _ADAPTED, anything else is an error.
uint16_t length
data[2..3] big-endian: bytes available to read; 0 means nothing to read.
uint8_t handle
data[0]; the first byte of every CMD_UNKNOWN4A_REQ that follows.