Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
platform_water_heater.cpp
Go to the documentation of this file.
1/// @file platform_water_heater.cpp
2/// @brief Water heater entity for a domestic hot water heater, and its opt-in companion sensors.
3/// @ingroup hioc_platforms
4
6
9
10#include "esphome/core/log.h"
11
12#include <cinttypes>
13#include <cmath>
14
15namespace esphome {
16namespace home_io_control {
17
18static const char *const TAG = "home_io_control.water_heater";
19
20namespace {
21
22/// @brief Heater mode <-> ESPHome mode. The single place this correspondence is defined (ADR 0068).
23struct WaterHeaterModeMapping {
24 DhwMode dhw;
25 water_heater::WaterHeaterMode mode;
26};
27constexpr WaterHeaterModeMapping WATER_HEATER_MODE_MAP[] = {
28 {DhwMode::AUTO, water_heater::WATER_HEATER_MODE_HEAT_PUMP},
29 {DhwMode::MANUAL_ECO_ON, water_heater::WATER_HEATER_MODE_ECO},
30 {DhwMode::MANUAL_ECO_OFF, water_heater::WATER_HEATER_MODE_PERFORMANCE},
31};
32
33/// @brief UI target step: every target the heater has shown is a whole degree (issue #198). The main
34/// parameter resolves far finer.
35constexpr float WATER_HEATER_TARGET_STEP_C = 1.0F;
36
37/// @brief Wh per kWh, for the energy sensor.
38constexpr float WH_PER_KWH = 1000.0F;
39
40} // namespace
41
42water_heater::WaterHeaterMode water_heater_mode_for(DhwMode mode) {
43 for (const auto &mapping : WATER_HEATER_MODE_MAP) {
44 if (mapping.dhw == mode)
45 return mapping.mode;
46 }
47 return water_heater::WATER_HEATER_MODE_HEAT_PUMP;
48}
49
50bool dhw_mode_for(water_heater::WaterHeaterMode mode, DhwMode &out) {
51 for (const auto &mapping : WATER_HEATER_MODE_MAP) {
52 if (mapping.mode == mode) {
53 out = mapping.dhw;
54 return true;
55 }
56 }
57 return false;
58}
59
60// === IOHomeWaterHeater ===
61
63 // A known device that is no water heater is a configuration mistake the codegen already refuses; fail
64 // loudly rather than let every command be rejected downstream, and before the binding registers it.
65 if (this->device_type_ != DeviceType::UNKNOWN &&
66 !has_feature(device_features(this->device_type_, this->subtype_, this->model_), device_feature::WATER_HEATER)) {
67 ESP_LOGE(TAG,
68 "Device %s is declared as %s, which is not a water heater (io_device_type: domestic_hot_water) — "
69 "disabling it",
70 this->device_id_.c_str(), format_device_kind(this->device_type_, this->subtype_).c_str());
71 this->mark_failed();
72 return;
73 }
74
75 // The initial poll reads the heater (IOHomeControlComponent::request_device_status()).
77 this, DeviceCapabilityClass::WATER_HEATER, std::nullopt,
78 [this](const std::string &id, const IoDevice &dev) { this->on_device_update_(id, dev); });
79}
80
81water_heater::WaterHeaterTraits IOHomeWaterHeater::traits() {
82 water_heater::WaterHeaterTraits traits;
83 // Both temperatures are filled only from a polled reply: until the first poll lands the card shows them
84 // empty, which is the honest state.
85 traits.add_feature_flags(
86 water_heater::WATER_HEATER_SUPPORTS_CURRENT_TEMPERATURE | water_heater::WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE |
87 water_heater::WATER_HEATER_SUPPORTS_OPERATION_MODE | water_heater::WATER_HEATER_SUPPORTS_AWAY_MODE);
88 traits.set_min_temperature(DHW_TARGET_MIN_C);
89 traits.set_max_temperature(DHW_TARGET_MAX_C);
90 traits.set_target_temperature_step(WATER_HEATER_TARGET_STEP_C);
91 traits.set_supported_modes({water_heater::WATER_HEATER_MODE_HEAT_PUMP, water_heater::WATER_HEATER_MODE_ECO,
92 water_heater::WATER_HEATER_MODE_PERFORMANCE});
93 return traits;
94}
95
96void IOHomeWaterHeater::control(const water_heater::WaterHeaterCall &call) {
97 if (this->parent_ == nullptr)
98 return;
99 bool changed = false;
100
101 if (call.get_mode().has_value()) {
102 DhwMode mode = DhwMode::AUTO;
103 if (!dhw_mode_for(*call.get_mode(), mode)) {
104 // ESPHome drops a mode the traits do not list before control(); this guards a direct caller.
105 ESP_LOGW(TAG, "Device %s: mode %" PRIu32 " is not one this heater offers", this->device_id_.c_str(),
106 static_cast<uint32_t>(*call.get_mode()));
107 } else if (this->parent_->set_water_heater_mode(this->device_id_, mode) && !this->observed_) {
108 // The read-back publishes through on_device_update_(); only before any reading the command is shown.
109 this->set_mode_(*call.get_mode());
110 changed = true;
111 }
112 }
113
114 // The heater reports away from FP16 in the read-back, which publishes it; the command is never shown.
115 if (call.get_away().has_value())
116 this->parent_->set_water_heater_away(this->device_id_, *call.get_away());
117
118 const float target = call.get_target_temperature();
119 // Away is judged on the call's request (a call that starts away sends no target), or else on what the
120 // heater reports. The mode is judged on what the heater holds after the mode write above (its read-back
121 // has already been folded in), not on the mode the call asked for: a refused mode write leaves the heater
122 // where it was.
123 const bool away = call.get_away().value_or(this->is_away());
124 if (!std::isnan(target) && away) {
125 ESP_LOGW(TAG, "Device %s: target ignored while away, the heater holds no target then", this->device_id_.c_str());
126 } else if (!std::isnan(target) && this->observed_ && this->mode_ == water_heater::WATER_HEATER_MODE_HEAT_PUMP) {
127 ESP_LOGW(TAG, "Device %s: target ignored in automatic mode, the heater chooses its own", this->device_id_.c_str());
128 } else if (!std::isnan(target) && this->parent_->set_water_heater_target(this->device_id_, target) &&
129 !this->observed_) {
130 // The settle poll reads the target back from the status reply.
131 this->set_target_temperature_(target);
132 changed = true;
133 }
134
135 if (changed)
136 this->publish_state();
137}
138
140 bool changed = false;
141 // NaN means "never read": two NaNs are the same state, not a change.
142 const auto differs = [](float a, float b) { return !(std::isnan(a) && std::isnan(b)) && a != b; };
143
144 if (differs(this->current_temperature_, readings.water_c)) {
145 this->set_current_temperature(readings.water_c);
146 changed = true;
147 }
148 // A target that is not a temperature (an alias the heater has not been seen sending) shows as unknown, and
149 // so does the target in automatic mode (the heater chooses its own) and while away (the heater reports a
150 // main parameter of 0 then, which would read as 20 degrees but is no target).
151 const bool automatic = readings.mode == static_cast<int16_t>(DhwMode::AUTO);
152 const float target = (automatic || readings.away == 1) ? NAN : readings.target_c;
153 if (differs(this->target_temperature_, target)) {
154 this->set_target_temperature_(target);
155 changed = true;
156 }
157 if (readings.mode >= 0) {
158 const water_heater::WaterHeaterMode mode = water_heater_mode_for(static_cast<DhwMode>(readings.mode));
159 if (mode != this->mode_) {
160 this->set_mode_(mode);
161 changed = true;
162 }
163 }
164 if (readings.away >= 0 && (readings.away != 0) != this->is_away()) {
165 this->set_state_flag_(water_heater::WATER_HEATER_STATE_AWAY, readings.away != 0);
166 changed = true;
167 }
168 this->observed_ =
169 this->observed_ || !std::isnan(readings.water_c) || !std::isnan(readings.target_c) || readings.mode >= 0;
170 return changed;
171}
172
173void IOHomeWaterHeater::on_device_update_(const std::string &id, const IoDevice &dev) {
174 if (id != this->device_id_)
175 return;
176 if (this->sync_from_readings_(dev.dhw))
177 this->publish_state();
178}
179
181 LOG_WATER_HEATER("", "IO-Homecontrol Water Heater", this);
182 ESP_LOGCONFIG(TAG, " Device ID: %s", this->device_id_.c_str());
183 ESP_LOGCONFIG(TAG, " Target temperature range: %.0f-%.0f C", static_cast<double>(DHW_TARGET_MIN_C),
184 static_cast<double>(DHW_TARGET_MAX_C));
185 ESP_LOGCONFIG(
186 TAG, " Status Poll Interval: %" PRIu32 " ms",
187 this->status_poll_interval_ms_ != 0 ? this->status_poll_interval_ms_ : HEATING_POLL_DEFAULT_INTERVAL_MS);
188 ESP_LOGCONFIG(TAG, " Status: readings, target and mode confirmed on hardware; away mode experimental");
189}
190
191// === Companions ===
192
194 this->register_companion_binding_([this](const IoDevice &dev) {
195 if (dev.dhw.energy_wh >= 0)
196 this->publish_state(static_cast<float>(dev.dhw.energy_wh) / WH_PER_KWH);
197 });
198}
199
201 ESP_LOGCONFIG(TAG, "IO-Homecontrol Water Heater Energy:");
202 ESP_LOGCONFIG(TAG, " Device ID: %s", this->device_id_.c_str());
203}
204
206 this->register_companion_binding_([this](const IoDevice &dev) {
207 int8_t value = -1;
208 switch (this->reading_) {
210 value = dev.dhw.heat_pump;
211 break;
213 value = dev.dhw.element;
214 break;
215 }
216 // "Off" must mean the heater said so: nothing read means no state.
217 if (value < 0) {
218 this->invalidate_state();
219 return;
220 }
221 this->publish_state(value != 0);
222 });
223}
224
226 const char *reading = "heat pump running";
227 switch (this->reading_) {
229 break;
231 reading = "electric backup running";
232 break;
233 }
234 ESP_LOGCONFIG(TAG, "IO-Homecontrol Water Heater %s:", reading);
235 ESP_LOGCONFIG(TAG, " Device ID: %s", this->device_id_.c_str());
236}
237
239 // No restore: the state comes only from the heater, and a reboot must not start or end a boost.
240 this->register_companion_binding_([this](const IoDevice &dev) {
241 // Shown only from the heater's FP16 report; a switch with no reading yet keeps its unknown state.
242 if (dev.dhw.boost >= 0)
243 this->publish_state(dev.dhw.boost != 0);
244 });
245}
246
248 if (this->parent_ == nullptr)
249 return;
250 // The status read inside set_water_heater_boost() updates IoDevice::dhw, and the binding above publishes it.
251 if (!this->parent_->set_water_heater_boost(this->device_id_, state)) {
252 ESP_LOGW(TAG, "Device %s: boost %s was not acknowledged", this->device_id_.c_str(), state ? "on" : "off");
253 }
254}
255
257 ESP_LOGCONFIG(TAG, "IO-Homecontrol Water Heater Boost:");
258 ESP_LOGCONFIG(TAG, " Device ID: %s", this->device_id_.c_str());
259 ESP_LOGCONFIG(TAG, " Status: experimental (a boost started here writes duration 1; the state is the heater's FP16)");
260}
261
262} // namespace home_io_control
263} // namespace esphome
void register_companion_binding_(const std::function< void(const IoDevice &)> &publish)
Shared setup() body: subscribe to this device's updates and publish the initial state.
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.
virtual bool set_water_heater_mode(const std::string &device_id, DhwMode mode)
Set a water heater's operating mode: a write of register 0x0006 (encode_dhw_mode_write()).
virtual bool set_water_heater_boost(const std::string &device_id, bool on)
Start or end a water heater's boost: a duration write of register 0x0000, then an EXECUTE with the op...
virtual bool set_water_heater_target(const std::string &device_id, float celsius)
Set a water heater's target temperature.
virtual bool set_water_heater_away(const std::string &device_id, bool on)
Start or end a water heater's away (holiday) mode: a duration write of register 0x0005 (255,...
void setup() override
Subscribe to the device's updates and publish what is already known.
@ ELECTRIC_BACKUP
Register 0x0021 byte 2: the electric element heats.
@ HEAT_PUMP
Register 0x0021 bytes 3/4: the heat pump runs.
void dump_config() override
Dump sensor configuration to the log.
void write_state(bool state) override
Start or end the boost; the read-back publishes the state.
void setup() override
Subscribe to the device's updates and publish what is already known.
void dump_config() override
Dump switch configuration to the log.
void setup() override
Subscribe to the device's updates and publish what is already known.
void dump_config() override
Dump sensor configuration to the log.
void dump_config() override
Dump configuration to the log.
void on_device_update_(const std::string &id, const IoDevice &dev)
Inbound device-update hook: fold IoDevice::dhw into the entity and publish when a shown field changed...
void setup() override
Register the entity with the shared hub and schedule the first poll of the heater.
bool observed_
True once a reading has been folded in.
water_heater::WaterHeaterTraits traits() override
Current and target temperature, the three modes, and the target range DHW_TARGET_MIN_C....
void control(const water_heater::WaterHeaterCall &call) override
Apply a Home Assistant request: a mode is written to register 0x0006, away mode goes through the oper...
bool sync_from_readings_(const DhwReadings &readings)
Copy the readings into the entity's fields.
DeviceFeatures device_features(DeviceType type, uint8_t subtype, DeviceModel model)
Capability bits of a (type, subtype, model).
DhwMode
Operating mode as register 0x0006 holds it.
Definition proto_dhw.h:145
@ MANUAL_ECO_ON
Manual mode with ECO on (heat pump only).
Definition proto_dhw.h:147
@ MANUAL_ECO_OFF
Manual mode with ECO off (the electric element may help).
Definition proto_dhw.h:148
@ AUTO
The heater's automatic mode (its own schedule and target).
Definition proto_dhw.h:146
static constexpr const char * TAG
@ UNKNOWN
Unknown/unspecified device.
constexpr bool has_feature(DeviceFeatures set, DeviceFeatures bit)
True when every bit of bit is set in set.
bool dhw_mode_for(water_heater::WaterHeaterMode mode, DhwMode &out)
Heater mode for an ESPHome water heater mode.
water_heater::WaterHeaterMode water_heater_mode_for(DhwMode mode)
ESPHome water heater mode for a heater mode.
@ WATER_HEATER
Domestic hot water heater (device_feature::WATER_HEATER).
std::string format_device_kind(DeviceType type, uint8_t subtype)
Human-readable description of a (type, subtype) for logs and reports.
static const char *const TAG
Water heater entity for a domestic hot water heater, and its opt-in companion sensors.
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.
What the hub has read from a domestic hot water heater.
Definition proto_dhw.h:162
int64_t energy_wh
Energy since installation in Wh; -1 until read.
Definition proto_dhw.h:175
float target_c
Target temperature (status reply main parameter); NaN until read or when the main parameter is not a ...
Definition proto_dhw.h:164
int8_t boost
Boost on (1) or off (0) as FP16 reports it; -1 until read.
Definition proto_dhw.h:171
int16_t mode
DhwMode value; -1 until read.
Definition proto_dhw.h:166
int8_t element
DhwHeatingState::element as 0/1; -1 until read.
Definition proto_dhw.h:168
int8_t away
Away on (1) or off (0) as FP16 reports it; -1 until read.
Definition proto_dhw.h:172
int8_t heat_pump
DhwHeatingState::heat_pump as 0/1; -1 until read.
Definition proto_dhw.h:169
float water_c
Water temperature (register 0x0017); NaN until read.
Definition proto_dhw.h:163
Runtime state of a paired IO‑Homecontrol device.
DhwReadings dhw
What the hub has read from a water heater (device_feature::WATER_HEATER): family-0x00 registers and t...