Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
platform_climate.cpp
Go to the documentation of this file.
1/// @file platform_climate.cpp
2/// @brief Experimental climate entity for IO-Homecontrol heaters, driven by a heating profile.
3/// @ingroup hioc_platforms
4
5#include "platform_climate.h"
6
9
10#include "esphome/core/log.h"
11
12#include <algorithm>
13#include <cinttypes>
14#include <cmath>
15#include <string>
16
17namespace esphome {
18namespace home_io_control {
19
20static const char *const TAG = "home_io_control.climate";
21
22namespace {
23
24/// @brief Profile HVAC mode <-> ESPHome climate mode. The single place this correspondence is defined.
25struct ClimateModeMapping {
26 HeatingHvacMode hvac;
27 climate::ClimateMode climate_mode;
28};
29constexpr ClimateModeMapping CLIMATE_MODE_MAP[] = {
30 {HeatingHvacMode::OFF, climate::CLIMATE_MODE_OFF},
31 {HeatingHvacMode::HEAT, climate::CLIMATE_MODE_HEAT},
32 {HeatingHvacMode::AUTO, climate::CLIMATE_MODE_AUTO},
33};
34
35/// @brief Profile standard preset <-> ESPHome preset. Custom presets are named by the profile.
36struct ClimatePresetMapping {
38 climate::ClimatePreset preset;
39};
40constexpr ClimatePresetMapping CLIMATE_PRESET_MAP[] = {
41 {HeatingPresetId::NONE, climate::CLIMATE_PRESET_NONE}, {HeatingPresetId::AWAY, climate::CLIMATE_PRESET_AWAY},
42 {HeatingPresetId::COMFORT, climate::CLIMATE_PRESET_COMFORT}, {HeatingPresetId::ECO, climate::CLIMATE_PRESET_ECO},
43 {HeatingPresetId::BOOST, climate::CLIMATE_PRESET_BOOST},
44};
45
46/// @brief UI target-temperature step. The wire carries 0.1 C (encode_heating_write() encodes a
47/// 16-bit tenths-of-a-degree setpoint); 0.5 C is a UI-only choice for a sane thermostat card.
48constexpr float CLIMATE_TARGET_TEMP_STEP_C = 0.5F;
49
50climate::ClimateMode to_climate_mode(HeatingHvacMode hvac) {
51 for (const auto &mapping : CLIMATE_MODE_MAP) {
52 if (mapping.hvac == hvac)
53 return mapping.climate_mode;
54 }
55 return climate::CLIMATE_MODE_OFF;
56}
57
58/// @return The ESPHome preset of a standard profile preset; CLIMATE_PRESET_NONE for a custom one.
59climate::ClimatePreset to_climate_preset(HeatingPresetId id) {
60 for (const auto &mapping : CLIMATE_PRESET_MAP) {
61 if (mapping.id == id)
62 return mapping.preset;
63 }
64 return climate::CLIMATE_PRESET_NONE;
65}
66
67} // namespace
68
71 return find_heating_profile(this->device_type_, this->subtype_, this->model_);
72 // Declared without a type: use the one the hub has learned from the device since.
73 const IoDevice *dev = this->parent_ != nullptr ? this->parent_->get_device(this->device_id_) : nullptr;
74 return dev != nullptr ? find_heating_profile(*dev) : nullptr;
75}
76
78 // Device gating, the single predicate. A known device this entity cannot drive (a non-climate
79 // type, or a heater kind without a profile) is a configuration mistake, so fail it loudly rather
80 // than let every command be rejected downstream. An UNKNOWN type is left to pass through: discovery
81 // may still resolve it to a climate type, exactly as the other platforms tolerate unknown bindings.
82 // This runs BEFORE register_device_binding_() so a misconfigured binding never injects a device
83 // into the hub registry and never leaves a live callback on a failed entity.
84 if (this->device_type_ != DeviceType::UNKNOWN &&
86 ESP_LOGE(TAG,
87 "Device %s is declared as %s, which this climate entity cannot drive yet (it drives an electrical "
88 "heater: io_device_type: electrical_heater, io_subtype: 0 or 1) — disabling it",
89 this->device_id_.c_str(), format_device_kind(this->device_type_, this->subtype_).c_str());
90 this->mark_failed();
91 return;
92 }
93
94 // The initial poll reads the heater (IOHomeControlComponent::request_device_status()), so the card
95 // shows measured values from the first minute instead of nothing.
97 this, DeviceCapabilityClass::CLIMATE, std::nullopt,
98 [this](const std::string &id, const IoDevice &dev) { this->on_device_update_(id, dev); });
99
100 if (this->has_range_)
102
103 // Custom presets are registered on the entity and referenced by the traits; IO's program mode,
104 // the comfort offsets and the secured level have no standard preset.
105 if (const HeatingProfile *profile = this->profile_(); profile != nullptr) {
106 if (profile->features.target_temperature)
107 this->max_temperature_c_ = profile->visual_max_c;
108 for (const HeatingPresetEntry &entry : profile->presets) {
109 if (entry.id == HeatingPresetId::CUSTOM)
110 this->custom_presets_.push_back(entry.custom_name);
111 }
112 this->set_supported_custom_presets(this->custom_presets_);
113 }
114}
115
116climate::ClimateTraits IOHomeClimate::traits() {
117 climate::ClimateTraits traits;
118 const HeatingProfile *profile = this->profile_();
119 if (profile == nullptr) {
120 // Type not known yet: the one mode every heater has.
121 traits.add_supported_mode(climate::CLIMATE_MODE_OFF);
122 return traits;
123 }
124
125 // Both are filled only from a polled reply (sync_from_readings_()): a card that advertises them
126 // shows an empty value until the first poll lands, which is the honest state.
127 if (profile->features.current_temperature)
128 traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
129 if (profile->features.action)
130 traits.add_feature_flags(climate::CLIMATE_SUPPORTS_ACTION);
131 for (const HeatingModeEntry &entry : profile->modes)
132 traits.add_supported_mode(to_climate_mode(entry.hvac));
133 for (const HeatingPresetEntry &entry : profile->presets) {
134 if (entry.id != HeatingPresetId::CUSTOM)
135 traits.add_supported_preset(to_climate_preset(entry.id));
136 }
137 // HEATING_TEMP_MIN_C up to the heater's own maximum setpoint once read, never past what
138 // encode_heating_write() accepts (HEATING_TEMP_MAX_C).
139 if (profile->features.target_temperature) {
140 traits.set_visual_min_temperature(profile->visual_min_c);
141 traits.set_visual_max_temperature(this->max_temperature_c_);
142 traits.set_visual_target_temperature_step(CLIMATE_TARGET_TEMP_STEP_C);
143 }
144 return traits;
145}
146
147void IOHomeClimate::control(const climate::ClimateCall &call) {
148 const HeatingProfile *profile = this->profile_();
149 if (profile == nullptr) {
150 ESP_LOGW(TAG, "Device %s: type not known yet, ignoring the request", this->device_id_.c_str());
151 return;
152 }
153 // Each field is independent: a failed send for one leaves that field's state in place and does not
154 // block the others. Every helper runs; publish once if any field advanced without a read-back.
155 const bool mode_changed = this->apply_mode_(*profile, call);
156 const bool custom_preset_changed = this->apply_custom_preset_(*profile, call);
157 const bool preset_changed = this->apply_preset_(*profile, call);
158 const bool temperature_changed = this->apply_target_temperature_(*profile, call);
159
160 // A read-back publishes through on_device_update_(); this publishes what a write advanced when no
161 // read-back has confirmed anything yet.
162 if (mode_changed || custom_preset_changed || preset_changed || temperature_changed)
163 this->publish_state();
164}
165
166bool IOHomeClimate::write_(const HeatingProfile &profile, HeatingFunction fn, uint8_t wire) {
167 switch (profile.path) {
169 return this->parent_->set_heating_level(this->device_id_, wire);
171 return this->parent_->send_heating_command(this->device_id_, fn, static_cast<float>(wire));
173 break; // its commands carry a main parameter and a functional parameter: write_preset_() and friends
174 }
175 return false;
176}
177
180 return this->write_(profile, entry.write_fn, entry.wire);
181 // A setpoint alias is a main parameter, with the functional parameter the profile names for it.
182 return this->parent_->set_heating_temperature(
183 this->device_id_, heating_prefixed_main_parameter(profile.mp_prefix, entry.wire), entry.fp);
184}
185
186bool IOHomeClimate::encode_target_(const HeatingProfile &profile, float celsius, uint16_t &main_parameter) {
187 const IoDevice *dev = this->parent_->get_device(this->device_id_);
188 if (dev == nullptr)
189 return false;
190 // A profile with a fixed range never takes the YAML range, whatever the record holds.
191 const float min_c = profile.fixed_range ? HEATING_TEMPERATURE_RANGE_MIN_C : dev->temperature_range_min_c;
192 const float max_c = profile.fixed_range ? HEATING_TEMPERATURE_RANGE_MAX_C : dev->temperature_range_max_c;
193 return encode_temperature_main_parameter(celsius, min_c, max_c, main_parameter);
194}
195
197 uint16_t main_parameter = entry.main_parameter;
198 if (main_parameter == HEATING_MP_FROM_TARGET) {
199 // Heat (manual) is the current target temperature with the manual flag: the target the card shows,
200 // else the one the device last reported.
201 const IoDevice *dev = this->parent_->get_device(this->device_id_);
202 float target = this->target_temperature;
203 if (std::isnan(target) && dev != nullptr)
204 target = dev->heating.setpoint_c;
205 if (std::isnan(target) || !this->encode_target_(profile, target, main_parameter)) {
206 ESP_LOGW(TAG, "Device %s: no target temperature known yet, set one before selecting heat",
207 this->device_id_.c_str());
208 return false;
209 }
210 }
211 return this->parent_->set_heating_temperature(this->device_id_, main_parameter, entry.fp);
212}
213
215 // A failed read is already logged by the hub, and the poll will find the register again; the
216 // entity keeps whatever it showed. A level write needs no call here: the hub arms a settle poll.
217 this->parent_->read_heating_register(this->device_id_, fn, nullptr, EXCHANGE_RETRY_COUNT);
218}
219
220bool IOHomeClimate::apply_mode_(const HeatingProfile &profile, const climate::ClimateCall &call) {
221 const auto &mode_opt = call.get_mode();
222 if (!mode_opt.has_value())
223 return false;
224 const climate::ClimateMode requested = *mode_opt;
225 for (const HeatingModeEntry &entry : profile.modes) {
226 if (to_climate_mode(entry.hvac) != requested)
227 continue;
228 const bool sent = profile.path == HeatingControlPath::TEMPERATURE_EXECUTE
229 ? this->write_temperature_mode_(profile, entry)
230 : this->write_(profile, profile.mode_fn, entry.wire);
231 if (!sent)
232 return false;
233 // Until a read has succeeded there is nothing observed to show: the commanded mode stands in. A mode
234 // that nothing reads back stays the last command.
235 const bool commanded = !this->observed_ || !profile.features.mode_observed;
236 this->mode_commanded_ = true;
237 if (commanded) {
238 this->mode = requested;
239 // A real HVAC mode supersedes a preset on the register and level paths; a temperature heater's
240 // preset follows its status reply instead, and so does a preset kept in a register of its own
241 // (a towel dryer's boost outlives a mode change).
242 if (profile.path != HeatingControlPath::TEMPERATURE_EXECUTE && profile.preset_observed.empty()) {
243 this->clear_custom_preset_();
244 this->preset.reset();
245 }
246 }
248 this->confirm_write_(profile.mode_fn);
249 return commanded && !this->observed_;
250 }
251 return commanded;
252 }
253 return false;
254}
255
256bool IOHomeClimate::apply_custom_preset_(const HeatingProfile &profile, const climate::ClimateCall &call) {
257 if (!call.has_custom_preset())
258 return false;
259 const std::string custom = std::string(call.get_custom_preset());
260 for (const HeatingPresetEntry &entry : profile.presets) {
261 if (entry.id != HeatingPresetId::CUSTOM || custom != entry.custom_name)
262 continue;
263 return this->commit_preset_(profile, entry);
264 }
265 return false;
266}
267
268bool IOHomeClimate::apply_preset_(const HeatingProfile &profile, const climate::ClimateCall &call) {
269 const auto &preset_opt = call.get_preset();
270 if (!preset_opt.has_value())
271 return false;
272 const climate::ClimatePreset requested = *preset_opt;
273 for (const HeatingPresetEntry &entry : profile.presets) {
274 if (entry.id == HeatingPresetId::CUSTOM || to_climate_preset(entry.id) != requested)
275 continue;
276 return this->commit_preset_(profile, entry);
277 }
278 return false;
279}
280
282 if (!entry.commandable) {
283 ESP_LOGW(TAG, "Device %s: preset '%s' is reported by the device and cannot be set", this->device_id_.c_str(),
284 entry.custom_name != nullptr ? entry.custom_name : "(standard)");
285 return false;
286 }
287 if (!this->write_preset_(profile, entry))
288 return false;
289 // Once a read has succeeded the card follows the read-back, not the command.
290 const bool commanded = !this->observed_;
291 if (commanded) {
292 if (!profile.preset_observed.empty()) {
293 // The preset has a register and a table of its own: its value is never a mode value.
294 this->apply_observed_preset_(profile, entry.wire);
295 } else if (profile.features.mode_observed) {
296 // The read-back uses the same table, so a commanded preset looks like a read one.
297 this->apply_observed_state_(profile, entry.wire);
298 } else if (entry.id == HeatingPresetId::CUSTOM) {
299 this->set_custom_preset_(entry.custom_name);
300 } else {
301 this->preset = to_climate_preset(entry.id);
302 this->clear_custom_preset_(); // a standard preset supersedes a custom one
303 this->mode = climate::CLIMATE_MODE_HEAT; // every alias preset heats
304 }
305 }
307 this->confirm_write_(entry.write_fn);
308 return commanded;
309}
310
311bool IOHomeClimate::apply_target_temperature_(const HeatingProfile &profile, const climate::ClimateCall &call) {
312 const auto &target_opt = call.get_target_temperature();
313 if (!target_opt.has_value())
314 return false;
315 if (!profile.features.target_temperature) {
316 ESP_LOGW(TAG, "Device %s has no adjustable setpoint; the request is ignored", this->device_id_.c_str());
317 return false;
318 }
319 const float target = *target_opt;
321 // The temperature travels in the main parameter, encoded over the device's range; the settle poll
322 // reads it back.
323 uint16_t main_parameter = 0;
324 if (!this->encode_target_(profile, target, main_parameter) ||
325 !this->parent_->set_heating_temperature(this->device_id_, main_parameter, profile.setpoint_fp))
326 return false;
327 const bool commanded = !this->observed_;
328 if (commanded) {
329 this->target_temperature = target;
330 this->clear_custom_preset_();
331 this->preset.reset();
332 }
333 return commanded;
334 }
335 if (!this->parent_->send_heating_command(this->device_id_, HeatingFunction::SET_TEMPERATURE, target))
336 return false;
337 const bool commanded = !this->observed_;
338 if (commanded)
339 this->target_temperature = target;
341 return commanded && !this->observed_;
342}
343
344bool IOHomeClimate::sync_temperatures_(const HeatingProfile &profile, const HeatingReadings &readings, float target_c,
345 bool &seen) {
346 bool changed = false;
347 // NaN means "never read" (or a sensor fault): two NaNs are the same state, not a change.
348 const auto differs = [](float a, float b) { return !(std::isnan(a) && std::isnan(b)) && a != b; };
349
350 if (profile.features.current_temperature) {
351 if (differs(this->current_temperature, readings.room_temperature_c)) {
352 this->current_temperature = readings.room_temperature_c;
353 changed = true;
354 }
355 seen = seen || !std::isnan(readings.room_temperature_c);
356 }
357
358 if (profile.features.target_temperature && !std::isnan(target_c)) {
359 seen = true;
360 if (differs(this->target_temperature, target_c)) {
361 this->target_temperature = target_c;
362 changed = true;
363 }
364 }
365
366 // The heater's own ceiling, but never past what a write can encode.
367 if (profile.features.target_temperature && !std::isnan(readings.max_setpoint_c) &&
369 seen = true;
370 const float max_c = std::min(readings.max_setpoint_c, profile.visual_max_c);
371 if (max_c != this->max_temperature_c_) {
372 this->max_temperature_c_ = max_c;
373 changed = true;
374 }
375 }
376 return changed;
377}
378
379bool IOHomeClimate::sync_action_(const HeatingProfile &profile, const HeatingReadings &readings, bool mode_known,
380 bool &seen) {
381 if (!profile.features.action)
382 return false;
383 // The action follows the mode: an off heater is not "idle". Until the profile's action source has
384 // been read only the off case is known.
385 climate::ClimateAction action = this->action;
386 if (this->mode == climate::CLIMATE_MODE_OFF && mode_known) {
387 action = climate::CLIMATE_ACTION_OFF;
388 } else if (profile.action_source == HeatingActionSource::REGULATOR && readings.regulator >= 0) {
389 seen = true;
390 action = readings.regulator == static_cast<int16_t>(HeatingRegulatorState::HEATING)
391 ? climate::CLIMATE_ACTION_HEATING
392 : climate::CLIMATE_ACTION_IDLE;
393 } else if (profile.action_source == HeatingActionSource::WORKING_RATE && readings.working_rate >= 0) {
394 seen = true;
395 action = readings.working_rate > 0 ? climate::CLIMATE_ACTION_HEATING : climate::CLIMATE_ACTION_IDLE;
396 }
397 if (action == this->action)
398 return false;
399 this->action = action;
400 return true;
401}
402
404 bool seen = false;
405 // While the heater's own programme runs, its comfort or eco period and the temperature it heats to
406 // replace the temporary-state preset and the comfort setpoint.
407 const ProgrammePeriodState period = resolve_programme_period(profile, readings);
408 const bool period_shown = period.active && !std::isnan(period.target_c);
409 bool changed =
410 this->sync_temperatures_(profile, readings, period_shown ? period.target_c : readings.setpoint_c, seen);
411
412 // The profile's mode register, or the heating level, whichever this profile's device reports.
413 const int16_t wire =
414 profile.path == HeatingControlPath::PRIVATE_REGISTERS ? readings.value_of(profile.mode_fn) : readings.level;
415 if (wire >= 0) {
416 seen = true;
417 changed = this->apply_observed_state_(profile, static_cast<uint8_t>(wire)) || changed;
418 }
419 // The period of the programme that runs, else the preset kept in a register of its own (a towel dryer's
420 // temporary state; with the programme running and no period named, that is "none").
421 const int16_t preset_wire = !profile.preset_observed.empty() ? readings.value_of(profile.preset_fn) : -1;
422 if (period_shown && period.preset_index >= 0) {
423 seen = true;
424 changed = this->show_preset_(profile, period.preset_index) || changed;
425 } else if (preset_wire >= 0) {
426 seen = true;
427 changed = this->apply_observed_preset_(profile, static_cast<uint8_t>(preset_wire)) || changed;
428 }
429 if (wire < 0 && profile.path == HeatingControlPath::TEMPERATURE_EXECUTE && !std::isnan(readings.setpoint_c)) {
430 // The device reports a plain temperature, so it is on no alias preset.
431 if (this->has_custom_preset()) {
432 this->clear_custom_preset_();
433 changed = true;
434 }
435 if (this->preset.has_value()) {
436 this->preset.reset();
437 changed = true;
438 }
439 }
440 changed = this->sync_action_(profile, readings, wire >= 0, seen) || changed;
441
442 // Where nothing reads the mode back, a device that reports a setpoint or an alias is evidently on:
443 // show heat until a mode is commanded, instead of the entity's default "off".
444 if (!profile.features.mode_observed && !this->mode_commanded_ && seen && this->mode == climate::CLIMATE_MODE_OFF) {
445 this->mode = climate::CLIMATE_MODE_HEAT;
446 changed = true;
447 }
448
449 this->observed_ = this->observed_ || seen;
450 return changed;
451}
452
453bool IOHomeClimate::apply_observed_state_(const HeatingProfile &profile, uint8_t wire) {
454 const HeatingObservedState *observed = find_observed_state(profile, wire);
455 if (observed == nullptr)
456 return false; // unknown value: leave the card as it was
457
458 // A mode nothing reads back stays the last command; the table then only names the preset.
459 bool changed = false;
460 if (profile.features.mode_observed) {
461 const climate::ClimateMode mode = to_climate_mode(observed->hvac);
462 changed = mode != this->mode;
463 this->mode = mode;
464 }
465 // With a preset table of its own, the mode register says nothing about the preset.
466 if (!profile.preset_observed.empty())
467 return changed;
468 return this->show_preset_(profile, observed->preset) || changed;
469}
470
471bool IOHomeClimate::apply_observed_preset_(const HeatingProfile &profile, uint8_t wire) {
472 const HeatingObservedState *observed = find_observed_preset(profile, wire);
473 if (observed == nullptr)
474 return false; // unknown value: leave the card as it was
475 return this->show_preset_(profile, observed->preset);
476}
477
478bool IOHomeClimate::show_preset_(const HeatingProfile &profile, int8_t preset_index) {
479 bool changed = false;
480 const HeatingPresetEntry *shown = preset_index >= 0 ? &profile.presets[preset_index] : nullptr;
481 if (shown != nullptr && shown->id == HeatingPresetId::CUSTOM) {
482 if (!this->has_custom_preset() || this->get_custom_preset() != shown->custom_name) {
483 this->set_custom_preset_(shown->custom_name);
484 changed = true;
485 }
486 } else {
487 if (this->has_custom_preset()) {
488 this->clear_custom_preset_();
489 changed = true;
490 }
491 if (shown != nullptr) {
492 const climate::ClimatePreset preset = to_climate_preset(shown->id);
493 if (!this->preset.has_value() || *this->preset != preset) {
494 this->preset = preset;
495 changed = true;
496 }
497 } else if (this->preset.has_value()) {
498 // The device reports it is on no preset.
499 this->preset.reset();
500 changed = true;
501 }
502 }
503 return changed;
504}
505
506void IOHomeClimate::on_device_update_(const std::string &id, const IoDevice &dev) {
507 if (id != this->device_id_)
508 return;
509 const HeatingProfile *profile = find_heating_profile(dev);
510 if (profile == nullptr)
511 return;
512 if (this->sync_from_readings_(*profile, dev.heating))
513 this->publish_state();
514}
515
517 LOG_CLIMATE("", "IO-Homecontrol Climate", this);
518 ESP_LOGCONFIG(TAG, " Device ID: %s", this->device_id_.c_str());
519 ESP_LOGCONFIG(TAG, " Target temperature range: %.1f-%.1f C (maximum from the heater once read)", HEATING_TEMP_MIN_C,
520 this->max_temperature_c_);
521 ESP_LOGCONFIG(
522 TAG, " Status Poll Interval: %" PRIu32 " ms",
523 this->status_poll_interval_ms_ != 0 ? this->status_poll_interval_ms_ : HEATING_POLL_DEFAULT_INTERVAL_MS);
524 if (const DeviceTypeInfo *model = find_device_model_info(this->device_type_, this->subtype_, this->model_))
525 ESP_LOGCONFIG(TAG, " Model: %s", model->name);
526 // Only the register profiles have run on hardware: an Atlantic AGILIA IO radiator (issue #165) and an
527 // Atlantic Adelis towel dryer (issue #202), both all the way.
528 const HeatingProfile *profile = this->profile_();
529 const bool registers = profile != nullptr && profile->path == HeatingControlPath::PRIVATE_REGISTERS;
530 const char *status = "experimental, unvalidated on hardware";
531 if (registers) {
532 status = profile->experimental_writes
533 ? "readings and setpoint confirmed on hardware; mode and preset writes experimental"
534 : "confirmed on hardware (setpoint, mode, presets, read-back)";
535 }
536 ESP_LOGCONFIG(TAG, " Status: %s", status);
537}
538
539} // namespace home_io_control
540} // namespace esphome
void register_device_binding_(Component *self, DeviceCapabilityClass entity_class, std::optional< bool > invert_override, std::function< void(const std::string &, const IoDevice &)> on_update, bool dimmable=false)
Perform the shared setup() registration ritual.
bool write_temperature_mode_(const HeatingProfile &profile, const HeatingModeEntry &entry)
Send an HVAC mode to a TEMPERATURE_EXECUTE heater: the mode entry's main parameter (the target temper...
bool apply_custom_preset_(const HeatingProfile &profile, const climate::ClimateCall &call)
Apply a custom preset, if present.
bool show_preset_(const HeatingProfile &profile, int8_t preset_index)
Show one of the profile's presets, or none.
std::vector< const char * > custom_presets_
Custom preset names of the profile, registered in setup().
void on_device_update_(const std::string &id, const IoDevice &dev)
Inbound device-update hook: fold IoDevice::heating into the entity and publish when something it show...
const HeatingProfile * profile_() const
The device's heating profile: from the declared type, else from the type the hub learned.
bool commit_preset_(const HeatingProfile &profile, const HeatingPresetEntry &entry)
Send one preset and show it: the tail both preset paths share.
bool sync_action_(const HeatingProfile &profile, const HeatingReadings &readings, bool mode_known, bool &seen)
Fold the profile's action source (the regulator state, or the working rate) into the entity's action.
bool sync_from_readings_(const HeatingProfile &profile, const HeatingReadings &readings)
Copy the readings into the entity's fields.
void dump_config() override
Dump configuration to the log.
bool apply_target_temperature_(const HeatingProfile &profile, const climate::ClimateCall &call)
Apply the target temperature, if present.
bool apply_observed_state_(const HeatingProfile &profile, uint8_t wire)
Show a device-reported mode register value or heating level.
bool mode_commanded_
A mode was commanded (matters where nothing reads the mode back).
bool sync_temperatures_(const HeatingProfile &profile, const HeatingReadings &readings, float target_c, bool &seen)
Fold the temperatures (current, target, maximum) of the readings into the entity.
bool apply_observed_preset_(const HeatingProfile &profile, uint8_t wire)
Show a device-reported value of the profile's preset register (HeatingProfile::preset_fn).
bool apply_mode_(const HeatingProfile &profile, const climate::ClimateCall &call)
Apply the request's HVAC mode, if present.
bool has_range_
YAML overrode the temperature encoding range.
float range_max_c_
YAML max_temperature:.
float range_min_c_
YAML min_temperature:.
void confirm_write_(HeatingFunction fn)
Read one register back after an accepted write so the entity shows the heater's value.
climate::ClimateTraits traits() override
Traits from the device's heating profile: its HVAC modes and presets, current temperature and action ...
bool apply_preset_(const HeatingProfile &profile, const climate::ClimateCall &call)
Apply a standard preset, if present; "none" selects the profile's NONE entry where it has one.
bool write_(const HeatingProfile &profile, HeatingFunction fn, uint8_t wire)
Send one preset value (or, on the register and level paths, one mode value) over the profile's contro...
void control(const climate::ClimateCall &call) override
Apply a Home Assistant climate control request.
void setup() override
Register the entity with the shared hub and schedule the first poll of the heater.
float max_temperature_c_
Upper end of the target range (heater's own maximum once read).
bool encode_target_(const HeatingProfile &profile, float celsius, uint16_t &main_parameter)
Encode a target temperature as a main parameter over the device's range (the fixed range for a profil...
bool observed_
True once a reading has been folded in.
bool write_preset_(const HeatingProfile &profile, const HeatingPresetEntry &entry)
Send one preset over the profile's control path: a register or level write, or, for a TEMPERATURE_EXE...
virtual bool send_heating_command(const std::string &device_id, HeatingFunction fn, float value)
Run one heating function on the heater's private register protocol (CMD_WRITE_PRIVATE 0x20): a write ...
virtual bool set_heating_temperature(const std::string &device_id, uint16_t main_parameter, const HeatingFp &fp)
Command a heater that carries a temperature or an alias in its main parameter (the heating temperatur...
virtual IoDevice * get_device(const std::string &device_id)
Retrieve a device by ID; returns nullptr if not found.
Definition hub_core.cpp:402
virtual bool read_heating_register(const std::string &device_id, HeatingFunction fn, float *value_out, uint8_t max_tries)
Read one register of a heater and fold the decoded value into IoDevice::heating.
virtual bool set_heating_level(const std::string &device_id, uint8_t level)
Set the heating level of a heater that is commanded with levels (the plain electrical heater).
virtual void set_device_temperature_range(const std::string &device_id, float min_c, float max_c)
Set the temperature encoding range of a heater that carries a temperature in its main parameter (YAML...
@ WORKING_RATE
The working rate (HeatingFunction::WORKING_RATE): heating above 0 %.
@ REGULATOR
The regulator register (HeatingFunction::REGULATOR_STATE): heating at state 2.
const HeatingObservedState * find_observed_state(const HeatingProfile &profile, uint8_t wire)
Find what a device-reported wire value shows on the card.
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.
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.
@ SET_TEMPERATURE
Register 0x0103, read/write: comfort setpoint, tenths of a degree.
static constexpr const char * TAG
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).
const DeviceTypeInfo * find_device_model_info(DeviceType type, uint8_t subtype, DeviceModel model)
Look up the model row with exactly this (type, subtype, model), without falling back.
@ UNKNOWN
Unknown/unspecified device.
@ 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.
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.
bool device_supports_climate_control(DeviceType type, uint8_t subtype)
Can a climate entity drive this device?
constexpr float HEATING_TEMP_MIN_C
Lowest setpoint this codec will encode.
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.
@ CLIMATE
Climate device (heating/cooling).
std::string format_device_kind(DeviceType type, uint8_t subtype)
Human-readable description of a (type, subtype) for logs and reports.
constexpr uint16_t heating_prefixed_main_parameter(uint8_t prefix, uint8_t low)
Main parameter of a prefixed value (a level or an alias).
HeatingHvacMode
HVAC modes a profile can offer.
static const char *const TAG
Experimental climate entity for IO-Homecontrol heaters, driven by a heating profile.
The IO node-type and subtype table: names, entity class, platform and behaviour flags.
Per-device poll scheduling, failure backoff, and follow-up-poll state machine.
One row of the node-type / subtype table.
One HVAC mode the card offers and the wire value that selects it.
HeatingFp fp
TEMPERATURE_EXECUTE: the functional parameter the mode sends.
uint16_t main_parameter
TEMPERATURE_EXECUTE: the main parameter the mode sends, or HEATING_MP_FROM_TARGET.
HeatingHvacMode hvac
Mode shown on the card.
uint8_t wire
PRIVATE_REGISTERS: mode register value.
What a device-reported wire value shows on the card.
int8_t preset
Index into the profile's presets shown with it, or -1 for none.
const char * custom_name
Name of a CUSTOM preset; nullptr otherwise.
HeatingFp fp
TEMPERATURE_EXECUTE: the functional parameter sent with the alias.
uint8_t wire
Value written: register value, or the low byte of a level or alias.
HeatingPresetId id
Standard preset, or CUSTOM.
HeatingFunction write_fn
PRIVATE_REGISTERS: register the preset writes. Unused for levels.
bool commandable
False for a preset the device only reports (secured).
bool mode_observed
A device reading decides the card's mode; false when the mode is the last command (nothing reads it b...
bool current_temperature
The card shows a measured temperature.
HeatingProfileFeatures features
What the card and its companions offer.
bool fixed_range
TEMPERATURE_EXECUTE: the temperature range is fixed, so the YAML min_temperature: / max_temperature: ...
uint8_t mp_prefix
High byte of a level or alias main parameter (0 for registers).
std::span< const HeatingPresetEntry > presets
Presets offered.
HeatingActionSource action_source
Where features.action reads from.
HeatingControlPath path
How the heater is commanded and read.
float visual_max_c
Highest target temperature the card offers (0 without a setpoint).
bool experimental_writes
PRIVATE_REGISTERS: the mode and preset writes are unconfirmed on a device.
float visual_min_c
Lowest target temperature the card offers (0 without a setpoint).
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...
std::span< const HeatingModeEntry > modes
HVAC modes offered, with the value each writes.
HeatingFp setpoint_fp
TEMPERATURE_EXECUTE: the functional parameter sent with a setpoint.
What the hub has read from one heater so far.
int16_t level
Low byte of the main parameter of a heater commanded with a prefixed value: a heating level,...
int16_t value_of(HeatingFunction fn) const
The stored integer value of a register (a mode, a state, a flag, a percentage).
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.
int16_t working_rate
Working rate, 0-100 %; -1 until read.
float max_setpoint_c
Highest setpoint the heater accepts; NaN until read.
int16_t regulator
HeatingRegulatorState value; -1 until read.
Runtime state of a paired IO‑Homecontrol device.
float temperature_range_min_c
Lower end of the temperature encoding range of a heater that carries a temperature in its main parame...
HeatingReadings heating
What the hub has read from a heater over the private register protocol (room temperature,...
float temperature_range_max_c
Upper end of that range (YAML max_temperature:).
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.