ESPHome 2026.11.0-dev
Loading...
Searching...
No Matches
hon_water_heater.cpp
Go to the documentation of this file.
1#include "hon_water_heater.h"
2
3#include <cmath>
4
6#ifdef USE_NETWORK
8#endif
9#include "esphome/core/log.h"
11
13
14ESPHOME_LOG_TAG(TAG, "haier_water_heater");
15using namespace water_heater_protocol;
16
17// Indexed by HonMode, with Unknown as the fallback for out-of-range values.
18PROGMEM_STRING_TABLE(HonModeLabels, "Unknown", "Normal", "ECO", "BPS");
19
21 this->status_set_warning(LOG_STR("Waiting for a valid appliance status frame"));
22 if (this->communication_sensor_ != nullptr)
26 [this]() { this->update_network_status_(); });
28}
29
31#ifdef USE_NETWORK
32 const bool ready = network::is_connected();
33#else
34 const bool ready = false;
35#endif
36 const uint8_t status = ready ? NETWORK_READY : NETWORK_NOT_READY;
37 if (status != this->network_status_) {
38 this->network_status_ = status;
39 this->network_pending_ = true;
40 ESP_LOGI(TAG, "Local network status changed: %02X", status);
41 }
42}
43
44void HonWaterHeater::send_frame_(FrameType type, const uint8_t *payload, size_t size) {
45 uint8_t wire[MAX_WIRE_FRAME];
46 const size_t length = encode_frame(type, payload, size, wire);
47 if (length == 0)
48 return;
49#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
50 char hex[format_hex_pretty_size(sizeof(wire))];
51 ESP_LOGV(TAG, "TX type=%02X: %s", static_cast<uint8_t>(type), format_hex_pretty_to(hex, wire, length));
52#endif
53 this->write_array(wire, length);
54}
55
56void HonWaterHeater::send_request_(uint32_t now, Awaiting kind, FrameType type, const uint8_t *payload, size_t size,
57 FrameType expected) {
58 this->send_frame_(type, payload, size);
59 this->awaiting_ = kind;
60 this->expected_type_ = expected;
61 this->request_sent_ = now;
62}
63
66 this->check_timeouts_(now);
67 if (this->awaiting_ != Awaiting::AWAITING_NONE || this->command_count_ != 0 || this->pending_off_)
68 return;
69 if (this->network_pending_ || now - this->network_sent_ >= NETWORK_REFRESH_MS)
70 return; // the network status goes out first, from loop()
71 // The version and device id are read once per cycle, the status on every other pass.
72 static constexpr uint8_t VERSION_REQUEST[] = {0x00, 0x07};
73 static constexpr uint8_t STATUS_REQUEST[] = {0x4D, 0x01};
74 if (this->poll_stage_ == 0) {
75 this->send_request_(now, Awaiting::AWAITING_POLL, FrameType::FRAME_TYPE_GET_VERSION, VERSION_REQUEST,
76 sizeof(VERSION_REQUEST), FrameType::FRAME_TYPE_VERSION);
77 } else if (this->poll_stage_ == 1) {
78 this->send_request_(now, Awaiting::AWAITING_POLL, FrameType::FRAME_TYPE_GET_DEVICE_ID, nullptr, 0,
79 FrameType::FRAME_TYPE_DEVICE_ID);
80 } else {
81 this->send_request_(now, Awaiting::AWAITING_POLL, FrameType::FRAME_TYPE_CONTROL, STATUS_REQUEST,
82 sizeof(STATUS_REQUEST), FrameType::FRAME_TYPE_STATUS);
83 }
84 this->poll_stage_ = (this->poll_stage_ + 1) % POLL_CYCLE;
85}
86
88 if (this->have_status_ && now - this->last_status_ >= this->communication_timeout_) {
89 this->have_status_ = false;
90 this->status_set_warning(LOG_STR("Heater communication timed out"));
91 ESP_LOGW(TAG, "Heater communication lost: no valid status for %u ms",
92 static_cast<unsigned>(this->communication_timeout_));
93 if (this->communication_sensor_ != nullptr)
95 }
96 const uint32_t waited = now - this->request_sent_;
97 switch (this->awaiting_) {
99 break;
101 if (waited >= RESPONSE_TIMEOUT_MS) {
102 ESP_LOGW(TAG, "No matching response type=%02X in %u ms", static_cast<uint8_t>(this->expected_type_),
103 static_cast<unsigned>(RESPONSE_TIMEOUT_MS));
105 }
106 break;
108 if (waited >= RESPONSE_TIMEOUT_MS) {
109 ESP_LOGW(TAG, "Control response timed out; discarding remaining commands");
111 this->command_count_ = 0;
112 }
113 break;
115 if (waited >= NETWORK_ACK_TIMEOUT_MS) {
116 ESP_LOGW(TAG, "Network status: no CONFIRM within %u ms", static_cast<unsigned>(NETWORK_ACK_TIMEOUT_MS));
118 }
119 break;
120 }
121}
122
125 return;
126 if (this->pending_off_) {
127 this->commands_[0] = {Parameter::PARAMETER_POWER, 0};
128 this->command_index_ = 0;
129 this->command_count_ = 1;
130 this->pending_off_ = false;
131 }
132 if (this->command_count_ == 0 && (this->network_pending_ || now - this->network_sent_ >= NETWORK_REFRESH_MS)) {
133 // Signal remains a placeholder; status represents local IP readiness, not cloud access.
134 const uint8_t payload[] = {0x00, this->network_status_, 0x00, 100};
135 this->send_request_(now, Awaiting::AWAITING_NETWORK, FrameType::FRAME_TYPE_NETWORK_STATUS, payload, sizeof(payload),
136 FrameType::FRAME_TYPE_CONFIRM);
137 this->network_sent_ = now;
138 this->network_pending_ = false;
139 return;
140 }
141 if (this->command_count_ != 0)
142 this->send_next_control_(now);
143}
144
146 uint8_t payload[4];
147 encode_control(this->commands_[this->command_index_], payload);
148 this->send_request_(now, Awaiting::AWAITING_CONTROL, FrameType::FRAME_TYPE_CONTROL, payload, sizeof(payload),
149 FrameType::FRAME_TYPE_STATUS);
150}
151
153 if (now - this->last_byte_ > FRAME_GAP_MS)
154 this->parser_.reset();
155 uint8_t budget = MAX_BYTES_PER_LOOP;
156 while (budget-- > 0 && this->available() > 0) {
157 uint8_t value;
158 if (!this->read_byte(&value))
159 break;
160 this->last_byte_ = now;
161 if (this->parser_.feed(value)) {
162 this->process_frame_();
163 } else if (this->parser_.complete()) {
164 if ((this->parser_.data()[FRAME_FLAGS_OFFSET] & FRAME_FLAG_ENCRYPTED) != 0) {
165 ESP_LOGW(TAG, "RX: XOR-encrypted frames are not supported and will be dropped");
166 } else {
167 ESP_LOGW(TAG, "RX: unsupported flags or invalid checksum/CRC");
168 }
169 }
170 }
171}
172
175 this->check_timeouts_(now);
176 this->send_pending_(now);
177 this->read_frames_(now);
178}
179
181 if (this->awaiting_ == Awaiting::AWAITING_POLL && type == this->expected_type_) {
182 ESP_LOGV(TAG, "Response matched type=%02X", static_cast<uint8_t>(type));
184 }
185 switch (type) {
186 case FrameType::FRAME_TYPE_GET_NETWORK_STATUS:
187 this->network_pending_ = true;
188 break;
189 case FrameType::FRAME_TYPE_CONFIRM:
191 ESP_LOGD(TAG, "Network status acknowledged");
193 }
194 break;
195 case FrameType::FRAME_TYPE_INVALID:
196 ESP_LOGW(TAG, "Appliance rejected request");
197 // Controls queued behind a rejected poll were never sent and keep their turn.
199 this->command_count_ = 0;
202 break;
203 case FrameType::FRAME_TYPE_REPORT:
204 this->send_frame_(FrameType::FRAME_TYPE_CONFIRM);
205 break;
206 default:
207 break;
208 }
209}
210
212 const Command &command = this->commands_[this->command_index_];
213 if (!command_confirmed(command, status))
214 return;
215 ESP_LOGD(TAG, "Control confirmed: parameter=%02X value=%02X", static_cast<uint8_t>(command.parameter), command.value);
217 if (++this->command_index_ == this->command_count_)
218 this->command_count_ = 0;
219}
220
222 const uint8_t *frame = this->parser_.data();
223 const auto type = static_cast<FrameType>(frame[FRAME_TYPE_OFFSET]);
224#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
225 char hex[format_hex_pretty_size(MAX_STORED_FRAME)];
226 ESP_LOGV(TAG, "RX decoded type=%02X flags=%02X: %s", static_cast<uint8_t>(type), frame[FRAME_FLAGS_OFFSET],
227 format_hex_pretty_to(hex, frame, this->parser_.stored_size()));
228#endif
230
231 Status status{};
232 if (!decode_status(frame, this->parser_.size(), status))
233 return;
234 const bool recovered = !this->have_status_;
235 if (status.mode == HonMode::HON_MODE_UNKNOWN && (recovered || this->reported_mode_ != HonMode::HON_MODE_UNKNOWN)) {
236 const uint8_t *payload = frame + FRAME_PAYLOAD_OFFSET;
237 ESP_LOGW(TAG,
238 "Unknown heater mode: payload[38..41]=%02X %02X %02X %02X; using last recognized active mode, or Electric "
239 "if none is known",
240 payload[38], payload[39], payload[40], payload[41]);
241 }
242 this->have_status_ = true;
244 this->reported_mode_ = status.mode;
245 if (this->awaiting_ == Awaiting::AWAITING_CONTROL && type == FrameType::FRAME_TYPE_STATUS)
247 this->status_clear_warning();
248 this->publish_status_(status);
249 // Published last so an automation on the sensor sees fresh heater state and can send a control.
250 if (recovered) {
251 ESP_LOGI(TAG, "Heater communication established");
252 if (this->communication_sensor_ != nullptr)
254 }
255}
256
261 switch (status.mode) {
262 case HonMode::HON_MODE_NORMAL:
264 break;
265 case HonMode::HON_MODE_ECO:
267 break;
268 case HonMode::HON_MODE_BPS:
270 break;
271 case HonMode::HON_MODE_UNKNOWN:
272 break;
273 }
274 // The base entity has no Unknown mode; the text sensor preserves the decoded result.
276 if (this->primary_sensor_ != nullptr)
278 if (this->secondary_sensor_ != nullptr) {
280 status.secondary_temperature <= MAX_REPORTED_TEMPERATURE ? status.secondary_temperature : NAN);
281 }
282 if (this->target_sensor_ != nullptr)
284 if (this->heating_time_sensor_ != nullptr)
286 if (this->water_sensor_ != nullptr) {
287 // Three segments of available hot water, published as 0, 33, 66 or 100 percent.
288 const uint8_t percent = status.available_water * 100 / 3;
289 this->water_sensor_->publish_state(status.available_water <= 3 ? percent : NAN);
290 }
291 if (this->power_sensor_ != nullptr)
292 this->power_sensor_->publish_state(status.powered);
293 if (this->heating_sensor_ != nullptr)
295 if (this->mode_sensor_ != nullptr) {
296 char label[HonModeLabels::MAX_LENGTH + 1];
297 ESPHOME_strncpy_P(label,
298 reinterpret_cast<ESPHOME_PGM_P>(HonModeLabels::get_progmem_str(
299 static_cast<uint8_t>(status.mode), static_cast<uint8_t>(HonMode::HON_MODE_UNKNOWN))),
300 sizeof(label));
301 this->mode_sensor_->publish_state(label);
302 }
303 this->publish_state();
304}
305
307 LOG_WATER_HEATER("", "Haier hOn", this);
308 ESP_LOGCONFIG(TAG,
309 " Polling interval: %u ms\n"
310 " Communication timeout: %u ms",
311 static_cast<unsigned>(POLL_INTERVAL_MS), static_cast<unsigned>(this->communication_timeout_));
312 LOG_SENSOR(" ", "Primary temperature", this->primary_sensor_);
313 LOG_SENSOR(" ", "Secondary temperature", this->secondary_sensor_);
314 LOG_SENSOR(" ", "Target temperature", this->target_sensor_);
315 LOG_SENSOR(" ", "Estimated heating time", this->heating_time_sensor_);
316 LOG_SENSOR(" ", "Available water", this->water_sensor_);
317 LOG_BINARY_SENSOR(" ", "Communication", this->communication_sensor_);
318 LOG_BINARY_SENSOR(" ", "Power", this->power_sensor_);
319 LOG_BINARY_SENSOR(" ", "Heating", this->heating_sensor_);
320 LOG_TEXT_SENSOR(" ", "Mode", this->mode_sensor_);
321}
322
326
327// The appliance mode a water heater mode maps to; Unknown for modes the appliance does not have.
329 switch (mode) {
331 return HonMode::HON_MODE_NORMAL;
333 return HonMode::HON_MODE_ECO;
335 return HonMode::HON_MODE_BPS;
336 default:
337 return HonMode::HON_MODE_UNKNOWN;
338 }
339}
340
342 using namespace water_heater;
343 const auto mode = call.get_mode();
344 const auto on = call.get_on();
345 const bool off = (on.has_value() && !*on) || (mode.has_value() && *mode == WATER_HEATER_MODE_OFF);
346 if (off) {
347 // Keep only the command in flight until its reply or timeout; unsent writes are dropped.
349 this->commands_[0] = this->commands_[this->command_index_];
350 this->command_index_ = 0;
351 this->command_count_ = 1;
352 } else {
353 this->command_count_ = 0;
354 }
355 this->pending_off_ = true;
356 return;
357 }
358 if (!this->have_status_ || App.get_loop_component_start_time() - this->last_status_ > CONTROL_STATUS_MAX_AGE_MS) {
359 ESP_LOGW(TAG, "Control rejected: no recent appliance status");
360 return;
361 }
362 if (this->command_count_ != 0 || this->pending_off_) {
363 ESP_LOGW(TAG, "Control busy; wait for the previous request to finish");
364 return;
365 }
366 const float temperature = call.get_target_temperature();
367 if (!std::isnan(temperature)) {
369 std::round(temperature) != temperature) {
370 ESP_LOGW(TAG, "Target must be a whole temperature from %u to %u C", MIN_TARGET_TEMPERATURE,
372 return;
373 }
374 const bool normal =
375 mode.has_value() ? *mode == WATER_HEATER_MODE_ELECTRIC : this->reported_mode_ == HonMode::HON_MODE_NORMAL;
376 if (!normal) {
377 ESP_LOGW(TAG, "Target can only be set in Normal mode");
378 return;
379 }
380 }
381 this->command_index_ = 0;
382 auto append = [this](Parameter parameter, uint8_t value) {
383 this->commands_[this->command_count_++] = {parameter, value};
384 };
385 // A write is only queued when it changes something, so a repeated state costs no round trip.
386 if (!this->is_on() && ((on.has_value() && *on) || mode.has_value()))
387 append(Parameter::PARAMETER_POWER, 1);
388 if (mode.has_value() && hon_mode(*mode) != this->reported_mode_) {
389 // Captures show the appliance moves between ECO and BPS through Normal, never directly.
390 if (*mode != WATER_HEATER_MODE_ELECTRIC && this->reported_mode_ != HonMode::HON_MODE_NORMAL)
391 append(Parameter::PARAMETER_ECO, 0);
392 switch (*mode) {
393 case WATER_HEATER_MODE_ELECTRIC:
394 append(Parameter::PARAMETER_ECO, 0);
395 break;
396 case WATER_HEATER_MODE_ECO:
397 append(Parameter::PARAMETER_ECO, 1);
398 break;
399 case WATER_HEATER_MODE_PERFORMANCE:
400 append(Parameter::PARAMETER_BPS, 1);
401 break;
402 default:
403 break;
404 }
405 }
406 if (!std::isnan(temperature))
407 append(Parameter::PARAMETER_TARGET_TEMPERATURE, static_cast<uint8_t>(temperature - TARGET_TEMPERATURE_OFFSET));
408}
409
411 using namespace water_heater;
412 WaterHeaterTraits traits;
414 traits.add_feature_flags(WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE | WATER_HEATER_SUPPORTS_OPERATION_MODE |
415 WATER_HEATER_SUPPORTS_ON_OFF);
417 {WATER_HEATER_MODE_OFF, WATER_HEATER_MODE_ELECTRIC, WATER_HEATER_MODE_ECO, WATER_HEATER_MODE_PERFORMANCE});
421 return traits;
422}
423
424} // namespace esphome::haier_water_heater
BedjetMode mode
BedJet operating mode.
uint8_t status
Definition bl0942.h:8
uint32_t IRAM_ATTR HOT get_loop_component_start_time() const
Get the cached time in milliseconds from when the current component started its loop execution.
void set_interval(const char *name, uint32_t interval, std::function< void()> &&f)
Set an interval function with a const char* name.
Definition component.cpp:88
void status_clear_warning()
Definition component.h:291
void publish_state(bool new_state)
Publish a new state to the front-end.
water_heater::WaterHeaterCallInternal make_call() override
void handle_frame_type_(water_heater_protocol::FrameType type)
static constexpr uint32_t NETWORK_STATUS_INTERVAL_ID
binary_sensor::BinarySensor * communication_sensor_
static constexpr uint32_t STATUS_POLL_INTERVAL_ID
void send_request_(uint32_t now, Awaiting kind, water_heater_protocol::FrameType type, const uint8_t *payload, size_t size, water_heater_protocol::FrameType expected)
water_heater_protocol::FrameType expected_type_
binary_sensor::BinarySensor * heating_sensor_
void publish_status_(const water_heater_protocol::Status &status)
void send_frame_(water_heater_protocol::FrameType type, const uint8_t *payload=nullptr, size_t size=0)
binary_sensor::BinarySensor * power_sensor_
water_heater_protocol::HonMode reported_mode_
static constexpr uint32_t NETWORK_ACK_TIMEOUT_MS
water_heater::WaterHeaterMode last_active_mode_
void control(const water_heater::WaterHeaterCall &call) override
void confirm_control_(const water_heater_protocol::Status &status)
static constexpr uint32_t NETWORK_STATUS_INTERVAL_MS
static constexpr uint32_t CONTROL_STATUS_MAX_AGE_MS
water_heater::WaterHeaterTraits traits() override
water_heater_protocol::FrameParser parser_
std::array< water_heater_protocol::Command, 4 > commands_
bool feed(uint8_t value)
Returns true once a valid frame is complete. A complete but invalid frame leaves complete() set.
Definition hon_parser.h:61
size_t size() const
Logical bytes received, including the checksum and CRC; may exceed MAX_STORED_FRAME.
Definition hon_parser.h:57
const uint8_t * data() const
The stored head of the frame, at most MAX_STORED_FRAME bytes; valid until the next feed or reset.
Definition hon_parser.h:55
void publish_state(float state)
Publish a new state to the front-end.
Definition sensor.cpp:65
void publish_state(const std::string &state)
Definition text_sensor.h:41
bool read_byte(uint8_t *data)
Definition uart.h:36
void write_array(const uint8_t *data, size_t len)
Definition uart.h:27
void set_state_flag_(uint32_t flag, bool value)
Set or clear a state flag. Should only be called from control().
bool is_on() const
Check if the water heater is on.
void set_supported_modes(WaterHeaterModeMask modes)
void add_feature_flags(uint32_t flags)
Get/set feature flags (see WaterHeaterFeature enum)
void set_supports_current_temperature(bool supports)
void set_min_temperature(float min_temperature)
void set_target_temperature_step(float target_temperature_step)
void set_max_temperature(float max_temperature)
uint8_t type
bool decode_status(const uint8_t *frame, size_t size, Status &status)
Decodes a status report or a polled status; false for any other frame or an implausible one.
Definition hon_parser.h:202
bool command_confirmed(const Command &command, const Status &status)
True when a status shows that the command took effect.
Definition hon_parser.h:248
size_t encode_frame(FrameType type, const uint8_t *payload, size_t size, uint8_t *wire)
Writes a clear-text frame with up to MAX_PAYLOAD bytes of payload into wire, which must hold MAX_WIRE...
Definition hon_parser.h:137
void encode_control(const Command &command, uint8_t *payload)
Fills the four-byte control payload for a command.
Definition hon_parser.h:240
PROGMEM_STRING_TABLE(HonModeLabels, "Unknown", "Normal", "ECO", "BPS")
ESPHOME_LOG_TAG(TAG, "haier_water_heater")
ESPHOME_ALWAYS_INLINE bool is_connected()
Return whether the node is connected to the network (through wifi, eth, ...)
Definition util.h:36
@ WATER_HEATER_STATE_ON
Water heater is on (not in standby)
char * format_hex_pretty_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator)
Format byte array as uppercase hex to buffer (base implementation).
Definition helpers.cpp:498
uint16_t size
Definition helpers.cpp:34
constexpr size_t format_hex_pretty_size(size_t byte_count)
Calculate buffer size needed for format_hex_pretty_to with separator: "XX:XX:...:XX\0".
Definition helpers.h:1533
Application App
Global storage of Application pointer - only one Application can exist.
static void uint32_t
uint16_t temperature
Definition sun_gtil2.cpp:12