Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
proto_heating.cpp
Go to the documentation of this file.
1/// @file proto_heating.cpp
2/// @brief Pure codec for the private register protocol of 2W heaters (CMD_WRITE_PRIVATE 0x20).
3/// @ingroup hioc_protocol
4
5#include "proto_heating.h"
6
7#include "proto_constants.h"
8#include "proto_sizes.h"
9
10namespace esphome {
11namespace home_io_control {
12
13namespace {
14
15/// Mask for the low byte of a 16-bit little-endian register value.
16constexpr uint16_t LOW_BYTE_MASK = 0xFF;
17
18/// Payload length of a read, and of a write that carries no value ({prefix, direction, reg-high, reg-low}).
19constexpr size_t PAYLOAD_LEN_HEADER = 4;
20/// Payload length for a write that carries one value byte (mode / presence / window detection).
21constexpr size_t PAYLOAD_LEN_ONE_VALUE = 5;
22/// Payload length for a write of a 16-bit value (the setpoint).
23constexpr size_t PAYLOAD_LEN_TWO_VALUE = HEATING_PAYLOAD_MAX_SIZE;
24
25/// Wire scale of the setpoint registers: round(degrees * this), little-endian 16-bit.
26constexpr float DEGREES_TO_TENTHS = 10.0F;
27/// Scale of the room-temperature register: signed hundredths of a degree.
28constexpr float HUNDREDTHS_PER_DEGREE = 100.0F;
29
30/// Highest regulator-state value the register defines (HeatingRegulatorState::HEATING).
31constexpr uint8_t REGULATOR_STATE_MAX = static_cast<uint8_t>(HeatingRegulatorState::HEATING);
32
33/// @brief How a register's value maps onto the bytes after the 4-byte header.
34enum class HeatingValueKind : uint8_t {
35 NONE, ///< No typed value: logged raw, never decoded.
36 TENTHS_U16, ///< Unsigned 16-bit little-endian tenths of a degree.
37 HUNDREDTHS_S16, ///< Signed 16-bit little-endian hundredths of a degree; -1 is a sensor fault.
38 BINARY, ///< One byte, 0 or 1.
39 MODE, ///< One HeatingMode byte.
40 ENUM_BYTE, ///< One byte of a small enumeration (regulator state).
41};
42
43/// @brief Who may do what with a register.
44enum class HeatingAccess : uint8_t {
45 READ_ONLY, ///< Reads only; a write is refused by the codec.
46 READ_WRITE, ///< Reads and writes.
47 WRITE_ONLY, ///< Writes only; the heater has no readable value for it.
48};
49
50/// @brief One register of the heater, the single description both directions are built from.
51///
52/// A request is {HEATING_PAYLOAD_PREFIX, direction, HEATING_REGISTER_HIGH_BYTE, register_low,
53/// [value...]}; `direction` is HEATING_DIRECTION_GET or HEATING_DIRECTION_SET.
54struct HeatingFunctionDescriptor {
55 const char *name; ///< Action-visible name.
56 HeatingFunction fn; ///< Function this row describes.
57 uint8_t register_low; ///< Payload byte 3: low byte of the 0x01xx register number.
58 HeatingAccess access; ///< Whether the register may be read, written or both.
59 HeatingValueKind kind; ///< How the value maps onto the trailing bytes.
60};
61
62constexpr HeatingFunctionDescriptor HEATING_FUNCTIONS[] = {
63 {"power_on", HeatingFunction::POWER_ON, 0x2C, HeatingAccess::READ_ONLY, HeatingValueKind::NONE},
64 {"set_temperature", HeatingFunction::SET_TEMPERATURE, 0x03, HeatingAccess::READ_WRITE,
65 HeatingValueKind::TENTHS_U16},
66 {"set_mode", HeatingFunction::SET_MODE, 0x00, HeatingAccess::READ_WRITE, HeatingValueKind::MODE},
67 {"set_presence", HeatingFunction::SET_PRESENCE, 0x10, HeatingAccess::WRITE_ONLY, HeatingValueKind::BINARY},
68 {"set_open_window_detection", HeatingFunction::SET_OPEN_WINDOW_DETECTION, 0x06, HeatingAccess::READ_WRITE,
69 HeatingValueKind::BINARY},
70 {"midnight_sync", HeatingFunction::MIDNIGHT_SYNC, 0x30, HeatingAccess::READ_ONLY, HeatingValueKind::NONE},
71 {"room_temperature", HeatingFunction::ROOM_TEMPERATURE, 0x15, HeatingAccess::READ_ONLY,
72 HeatingValueKind::HUNDREDTHS_S16},
73 {"regulator_state", HeatingFunction::REGULATOR_STATE, 0x17, HeatingAccess::READ_ONLY, HeatingValueKind::ENUM_BYTE},
74 {"max_setpoint", HeatingFunction::MAX_SETPOINT, 0x02, HeatingAccess::READ_ONLY, HeatingValueKind::TENTHS_U16},
75 {"window_open", HeatingFunction::WINDOW_OPEN, 0x0E, HeatingAccess::READ_ONLY, HeatingValueKind::BINARY},
76};
77
78/// @brief Look up the descriptor for a function.
79/// @param fn Function to resolve.
80/// @return Pointer into the constexpr table, or nullptr for an out-of-range value.
81const HeatingFunctionDescriptor *descriptor_for(HeatingFunction fn) {
82 for (const auto &d : HEATING_FUNCTIONS) {
83 if (d.fn == fn)
84 return &d;
85 }
86 return nullptr;
87}
88
89/// @brief Write the 4-byte request header for a row.
90void write_header(const HeatingFunctionDescriptor &d, uint8_t direction, uint8_t *out) {
92 out[1] = direction;
94 out[3] = d.register_low;
95}
96
97/// @brief Number of value bytes behind the header for a kind.
98size_t value_length(HeatingValueKind kind) {
99 switch (kind) {
100 case HeatingValueKind::NONE:
101 return 0;
102 case HeatingValueKind::TENTHS_U16:
103 case HeatingValueKind::HUNDREDTHS_S16:
104 return 2;
105 case HeatingValueKind::BINARY:
106 case HeatingValueKind::MODE:
107 case HeatingValueKind::ENUM_BYTE:
108 return 1;
109 }
110 return 0;
111}
112
113} // namespace
114
116 const HeatingFunctionDescriptor *d = descriptor_for(fn);
117 return d != nullptr ? d->name : "unknown";
118}
119
121 const HeatingFunctionDescriptor *d = descriptor_for(fn);
122 return d != nullptr && d->access != HeatingAccess::READ_ONLY;
123}
124
126 const HeatingFunctionDescriptor *d = descriptor_for(fn);
127 return d != nullptr && d->access != HeatingAccess::WRITE_ONLY;
128}
129
131 const HeatingFunctionDescriptor *d = descriptor_for(fn);
132 if (d == nullptr || d->access == HeatingAccess::WRITE_ONLY)
133 return 0;
134 write_header(*d, HEATING_DIRECTION_GET, out);
135 return PAYLOAD_LEN_HEADER;
136}
137
138size_t encode_heating_write(HeatingFunction fn, float value, uint8_t out[HEATING_PAYLOAD_MAX_SIZE]) {
139 const HeatingFunctionDescriptor *d = descriptor_for(fn);
140 // A read-only register is never written, whatever the caller asks: the heater's window sensor and
141 // its measurements are not settings.
142 if (d == nullptr || d->access == HeatingAccess::READ_ONLY)
143 return 0;
144
145 switch (d->kind) {
146 case HeatingValueKind::TENTHS_U16: {
147 // Reject NaN / infinity and anything outside the representable range rather than emitting a
148 // wrapped byte.
149 if (!std::isfinite(value) || value < HEATING_TEMP_MIN_C || value > HEATING_TEMP_MAX_C)
150 return 0;
151 // Multiply in float (not double): the float32 product of e.g. 20.55f * 10.0f is exactly
152 // 205.5f, and std::round() takes that half away from zero to 206 (0x00CE). Truncating or
153 // rounding toward even would both be wrong here.
154 // NB: this exactness relies on FLT_EVAL_METHOD == 0 (operands evaluated at their own single
155 // precision, no hidden widening to double). That holds on x86-64 SSE and on Xtensa; the
156 // proto_heating test pins 20.55 C, which is one ULP from flipping to 0xCD under any
157 // excess-precision evaluation.
158 const auto tenths = static_cast<uint16_t>(std::round(value * DEGREES_TO_TENTHS));
159 write_header(*d, HEATING_DIRECTION_SET, out);
160 out[4] = static_cast<uint8_t>(tenths & LOW_BYTE_MASK);
161 out[PAYLOAD_LEN_TWO_VALUE - 1] = static_cast<uint8_t>(tenths >> BITS_PER_BYTE);
162 return PAYLOAD_LEN_TWO_VALUE;
163 }
164
165 case HeatingValueKind::MODE:
166 // Exact float match against the four modes this component writes; a non-integral or unknown
167 // value (the "external" mode included) is rejected, not coerced.
168 if (value != static_cast<float>(HeatingMode::AUTO) && value != static_cast<float>(HeatingMode::MANUAL) &&
169 value != static_cast<float>(HeatingMode::PROG) && value != static_cast<float>(HeatingMode::OFF))
170 return 0;
171 write_header(*d, HEATING_DIRECTION_SET, out);
172 out[4] = static_cast<uint8_t>(value);
173 return PAYLOAD_LEN_ONE_VALUE;
174
175 case HeatingValueKind::BINARY:
176 if (value != 0.0F && value != 1.0F)
177 return 0;
178 write_header(*d, HEATING_DIRECTION_SET, out);
179 out[4] = static_cast<uint8_t>(value);
180 return PAYLOAD_LEN_ONE_VALUE;
181
182 case HeatingValueKind::NONE:
183 case HeatingValueKind::HUNDREDTHS_S16:
184 case HeatingValueKind::ENUM_BYTE:
185 return 0; // no writable register has this encoding
186 }
187 return 0;
188}
189
191 const HeatingFunctionDescriptor *d = descriptor_for(fn);
192 if (d == nullptr)
193 return 0;
194 return d->access == HeatingAccess::READ_ONLY ? encode_heating_read(fn, out) : encode_heating_write(fn, value, out);
195}
196
197bool decode_heating_reply(const IoFrame &reply, HeatingFunction fn, float &value) {
198 const HeatingFunctionDescriptor *d = descriptor_for(fn);
199 if (d == nullptr || d->kind == HeatingValueKind::NONE || reply.cmd != CMD_WRITE_PRIVATE_ACK)
200 return false;
201
202 const size_t value_len = value_length(d->kind);
203 if (reply.data_len < PAYLOAD_LEN_HEADER + value_len)
204 return false;
205 const uint8_t *data = reply.data;
206 if (data[0] != HEATING_PAYLOAD_PREFIX || data[2] != HEATING_REGISTER_HIGH_BYTE || data[3] != d->register_low)
207 return false;
208 if (data[1] != HEATING_DIRECTION_GET && data[1] != HEATING_DIRECTION_SET)
209 return false;
210
211 const uint8_t *v = data + PAYLOAD_LEN_HEADER;
212 const auto raw16 = static_cast<uint16_t>(v[0] | (v[1] << BITS_PER_BYTE));
213 switch (d->kind) {
214 case HeatingValueKind::TENTHS_U16:
215 value = static_cast<float>(raw16) / DEGREES_TO_TENTHS;
216 return true;
217
218 case HeatingValueKind::HUNDREDTHS_S16: {
219 const auto signed_raw = static_cast<int16_t>(raw16);
220 value = signed_raw == HEATING_SENSOR_FAULT_RAW ? NAN : static_cast<float>(signed_raw) / HUNDREDTHS_PER_DEGREE;
221 return true;
222 }
223
224 case HeatingValueKind::BINARY:
225 if (v[0] > 1)
226 return false;
227 value = static_cast<float>(v[0]);
228 return true;
229
230 case HeatingValueKind::MODE:
231 if (v[0] > static_cast<uint8_t>(HeatingMode::OFF))
232 return false;
233 value = static_cast<float>(v[0]);
234 return true;
235
236 case HeatingValueKind::ENUM_BYTE:
237 if (v[0] > REGULATOR_STATE_MAX)
238 return false;
239 value = static_cast<float>(v[0]);
240 return true;
241
242 case HeatingValueKind::NONE:
243 return false;
244 }
245 return false;
246}
247
249 switch (fn) {
251 this->room_temperature_c = value;
252 return true;
254 this->setpoint_c = value;
255 return true;
257 this->max_setpoint_c = value;
258 return true;
260 this->mode = static_cast<int16_t>(value);
261 return true;
263 this->regulator = static_cast<int16_t>(value);
264 return true;
266 this->window_open = static_cast<int16_t>(value);
267 return true;
269 this->open_window_detection = static_cast<int16_t>(value);
270 return true;
274 return false;
275 }
276 return false;
277}
278
280 switch (fn) {
282 return !std::isnan(this->room_temperature_c);
284 return !std::isnan(this->setpoint_c);
286 return !std::isnan(this->max_setpoint_c);
288 return this->mode >= 0;
290 return this->regulator >= 0;
292 return this->window_open >= 0;
294 return this->open_window_detection >= 0;
298 return false;
299 }
300 return false;
301}
302
303} // namespace home_io_control
304} // namespace esphome
bool decode_heating_reply(const IoFrame &reply, HeatingFunction fn, float &value)
Decode the value a heater answered a register read or write with.
constexpr size_t HEATING_PAYLOAD_MAX_SIZE
Largest payload any function produces: a set of a 16-bit value (4-byte header + 2 value bytes).
size_t encode_heating_payload(HeatingFunction fn, float value, uint8_t out[HEATING_PAYLOAD_MAX_SIZE])
Encode the function the heating_control action runs: a read for a read-only register,...
HeatingFunction
Registers and functions of a 2W heater, one row of the descriptor table each.
@ MIDNIGHT_SYNC
Register 0x0130, read: the comfort/eco/auto setpoint block. Logged, never decoded.
@ ROOM_TEMPERATURE
Register 0x0115, read-only: measured room temperature, signed hundredths of a degree.
@ MAX_SETPOINT
Register 0x0102, read-only: highest setpoint the heater accepts, tenths of a degree.
@ POWER_ON
Register 0x012C, read: the paired-device list. Logged, never decoded.
@ SET_PRESENCE
Register 0x0110, write-only: someone is in the room (1) or not (0).
@ REGULATOR_STATE
Register 0x0117, read-only: 0 none, 1 standby, 2 heating.
@ WINDOW_OPEN
Register 0x010E, read-only: the heater's window sensor, 0 closed / 1 open.
@ SET_OPEN_WINDOW_DETECTION
Register 0x0106, read/write: open-window detection on (1) or off (0).
@ SET_TEMPERATURE
Register 0x0103, read/write: comfort setpoint, tenths of a degree.
@ SET_MODE
Register 0x0100, read/write: operating mode (HeatingMode).
constexpr uint8_t HEATING_REGISTER_HIGH_BYTE
Payload byte at index 2: the high byte of the 16-bit register number, constant 0x01.
bool heating_function_is_readable(HeatingFunction fn)
Whether the register behind a function can be read.
constexpr float HEATING_TEMP_MAX_C
Highest setpoint this codec will encode.
const char * heating_function_name(HeatingFunction fn)
Stable lowercase name for a heating function ("power_on", "set_temperature", ...).
constexpr uint8_t HEATING_DIRECTION_SET
Payload byte at index 1 of a write ("set") request.
size_t encode_heating_write(HeatingFunction fn, float value, uint8_t out[HEATING_PAYLOAD_MAX_SIZE])
Encode a write ("set") of one register into a CMD_WRITE_PRIVATE (0x20) payload.
@ PROG
Program mode; the device runs its own stored weekly schedule.
@ MANUAL
Manual mode; follows the last written setpoint.
@ AUTO
Automatic mode; the device manages the setpoint itself.
constexpr int16_t HEATING_SENSOR_FAULT_RAW
Measured value in a reading when the heater reports a sensor fault (raw -1).
constexpr uint8_t HEATING_PAYLOAD_PREFIX
Leading payload byte, common to every function.
bool heating_function_is_writable(HeatingFunction fn)
Whether the register behind a function can be written.
size_t encode_heating_read(HeatingFunction fn, uint8_t out[HEATING_PAYLOAD_MAX_SIZE])
Encode a read ("get") of one register into a CMD_WRITE_PRIVATE (0x20) payload.
constexpr uint8_t HEATING_DIRECTION_GET
Payload byte at index 1 of a read ("get") request.
@ NONE
target_fw is unknown, or its required bootloader matches bootloader_version.
IO-Homecontrol command IDs, result codes and protocol enumerations.
Pure codec for the private register protocol of 2W heaters (CMD_WRITE_PRIVATE 0x20).
Fundamental IO-Homecontrol frame and crypto size constants.
int16_t window_open
0 closed / 1 open; -1 until read.
int16_t open_window_detection
1 detection on / 0 off; -1 until read.
bool has(HeatingFunction fn) const
Has this register been read yet?
float setpoint_c
Comfort setpoint as the heater holds it; NaN until read.
float room_temperature_c
Measured room temperature; NaN until read or on a sensor fault.
float max_setpoint_c
Highest setpoint the heater accepts; NaN until read.
int16_t mode
HeatingMode value; -1 until read.
int16_t regulator
HeatingRegulatorState value; -1 until read.
bool store(HeatingFunction fn, float value)
Fold one decoded register value into the readings.
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 data_len
Actual length of data.