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_frame.h"
8
9#include <cctype>
10#include <cstdio>
11#include <cstring>
12#include <string>
13
14namespace esphome {
15namespace home_io_control {
16
17namespace {
18
19constexpr uint8_t UTF8_SINGLE_BYTE_MAX = 0x80;
20constexpr uint8_t UTF8_TWO_BYTE_LEAD_BASE = 0xC0;
21constexpr uint8_t UTF8_CONTINUATION_BASE = 0x80;
22constexpr uint8_t UTF8_CONTINUATION_MASK = 0x3F;
23constexpr uint8_t UTF8_TWO_BYTE_SHIFT = 6;
24constexpr uint8_t NAME_PADDING_NUL = 0x00;
25constexpr uint8_t NAME_PADDING_SPACE = 0x20;
26constexpr uint8_t UTF8_TWO_BYTE_MASK = 0xE0;
27constexpr uint8_t UTF8_TWO_BYTE_PREFIX = 0xC0;
28constexpr uint8_t UTF8_THREE_BYTE_MASK = 0xF0;
29constexpr uint8_t UTF8_THREE_BYTE_PREFIX = 0xE0;
30constexpr uint8_t UTF8_FOUR_BYTE_MASK = 0xF8;
31constexpr uint8_t UTF8_FOUR_BYTE_PREFIX = 0xF0;
32constexpr uint8_t UTF8_CONTINUATION_PREFIX_MASK = 0xC0;
33constexpr uint8_t UTF8_CONTINUATION_PREFIX = 0x80;
34constexpr uint8_t UTF8_TWO_BYTE_VALUE_MASK = 0x1F;
35constexpr uint8_t ASCII_MAX = 0x7F;
36constexpr uint8_t DISCOVERY_TIMESTAMP_MSB_SHIFT = 8; ///< Shift for the timestamp field's big-endian MSB.
37
38std::string latin1_to_utf8(const uint8_t *data, size_t len) {
39 std::string result;
40 result.reserve(len * 2);
41
42 for (size_t index = 0; index < len; index++) {
43 uint8_t const byte = data[index];
44 if (byte < UTF8_SINGLE_BYTE_MAX) {
45 if (result.length() + 1 >= DEVICE_NAME_BUFFER_SIZE)
46 break;
47 result.push_back(static_cast<char>(byte));
48 continue;
49 }
50
51 if (result.length() + 2 >= DEVICE_NAME_BUFFER_SIZE)
52 break;
53
54 result.push_back(static_cast<char>(UTF8_TWO_BYTE_LEAD_BASE | (byte >> UTF8_TWO_BYTE_SHIFT)));
55 result.push_back(static_cast<char>(UTF8_CONTINUATION_BASE | (byte & UTF8_CONTINUATION_MASK)));
56 }
57
58 return result;
59}
60
61} // namespace
62
63std::string trim_ascii_whitespace(const std::string &value) {
64 size_t begin = 0;
65 while (begin < value.length() && std::isspace(static_cast<unsigned char>(value[begin])) != 0)
66 begin++;
67
68 size_t end = value.length();
69 while (end > begin && std::isspace(static_cast<unsigned char>(value[end - 1])) != 0)
70 end--;
71
72 return value.substr(begin, end - begin);
73}
74
75std::string decode_device_name_payload(const uint8_t *data, uint8_t len) {
76 if (data == nullptr || len == 0)
77 return {};
78
79 const uint8_t begin = data[0] > NAME_PADDING_SPACE ? 0 : 1;
80 if (begin >= len)
81 return {};
82
83 size_t raw_len = len - begin;
84 while (raw_len > 0 &&
85 (data[begin + raw_len - 1] == NAME_PADDING_NUL || data[begin + raw_len - 1] == NAME_PADDING_SPACE))
86 raw_len--;
87
88 if (raw_len == 0)
89 return {};
90
91 return latin1_to_utf8(data + begin, raw_len);
92}
93
95 uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE],
96 std::string &normalized_name) {
97 if (payload == nullptr)
99
100 std::memset(payload, 0, DEVICE_NAME_WRITE_PAYLOAD_SIZE);
101 normalized_name.clear();
102
103 const std::string trimmed_name = trim_ascii_whitespace(name);
104 if (trimmed_name.empty())
106
107 uint8_t latin1_len = 0;
108 for (size_t index = 0; index < trimmed_name.length();) {
109 const auto byte = static_cast<uint8_t>(trimmed_name[index]);
110 uint16_t codepoint = 0;
111 size_t advance = 1;
112
113 if (byte <= ASCII_MAX) {
114 codepoint = byte;
115 } else if ((byte & UTF8_TWO_BYTE_MASK) == UTF8_TWO_BYTE_PREFIX) {
116 if (index + 1 >= trimmed_name.length())
118
119 const auto continuation = static_cast<uint8_t>(trimmed_name[index + 1]);
120 if ((continuation & UTF8_CONTINUATION_PREFIX_MASK) != UTF8_CONTINUATION_PREFIX)
122
123 codepoint = static_cast<uint16_t>(((byte & UTF8_TWO_BYTE_VALUE_MASK) << UTF8_TWO_BYTE_SHIFT) |
124 (continuation & UTF8_CONTINUATION_MASK));
125 if (codepoint < UTF8_SINGLE_BYTE_MAX)
127 advance = 2;
128 } else if ((byte & UTF8_THREE_BYTE_MASK) == UTF8_THREE_BYTE_PREFIX ||
129 (byte & UTF8_FOUR_BYTE_MASK) == UTF8_FOUR_BYTE_PREFIX) {
131 } else {
133 }
134
135 if (codepoint > LATIN1_CODEPOINT_MAX)
137
138 if (latin1_len >= DEVICE_NAME_WRITE_CHAR_LIMIT)
140
141 payload[latin1_len++] = static_cast<uint8_t>(codepoint);
142 index += advance;
143 }
144
145 normalized_name = latin1_to_utf8(payload, latin1_len);
147}
148
150 switch (error) {
152 return "NONE";
154 return "EMPTY";
156 return "TOO_LONG";
158 return "INVALID_UTF8";
160 return "UNSUPPORTED_CHAR";
161 default:
162 return "UNKNOWN_DEVICE_NAME_VALIDATION_ERROR";
163 }
164}
165
167 switch (error) {
169 return "name accepted";
171 return "device name must not be empty";
173 return "device name exceeds the 15-character write limit";
175 return "device name must be valid UTF-8";
177 return "device name contains characters outside Latin-1";
178 default:
179 return "unknown device-name validation error";
180 }
181}
182
184 if (addr[0] != 0x00)
186
187 uint8_t const suffix = addr[2] & ADDRESS_SUFFIX_MASK;
188 bool const has_type_bits = (addr[1] != 0) || ((addr[2] & 0xC0) != 0);
189
190 // Discovery suffix (0x3B) takes priority — typed discovery (e.g., 00 01 3B) is still DISCOVERY.
191 if (suffix == ADDRESS_SUFFIX_DISCOVERY)
193
194 // When type bits are present with broadcast suffix (0x3F), this is a typed broadcast
195 // addressing all devices of a specific type (e.g., 00 01 BF = "all light devices").
196 // Only 00 00 3F (no type bits) is the true "all device types" broadcast.
197 if (has_type_bits)
199 if (suffix == ADDRESS_SUFFIX_BROADCAST)
201 if (addr[1] == 0 && addr[2] == 0)
203
205}
206
207const char *address_class_name(AddressClass address_class) {
208 switch (address_class) {
210 return "unicast";
212 return "broadcast_all";
214 return "broadcast_type";
216 return "discovery";
218 default:
219 return "unknown_broadcast";
220 }
221}
222
224 if (addr[0] != 0x00)
225 return DeviceType::UNKNOWN;
226
227 // Device type is encoded in bits [9:2] of the combined bytes 1–2:
228 // type = (addr[1] << 2) | (addr[2] >> 6)
229 uint16_t const type_raw =
230 (static_cast<uint16_t>(addr[1]) << DEVICE_TYPE_LOW_BITS_SHIFT) | (addr[2] >> DEVICE_TYPE_HIGH_BITS_SHIFT);
231
232 if (type_raw > static_cast<uint16_t>(DeviceType::SWINGING_SHUTTER))
233 return DeviceType::UNKNOWN;
234
235 return static_cast<DeviceType>(type_raw);
236}
237
238void decode_1w_main_intent(uint8_t main0, uint8_t main1, char *out, size_t out_size) {
239 if (out_size == 0)
240 return;
241 // Special command codes (same wire values as 2W).
242 if (main0 == POS_STOP) {
243 snprintf(out, out_size, "STOP");
244 return;
245 }
246 if (main0 == POS_FAVORITE) {
247 if (main1 == POS_VENT_MODIFIER) {
248 snprintf(out, out_size, "VENT");
249 } else {
250 snprintf(out, out_size, "FAVORITE");
251 }
252 return;
253 }
254 if (main0 == POS_UNKNOWN) {
255 snprintf(out, out_size, "UNCHANGED");
256 return;
257 }
258 if (main0 == POS_FORCE_OPEN) {
259 // Note: 0x64 (100) is also the wire value for position 50% (50*2=100). The protocol
260 // uses the same byte value for both. In practice, physical remotes rarely send numeric
261 // 50% positions — they use open/close/stop/favorite. FORCE_OPEN is the more likely
262 // interpretation for diagnostic decode of overheard 1W traffic.
263 snprintf(out, out_size, "FORCE_OPEN");
264 return;
265 }
266 if (main0 == POS_SECURED_TARGET) {
267 snprintf(out, out_size, "SECURED_TARGET");
268 return;
269 }
270 if (main0 == POS_DEFAULT) {
271 snprintf(out, out_size, "DEFAULT");
272 return;
273 }
274 // Numeric position: wire value is position_percent * 2 (0=open, 200=closed).
275 // NOLINTNEXTLINE(cppcoreguidelines-avoid-magic-numbers,readability-magic-numbers)
276 if (main0 <= 200) {
277 uint8_t const percent = main0 / 2;
278 if (percent == 0) {
279 snprintf(out, out_size, "OPEN");
280 // NOLINTNEXTLINE(cppcoreguidelines-avoid-magic-numbers,readability-magic-numbers)
281 } else if (percent == 100) {
282 snprintf(out, out_size, "CLOSE");
283 } else {
284 snprintf(out, out_size, "position %u%%", percent);
285 }
286 return;
287 }
288 // Unknown special code.
289 snprintf(out, out_size, "0x%02X", main0);
290}
291
292std::optional<float> oneway_intent_to_target(uint8_t main0, uint8_t main1) {
293 (void) main1;
294 // Special codes with no settled position (or explicitly "stop") never resolve to a target;
295 // mirrors decode_1w_main_intent()'s branch order so the two stay in agreement.
296 if (main0 == POS_STOP || main0 == POS_FAVORITE || main0 == POS_UNKNOWN || main0 == POS_FORCE_OPEN ||
297 main0 == POS_SECURED_TARGET || main0 == POS_DEFAULT) {
298 return std::nullopt;
299 }
300 // Wire value is position_percent * 2 (0=open, 200=closed); divide as an integer first,
301 // matching decode_1w_main_intent()'s percent computation, then convert to float.
302 // NOLINTNEXTLINE(cppcoreguidelines-avoid-magic-numbers,readability-magic-numbers)
303 if (main0 <= 200) {
304 uint8_t const percent = main0 / 2;
305 return static_cast<float>(percent);
306 }
307 return std::nullopt;
308}
309
310/// @brief Minimum data bytes for decode of execute/activate‑mode intent fields.
311static constexpr uint8_t ONEWAY_EXECUTE_MIN_DATA_LEN = 4; // originator(1) + ACEI(1) + main[2].
312
314 OneWayFrameInfo info{};
315 memcpy(info.src, frame.src, NODE_ID_SIZE);
316 info.address_class = classify_address(frame.dst);
318 info.cmd = frame.cmd;
319 info.data_len = frame.data_len;
320
321 // CMD 0x00 (execute) and 0x01 (activate mode) share the same initial payload layout:
322 // originator(1) + ACEI(1) + main[2]. CMD 0x20 (write private) has a different layout
323 // (register-based) and is not decoded here.
324 if ((frame.cmd == CMD_EXECUTE || frame.cmd == CMD_ACTIVATE_MODE) && frame.data_len >= ONEWAY_EXECUTE_MIN_DATA_LEN) {
325 info.has_intent = true;
326 info.originator = frame.data[0];
327 info.acei_level = (frame.data[1] & ACEI_LEVEL_MASK) >> ACEI_LEVEL_SHIFT;
328 info.main0 = frame.data[2];
329 info.main1 = frame.data[3];
330 decode_1w_main_intent(frame.data[2], frame.data[3], info.intent, sizeof(info.intent));
331 }
332
333 return info;
334}
335
336DiscoveryResponseInfo decode_discovery_response(const IoFrame &frame, IoDevice &device, std::string &device_id) {
338
339 memcpy(device.node_id, frame.src, NODE_ID_SIZE);
341 if (info.metadata_complete) {
342 device.type = decode_packed_device_type(frame.data[0], frame.data[1]);
343 device.subtype = decode_packed_device_subtype(frame.data[1]);
344 device.inverted = default_inverted_for_type(device.type);
345 } else {
346 device.type = DeviceType::UNKNOWN;
347 device.subtype = 0;
348 device.inverted = false;
349 }
350 device.position = UNKNOWN_POSITION;
351 device.target = UNKNOWN_POSITION;
352 device.is_stopped = true;
353 device_id = node_id_to_string(device.node_id);
354
358 }
361 }
364 }
366 info.timestamp =
367 static_cast<uint16_t>((frame.data[DISCOVERY_RESP_TIMESTAMP_OFFSET] << DISCOVERY_TIMESTAMP_MSB_SHIFT) |
369 }
370
371 return info;
372}
373
374} // namespace home_io_control
375} // namespace esphome
static constexpr uint8_t DEVICE_NAME_BUFFER_SIZE
Device name storage including null terminator.
static constexpr uint8_t DEVICE_METADATA_SIZE
Packed device metadata uses two bytes where the high 8 bits carry the upper type bits and the low byt...
static constexpr float UNKNOWN_POSITION
Sentinel value meaning "position is not known yet".
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
static constexpr uint8_t ONEWAY_EXECUTE_MIN_DATA_LEN
Minimum data bytes for decode of execute/activate‑mode intent fields.
static constexpr uint8_t CMD_ACTIVATE_MODE
Activate device mode (scene, ventilation) — requires auth.
static constexpr uint8_t DEVICE_TYPE_HIGH_BITS_SHIFT
DeviceType
Device type identifiers reported by IO‑Homecontrol products.
@ UNKNOWN
Unknown/unspecified device.
static constexpr uint8_t DISCOVERY_RESP_MANUFACTURER_OFFSET
Manufacturer ID at data[5].
static constexpr uint8_t POS_UNKNOWN
Wire value: position unknown / keep current.
static constexpr uint8_t POS_FORCE_OPEN
Ambiguous wire value used only for passive 1W-traffic intent decoding (decode_1w_main_intent() / onew...
bool default_inverted_for_type(DeviceType type)
Determine whether a device type has inverted position mapping by default.
static constexpr uint16_t LATIN1_CODEPOINT_MAX
Highest Unicode code point representable in Latin-1.
static constexpr uint8_t ADDRESS_SUFFIX_BROADCAST
Suffix for "all devices of this type" broadcast.
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.
static constexpr uint8_t ADDRESS_SUFFIX_DISCOVERY
Suffix for discovery-related broadcasts.
static constexpr uint8_t ACEI_LEVEL_MASK
Bits [7:5]: priority level (0–7).
static constexpr uint8_t ADDRESS_SUFFIX_MASK
Well-known address suffix values in the broadcast address space.
OneWayFrameInfo decode_1w_frame(const IoFrame &frame)
Decode a parsed 1W frame into a structured OneWayFrameInfo.
static constexpr uint8_t DISCOVERY_RESP_BACKBONE_OFFSET
Byte offsets within CMD_DISCOVER_RESP (0x29) payload data.
static constexpr uint8_t DEVICE_NAME_WRITE_PAYLOAD_SIZE
Fixed write payload: 15 visible chars plus trailing null/padding.
static constexpr uint8_t POS_VENT_MODIFIER
Modifier byte for the ventilation command.
DeviceType decode_packed_device_type(uint8_t type_msb, uint8_t type_subtype)
Decode a protocol-packed device type from two metadata bytes.
static constexpr uint8_t POS_DEFAULT
Wire value for the default position command.
const char * device_name_validation_error_name(DeviceNameValidationError error)
Return a stable symbolic name for a device-name validation result.
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.
static constexpr uint8_t DISCOVERY_RESP_FLAGS_OFFSET
Flags byte at data[6].
std::string trim_ascii_whitespace(const std::string &value)
Trim leading and trailing ASCII whitespace from a string.
static constexpr uint8_t CMD_EXECUTE
Set position/open/close/stop — requires authentication.
static constexpr uint8_t DEVICE_NAME_WRITE_CHAR_LIMIT
Reference write limit before the trailing null.
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.
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.
static constexpr uint8_t ACEI_LEVEL_SHIFT
Shift for priority level extraction.
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.
static constexpr uint8_t POS_FAVORITE
Wire value: move to favorite/"My" position.
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 DISCOVERY_RESP_FULL_SIZE
Full discovery response payload size.
static constexpr uint8_t DISCOVERY_RESP_TIMESTAMP_OFFSET
Timestamp starts at data[7] (2 bytes).
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.
static constexpr uint8_t POS_STOP
Position values in the IO protocol.
static constexpr uint8_t DEVICE_TYPE_LOW_BITS_SHIFT
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.
static constexpr uint8_t POS_SECURED_TARGET
Wire value for the secured target position command.
Device-name, address-classification and 1W-frame codecs.
IO-Homecontrol command IDs, result codes and protocol enumerations.
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.
bool inverted
True if open/close positions are swapped (e.g., horizontal awning).
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:71
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes).
Definition proto_frame.h:77
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:75
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:74
uint8_t data_len
Actual length of data.
Definition proto_frame.h:78
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).