Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
proto_heating_profiles.cpp
Go to the documentation of this file.
1/// @file proto_heating_profiles.cpp
2/// @brief The heating profile table and its lookups.
3/// @ingroup hioc_protocol
4
6
8#include "proto_sizes.h"
9
10#include <algorithm>
11#include <cmath>
12#include <iterator>
13#include <span>
14
15namespace esphome {
16namespace home_io_control {
17
18namespace {
19
20/// Mask for the low byte of a main parameter, where a heating level sits.
21constexpr uint16_t LOW_BYTE_MASK = 0xFF;
22
23/// Wire scale of the temperature registers: the device holds whole tenths of a degree.
24constexpr float TENTHS_PER_DEGREE = 10.0F;
25
26/// The card shows no preset for a device-reported value.
27constexpr int8_t NO_PRESET = -1;
28
29/// Compile-time failure for preset_index(): a non-constexpr call is not a constant expression, so a
30/// lookup that reaches it stops the build.
31void preset_missing_from_list() {}
32
33/// @return Whether two optional names are the same text (nullptr equals nullptr only).
34constexpr bool same_name(const char *a, const char *b) {
35 if (a == nullptr || b == nullptr)
36 return a == b;
37 while (*a != '\0' && *a == *b) {
38 a++;
39 b++;
40 }
41 return *a == *b;
42}
43
44/// @brief Index of a preset in a profile's preset list, found by its id and, for CUSTOM, its name.
45/// @details Evaluated at compile time, so an observed state that names a preset the list lacks (or a
46/// misspelled custom name) fails the build, and reordering the list moves every index with it.
47/// @param presets The profile's presets.
48/// @param id Preset id to find.
49/// @param custom_name Name of a CUSTOM preset; nullptr for a standard one.
50consteval int8_t preset_index(std::span<const HeatingPresetEntry> presets, HeatingPresetId id,
51 const char *custom_name = nullptr) {
52 for (size_t i = 0; i < presets.size(); i++) {
53 if (presets[i].id == id && same_name(presets[i].custom_name, custom_name))
54 return static_cast<int8_t>(i);
55 }
56 preset_missing_from_list();
57 return NO_PRESET;
58}
59
60// === Electrical heater with an adjustable setpoint (52/1): private registers ===
61
62constexpr HeatingModeEntry SETPOINT_HEATER_MODES[] = {
63 {HeatingHvacMode::OFF, static_cast<uint8_t>(HeatingMode::OFF)},
64 {HeatingHvacMode::HEAT, static_cast<uint8_t>(HeatingMode::MANUAL)},
65 {HeatingHvacMode::AUTO, static_cast<uint8_t>(HeatingMode::AUTO)},
66};
67
68/// Both automatic modes show as HVAC auto; these presets are how a user picks between them.
69constexpr HeatingPresetEntry SETPOINT_HEATER_PRESETS[] = {
70 {"Program", HeatingPresetId::CUSTOM, HeatingFunction::SET_MODE, static_cast<uint8_t>(HeatingMode::PROG), true},
71 {"Auto", HeatingPresetId::CUSTOM, HeatingFunction::SET_MODE, static_cast<uint8_t>(HeatingMode::AUTO), true},
72};
73
74constexpr HeatingObservedState SETPOINT_HEATER_OBSERVED[] = {
75 {static_cast<uint8_t>(HeatingMode::AUTO), HeatingHvacMode::AUTO,
76 preset_index(SETPOINT_HEATER_PRESETS, HeatingPresetId::CUSTOM, "Auto")},
77 {static_cast<uint8_t>(HeatingMode::MANUAL), HeatingHvacMode::HEAT, NO_PRESET},
78 // AUTO and PROG both show as HVAC auto; the preset tells them apart.
79 {static_cast<uint8_t>(HeatingMode::PROG), HeatingHvacMode::AUTO,
80 preset_index(SETPOINT_HEATER_PRESETS, HeatingPresetId::CUSTOM, "Program")},
81 // The setpoint comes from another controller; heat is the nearest mode. Never written here.
82 {static_cast<uint8_t>(HeatingMode::EXTERNAL_SETPOINT), HeatingHvacMode::HEAT, NO_PRESET},
83 {static_cast<uint8_t>(HeatingMode::OFF), HeatingHvacMode::OFF, NO_PRESET},
84};
85
86/// The room temperature leads: it is the value a user looks at, so a poll that dies half-way still
87/// delivered it.
88constexpr HeatingFunction SETPOINT_HEATER_POLL[] = {
91};
92
93/// Properties and settings that change rarely: read until they have been read once.
94constexpr HeatingFunction SETPOINT_HEATER_BOOT[] = {
97};
98
99// === Heater with a night light (52/1, model HEATER_WITH_LIGHT): the radiator plus its light ===
100
101/// The light's state and brightness, read after the radiator's own poll. Both change from the heater's
102/// own controller (issue #222: a Cozytouch switches the light), so they are read on every poll, not once.
103constexpr HeatingFunction HEATER_WITH_LIGHT_EXTRA_POLL[] = {
106};
107
108// === Towel dryer (52/1, model TOWEL_DRYER): private registers of its own ===
109//
110// Register values as two Atlantic Adelis units report them (issue #202, corpus
111// atlantic_adelis_probe_*): the mode in 0x0136, boost and drying in 0x013E, separate from the mode.
112
113/// PROG is the device's own program, so it is HVAC auto; there is no separate "Program" preset as on
114/// the radiator. Value 1 of the register has never been seen and is not offered.
115constexpr HeatingModeEntry TOWEL_DRYER_MODES[] = {
116 {HeatingHvacMode::OFF, static_cast<uint8_t>(TowelDryerMode::OFF)},
117 {HeatingHvacMode::HEAT, static_cast<uint8_t>(TowelDryerMode::MANUAL)},
118 {HeatingHvacMode::AUTO, static_cast<uint8_t>(TowelDryerMode::PROG)},
119};
120
121/// The wire value of a preset the device only reports: it has no register value of its own (the period
122/// comes from TOWEL_DRYER_PROGRAMME_PERIOD) and is never written.
123constexpr uint8_t TOWEL_DRYER_NO_WIRE = 0;
124
125/// Boost and drying are timed runs on top of the mode; "none" ends the one that runs. Comfort and eco are
126/// the periods of the device's own programme: shown while it runs, never commanded.
127constexpr HeatingPresetEntry TOWEL_DRYER_PRESETS[] = {
129 static_cast<uint8_t>(HeatingTemporaryState::NONE), true},
131 static_cast<uint8_t>(HeatingTemporaryState::BOOST), true},
133 static_cast<uint8_t>(HeatingTemporaryState::DRYING), true},
134 {nullptr, HeatingPresetId::COMFORT, HeatingFunction::TEMPORARY_STATE, TOWEL_DRYER_NO_WIRE, false},
135 {nullptr, HeatingPresetId::ECO, HeatingFunction::TEMPORARY_STATE, TOWEL_DRYER_NO_WIRE, false},
136};
137
138/// The mode register names the mode only; TOWEL_DRYER_OBSERVED_PRESETS names the preset.
139constexpr HeatingObservedState TOWEL_DRYER_OBSERVED[] = {
140 {static_cast<uint8_t>(TowelDryerMode::OFF), HeatingHvacMode::OFF, NO_PRESET},
141 {static_cast<uint8_t>(TowelDryerMode::PROG), HeatingHvacMode::AUTO, NO_PRESET},
142 {static_cast<uint8_t>(TowelDryerMode::MANUAL), HeatingHvacMode::HEAT, NO_PRESET},
143};
144
145/// Temporary state -> preset. The hvac column is not consulted for a preset table. Comfort and eco have no
146/// row: the temporary state does not tell them apart.
147constexpr HeatingObservedState TOWEL_DRYER_OBSERVED_PRESETS[] = {
149 preset_index(TOWEL_DRYER_PRESETS, HeatingPresetId::NONE)},
151 preset_index(TOWEL_DRYER_PRESETS, HeatingPresetId::BOOST)},
153 preset_index(TOWEL_DRYER_PRESETS, HeatingPresetId::CUSTOM, "Drying")},
154};
155
156/// Room temperature first, as on the radiator. The setpoint is 0x0103, which a climate write is known to
157/// change on this device (issue #202); the window registers are not read, the dryer has no window function.
158constexpr HeatingFunction TOWEL_DRYER_POLL[] = {
161};
162
163/// The maximum setpoint, and the eco setback that tells an eco period from a comfort one. The setback is
164/// read again when the temperature in force fits neither period (the user changed it on the display).
165constexpr HeatingFunction TOWEL_DRYER_BOOT[] = {
168};
169
170/// The programme runs when the mode register reads PROG and no boost or drying is on top of it. The
171/// temperature in force then is the comfort setpoint in a comfort period and the comfort setpoint minus the
172/// setback in an eco period.
173/// \todo Both periods are read on one unit (BD684B); a second unit or a second read confirms them. Until
174/// then the card shows them as experimental.
175constexpr HeatingProgrammePeriod TOWEL_DRYER_PROGRAMME_PERIOD = {
176 static_cast<uint8_t>(TowelDryerMode::PROG),
177 static_cast<uint8_t>(HeatingTemporaryState::NONE),
181 preset_index(TOWEL_DRYER_PRESETS, HeatingPresetId::COMFORT),
182 preset_index(TOWEL_DRYER_PRESETS, HeatingPresetId::ECO),
183};
184
185// === Plain electrical heater (52/0): heating levels ===
186
187constexpr HeatingModeEntry LEVEL_HEATER_MODES[] = {
188 {HeatingHvacMode::OFF, heating_level::OFF},
189 {HeatingHvacMode::HEAT, heating_level::COMFORT},
190};
191
192constexpr HeatingPresetEntry LEVEL_HEATER_PRESETS[] = {
193 // write_fn is unused on this path; SET_MODE is a placeholder that is never sent.
194 {nullptr, HeatingPresetId::AWAY, HeatingFunction::SET_MODE, heating_level::FROST_PROTECTION, true},
195 {nullptr, HeatingPresetId::ECO, HeatingFunction::SET_MODE, heating_level::ECO, true},
196 {nullptr, HeatingPresetId::COMFORT, HeatingFunction::SET_MODE, heating_level::COMFORT, true},
197 {nullptr, HeatingPresetId::BOOST, HeatingFunction::SET_MODE, heating_level::BOOST, true},
198 {"Comfort -1", HeatingPresetId::CUSTOM, HeatingFunction::SET_MODE, heating_level::COMFORT_MINUS_1, true},
199 {"Comfort -2", HeatingPresetId::CUSTOM, HeatingFunction::SET_MODE, heating_level::COMFORT_MINUS_2, true},
200 // The device reports it, a controller cannot set it.
201 {"Secured", HeatingPresetId::CUSTOM, HeatingFunction::SET_MODE, heating_level::SECURED, false},
202};
203
204constexpr HeatingObservedState LEVEL_HEATER_OBSERVED[] = {
205 {heating_level::OFF, HeatingHvacMode::OFF, NO_PRESET},
206 {heating_level::FROST_PROTECTION, HeatingHvacMode::HEAT, preset_index(LEVEL_HEATER_PRESETS, HeatingPresetId::AWAY)},
207 {heating_level::ECO, HeatingHvacMode::HEAT, preset_index(LEVEL_HEATER_PRESETS, HeatingPresetId::ECO)},
208 {heating_level::COMFORT, HeatingHvacMode::HEAT, preset_index(LEVEL_HEATER_PRESETS, HeatingPresetId::COMFORT)},
209 {heating_level::BOOST, HeatingHvacMode::HEAT, preset_index(LEVEL_HEATER_PRESETS, HeatingPresetId::BOOST)},
210 {heating_level::COMFORT_MINUS_1, HeatingHvacMode::HEAT,
211 preset_index(LEVEL_HEATER_PRESETS, HeatingPresetId::CUSTOM, "Comfort -1")},
212 {heating_level::COMFORT_MINUS_2, HeatingHvacMode::HEAT,
213 preset_index(LEVEL_HEATER_PRESETS, HeatingPresetId::CUSTOM, "Comfort -2")},
214 {heating_level::SECURED, HeatingHvacMode::HEAT,
215 preset_index(LEVEL_HEATER_PRESETS, HeatingPresetId::CUSTOM, "Secured")},
216};
217
218// === Heating temperature interface (14/0) and heat pump (22/0): temperature in the main parameter ===
219
220constexpr HeatingFp NO_FP{};
221constexpr HeatingFp THERMOSTAT_MANUAL{HeatingFpKind::FP9, heating_mode_flags::MANUAL};
222constexpr HeatingFp HEAT_PUMP_ON{HeatingFpKind::FP16, heating_fp16::ON};
223
224/// Thermostat modes as the device's own controller sends them: off is the off marker with FP9 0, auto is
225/// the comfort alias with FP9 1, heat (manual) is the current target temperature with FP9 2.
226constexpr HeatingModeEntry TEMPERATURE_INTERFACE_MODES[] = {
227 {HeatingHvacMode::OFF, 0, HEATING_OFF_MP, {HeatingFpKind::FP9, heating_mode_flags::OFF}},
228 {HeatingHvacMode::HEAT, 0, HEATING_MP_FROM_TARGET, THERMOSTAT_MANUAL},
230 0,
231 heating_prefixed_main_parameter(HEATING_ALIAS_MP_PREFIX, heating_alias::COMFORT),
232 {HeatingFpKind::FP9, heating_mode_flags::AUTO}},
233};
234
235/// A setpoint alias is selected by its main parameter alone, with no functional parameter.
236constexpr HeatingPresetEntry TEMPERATURE_INTERFACE_PRESETS[] = {
237 {nullptr, HeatingPresetId::COMFORT, HeatingFunction::SET_MODE, heating_alias::COMFORT, true, NO_FP},
238 {nullptr, HeatingPresetId::ECO, HeatingFunction::SET_MODE, heating_alias::THERMOSTAT_ECO, true, NO_FP},
239 {"Secured", HeatingPresetId::CUSTOM, HeatingFunction::SET_MODE, heating_alias::SECURED, true, NO_FP},
240};
241
242/// The mode is the last command (nothing reads it back), so the hvac column is not consulted; the rows
243/// only name the preset an alias stands for.
244constexpr HeatingObservedState TEMPERATURE_INTERFACE_OBSERVED[] = {
245 {heating_alias::COMFORT, HeatingHvacMode::HEAT,
246 preset_index(TEMPERATURE_INTERFACE_PRESETS, HeatingPresetId::COMFORT)},
247 {heating_alias::THERMOSTAT_ECO, HeatingHvacMode::HEAT,
248 preset_index(TEMPERATURE_INTERFACE_PRESETS, HeatingPresetId::ECO)},
249 {heating_alias::SECURED, HeatingHvacMode::HEAT,
250 preset_index(TEMPERATURE_INTERFACE_PRESETS, HeatingPresetId::CUSTOM, "Secured")},
251};
252
253/// Heat pump: off is the off marker with FP16 off; on is the comfort alias with FP16 on, which is also
254/// what a preset sends. No mode selects cooling.
255constexpr HeatingModeEntry HEAT_PUMP_MODES[] = {
256 {HeatingHvacMode::OFF, 0, HEATING_OFF_MP, {HeatingFpKind::FP16, heating_fp16::OFF}},
257 {HeatingHvacMode::HEAT, 0, heating_prefixed_main_parameter(HEATING_ALIAS_MP_PREFIX, heating_alias::COMFORT),
258 HEAT_PUMP_ON},
259};
260
261constexpr HeatingPresetEntry HEAT_PUMP_PRESETS[] = {
262 {nullptr, HeatingPresetId::COMFORT, HeatingFunction::SET_MODE, heating_alias::COMFORT, true, HEAT_PUMP_ON},
263 {nullptr, HeatingPresetId::ECO, HeatingFunction::SET_MODE, heating_alias::HEAT_PUMP_ECO, true, HEAT_PUMP_ON},
264 {"Setback", HeatingPresetId::CUSTOM, HeatingFunction::SET_MODE, heating_alias::HEAT_PUMP_SETBACK, true,
265 HEAT_PUMP_ON},
266 {"Halted", HeatingPresetId::CUSTOM, HeatingFunction::SET_MODE, heating_alias::HEAT_PUMP_HALTED, true, HEAT_PUMP_ON},
267};
268
269constexpr HeatingObservedState HEAT_PUMP_OBSERVED[] = {
270 {heating_alias::COMFORT, HeatingHvacMode::HEAT, preset_index(HEAT_PUMP_PRESETS, HeatingPresetId::COMFORT)},
271 {heating_alias::HEAT_PUMP_ECO, HeatingHvacMode::HEAT, preset_index(HEAT_PUMP_PRESETS, HeatingPresetId::ECO)},
272 {heating_alias::HEAT_PUMP_SETBACK, HeatingHvacMode::HEAT,
273 preset_index(HEAT_PUMP_PRESETS, HeatingPresetId::CUSTOM, "Setback")},
274 {heating_alias::HEAT_PUMP_HALTED, HeatingHvacMode::HEAT,
275 preset_index(HEAT_PUMP_PRESETS, HeatingPresetId::CUSTOM, "Halted")},
276};
277
278constexpr float TEMPERATURE_VISUAL_MIN_C = 5.0F; ///< Lowest target the card offers for a temperature profile.
279constexpr float TEMPERATURE_VISUAL_MAX_C = 30.0F; ///< Highest target the card offers for a temperature profile.
280
281/// The electrical heater with an adjustable setpoint (52/1), the AGILIA radiator: a table row, and the
282/// base of the model that adds a night light.
283constexpr HeatingProfile SETPOINT_HEATER_PROFILE = {
285 .subtype = 1,
286 .model = DeviceModel::NONE,
288 .features = {.target_temperature = true, .current_temperature = true, .action = true, .mode_observed = true},
289 .modes = SETPOINT_HEATER_MODES,
290 .visual_min_c = HEATING_TEMP_MIN_C,
291 .visual_max_c = HEATING_TEMP_MAX_C,
292 .presets = SETPOINT_HEATER_PRESETS,
293 .observed = SETPOINT_HEATER_OBSERVED,
294 .poll_registers = SETPOINT_HEATER_POLL,
295 .boot_registers = SETPOINT_HEATER_BOOT,
296};
297
298/// @brief The radiator's profile under the HEATER_WITH_LIGHT model, with the light read after its poll.
299/// @details Built from SETPOINT_HEATER_PROFILE rather than restated, so the two stay one heater with one
300/// extra part.
301constexpr HeatingProfile heater_with_light_profile() {
302 HeatingProfile profile = SETPOINT_HEATER_PROFILE;
304 profile.extra_poll_registers = HEATER_WITH_LIGHT_EXTRA_POLL;
305 return profile;
306}
307
308/// The heater with a night light (52/1, model HEATER_WITH_LIGHT).
309constexpr HeatingProfile HEATER_WITH_LIGHT_PROFILE = heater_with_light_profile();
310
311constexpr HeatingProfile HEATING_PROFILES[] = {
312 {
314 .subtype = 0,
315 .model = DeviceModel::NONE,
317 .features = {.target_temperature = true, .current_temperature = false, .action = false, .mode_observed = false},
318 .modes = TEMPERATURE_INTERFACE_MODES,
319 .setpoint_fp = THERMOSTAT_MANUAL,
320 .visual_min_c = TEMPERATURE_VISUAL_MIN_C,
321 .visual_max_c = TEMPERATURE_VISUAL_MAX_C,
322 .presets = TEMPERATURE_INTERFACE_PRESETS,
323 .observed = TEMPERATURE_INTERFACE_OBSERVED,
324 .mp_prefix = HEATING_ALIAS_MP_PREFIX,
325 },
326 {
327 .type = DeviceType::HEAT_PUMP,
328 .subtype = 0,
329 .model = DeviceModel::NONE,
331 .features = {.target_temperature = true, .current_temperature = false, .action = false, .mode_observed = false},
332 .modes = HEAT_PUMP_MODES,
333 .visual_min_c = TEMPERATURE_VISUAL_MIN_C,
334 .visual_max_c = TEMPERATURE_VISUAL_MAX_C,
335 .presets = HEAT_PUMP_PRESETS,
336 .observed = HEAT_PUMP_OBSERVED,
337 .mp_prefix = HEATING_ALIAS_MP_PREFIX,
338 .fixed_range = true,
339 },
340 {
342 .subtype = 0,
343 .model = DeviceModel::NONE,
345 .features = {.target_temperature = false, .current_temperature = false, .action = false, .mode_observed = true},
346 .modes = LEVEL_HEATER_MODES,
347 .presets = LEVEL_HEATER_PRESETS,
348 .observed = LEVEL_HEATER_OBSERVED,
349 .mp_prefix = HEATING_LEVEL_MP_PREFIX,
350 },
351 SETPOINT_HEATER_PROFILE,
352 HEATER_WITH_LIGHT_PROFILE,
353 {
355 .subtype = 1,
358 .features = {.target_temperature = true, .current_temperature = true, .action = true, .mode_observed = true},
359 .modes = TOWEL_DRYER_MODES,
360 .visual_min_c = HEATING_TEMP_MIN_C,
361 .visual_max_c = HEATING_TEMP_MAX_C,
362 .programme_period = &TOWEL_DRYER_PROGRAMME_PERIOD,
363 .presets = TOWEL_DRYER_PRESETS,
364 .observed = TOWEL_DRYER_OBSERVED,
365 .poll_registers = TOWEL_DRYER_POLL,
366 .boot_registers = TOWEL_DRYER_BOOT,
367 .preset_observed = TOWEL_DRYER_OBSERVED_PRESETS,
370 .action_source = HeatingActionSource::WORKING_RATE,
371 },
372};
373
374// === Table consistency, checked at build time ===
375
376/// @return Whether every observed row of `rows` shows no preset or one inside a list of `preset_count`.
377constexpr bool observed_within(std::span<const HeatingObservedState> rows, size_t preset_count) {
378 return std::ranges::all_of(rows, [preset_count](const HeatingObservedState &row) {
379 return row.preset >= NO_PRESET && row.preset < static_cast<int8_t>(preset_count);
380 });
381}
382
383/// @return Whether a profile's programme period (if any) names presets inside its preset list.
384constexpr bool programme_period_within(const HeatingProfile &profile) {
385 const HeatingProgrammePeriod *period = profile.programme_period;
386 const auto count = static_cast<int8_t>(profile.presets.size());
387 return period == nullptr || (period->comfort_preset < count && period->eco_preset < count);
388}
389
390/// @return Whether every preset index a profile names lies inside its preset list.
391constexpr bool preset_indices_within_lists() {
392 return std::ranges::all_of(HEATING_PROFILES, [](const HeatingProfile &profile) {
393 return observed_within(profile.observed, profile.presets.size()) &&
394 observed_within(profile.preset_observed, profile.presets.size()) && programme_period_within(profile);
395 });
396}
397
398static_assert(preset_indices_within_lists(), "a profile names a preset index outside its preset list");
399
400const HeatingProfile *find_exact_profile(DeviceType type, uint8_t subtype, DeviceModel model) {
401 for (const HeatingProfile &profile : HEATING_PROFILES) {
402 if (profile.type == type && profile.subtype == subtype && profile.model == model)
403 return &profile;
404 }
405 return nullptr;
406}
407
408} // namespace
409
410const HeatingProfile *find_heating_profile(DeviceType type, uint8_t subtype, DeviceModel model) {
411 if (model != DeviceModel::NONE) {
412 if (const HeatingProfile *model_profile = find_exact_profile(type, subtype, model))
413 return model_profile;
414 }
415 const HeatingProfile *profile = find_exact_profile(type, subtype, DeviceModel::NONE);
416 if (profile != nullptr || subtype == 0)
417 return profile;
418 // A subtype the type table names on its own (the Atlantic heat pumps, 22/1 and 22/2) is a different
419 // device from the type's own row and must not inherit its profile; only an unnamed subtype does.
420 return find_exact_device_type_info(type, subtype) == nullptr ? find_exact_profile(type, 0, DeviceModel::NONE)
421 : nullptr;
422}
423
425 return find_heating_profile(dev.type, dev.subtype, dev.model);
426}
427
428// The path predicates ask the (type, subtype) profile: a model's profile keeps its path.
429
430bool device_uses_heating_registers(DeviceType type, uint8_t subtype) {
431 const HeatingProfile *profile = find_heating_profile(type, subtype, DeviceModel::NONE);
432 return profile != nullptr && profile->path == HeatingControlPath::PRIVATE_REGISTERS;
433}
434
435bool device_accepts_register_probe(DeviceType type, uint8_t subtype) {
436 // A water heater's register family is a fact of the (type, subtype), as the heater's control path is.
437 return has_feature(device_features(type, subtype, DeviceModel::NONE), device_feature::WATER_HEATER) ||
438 device_uses_heating_registers(type, subtype);
439}
440
441bool device_uses_heating_levels(DeviceType type, uint8_t subtype) {
442 const HeatingProfile *profile = find_heating_profile(type, subtype, DeviceModel::NONE);
443 return profile != nullptr && profile->path == HeatingControlPath::HEATING_LEVEL;
444}
445
446bool device_uses_heating_temperature(DeviceType type, uint8_t subtype) {
447 const HeatingProfile *profile = find_heating_profile(type, subtype, DeviceModel::NONE);
448 return profile != nullptr && profile->path == HeatingControlPath::TEMPERATURE_EXECUTE;
449}
450
451bool encode_temperature_main_parameter(float celsius, float min_c, float max_c, uint16_t &main_parameter) {
452 if (!std::isfinite(celsius) || !std::isfinite(min_c) || !std::isfinite(max_c) || !(max_c > min_c))
453 return false;
454 const float steps = std::round((celsius - min_c) / (max_c - min_c) * static_cast<float>(HEATING_TEMPERATURE_MP_SPAN));
455 main_parameter = static_cast<uint16_t>(std::clamp(steps, 0.0F, static_cast<float>(HEATING_TEMPERATURE_MP_SPAN)));
456 return true;
457}
458
459bool decode_temperature_main_parameter(uint16_t main_parameter, float min_c, float max_c, float &celsius) {
460 if (main_parameter > HEATING_TEMPERATURE_MP_SPAN || !std::isfinite(min_c) || !std::isfinite(max_c) ||
461 !(max_c > min_c))
462 return false;
463 celsius =
464 min_c + (static_cast<float>(main_parameter) / static_cast<float>(HEATING_TEMPERATURE_MP_SPAN) * (max_c - min_c));
465 return true;
466}
467
468bool decode_heating_level(uint16_t main_parameter, uint8_t &level) {
469 if ((main_parameter >> BITS_PER_BYTE) != HEATING_LEVEL_MP_PREFIX)
470 return false;
471 level = static_cast<uint8_t>(main_parameter & LOW_BYTE_MASK);
472 return true;
473}
474
475namespace {
476
477/// @return The row of `rows` for a wire value, or nullptr when there is none.
478const HeatingObservedState *find_observed_row(std::span<const HeatingObservedState> rows, uint8_t wire) {
479 for (const HeatingObservedState &row : rows) {
480 if (row.wire == wire)
481 return &row;
482 }
483 return nullptr;
484}
485
486} // namespace
487
488const HeatingObservedState *find_observed_state(const HeatingProfile &profile, uint8_t wire) {
489 return find_observed_row(profile.observed, wire);
490}
491
492const HeatingObservedState *find_observed_preset(const HeatingProfile &profile, uint8_t wire) {
493 return find_observed_row(profile.preset_observed, wire);
494}
495
496// === Programme period ===
497
498bool programme_period_active(const HeatingProfile &profile, const HeatingReadings &readings) {
499 const HeatingProgrammePeriod *period = profile.programme_period;
500 if (period == nullptr)
501 return false;
502 // value_of() is -1 for an unread register, which never equals a (non-negative) register value.
503 const auto is_value = [&readings](HeatingFunction fn, uint8_t value) {
504 return readings.value_of(fn) == static_cast<int16_t>(value);
505 };
506 return is_value(profile.mode_fn, period->programme_mode) && is_value(profile.preset_fn, period->idle_preset_state);
507}
508
509namespace {
510
511/// @return A temperature in whole tenths of a degree, the resolution the device holds it in.
512int32_t to_tenths(float celsius) { return static_cast<int32_t>(std::lround(celsius * TENTHS_PER_DEGREE)); }
513
514} // namespace
515
518 if (!programme_period_active(profile, readings))
519 return state;
520 const HeatingProgrammePeriod &period = *profile.programme_period;
521 state.active = true;
522 if (!readings.has(period.in_force_fn))
523 return state;
524 // The temperature in force is held in whole tenths, so the comparison is on whole tenths: 20.0 - 3.5 is
525 // 16.5 only there, not in float.
526 state.target_c = readings.temperature_of(period.in_force_fn);
527 if (!readings.has(period.comfort_fn))
528 return state;
529 const int32_t in_force = to_tenths(state.target_c);
530 const int32_t comfort = to_tenths(readings.temperature_of(period.comfort_fn));
531 if (in_force == comfort) {
532 state.preset_index = period.comfort_preset;
533 } else if (readings.has(period.setback_fn) &&
534 in_force == comfort - to_tenths(readings.temperature_of(period.setback_fn))) {
535 state.preset_index = period.eco_preset;
536 }
537 return state;
538}
539
541 if (profile.programme_period != nullptr && !programme_period_active(profile, readings))
542 readings.clear(profile.programme_period->in_force_fn);
543}
544
545} // namespace home_io_control
546} // namespace esphome
void clear_inactive_programme_reading(const HeatingProfile &profile, HeatingReadings &readings)
Drop the temperature in force from the readings when the programme does not run.
@ WORKING_RATE
The working rate (HeatingFunction::WORKING_RATE): heating above 0 %.
const HeatingObservedState * find_observed_state(const HeatingProfile &profile, uint8_t wire)
Find what a device-reported wire value shows on the card.
DeviceFeatures device_features(DeviceType type, uint8_t subtype, DeviceModel model)
Capability bits of a (type, subtype, model).
bool encode_temperature_main_parameter(float celsius, float min_c, float max_c, uint16_t &main_parameter)
Encode a temperature as a main parameter over an encoding range.
@ FP9
Functional parameter 9, one byte.
@ FP16
Functional parameter 16, two bytes.
const HeatingProfile * find_heating_profile(DeviceType type, uint8_t subtype, DeviceModel model)
Look up the heating profile for a (type, subtype, model).
HeatingFunction
Registers and functions of a 2W heater, one row of the descriptor table each.
@ TOWEL_MODE
Register 0x0136, read/write: a towel dryer's mode (TowelDryerMode).
@ ROOM_TEMPERATURE
Register 0x0115, read-only: measured room temperature, signed hundredths of a degree.
@ WORKING_RATE
Register 0x0126, read-only: share of the time the element heats, 0-100 %.
@ NIGHT_LIGHT
Register 0x0144, read/write: a heater's night light on (1) or off (0).
@ MAX_SETPOINT
Register 0x0102, read-only: highest setpoint the heater accepts, tenths of a degree.
@ TEMPORARY_STATE
Register 0x013E, read/write: a towel dryer's temporary state (HeatingTemporaryState).
@ NIGHT_LIGHT_BRIGHTNESS
Register 0x0142, read/write: the night light's brightness, 0-100 %, 16-bit.
@ 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.
@ TEMPERATURE_IN_FORCE
Register 0x0137, read-only: the temperature a towel dryer holds right now, tenths of a degree.
@ ECO_SETBACK
Register 0x0104, read-only: how far below the comfort setpoint a towel dryer's eco period heats,...
@ SET_MODE
Register 0x0100, read/write: operating mode (HeatingMode).
bool decode_temperature_main_parameter(uint16_t main_parameter, float min_c, float max_c, float &celsius)
Decode a main parameter as a temperature over an encoding range (the inverse of encode_temperature_ma...
@ PROG
Program mode: the device runs its own weekly program.
@ MANUAL
Manual mode: the device holds the comfort setpoint.
HeatingPresetId
Presets a profile can offer. CUSTOM entries carry their own name.
@ CUSTOM
Named by HeatingPresetEntry::custom_name.
@ AWAY
Nobody is in the room (the frost-protection level on a level heater).
@ NONE
No preset: selecting it ends the one that runs (a towel dryer's boost or drying).
DeviceModel
A product model below a (node type, subtype): for products that report the same wire type but need di...
@ HEATER_WITH_LIGHT
Heater with a night light under the electrical heater with an adjustable setpoint (52/1).
@ NONE
No model: the (type, subtype) row.
@ TOWEL_DRYER
Towel dryer under the electrical heater with an adjustable setpoint (52/1).
constexpr float HEATING_TEMP_MAX_C
Highest setpoint this codec will encode.
bool programme_period_active(const HeatingProfile &profile, const HeatingReadings &readings)
Does the heater's own programme run right now, with no timed run on top of it?
DeviceType
IO-Homecontrol node types.
@ HEATING_TEMPERATURE_INTERFACE
Heating temperature interface.
@ TEMPERATURE_EXECUTE
Standard EXECUTE with a temperature or an alias as the main parameter; read with 0x03.
@ PRIVATE_REGISTERS
CMD_WRITE_PRIVATE register reads and writes (proto_heating.h).
@ HEATING_LEVEL
Standard EXECUTE with a heating level as the main parameter; read with 0x03.
constexpr bool has_feature(DeviceFeatures set, DeviceFeatures bit)
True when every bit of bit is set in set.
const HeatingObservedState * find_observed_preset(const HeatingProfile &profile, uint8_t wire)
Find what a device-reported value of the preset register (preset_fn) shows on the card.
const DeviceTypeInfo * find_exact_device_type_info(DeviceType type, uint8_t subtype)
Look up the table row with exactly this (type, subtype), without falling back.
constexpr float HEATING_TEMP_MIN_C
Lowest setpoint this codec will encode.
bool device_uses_heating_levels(DeviceType type, uint8_t subtype)
Does a climate entity drive this device with heating levels?
bool decode_heating_level(uint16_t main_parameter, uint8_t &level)
Read a heating level out of a main parameter.
@ PROG
Program mode; the device runs its own stored weekly schedule.
@ EXTERNAL_SETPOINT
Setpoint comes from an external controller.
@ MANUAL
Manual mode; follows the last written setpoint.
@ AUTO
Automatic mode; the device manages the setpoint itself.
bool device_uses_heating_registers(DeviceType type, uint8_t subtype)
Does a climate entity drive this device over the private register protocol?
ProgrammePeriodState resolve_programme_period(const HeatingProfile &profile, const HeatingReadings &readings)
Which period of its programme a heater is in, and the temperature it heats to.
bool device_uses_heating_temperature(DeviceType type, uint8_t subtype)
Does a climate entity drive this device with a temperature or an alias in the main parameter?
bool device_accepts_register_probe(DeviceType type, uint8_t subtype)
May the diagnostic register-read probes (register_00, register_01) be sent to this device?
@ DRYING
Drying: full power for the drying duration.
@ BOOST
Boost: full power for the boost duration.
@ NONE
No temporary state: the mode applies.
constexpr uint16_t heating_prefixed_main_parameter(uint8_t prefix, uint8_t low)
Main parameter of a prefixed value (a level or an alias).
The IO node-type and subtype table: names, entity class, platform and behaviour flags.
Heating profiles: what a climate card offers for each kind of heater, and how.
Fundamental IO-Homecontrol frame and crypto size constants.
A functional parameter and its value.
One HVAC mode the card offers and the wire value that selects it.
What a device-reported wire value shows on the card.
DeviceModel model
Model (NONE = the (type, subtype) row's own profile).
std::span< const HeatingObservedState > observed
Device-reported value -> card state.
const HeatingProgrammePeriod * programme_period
PRIVATE_REGISTERS: how the heater's own programme names the comfort or eco period it is in; nullptr f...
HeatingControlPath path
How the heater is commanded and read.
HeatingFunction preset_fn
PRIVATE_REGISTERS with preset_observed: the register the card's preset is read from.
HeatingFunction mode_fn
PRIVATE_REGISTERS: the register the HVAC modes write and the card's mode is read from.
std::span< const HeatingObservedState > preset_observed
Device-reported value of preset_fn -> preset; empty when the mode register names the preset too (obse...
How a heater's own weekly programme shows which period runs: the registers that tell,...
HeatingFunction in_force_fn
Register of the temperature in force.
uint8_t programme_mode
Value of HeatingProfile::mode_fn while the programme runs.
int8_t comfort_preset
Index into HeatingProfile::presets shown in a comfort period.
int8_t eco_preset
Index into HeatingProfile::presets shown in an eco period.
HeatingFunction setback_fn
Register of the eco setback, a distance below the comfort setpoint.
HeatingFunction comfort_fn
Register of the comfort setpoint.
uint8_t idle_preset_state
Value of HeatingProfile::preset_fn with no timed run on top.
What the hub has read from one heater so far.
int16_t value_of(HeatingFunction fn) const
The stored integer value of a register (a mode, a state, a flag, a percentage).
bool has(HeatingFunction fn) const
Has this register been read yet?
bool clear(HeatingFunction fn)
Forget a register's value: it reads as "never read" again.
float temperature_of(HeatingFunction fn) const
The stored temperature of a register read in degrees.
Runtime state of a paired IO‑Homecontrol device.
uint8_t subtype
Device subtype (manufacturer‑specific).
DeviceModel model
YAML-declared product model below the subtype (io_model:); never on the wire.
DeviceType type
Device type (shutter, awning, etc.).
What a heater's programme period resolves to, from its readings.
int8_t preset_index
Index into the profile's presets of the period, or -1 when the temperature in force fits neither peri...
bool active
The programme runs (and no timed run is on top of it).
float target_c
The temperature in force; NaN until it has been read.