[haier_water_heater] Initial water-heater implementation tested with Haier VH3W (#20765)

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
TheLexus
2026-10-16 08:50:13 -10:00
committed by GitHub
co-authored by pre-commit-ci-lite[bot] J. Nick Koston J. Nick Koston
parent cd6a34401b
commit ab0c32c2aa
15 changed files with 1567 additions and 0 deletions
+1
View File
@@ -252,6 +252,7 @@ esphome/components/haier/text_sensor/* @paveldn
esphome/components/haier_tundra/* @fauxpark
esphome/components/haier_tundra/button/* @fauxpark
esphome/components/haier_tundra/switch/* @fauxpark
esphome/components/haier_water_heater/* @TheLexus
esphome/components/havells_solar/* @sourabhjaiswal
esphome/components/hbridge/fan/* @WeekendWarrior
esphome/components/hbridge/light/* @DotNetDann
@@ -0,0 +1,4 @@
"""Haier water heater component."""
DOMAIN = "haier_water_heater"
CODEOWNERS = ["@TheLexus"]
@@ -0,0 +1,262 @@
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include "esphome/core/helpers.h"
namespace esphome::haier_water_heater::water_heater_protocol {
// A logical frame, after the FF FF separator and the FF 55 escapes are removed:
// [length][flags][5 reserved bytes][type][payload...][checksum][crc high][crc low]
// The length counts every byte before the checksum; the CRC is only present when its flag is set.
static constexpr uint8_t FRAME_FLAGS_OFFSET = 1;
static constexpr uint8_t FRAME_TYPE_OFFSET = 7;
static constexpr uint8_t FRAME_PAYLOAD_OFFSET = 8;
static constexpr uint8_t FRAME_FLAG_CRC = 0x40;
static constexpr uint8_t FRAME_FLAG_ENCRYPTED = 0x80;
static constexpr uint8_t MIN_FRAME_LENGTH = 8;
static constexpr uint8_t MAX_FRAME_LENGTH = 0xF1;
// The longest frame that is decoded is the 61-byte status report. Longer frames are still checked
// in full, but only their head is kept.
static constexpr size_t MAX_STORED_FRAME = 64;
static constexpr size_t MAX_PAYLOAD = 4;
static constexpr size_t MAX_WIRE_FRAME = 28; // 2 separators, up to 12 escaped bytes, an escaped checksum
enum class FrameType : uint8_t {
FRAME_TYPE_CONTROL = 0x01,
FRAME_TYPE_STATUS = 0x02,
FRAME_TYPE_INVALID = 0x03, // the appliance rejected the last request
FRAME_TYPE_CONFIRM = 0x05,
FRAME_TYPE_REPORT = 0x06, // an unsolicited status, which expects a CONFIRM back
FRAME_TYPE_GET_VERSION = 0x61,
FRAME_TYPE_VERSION = 0x62,
FRAME_TYPE_GET_DEVICE_ID = 0x70,
FRAME_TYPE_DEVICE_ID = 0x71,
FRAME_TYPE_GET_NETWORK_STATUS = 0xF0,
FRAME_TYPE_NETWORK_STATUS = 0xF7,
};
// Bounded receive parser. The checksum and CRC are kept running while the bytes arrive, so a frame
// longer than the stored head is validated without being stored.
class FrameParser {
public:
void reset() {
this->size_ = 0;
this->separators_ = 0;
this->escape_ = false;
this->complete_ = false;
this->checksum_ = 0;
this->crc_ = 0;
}
bool complete() const { return this->complete_; }
/// The stored head of the frame, at most MAX_STORED_FRAME bytes; valid until the next feed or reset.
const uint8_t *data() const { return this->buffer_.data(); }
/// Logical bytes received, including the checksum and CRC; may exceed MAX_STORED_FRAME.
size_t size() const { return this->size_; }
size_t stored_size() const { return std::min<size_t>(this->size_, MAX_STORED_FRAME); }
/// Returns true once a valid frame is complete. A complete but invalid frame leaves complete() set.
bool feed(uint8_t value) {
if (this->complete_) {
this->reset();
}
if (this->separators_ < 2) {
this->separators_ = value == 0xFF ? this->separators_ + 1 : 0;
return false;
}
if (this->escape_) {
this->escape_ = false;
if (value == 0xFF) {
// A new unescaped separator pair: start over without losing synchronisation.
this->size_ = 0;
this->checksum_ = 0;
this->crc_ = 0;
return false;
}
if (value != 0x55) {
this->reset();
return false;
}
value = 0xFF;
} else if (value == 0xFF) {
this->escape_ = true;
return false;
}
if (this->size_ == 0 && (value < MIN_FRAME_LENGTH || value > MAX_FRAME_LENGTH)) {
this->reset();
return false;
}
const uint8_t index = this->size_++;
if (index < MAX_STORED_FRAME) {
this->buffer_[index] = value;
}
const uint8_t length = this->buffer_[0];
if (index < length) {
this->checksum_ += value;
if (value == 0xFF) {
this->checksum_ += 0x55; // The escape byte counts towards the checksum.
}
this->crc_ = crc16(&value, 1, this->crc_, 0xA001, false, false);
} else if (index == length) {
this->received_checksum_ = value;
} else if (index == length + 1) {
this->received_crc_ = static_cast<uint16_t>(value) << 8;
} else {
this->received_crc_ |= value;
}
if (this->size_ < 2) {
return false;
}
const bool crc = (this->buffer_[FRAME_FLAGS_OFFSET] & FRAME_FLAG_CRC) != 0;
if (this->size_ != length + 1 + (crc ? 2 : 0)) {
return false;
}
this->complete_ = true;
if ((this->buffer_[FRAME_FLAGS_OFFSET] & ~FRAME_FLAG_CRC) != 0 || this->checksum_ != this->received_checksum_) {
return false;
}
return !crc || this->crc_ == this->received_crc_;
}
protected:
std::array<uint8_t, MAX_STORED_FRAME> buffer_{};
uint16_t crc_{0};
uint16_t received_crc_{0};
uint8_t size_{0};
uint8_t separators_{0};
uint8_t checksum_{0};
uint8_t received_checksum_{0};
bool escape_{false};
bool complete_{false};
};
/// Writes a clear-text frame with up to MAX_PAYLOAD bytes of payload into wire, which must hold
/// MAX_WIRE_FRAME bytes. Returns the number of bytes written, or 0 when the payload is too long.
inline size_t encode_frame(FrameType type, const uint8_t *payload, size_t size, uint8_t *wire) {
if (size > MAX_PAYLOAD) {
return 0;
}
uint8_t core[FRAME_PAYLOAD_OFFSET + MAX_PAYLOAD] = {static_cast<uint8_t>(FRAME_PAYLOAD_OFFSET + size)};
core[FRAME_TYPE_OFFSET] = static_cast<uint8_t>(type);
for (size_t i = 0; i < size; i++) {
core[FRAME_PAYLOAD_OFFSET + i] = payload[i];
}
size_t count = 0;
wire[count++] = 0xFF;
wire[count++] = 0xFF;
uint8_t checksum = 0;
for (size_t i = 0; i < FRAME_PAYLOAD_OFFSET + size; i++) {
wire[count++] = core[i];
checksum += core[i];
if (core[i] == 0xFF) {
wire[count++] = 0x55;
checksum += 0x55;
}
}
wire[count++] = checksum;
if (checksum == 0xFF) {
wire[count++] = 0x55;
}
return count;
}
enum class HonMode : uint8_t { HON_MODE_UNKNOWN, HON_MODE_NORMAL, HON_MODE_ECO, HON_MODE_BPS };
// The appliance reports and takes target temperatures with this offset.
static constexpr uint8_t TARGET_TEMPERATURE_OFFSET = 30;
static constexpr uint8_t MAX_REPORTED_TEMPERATURE = 100;
struct Status {
uint8_t current_temperature;
uint8_t target_temperature;
uint8_t secondary_temperature;
uint8_t estimated_heating_time;
uint8_t available_water; // 0 to 3 segments
bool powered;
bool heating;
HonMode mode;
};
// The mode is spread over three flag bytes; exactly one of the three indications is set in a known mode.
inline HonMode decode_mode(const uint8_t *payload) {
const bool normal = (payload[38] & 0x10) && (payload[39] & 0x10);
const bool eco = (payload[41] & 0x04) != 0;
const bool bps = (payload[40] & 0x04) != 0;
if (unsigned(normal) + unsigned(eco) + unsigned(bps) != 1) {
return HonMode::HON_MODE_UNKNOWN;
}
if (normal) {
return HonMode::HON_MODE_NORMAL;
}
return eco ? HonMode::HON_MODE_ECO : HonMode::HON_MODE_BPS;
}
static constexpr uint8_t STATUS_FRAME_LENGTH = 60;
/// Decodes a status report or a polled status; false for any other frame or an implausible one.
/// Offsets are into the payload, which starts with 6D 01, as captured from a VH3W. The secondary
/// temperature's physical location and the heating time's unit (assumed minutes) are unconfirmed.
/// Available water is 0 to 3 segments of usable hot water, not the tank fill level.
inline bool decode_status(const uint8_t *frame, size_t size, Status &status) {
if (size <= STATUS_FRAME_LENGTH || frame[0] != STATUS_FRAME_LENGTH) {
return false;
}
const auto type = static_cast<FrameType>(frame[FRAME_TYPE_OFFSET]);
if (type != FrameType::FRAME_TYPE_STATUS && type != FrameType::FRAME_TYPE_REPORT) {
return false;
}
const uint8_t *payload = frame + FRAME_PAYLOAD_OFFSET;
if (payload[0] != 0x6D || payload[1] != 0x01) {
return false;
}
status.current_temperature = payload[2];
status.target_temperature = payload[3] + TARGET_TEMPERATURE_OFFSET;
status.estimated_heating_time = payload[7];
status.secondary_temperature = payload[15];
status.powered = (payload[39] & 0x01) != 0;
status.heating = (payload[41] & 0x01) != 0;
status.mode = decode_mode(payload);
status.available_water = payload[45];
return payload[2] <= MAX_REPORTED_TEMPERATURE && payload[3] <= MAX_REPORTED_TEMPERATURE - TARGET_TEMPERATURE_OFFSET;
}
// A control write: one parameter of the 0x5D group and its value.
enum class Parameter : uint8_t {
PARAMETER_POWER = 0x01,
PARAMETER_TARGET_TEMPERATURE = 0x02, // value is the temperature minus TARGET_TEMPERATURE_OFFSET
PARAMETER_ECO = 0x05, // 1 selects ECO, 0 selects Normal
PARAMETER_BPS = 0x17,
};
static constexpr uint8_t CONTROL_GROUP = 0x5D;
struct Command {
Parameter parameter;
uint8_t value;
};
/// Fills the four-byte control payload for a command.
inline void encode_control(const Command &command, uint8_t *payload) {
payload[0] = CONTROL_GROUP;
payload[1] = static_cast<uint8_t>(command.parameter);
payload[2] = 0x00;
payload[3] = command.value;
}
/// True when a status shows that the command took effect.
inline bool command_confirmed(const Command &command, const Status &status) {
switch (command.parameter) {
case Parameter::PARAMETER_POWER:
return status.powered == (command.value != 0);
case Parameter::PARAMETER_TARGET_TEMPERATURE:
return status.target_temperature == command.value + TARGET_TEMPERATURE_OFFSET;
case Parameter::PARAMETER_ECO:
return status.mode == (command.value != 0 ? HonMode::HON_MODE_ECO : HonMode::HON_MODE_NORMAL);
case Parameter::PARAMETER_BPS:
return status.mode == HonMode::HON_MODE_BPS;
}
return false;
}
} // namespace esphome::haier_water_heater::water_heater_protocol
@@ -0,0 +1,424 @@
#include "hon_water_heater.h"
#include <cmath>
#include "esphome/core/application.h"
#ifdef USE_NETWORK
#include "esphome/components/network/util.h"
#endif
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::haier_water_heater {
ESPHOME_LOG_TAG(TAG, "haier_water_heater");
using namespace water_heater_protocol;
// Indexed by HonMode, with Unknown as the fallback for out-of-range values.
PROGMEM_STRING_TABLE(HonModeLabels, "Unknown", "Normal", "ECO", "BPS");
void HonWaterHeater::setup() {
this->status_set_warning(LOG_STR("Waiting for a valid appliance status frame"));
if (this->communication_sensor_ != nullptr)
this->communication_sensor_->publish_state(false);
this->update_network_status_();
this->set_interval(NETWORK_STATUS_INTERVAL_ID, NETWORK_STATUS_INTERVAL_MS,
[this]() { this->update_network_status_(); });
this->set_interval(STATUS_POLL_INTERVAL_ID, POLL_INTERVAL_MS, [this]() { this->poll_status_(); });
}
void HonWaterHeater::update_network_status_() {
#ifdef USE_NETWORK
const bool ready = network::is_connected();
#else
const bool ready = false;
#endif
const uint8_t status = ready ? NETWORK_READY : NETWORK_NOT_READY;
if (status != this->network_status_) {
this->network_status_ = status;
this->network_pending_ = true;
ESP_LOGI(TAG, "Local network status changed: %02X", status);
}
}
void HonWaterHeater::send_frame_(FrameType type, const uint8_t *payload, size_t size) {
uint8_t wire[MAX_WIRE_FRAME];
const size_t length = encode_frame(type, payload, size, wire);
if (length == 0)
return;
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex[format_hex_pretty_size(sizeof(wire))];
ESP_LOGV(TAG, "TX type=%02X: %s", static_cast<uint8_t>(type), format_hex_pretty_to(hex, wire, length));
#endif
this->write_array(wire, length);
}
void HonWaterHeater::send_request_(uint32_t now, Awaiting kind, FrameType type, const uint8_t *payload, size_t size,
FrameType expected) {
this->send_frame_(type, payload, size);
this->awaiting_ = kind;
this->expected_type_ = expected;
this->request_sent_ = now;
}
void HonWaterHeater::poll_status_() {
const uint32_t now = App.get_loop_component_start_time();
this->check_timeouts_(now);
if (this->awaiting_ != Awaiting::AWAITING_NONE || this->command_count_ != 0 || this->pending_off_)
return;
if (this->network_pending_ || now - this->network_sent_ >= NETWORK_REFRESH_MS)
return; // the network status goes out first, from loop()
// The version and device id are read once per cycle, the status on every other pass.
static constexpr uint8_t VERSION_REQUEST[] = {0x00, 0x07};
static constexpr uint8_t STATUS_REQUEST[] = {0x4D, 0x01};
if (this->poll_stage_ == 0) {
this->send_request_(now, Awaiting::AWAITING_POLL, FrameType::FRAME_TYPE_GET_VERSION, VERSION_REQUEST,
sizeof(VERSION_REQUEST), FrameType::FRAME_TYPE_VERSION);
} else if (this->poll_stage_ == 1) {
this->send_request_(now, Awaiting::AWAITING_POLL, FrameType::FRAME_TYPE_GET_DEVICE_ID, nullptr, 0,
FrameType::FRAME_TYPE_DEVICE_ID);
} else {
this->send_request_(now, Awaiting::AWAITING_POLL, FrameType::FRAME_TYPE_CONTROL, STATUS_REQUEST,
sizeof(STATUS_REQUEST), FrameType::FRAME_TYPE_STATUS);
}
this->poll_stage_ = (this->poll_stage_ + 1) % POLL_CYCLE;
}
void HonWaterHeater::check_timeouts_(uint32_t now) {
if (this->have_status_ && now - this->last_status_ >= this->communication_timeout_) {
this->have_status_ = false;
this->status_set_warning(LOG_STR("Heater communication timed out"));
ESP_LOGW(TAG, "Heater communication lost: no valid status for %u ms",
static_cast<unsigned>(this->communication_timeout_));
if (this->communication_sensor_ != nullptr)
this->communication_sensor_->publish_state(false);
}
const uint32_t waited = now - this->request_sent_;
switch (this->awaiting_) {
case Awaiting::AWAITING_NONE:
break;
case Awaiting::AWAITING_POLL:
if (waited >= RESPONSE_TIMEOUT_MS) {
ESP_LOGW(TAG, "No matching response type=%02X in %u ms", static_cast<uint8_t>(this->expected_type_),
static_cast<unsigned>(RESPONSE_TIMEOUT_MS));
this->awaiting_ = Awaiting::AWAITING_NONE;
}
break;
case Awaiting::AWAITING_CONTROL:
if (waited >= RESPONSE_TIMEOUT_MS) {
ESP_LOGW(TAG, "Control response timed out; discarding remaining commands");
this->awaiting_ = Awaiting::AWAITING_NONE;
this->command_count_ = 0;
}
break;
case Awaiting::AWAITING_NETWORK:
if (waited >= NETWORK_ACK_TIMEOUT_MS) {
ESP_LOGW(TAG, "Network status: no CONFIRM within %u ms", static_cast<unsigned>(NETWORK_ACK_TIMEOUT_MS));
this->awaiting_ = Awaiting::AWAITING_NONE;
}
break;
}
}
void HonWaterHeater::send_pending_(uint32_t now) {
if (this->awaiting_ != Awaiting::AWAITING_NONE)
return;
if (this->pending_off_) {
this->commands_[0] = {Parameter::PARAMETER_POWER, 0};
this->command_index_ = 0;
this->command_count_ = 1;
this->pending_off_ = false;
}
if (this->command_count_ == 0 && (this->network_pending_ || now - this->network_sent_ >= NETWORK_REFRESH_MS)) {
// Signal remains a placeholder; status represents local IP readiness, not cloud access.
const uint8_t payload[] = {0x00, this->network_status_, 0x00, 100};
this->send_request_(now, Awaiting::AWAITING_NETWORK, FrameType::FRAME_TYPE_NETWORK_STATUS, payload, sizeof(payload),
FrameType::FRAME_TYPE_CONFIRM);
this->network_sent_ = now;
this->network_pending_ = false;
return;
}
if (this->command_count_ != 0)
this->send_next_control_(now);
}
void HonWaterHeater::send_next_control_(uint32_t now) {
uint8_t payload[4];
encode_control(this->commands_[this->command_index_], payload);
this->send_request_(now, Awaiting::AWAITING_CONTROL, FrameType::FRAME_TYPE_CONTROL, payload, sizeof(payload),
FrameType::FRAME_TYPE_STATUS);
}
void HonWaterHeater::read_frames_(uint32_t now) {
if (now - this->last_byte_ > FRAME_GAP_MS)
this->parser_.reset();
uint8_t budget = MAX_BYTES_PER_LOOP;
while (budget-- > 0 && this->available() > 0) {
uint8_t value;
if (!this->read_byte(&value))
break;
this->last_byte_ = now;
if (this->parser_.feed(value)) {
this->process_frame_();
} else if (this->parser_.complete()) {
if ((this->parser_.data()[FRAME_FLAGS_OFFSET] & FRAME_FLAG_ENCRYPTED) != 0) {
ESP_LOGW(TAG, "RX: XOR-encrypted frames are not supported and will be dropped");
} else {
ESP_LOGW(TAG, "RX: unsupported flags or invalid checksum/CRC");
}
}
}
}
void HonWaterHeater::loop() {
const uint32_t now = App.get_loop_component_start_time();
this->check_timeouts_(now);
this->send_pending_(now);
this->read_frames_(now);
}
void HonWaterHeater::handle_frame_type_(FrameType type) {
if (this->awaiting_ == Awaiting::AWAITING_POLL && type == this->expected_type_) {
ESP_LOGV(TAG, "Response matched type=%02X", static_cast<uint8_t>(type));
this->awaiting_ = Awaiting::AWAITING_NONE;
}
switch (type) {
case FrameType::FRAME_TYPE_GET_NETWORK_STATUS:
this->network_pending_ = true;
break;
case FrameType::FRAME_TYPE_CONFIRM:
if (this->awaiting_ == Awaiting::AWAITING_NETWORK) {
ESP_LOGD(TAG, "Network status acknowledged");
this->awaiting_ = Awaiting::AWAITING_NONE;
}
break;
case FrameType::FRAME_TYPE_INVALID:
ESP_LOGW(TAG, "Appliance rejected request");
// Controls queued behind a rejected poll were never sent and keep their turn.
if (this->awaiting_ == Awaiting::AWAITING_CONTROL)
this->command_count_ = 0;
if (this->awaiting_ != Awaiting::AWAITING_POLL)
this->awaiting_ = Awaiting::AWAITING_NONE;
break;
case FrameType::FRAME_TYPE_REPORT:
this->send_frame_(FrameType::FRAME_TYPE_CONFIRM);
break;
default:
break;
}
}
void HonWaterHeater::confirm_control_(const Status &status) {
const Command &command = this->commands_[this->command_index_];
if (!command_confirmed(command, status))
return;
ESP_LOGD(TAG, "Control confirmed: parameter=%02X value=%02X", static_cast<uint8_t>(command.parameter), command.value);
this->awaiting_ = Awaiting::AWAITING_NONE;
if (++this->command_index_ == this->command_count_)
this->command_count_ = 0;
}
void HonWaterHeater::process_frame_() {
const uint8_t *frame = this->parser_.data();
const auto type = static_cast<FrameType>(frame[FRAME_TYPE_OFFSET]);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex[format_hex_pretty_size(MAX_STORED_FRAME)];
ESP_LOGV(TAG, "RX decoded type=%02X flags=%02X: %s", static_cast<uint8_t>(type), frame[FRAME_FLAGS_OFFSET],
format_hex_pretty_to(hex, frame, this->parser_.stored_size()));
#endif
this->handle_frame_type_(type);
Status status{};
if (!decode_status(frame, this->parser_.size(), status))
return;
const bool recovered = !this->have_status_;
if (status.mode == HonMode::HON_MODE_UNKNOWN && (recovered || this->reported_mode_ != HonMode::HON_MODE_UNKNOWN)) {
const uint8_t *payload = frame + FRAME_PAYLOAD_OFFSET;
ESP_LOGW(TAG,
"Unknown heater mode: payload[38..41]=%02X %02X %02X %02X; using last recognized active mode, or Electric "
"if none is known",
payload[38], payload[39], payload[40], payload[41]);
}
this->have_status_ = true;
this->last_status_ = App.get_loop_component_start_time();
this->reported_mode_ = status.mode;
if (this->awaiting_ == Awaiting::AWAITING_CONTROL && type == FrameType::FRAME_TYPE_STATUS)
this->confirm_control_(status);
this->status_clear_warning();
this->publish_status_(status);
// Published last so an automation on the sensor sees fresh heater state and can send a control.
if (recovered) {
ESP_LOGI(TAG, "Heater communication established");
if (this->communication_sensor_ != nullptr)
this->communication_sensor_->publish_state(true);
}
}
void HonWaterHeater::publish_status_(const Status &status) {
this->current_temperature_ = status.current_temperature;
this->target_temperature_ = status.target_temperature;
this->set_state_flag_(water_heater::WATER_HEATER_STATE_ON, status.powered);
switch (status.mode) {
case HonMode::HON_MODE_NORMAL:
this->last_active_mode_ = water_heater::WATER_HEATER_MODE_ELECTRIC;
break;
case HonMode::HON_MODE_ECO:
this->last_active_mode_ = water_heater::WATER_HEATER_MODE_ECO;
break;
case HonMode::HON_MODE_BPS:
this->last_active_mode_ = water_heater::WATER_HEATER_MODE_PERFORMANCE;
break;
case HonMode::HON_MODE_UNKNOWN:
break;
}
// The base entity has no Unknown mode; the text sensor preserves the decoded result.
this->mode_ = status.powered ? this->last_active_mode_ : water_heater::WATER_HEATER_MODE_OFF;
if (this->primary_sensor_ != nullptr)
this->primary_sensor_->publish_state(status.current_temperature);
if (this->secondary_sensor_ != nullptr) {
this->secondary_sensor_->publish_state(
status.secondary_temperature <= MAX_REPORTED_TEMPERATURE ? status.secondary_temperature : NAN);
}
if (this->target_sensor_ != nullptr)
this->target_sensor_->publish_state(status.target_temperature);
if (this->heating_time_sensor_ != nullptr)
this->heating_time_sensor_->publish_state(status.estimated_heating_time);
if (this->water_sensor_ != nullptr) {
// Three segments of available hot water, published as 0, 33, 66 or 100 percent.
const uint8_t percent = status.available_water * 100 / 3;
this->water_sensor_->publish_state(status.available_water <= 3 ? percent : NAN);
}
if (this->power_sensor_ != nullptr)
this->power_sensor_->publish_state(status.powered);
if (this->heating_sensor_ != nullptr)
this->heating_sensor_->publish_state(status.heating);
if (this->mode_sensor_ != nullptr) {
char label[HonModeLabels::MAX_LENGTH + 1];
ESPHOME_strncpy_P(label,
reinterpret_cast<ESPHOME_PGM_P>(HonModeLabels::get_progmem_str(
static_cast<uint8_t>(status.mode), static_cast<uint8_t>(HonMode::HON_MODE_UNKNOWN))),
sizeof(label));
this->mode_sensor_->publish_state(label);
}
this->publish_state();
}
void HonWaterHeater::dump_config() {
LOG_WATER_HEATER("", "Haier hOn", this);
ESP_LOGCONFIG(TAG,
" Polling interval: %u ms\n"
" Communication timeout: %u ms",
static_cast<unsigned>(POLL_INTERVAL_MS), static_cast<unsigned>(this->communication_timeout_));
LOG_SENSOR(" ", "Primary temperature", this->primary_sensor_);
LOG_SENSOR(" ", "Secondary temperature", this->secondary_sensor_);
LOG_SENSOR(" ", "Target temperature", this->target_sensor_);
LOG_SENSOR(" ", "Estimated heating time", this->heating_time_sensor_);
LOG_SENSOR(" ", "Available water", this->water_sensor_);
LOG_BINARY_SENSOR(" ", "Communication", this->communication_sensor_);
LOG_BINARY_SENSOR(" ", "Power", this->power_sensor_);
LOG_BINARY_SENSOR(" ", "Heating", this->heating_sensor_);
LOG_TEXT_SENSOR(" ", "Mode", this->mode_sensor_);
}
water_heater::WaterHeaterCallInternal HonWaterHeater::make_call() {
return water_heater::WaterHeaterCallInternal(this);
}
// The appliance mode a water heater mode maps to; Unknown for modes the appliance does not have.
static HonMode hon_mode(water_heater::WaterHeaterMode mode) {
switch (mode) {
case water_heater::WATER_HEATER_MODE_ELECTRIC:
return HonMode::HON_MODE_NORMAL;
case water_heater::WATER_HEATER_MODE_ECO:
return HonMode::HON_MODE_ECO;
case water_heater::WATER_HEATER_MODE_PERFORMANCE:
return HonMode::HON_MODE_BPS;
default:
return HonMode::HON_MODE_UNKNOWN;
}
}
void HonWaterHeater::control(const water_heater::WaterHeaterCall &call) {
using namespace water_heater;
const auto mode = call.get_mode();
const auto on = call.get_on();
const bool off = (on.has_value() && !*on) || (mode.has_value() && *mode == WATER_HEATER_MODE_OFF);
if (off) {
// Keep only the command in flight until its reply or timeout; unsent writes are dropped.
if (this->awaiting_ == Awaiting::AWAITING_CONTROL) {
this->commands_[0] = this->commands_[this->command_index_];
this->command_index_ = 0;
this->command_count_ = 1;
} else {
this->command_count_ = 0;
}
this->pending_off_ = true;
return;
}
if (!this->have_status_ || App.get_loop_component_start_time() - this->last_status_ > CONTROL_STATUS_MAX_AGE_MS) {
ESP_LOGW(TAG, "Control rejected: no recent appliance status");
return;
}
if (this->command_count_ != 0 || this->pending_off_) {
ESP_LOGW(TAG, "Control busy; wait for the previous request to finish");
return;
}
const float temperature = call.get_target_temperature();
if (!std::isnan(temperature)) {
if (!std::isfinite(temperature) || temperature < MIN_TARGET_TEMPERATURE || temperature > MAX_TARGET_TEMPERATURE ||
std::round(temperature) != temperature) {
ESP_LOGW(TAG, "Target must be a whole temperature from %u to %u C", MIN_TARGET_TEMPERATURE,
MAX_TARGET_TEMPERATURE);
return;
}
const bool normal =
mode.has_value() ? *mode == WATER_HEATER_MODE_ELECTRIC : this->reported_mode_ == HonMode::HON_MODE_NORMAL;
if (!normal) {
ESP_LOGW(TAG, "Target can only be set in Normal mode");
return;
}
}
this->command_index_ = 0;
auto append = [this](Parameter parameter, uint8_t value) {
this->commands_[this->command_count_++] = {parameter, value};
};
// A write is only queued when it changes something, so a repeated state costs no round trip.
if (!this->is_on() && ((on.has_value() && *on) || mode.has_value()))
append(Parameter::PARAMETER_POWER, 1);
if (mode.has_value() && hon_mode(*mode) != this->reported_mode_) {
// Captures show the appliance moves between ECO and BPS through Normal, never directly.
if (*mode != WATER_HEATER_MODE_ELECTRIC && this->reported_mode_ != HonMode::HON_MODE_NORMAL)
append(Parameter::PARAMETER_ECO, 0);
switch (*mode) {
case WATER_HEATER_MODE_ELECTRIC:
append(Parameter::PARAMETER_ECO, 0);
break;
case WATER_HEATER_MODE_ECO:
append(Parameter::PARAMETER_ECO, 1);
break;
case WATER_HEATER_MODE_PERFORMANCE:
append(Parameter::PARAMETER_BPS, 1);
break;
default:
break;
}
}
if (!std::isnan(temperature))
append(Parameter::PARAMETER_TARGET_TEMPERATURE, static_cast<uint8_t>(temperature - TARGET_TEMPERATURE_OFFSET));
}
water_heater::WaterHeaterTraits HonWaterHeater::traits() {
using namespace water_heater;
WaterHeaterTraits traits;
traits.set_supports_current_temperature(true);
traits.add_feature_flags(WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE | WATER_HEATER_SUPPORTS_OPERATION_MODE |
WATER_HEATER_SUPPORTS_ON_OFF);
traits.set_supported_modes(
{WATER_HEATER_MODE_OFF, WATER_HEATER_MODE_ELECTRIC, WATER_HEATER_MODE_ECO, WATER_HEATER_MODE_PERFORMANCE});
traits.set_min_temperature(MIN_TARGET_TEMPERATURE);
traits.set_max_temperature(MAX_TARGET_TEMPERATURE);
traits.set_target_temperature_step(1);
return traits;
}
} // namespace esphome::haier_water_heater
@@ -0,0 +1,96 @@
#pragma once
#include "hon_parser.h"
#include "esphome/components/uart/uart.h"
#include "esphome/components/water_heater/water_heater.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
#include "esphome/components/text_sensor/text_sensor.h"
#include "esphome/core/component.h"
namespace esphome::haier_water_heater {
class HonWaterHeater : public Component, public water_heater::WaterHeater, public uart::UARTDevice {
public:
void setup() override;
void loop() override;
void dump_config() override;
water_heater::WaterHeaterCallInternal make_call() override;
void set_communication_timeout(uint32_t value) { this->communication_timeout_ = value; }
void set_primary_temperature_sensor(sensor::Sensor *value) { this->primary_sensor_ = value; }
void set_secondary_temperature_sensor(sensor::Sensor *value) { this->secondary_sensor_ = value; }
void set_target_temperature_sensor(sensor::Sensor *value) { this->target_sensor_ = value; }
void set_estimated_heating_time_sensor(sensor::Sensor *value) { this->heating_time_sensor_ = value; }
void set_available_water_sensor(sensor::Sensor *value) { this->water_sensor_ = value; }
void set_communication_sensor(binary_sensor::BinarySensor *value) { this->communication_sensor_ = value; }
void set_power_sensor(binary_sensor::BinarySensor *value) { this->power_sensor_ = value; }
void set_heating_sensor(binary_sensor::BinarySensor *value) { this->heating_sensor_ = value; }
void set_mode_sensor(text_sensor::TextSensor *value) { this->mode_sensor_ = value; }
protected:
static constexpr uint32_t NETWORK_STATUS_INTERVAL_ID = 0;
static constexpr uint32_t STATUS_POLL_INTERVAL_ID = 1;
static constexpr uint32_t NETWORK_STATUS_INTERVAL_MS = 500;
static constexpr uint32_t POLL_INTERVAL_MS = 5000;
static constexpr uint32_t RESPONSE_TIMEOUT_MS = 5000; // a polled or controlled reply
static constexpr uint32_t NETWORK_ACK_TIMEOUT_MS = 2000; // the CONFIRM of a network status
static constexpr uint32_t NETWORK_REFRESH_MS = 30000; // resend the network status this often
static constexpr uint32_t CONTROL_STATUS_MAX_AGE_MS = 15000; // a control needs a status this fresh
static constexpr uint32_t FRAME_GAP_MS = 300; // silence that ends a partial frame
static constexpr uint8_t POLL_CYCLE = 12; // version and id are re-read once per cycle
static constexpr uint8_t MAX_BYTES_PER_LOOP = 255;
static constexpr uint8_t MIN_TARGET_TEMPERATURE = 35;
static constexpr uint8_t MAX_TARGET_TEMPERATURE = 75;
static constexpr uint8_t NETWORK_READY = 0x07;
static constexpr uint8_t NETWORK_NOT_READY = 0x05;
// The link carries one request at a time; this is the one whose answer is outstanding.
enum class Awaiting : uint8_t { AWAITING_NONE, AWAITING_POLL, AWAITING_NETWORK, AWAITING_CONTROL };
void control(const water_heater::WaterHeaterCall &call) override;
water_heater::WaterHeaterTraits traits() override;
void check_timeouts_(uint32_t now);
void send_pending_(uint32_t now);
void read_frames_(uint32_t now);
void poll_status_();
void update_network_status_();
void send_frame_(water_heater_protocol::FrameType type, const uint8_t *payload = nullptr, size_t size = 0);
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);
void send_next_control_(uint32_t now);
void process_frame_();
void handle_frame_type_(water_heater_protocol::FrameType type);
void confirm_control_(const water_heater_protocol::Status &status);
void publish_status_(const water_heater_protocol::Status &status);
water_heater_protocol::FrameParser parser_;
sensor::Sensor *primary_sensor_{nullptr};
sensor::Sensor *secondary_sensor_{nullptr};
sensor::Sensor *target_sensor_{nullptr};
sensor::Sensor *heating_time_sensor_{nullptr};
sensor::Sensor *water_sensor_{nullptr};
binary_sensor::BinarySensor *communication_sensor_{nullptr};
binary_sensor::BinarySensor *power_sensor_{nullptr};
binary_sensor::BinarySensor *heating_sensor_{nullptr};
text_sensor::TextSensor *mode_sensor_{nullptr};
uint32_t request_sent_{0};
uint32_t network_sent_{0};
uint32_t last_status_{0};
uint32_t last_byte_{0};
uint32_t communication_timeout_{20000};
water_heater::WaterHeaterMode last_active_mode_{water_heater::WATER_HEATER_MODE_ELECTRIC};
std::array<water_heater_protocol::Command, 4> commands_{}; // power, to Normal, mode, temperature at most
water_heater_protocol::HonMode reported_mode_{water_heater_protocol::HonMode::HON_MODE_UNKNOWN};
water_heater_protocol::FrameType expected_type_{water_heater_protocol::FrameType::FRAME_TYPE_STATUS};
Awaiting awaiting_{Awaiting::AWAITING_NONE};
uint8_t network_status_{NETWORK_NOT_READY};
uint8_t command_count_{0};
uint8_t command_index_{0};
uint8_t poll_stage_{0};
bool network_pending_{true};
bool have_status_{false};
bool pending_off_{false};
};
} // namespace esphome::haier_water_heater
@@ -0,0 +1,110 @@
"""Local controller for Haier hOn water heaters."""
import esphome.codegen as cg
from esphome.components import binary_sensor, sensor, text_sensor, uart, water_heater
import esphome.config_validation as cv
from esphome.const import (
CONF_MODE,
CONF_POWER,
CONF_TARGET_TEMPERATURE,
DEVICE_CLASS_CONNECTIVITY,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_HEAT,
DEVICE_CLASS_TEMPERATURE,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_MINUTE,
UNIT_PERCENT,
)
from esphome.types import ConfigType
CONF_AVAILABLE_WATER = "available_water"
CONF_COMMUNICATION = "communication"
CONF_COMMUNICATION_TIMEOUT = "communication_timeout"
CONF_ESTIMATED_HEATING_TIME = "estimated_heating_time"
CONF_HEATING = "heating"
CONF_PRIMARY_TEMPERATURE = "primary_temperature"
CONF_SECONDARY_TEMPERATURE = "secondary_temperature"
CODEOWNERS = ["@TheLexus"]
DEPENDENCIES = ["uart"]
AUTO_LOAD = ["sensor", "binary_sensor", "text_sensor"]
haier_water_heater_ns = cg.esphome_ns.namespace("haier_water_heater")
HonWaterHeater = haier_water_heater_ns.class_(
"HonWaterHeater", water_heater.WaterHeater, cg.Component, uart.UARTDevice
)
_TEMPERATURE_SENSOR = sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=0,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
)
CONFIG_SCHEMA = (
water_heater.water_heater_schema(HonWaterHeater)
.extend(
{
cv.Optional(
CONF_COMMUNICATION_TIMEOUT, default="20s"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_PRIMARY_TEMPERATURE): _TEMPERATURE_SENSOR,
cv.Optional(CONF_SECONDARY_TEMPERATURE): _TEMPERATURE_SENSOR,
cv.Optional(CONF_TARGET_TEMPERATURE): _TEMPERATURE_SENSOR,
cv.Optional(CONF_ESTIMATED_HEATING_TIME): sensor.sensor_schema(
unit_of_measurement=UNIT_MINUTE,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DURATION,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_AVAILABLE_WATER): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_COMMUNICATION): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_CONNECTIVITY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_POWER): binary_sensor.binary_sensor_schema(),
cv.Optional(CONF_HEATING): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_HEAT
),
cv.Optional(CONF_MODE): text_sensor.text_sensor_schema(),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"haier_water_heater",
baud_rate=9600,
require_rx=True,
require_tx=True,
data_bits=8,
parity="NONE",
stop_bits=1,
)
async def to_code(config: ConfigType) -> None:
var = await water_heater.new_water_heater(config)
await cg.register_component(var, config)
cg.add(var.set_communication_timeout(config[CONF_COMMUNICATION_TIMEOUT]))
await uart.register_uart_device(var, config)
sensors = sensor.sub_sensors(config)
await sensors(CONF_PRIMARY_TEMPERATURE, var.set_primary_temperature_sensor)
await sensors(CONF_SECONDARY_TEMPERATURE, var.set_secondary_temperature_sensor)
await sensors(CONF_TARGET_TEMPERATURE, var.set_target_temperature_sensor)
await sensors(CONF_ESTIMATED_HEATING_TIME, var.set_estimated_heating_time_sensor)
await sensors(CONF_AVAILABLE_WATER, var.set_available_water_sensor)
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_COMMUNICATION, var.set_communication_sensor)
await binary_sensors(CONF_POWER, var.set_power_sensor)
await binary_sensors(CONF_HEATING, var.set_heating_sensor)
text_sensors = text_sensor.sub_text_sensors(config)
await text_sensors(CONF_MODE, var.set_mode_sensor)
@@ -0,0 +1,115 @@
"""Tests for the Haier water heater configuration."""
import pytest
from esphome import config_validation as cv
from esphome.components import uart
from esphome.components.haier_water_heater.water_heater import (
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
)
from esphome.config import Config
from esphome.const import PlatformFramework
from esphome.core import ID
from tests.component_tests.types import SetCoreConfigCallable
def test_visible_by_default(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
config = CONFIG_SCHEMA({"name": "Heater capture"})
assert config.get("internal", False) is False
assert "protocol" not in config
assert config["communication_timeout"].total_milliseconds == 20000
@pytest.mark.parametrize("protocol", ["HON", "SMARTAIR2", "unknown"])
def test_rejects_protocol_option(
set_core_config: SetCoreConfigCallable, protocol: str
) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA({"name": "Heater capture", "protocol": protocol})
def test_rejects_climate_control_options(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA(
{"name": "Heater capture", "control_method": "SET_GROUP_PARAMETERS"}
)
@pytest.mark.parametrize("missing", [None, "rx_pin", "tx_pin"])
@pytest.mark.parametrize(
("setting", "value"),
[
(None, None),
("baud_rate", 115200),
("data_bits", 7),
("parity", "EVEN"),
("stop_bits", 2),
],
)
def test_uart_requirements(
set_core_config: SetCoreConfigCallable,
missing: str | None,
setting: str | None,
value: int | str | None,
) -> None:
bus_id = ID("heater_uart", is_declaration=True, type=uart.IDFUARTComponent)
bus = {
"id": bus_id,
"baud_rate": 9600,
"data_bits": 8,
"parity": "NONE",
"stop_bits": 1,
"rx_pin": {"number": 16},
"tx_pin": {"number": 17},
}
if missing is not None:
del bus[missing]
if setting is not None:
bus[setting] = value
full = Config()
full["uart"] = [bus]
full.declare_ids.append((bus_id, ["uart", 0, "id"]))
set_core_config(PlatformFramework.ESP32_IDF, full_config=full)
config = {"uart_id": ID("heater_uart", type=uart.IDFUARTComponent)}
if missing is None and setting is None:
FINAL_VALIDATE_SCHEMA(config)
else:
with pytest.raises(cv.Invalid):
FINAL_VALIDATE_SCHEMA(config)
def test_read_only_entities(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
config = CONFIG_SCHEMA(
{
"name": "Water Heater",
"internal": False,
"target_temperature": {"name": "Target"},
"estimated_heating_time": {"name": "Estimated Heating Time"},
"available_water": {"name": "Water Segments"},
"communication": {"name": "Communication"},
"power": {"name": "Power"},
"heating": {"name": "Heating"},
"mode": {"name": "Mode"},
}
)
assert config["target_temperature"]["unit_of_measurement"] == "°C"
assert config["estimated_heating_time"]["unit_of_measurement"] == "min"
assert config["estimated_heating_time"]["device_class"] == "duration"
assert config["communication"]["device_class"] == "connectivity"
assert config["heating"]["device_class"] == "heat"
assert config["internal"] is False
def test_custom_communication_timeout(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
config = CONFIG_SCHEMA({"name": "Heater", "communication_timeout": "7500ms"})
assert config["communication_timeout"].total_milliseconds == 7500
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA({"name": "Heater", "communication_timeout": "-1s"})
@@ -0,0 +1,12 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# Include the real entity classes used by the controller fixtures.
manifest.auto_load = [
"uart",
"water_heater",
"sensor",
"binary_sensor",
"text_sensor",
]
@@ -0,0 +1,23 @@
water_heater:
- platform: haier_water_heater
id: haier_water_heater_device
name: Haier Water Heater
communication_timeout: 25s
primary_temperature:
name: Primary temperature zone
secondary_temperature:
name: Secondary temperature zone
estimated_heating_time:
name: Haier Estimated Heating Time
communication:
name: Haier Communication
target_temperature:
name: Haier Target Temperature
available_water:
name: Haier Available Water
power:
name: Haier Power
heating:
name: Haier Heating
mode:
name: Haier Mode
@@ -0,0 +1,3 @@
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
haier_water_heater: !include common.yaml
@@ -0,0 +1,3 @@
packages:
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
haier_water_heater: !include common.yaml
@@ -0,0 +1,3 @@
packages:
uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml
haier_water_heater: !include common.yaml
@@ -0,0 +1,219 @@
#include "esphome/components/haier_water_heater/hon_parser.h"
#include <gtest/gtest.h>
#include <sstream>
#include <string>
#include <vector>
using namespace esphome::haier_water_heater::water_heater_protocol;
namespace esphome::haier_water_heater::testing {
std::vector<uint8_t> bytes(const char *hex) {
std::istringstream stream(hex);
unsigned byte;
std::vector<uint8_t> result;
while (stream >> std::hex >> byte)
result.push_back(byte);
return result;
}
unsigned feed(FrameParser &parser, const std::vector<uint8_t> &wire) {
unsigned count = 0;
for (auto byte : wire)
count += parser.feed(byte);
return count;
}
unsigned feed_head(FrameParser &parser, const std::vector<uint8_t> &wire, size_t count) {
unsigned frames = 0;
for (size_t i = 0; i < count; i++)
frames += parser.feed(wire[i]);
return frames;
}
TEST(HonProtocolTest, EncodesRequestsAndEscapesBytes) {
FrameParser parser;
uint8_t wire[MAX_WIRE_FRAME];
const uint8_t version[] = {0x00, 0x07};
const uint8_t query[] = {0x4D, 0x01};
auto check_tx = [&](FrameType type, const uint8_t *payload, size_t size, const char *expected) {
const auto count = encode_frame(type, payload, size, wire);
EXPECT_EQ(std::vector<uint8_t>(wire, wire + count), bytes(expected));
ASSERT_EQ(feed(parser, std::vector<uint8_t>(wire, wire + count)), 1u);
EXPECT_EQ(parser.data()[FRAME_FLAGS_OFFSET], 0);
EXPECT_EQ(parser.data()[FRAME_TYPE_OFFSET], static_cast<uint8_t>(type));
};
check_tx(FrameType::FRAME_TYPE_GET_VERSION, version, 2, "FF FF 0A 00 00 00 00 00 00 61 00 07 72");
check_tx(FrameType::FRAME_TYPE_GET_DEVICE_ID, nullptr, 0, "FF FF 08 00 00 00 00 00 00 70 78");
check_tx(FrameType::FRAME_TYPE_CONTROL, query, 2, "FF FF 0A 00 00 00 00 00 00 01 4D 01 59");
check_tx(FrameType::FRAME_TYPE_CONFIRM, nullptr, 0, "FF FF 08 00 00 00 00 00 00 05 0D");
auto check_control = [&](Command command, const char *expected) {
uint8_t payload[4];
encode_control(command, payload);
check_tx(FrameType::FRAME_TYPE_CONTROL, payload, sizeof(payload), expected);
};
check_control({Parameter::PARAMETER_POWER, 1}, "FF FF 0C 00 00 00 00 00 00 01 5D 01 00 01 6C");
check_control({Parameter::PARAMETER_ECO, 1}, "FF FF 0C 00 00 00 00 00 00 01 5D 05 00 01 70");
check_control({Parameter::PARAMETER_ECO, 0}, "FF FF 0C 00 00 00 00 00 00 01 5D 05 00 00 6F");
check_control({Parameter::PARAMETER_BPS, 1}, "FF FF 0C 00 00 00 00 00 00 01 5D 17 00 01 82");
check_control({Parameter::PARAMETER_TARGET_TEMPERATURE, 6}, "FF FF 0C 00 00 00 00 00 00 01 5D 02 00 06 72");
const uint8_t network[] = {0, 7, 0, 100};
check_tx(FrameType::FRAME_TYPE_NETWORK_STATUS, network, 4, "FF FF 0C 00 00 00 00 00 00 F7 00 07 00 64 6E");
const uint8_t escaped[] = {0xFF, 0x55};
const size_t escaped_size = encode_frame(FrameType::FRAME_TYPE_CONTROL, escaped, 2, wire);
EXPECT_EQ(feed(parser, std::vector<uint8_t>(wire, wire + escaped_size)), 1u);
EXPECT_EQ(encode_frame(FrameType::FRAME_TYPE_CONTROL, escaped, 5, wire), 0u);
}
struct StatusCase {
const char *name;
const char *wire;
uint8_t current_temperature;
uint8_t target_temperature;
uint8_t estimated_heating_time;
bool powered;
bool heating;
HonMode mode;
uint8_t available_water;
};
// Captured from a VH3W on 2026-10-14; the name gives the packet of the session.
static const StatusCase STATUS_CASES[] = {
{"packet 4",
"FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 00 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 "
"2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 10 10 00 00 00 00 00 02 00 00 00 00 00 00 2E",
53, 35, 0, false, false, HonMode::HON_MODE_NORMAL, 2},
{"packet 33",
"FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 00 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 "
"2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 10 11 00 00 00 00 00 02 00 00 00 00 00 00 2F",
53, 35, 0, true, false, HonMode::HON_MODE_NORMAL, 2},
{"packet 36",
"FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 00 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 "
"2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 00 01 00 04 00 00 00 02 00 00 00 00 00 00 13",
53, 35, 0, true, false, HonMode::HON_MODE_ECO, 2},
{"packet 39",
"FF FF 3C 00 00 00 00 00 00 06 6D 01 35 32 00 00 00 1D 00 00 00 32 19 00 00 35 00 00 00 00 13 00 "
"2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 00 01 04 01 00 00 00 02 00 00 00 00 00 00 6D",
53, 80, 29, true, true, HonMode::HON_MODE_BPS, 2},
{"packet 50",
"FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 01 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 "
"2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 00 01 00 04 00 00 00 02 00 00 00 00 00 00 14",
53, 35, 0, true, false, HonMode::HON_MODE_ECO, 2},
{"packet 52",
"FF FF 3C 00 00 00 00 00 00 06 6D 01 35 32 00 01 00 1D 00 00 00 32 19 00 00 35 00 00 00 00 13 00 "
"2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 00 01 04 01 00 00 00 02 00 00 00 00 00 00 6E",
53, 80, 29, true, true, HonMode::HON_MODE_BPS, 2},
{"packet 55",
"FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 01 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 "
"2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 10 11 00 00 00 00 00 02 00 00 00 00 00 00 30",
53, 35, 0, true, false, HonMode::HON_MODE_NORMAL, 2},
{"packet 58",
"FF FF 3C 00 00 00 00 00 00 06 6D 01 35 05 00 01 00 00 00 00 00 32 19 00 00 35 00 00 00 00 13 00 "
"2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 10 10 00 00 00 00 00 02 00 00 00 00 00 00 2F",
53, 35, 0, false, false, HonMode::HON_MODE_NORMAL, 2},
{"target 35 C",
"FF FF 3C 00 00 00 00 00 00 06 6D 01 1E 05 00 00 00 06 00 00 00 32 19 00 00 1E 00 00 00 00 13 00 "
"2D 13 00 2D 00 00 00 00 00 00 22 00 00 00 00 00 10 10 00 00 00 00 00 00 00 00 00 00 00 00 04",
30, 35, 6, false, false, HonMode::HON_MODE_NORMAL, 0},
{"target 40 C",
"FF FF 3C 00 00 00 00 00 00 06 6D 01 1E 0A 00 01 00 0B 00 00 00 32 19 00 00 1E 00 00 00 00 13 00 "
"2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 10 11 00 01 00 00 00 00 00 00 00 00 00 00 20",
30, 40, 11, true, true, HonMode::HON_MODE_NORMAL, 0},
{"target 75 C",
"FF FF 3C 00 00 00 00 00 00 06 6D 01 1F 2D 00 01 00 2F 00 00 00 32 19 00 00 1F 00 00 00 00 13 00 "
"2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 10 11 80 01 00 00 00 00 00 00 00 00 00 00 E9",
31, 75, 47, true, true, HonMode::HON_MODE_NORMAL, 0},
{"target back to 35 C",
"FF FF 3C 00 00 00 00 00 00 06 6D 01 20 05 00 01 00 04 00 00 00 32 19 00 00 20 00 00 00 00 13 00 "
"2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 10 11 00 01 00 00 00 00 00 00 00 00 00 00 18",
32, 35, 4, true, true, HonMode::HON_MODE_NORMAL, 0},
};
TEST(HonProtocolTest, DecodesCapturedStatusFrames) {
FrameParser parser;
for (const auto &expected : STATUS_CASES) {
SCOPED_TRACE(expected.name);
Status status{};
ASSERT_EQ(feed(parser, bytes(expected.wire)), 1u);
ASSERT_TRUE(decode_status(parser.data(), parser.size(), status));
EXPECT_EQ(status.current_temperature, expected.current_temperature);
EXPECT_EQ(status.target_temperature, expected.target_temperature);
EXPECT_EQ(status.estimated_heating_time, expected.estimated_heating_time);
EXPECT_EQ(status.powered, expected.powered);
EXPECT_EQ(status.heating, expected.heating);
EXPECT_EQ(status.mode, expected.mode);
EXPECT_EQ(status.available_water, expected.available_water);
}
}
TEST(HonProtocolTest, ConfirmsCommandsAgainstStatus) {
Status status{};
status.powered = true;
status.target_temperature = 40;
status.mode = HonMode::HON_MODE_ECO;
EXPECT_TRUE(command_confirmed({Parameter::PARAMETER_POWER, 1}, status));
EXPECT_FALSE(command_confirmed({Parameter::PARAMETER_POWER, 0}, status));
EXPECT_TRUE(command_confirmed({Parameter::PARAMETER_TARGET_TEMPERATURE, 10}, status));
EXPECT_FALSE(command_confirmed({Parameter::PARAMETER_TARGET_TEMPERATURE, 11}, status));
EXPECT_TRUE(command_confirmed({Parameter::PARAMETER_ECO, 1}, status));
EXPECT_FALSE(command_confirmed({Parameter::PARAMETER_ECO, 0}, status));
EXPECT_FALSE(command_confirmed({Parameter::PARAMETER_BPS, 1}, status));
}
TEST(HonProtocolTest, KeepsTemperatureZonesIndependent) {
Status status{};
uint8_t independent[61] = {};
independent[0] = STATUS_FRAME_LENGTH;
independent[FRAME_TYPE_OFFSET] = static_cast<uint8_t>(FrameType::FRAME_TYPE_STATUS);
independent[8] = 0x6D;
independent[9] = 1;
independent[10] = 42;
independent[11] = 5;
independent[23] = 43;
ASSERT_TRUE(decode_status(independent, sizeof(independent), status));
EXPECT_EQ(status.current_temperature, 42);
EXPECT_EQ(status.secondary_temperature, 43);
}
TEST(HonProtocolTest, RejectsCorruptionAndResynchronizes) {
FrameParser parser;
Status status{};
auto valid = bytes("FF FF 3C 00 00 00 00 00 00 06 6D 01 35 32 00 00 00 1D 00 00 00 32 19 00 00 35 00 00 00 00 13 00 "
"2D 13 00 2D 00 0F 00 00 00 00 22 00 00 00 00 00 00 01 04 01 00 00 00 02 00 00 00 00 00 00 6D");
auto damaged = valid;
damaged.back() ^= 1;
EXPECT_EQ(feed(parser, damaged), 0u);
EXPECT_EQ(feed(parser, valid), 1u);
parser.reset();
EXPECT_EQ(feed_head(parser, valid, 20), 0u);
parser.reset(); // Partial frame timeout.
EXPECT_EQ(feed(parser, valid), 1u);
parser.reset();
EXPECT_EQ(feed_head(parser, valid, 20), 0u);
EXPECT_EQ(feed(parser, valid), 1u); // New separator resynchronizes.
EXPECT_EQ(feed(parser, bytes("FF FF 07 7F FF 55 DA")), 0u);
EXPECT_EQ(feed(parser, valid), 1u);
auto crc = bytes("FF FF 0D 40 00 00 00 00 00 01 4D 01 FF 55 BB FF 55 FF 55 D1 3C");
EXPECT_EQ(feed(parser, crc), 1u);
EXPECT_FALSE(decode_status(parser.data(), parser.size(), status));
crc.back() ^= 1;
EXPECT_EQ(feed(parser, crc), 0u);
EXPECT_EQ(feed(parser, bytes("FF FF 08 00 00 00 00 00 00 05 0D")), 1u);
EXPECT_FALSE(decode_status(parser.data(), 0, status));
}
TEST(HonProtocolTest, ValidatesFramesLongerThanTheStoredHead) {
FrameParser parser;
std::vector<uint8_t> frame(MAX_FRAME_LENGTH + 1, 0);
frame[0] = MAX_FRAME_LENGTH;
frame[FRAME_TYPE_OFFSET] = static_cast<uint8_t>(FrameType::FRAME_TYPE_VERSION);
frame[MAX_FRAME_LENGTH - 1] = 0x7E; // a byte past the stored head still counts
uint8_t checksum = 0;
for (size_t i = 0; i < MAX_FRAME_LENGTH; i++)
checksum += frame[i];
frame[MAX_FRAME_LENGTH] = checksum;
std::vector<uint8_t> wire = {0xFF, 0xFF};
wire.insert(wire.end(), frame.begin(), frame.end());
EXPECT_EQ(feed(parser, wire), 1u);
EXPECT_EQ(parser.size(), MAX_FRAME_LENGTH + 1u);
EXPECT_EQ(parser.stored_size(), MAX_STORED_FRAME);
EXPECT_EQ(parser.data()[FRAME_TYPE_OFFSET], static_cast<uint8_t>(FrameType::FRAME_TYPE_VERSION));
wire.back() ^= 1;
EXPECT_EQ(feed(parser, wire), 0u);
EXPECT_TRUE(parser.complete());
}
} // namespace esphome::haier_water_heater::testing
@@ -0,0 +1,292 @@
#include <gtest/gtest.h>
#include <array>
#include <deque>
#include <new>
#include <vector>
#include "esphome/components/haier_water_heater/hon_water_heater.h"
#include "esphome/core/application.h"
namespace esphome::haier_water_heater::testing {
class RecordingUART : public uart::UARTComponent {
public:
std::deque<uint8_t> rx;
std::vector<std::vector<uint8_t>> sent;
void write_array(const uint8_t *data, size_t size) override { this->sent.emplace_back(data, data + size); }
bool read_array(uint8_t *data, size_t size) override {
if (this->rx.size() < size)
return false;
for (size_t i = 0; i < size; i++) {
data[i] = this->rx.front();
this->rx.pop_front();
}
return true;
}
bool peek_byte(uint8_t *data) override {
if (this->rx.empty())
return false;
*data = this->rx.front();
return true;
}
size_t available() override { return this->rx.size(); }
uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; }
void check_logger_conflict() override {}
#if defined(USE_ESP8266) || defined(USE_ESP32)
void load_settings(bool dump_config) override {}
#endif
};
class TestableHeater : public HonWaterHeater {
public:
using HonWaterHeater::poll_status_;
void set_network_pending(bool value) { this->network_pending_ = value; }
bool waiting_poll() const { return this->awaiting_ == Awaiting::AWAITING_POLL; }
bool pending_off() const { return this->pending_off_; }
uint8_t command_count() const { return this->command_count_; }
void cancel_test_intervals() {
this->cancel_interval(NETWORK_STATUS_INTERVAL_ID);
this->cancel_interval(STATUS_POLL_INTERVAL_ID);
}
};
class HonControllerTest : public ::testing::Test {
protected:
void SetUp() override {
// The test runner skips generated setup(), which normally constructs App and its scheduler.
static const bool APP_CONSTRUCTED = (new (&App) Application(), true);
(void) APP_CONSTRUCTED;
this->heater_.set_uart_parent(&this->uart_);
this->heater_.set_network_pending(false);
this->set_clock_(100);
}
void TearDown() override { this->heater_.cancel_test_intervals(); }
void set_clock_(uint32_t ms) { LoopBlockingGuard clock{nullptr, nullptr, ms}; }
void status_(bool powered, bool unknown = false, bool eco = false, bool bps = false) {
std::array<uint8_t, 61> core{};
core[0] = 60;
core[7] = 2;
core[8] = 0x6D;
core[9] = 1;
core[10] = 42;
core[11] = 5;
core[23] = 43;
core[46] = (unknown || eco || bps) ? 0 : 16;
core[47] = ((unknown || eco || bps) ? 0 : 16) | (powered ? 1 : 0);
core[48] = bps ? 4 : 0;
core[49] = eco ? 4 : 0;
for (size_t i = 0; i < 60; i++) {
core[60] += core[i];
if (core[i] == 0xFF)
core[60] += 0x55;
}
this->uart_.rx.insert(this->uart_.rx.end(), {0xFF, 0xFF});
for (uint8_t byte : core) {
this->uart_.rx.push_back(byte);
if (byte == 0xFF)
this->uart_.rx.push_back(0x55);
}
this->heater_.loop();
}
void request_bps_() {
auto call = this->heater_.make_call();
call.set_mode(water_heater::WATER_HEATER_MODE_PERFORMANCE);
call.perform();
this->heater_.loop();
}
void reject_() {
uint8_t wire[water_heater_protocol::MAX_WIRE_FRAME];
const size_t count =
water_heater_protocol::encode_frame(water_heater_protocol::FrameType::FRAME_TYPE_INVALID, nullptr, 0, wire);
this->uart_.rx.insert(this->uart_.rx.end(), wire, wire + count);
this->heater_.loop();
}
void request_off_() {
auto call = this->heater_.make_call();
call.set_on(false);
call.perform();
}
void expect_off_() {
ASSERT_FALSE(this->uart_.sent.empty());
const std::vector<uint8_t> expected = {0xFF, 0xFF, 0x0C, 0, 0, 0, 0, 0, 0, 1, 0x5D, 1, 0, 0, 0x6B};
EXPECT_EQ(this->uart_.sent.back(), expected);
}
RecordingUART uart_;
TestableHeater heater_;
};
TEST_F(HonControllerTest, OffIgnoresTemperatureWithoutStatus) {
auto call = this->heater_.make_call();
call.set_mode(water_heater::WATER_HEATER_MODE_OFF);
call.set_target_temperature(40);
call.perform();
this->heater_.loop();
this->expect_off_();
}
TEST_F(HonControllerTest, OffSurvivesTimeoutAndCancelsQueuedMode) {
this->status_(false);
this->request_bps_();
ASSERT_EQ(this->heater_.command_count(), 2);
auto call = this->heater_.make_call();
call.set_on(false);
call.set_target_temperature(999);
call.perform();
EXPECT_TRUE(this->heater_.pending_off());
EXPECT_EQ(this->heater_.command_count(), 1);
this->set_clock_(5101);
this->heater_.loop();
this->expect_off_();
EXPECT_FALSE(this->heater_.pending_off());
EXPECT_EQ(this->uart_.sent.size(), 2);
}
TEST_F(HonControllerTest, ActiveModeIsNotRequestedAgain) {
this->status_(true, false, false, true);
this->request_bps_();
EXPECT_EQ(this->heater_.command_count(), 0);
EXPECT_TRUE(this->uart_.sent.empty());
auto call = this->heater_.make_call();
call.set_mode(water_heater::WATER_HEATER_MODE_ECO);
call.perform();
EXPECT_EQ(this->heater_.command_count(), 2); // through Normal, then ECO
}
TEST_F(HonControllerTest, PowerOnIsNotRequestedAgain) {
this->status_(true);
auto call = this->heater_.make_call();
call.set_on(true);
call.set_target_temperature(40);
call.perform();
EXPECT_EQ(this->heater_.command_count(), 1); // the temperature only
}
TEST_F(HonControllerTest, OffFollowsConfirmedInFlightCommand) {
this->status_(false);
this->request_bps_();
this->request_off_();
this->set_clock_(200);
this->status_(true);
this->heater_.loop();
this->expect_off_();
EXPECT_EQ(this->uart_.sent.size(), 2);
}
TEST_F(HonControllerTest, CommunicationTimeoutAndRecovery) {
binary_sensor::BinarySensor health;
this->heater_.set_communication_sensor(&health);
this->heater_.setup();
ASSERT_TRUE(health.has_state());
EXPECT_FALSE(health.state);
EXPECT_TRUE(this->heater_.status_has_warning());
this->status_(false);
EXPECT_TRUE(health.state);
EXPECT_FALSE(this->heater_.status_has_warning());
this->set_clock_(20099);
this->heater_.loop();
EXPECT_TRUE(health.state);
this->set_clock_(20100);
this->heater_.loop();
EXPECT_FALSE(health.state);
EXPECT_TRUE(this->heater_.status_has_warning());
this->heater_.loop();
EXPECT_FALSE(health.state);
this->status_(false);
EXPECT_TRUE(health.state);
EXPECT_FALSE(this->heater_.status_has_warning());
}
TEST_F(HonControllerTest, ControlFromCommunicationRecoveryIsAccepted) {
binary_sensor::BinarySensor health;
this->heater_.set_communication_sensor(&health);
health.add_on_state_callback([this](bool state) {
if (!state)
return;
EXPECT_TRUE(this->heater_.is_on());
auto call = this->heater_.make_call();
call.set_target_temperature(40);
call.perform();
});
this->status_(true);
EXPECT_EQ(this->heater_.command_count(), 1);
}
TEST_F(HonControllerTest, PollTimeoutReleasesNetworkUpdateThenPollingResumes) {
this->heater_.poll_status_();
ASSERT_TRUE(this->heater_.waiting_poll());
this->heater_.set_network_pending(true);
this->set_clock_(5099);
this->heater_.loop();
ASSERT_EQ(this->uart_.sent.size(), 1);
this->set_clock_(5100);
this->heater_.poll_status_();
this->heater_.loop();
EXPECT_FALSE(this->heater_.waiting_poll());
ASSERT_EQ(this->uart_.sent.size(), 2);
EXPECT_EQ(this->uart_.sent.back()[9], 0xF7);
this->set_clock_(7201);
this->heater_.loop();
this->set_clock_(10100);
this->heater_.poll_status_();
EXPECT_TRUE(this->heater_.waiting_poll());
EXPECT_EQ(this->uart_.sent.back()[9], 0x70);
}
TEST_F(HonControllerTest, OffFollowsPollTimeout) {
this->heater_.poll_status_();
this->request_off_();
this->set_clock_(5100);
this->heater_.loop();
this->expect_off_();
}
TEST_F(HonControllerTest, RejectionDropsOnlyTheControlInFlight) {
this->status_(false);
this->heater_.poll_status_();
ASSERT_TRUE(this->heater_.waiting_poll());
this->request_bps_();
ASSERT_EQ(this->heater_.command_count(), 2);
this->reject_();
EXPECT_TRUE(this->heater_.waiting_poll());
EXPECT_EQ(this->heater_.command_count(), 2);
this->set_clock_(5100);
this->heater_.loop(); // the poll times out and the first queued control goes out
ASSERT_EQ(this->uart_.sent.size(), 2);
EXPECT_EQ(this->uart_.sent.back()[10], 0x5D);
this->reject_();
EXPECT_EQ(this->heater_.command_count(), 0);
EXPECT_FALSE(this->heater_.waiting_poll());
}
TEST_F(HonControllerTest, UnknownModeRetainsActiveModeAcrossOff) {
this->status_(true, true);
EXPECT_TRUE(this->heater_.is_on());
EXPECT_EQ(this->heater_.get_mode(), water_heater::WATER_HEATER_MODE_ELECTRIC);
this->status_(true, false, true);
EXPECT_EQ(this->heater_.get_mode(), water_heater::WATER_HEATER_MODE_ECO);
this->status_(false, true);
EXPECT_EQ(this->heater_.get_mode(), water_heater::WATER_HEATER_MODE_OFF);
this->status_(true, true);
EXPECT_TRUE(this->heater_.is_on());
EXPECT_EQ(this->heater_.get_mode(), water_heater::WATER_HEATER_MODE_ECO);
this->status_(true);
EXPECT_EQ(this->heater_.get_mode(), water_heater::WATER_HEATER_MODE_ELECTRIC);
}
TEST_F(HonControllerTest, CustomCommunicationTimeout) {
binary_sensor::BinarySensor health;
this->heater_.set_communication_sensor(&health);
this->heater_.set_communication_timeout(7500);
this->heater_.setup();
this->status_(false);
this->set_clock_(7599);
this->heater_.loop();
EXPECT_TRUE(health.state);
this->set_clock_(7600);
this->heater_.loop();
EXPECT_FALSE(health.state);
this->status_(false);
EXPECT_TRUE(health.state);
}
} // namespace esphome::haier_water_heater::testing