diff --git a/CODEOWNERS b/CODEOWNERS index f2f24f1106f..38d2da4d870 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/esphome/components/haier_water_heater/__init__.py b/esphome/components/haier_water_heater/__init__.py new file mode 100644 index 00000000000..7ecd409dd80 --- /dev/null +++ b/esphome/components/haier_water_heater/__init__.py @@ -0,0 +1,4 @@ +"""Haier water heater component.""" + +DOMAIN = "haier_water_heater" +CODEOWNERS = ["@TheLexus"] diff --git a/esphome/components/haier_water_heater/hon_parser.h b/esphome/components/haier_water_heater/hon_parser.h new file mode 100644 index 00000000000..1f7e22abc76 --- /dev/null +++ b/esphome/components/haier_water_heater/hon_parser.h @@ -0,0 +1,262 @@ +#pragma once + +#include +#include +#include + +#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(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(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 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(FRAME_PAYLOAD_OFFSET + size)}; + core[FRAME_TYPE_OFFSET] = static_cast(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(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(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 diff --git a/esphome/components/haier_water_heater/hon_water_heater.cpp b/esphome/components/haier_water_heater/hon_water_heater.cpp new file mode 100644 index 00000000000..3f190de5b83 --- /dev/null +++ b/esphome/components/haier_water_heater/hon_water_heater.cpp @@ -0,0 +1,424 @@ +#include "hon_water_heater.h" + +#include + +#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(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(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(this->expected_type_), + static_cast(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(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(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(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(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(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(HonModeLabels::get_progmem_str( + static_cast(status.mode), static_cast(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(POLL_INTERVAL_MS), static_cast(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(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 diff --git a/esphome/components/haier_water_heater/hon_water_heater.h b/esphome/components/haier_water_heater/hon_water_heater.h new file mode 100644 index 00000000000..5f8ede184e7 --- /dev/null +++ b/esphome/components/haier_water_heater/hon_water_heater.h @@ -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 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 diff --git a/esphome/components/haier_water_heater/water_heater.py b/esphome/components/haier_water_heater/water_heater.py new file mode 100644 index 00000000000..33a54a815b0 --- /dev/null +++ b/esphome/components/haier_water_heater/water_heater.py @@ -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) diff --git a/tests/component_tests/haier_water_heater/__init__.py b/tests/component_tests/haier_water_heater/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/haier_water_heater/test_water_heater.py b/tests/component_tests/haier_water_heater/test_water_heater.py new file mode 100644 index 00000000000..518b5d26867 --- /dev/null +++ b/tests/component_tests/haier_water_heater/test_water_heater.py @@ -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"}) diff --git a/tests/components/haier_water_heater/__init__.py b/tests/components/haier_water_heater/__init__.py new file mode 100644 index 00000000000..7a32bd70c15 --- /dev/null +++ b/tests/components/haier_water_heater/__init__.py @@ -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", + ] diff --git a/tests/components/haier_water_heater/common.yaml b/tests/components/haier_water_heater/common.yaml new file mode 100644 index 00000000000..657fdf25334 --- /dev/null +++ b/tests/components/haier_water_heater/common.yaml @@ -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 diff --git a/tests/components/haier_water_heater/test.esp32-idf.yaml b/tests/components/haier_water_heater/test.esp32-idf.yaml new file mode 100644 index 00000000000..dd692146863 --- /dev/null +++ b/tests/components/haier_water_heater/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + haier_water_heater: !include common.yaml diff --git a/tests/components/haier_water_heater/test.esp8266-ard.yaml b/tests/components/haier_water_heater/test.esp8266-ard.yaml new file mode 100644 index 00000000000..1c3b570d552 --- /dev/null +++ b/tests/components/haier_water_heater/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + haier_water_heater: !include common.yaml diff --git a/tests/components/haier_water_heater/test.rp2040-ard.yaml b/tests/components/haier_water_heater/test.rp2040-ard.yaml new file mode 100644 index 00000000000..b7119efb4ab --- /dev/null +++ b/tests/components/haier_water_heater/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + haier_water_heater: !include common.yaml diff --git a/tests/components/haier_water_heater/test_hon_protocol.cpp b/tests/components/haier_water_heater/test_hon_protocol.cpp new file mode 100644 index 00000000000..f9feadf8a79 --- /dev/null +++ b/tests/components/haier_water_heater/test_hon_protocol.cpp @@ -0,0 +1,219 @@ +#include "esphome/components/haier_water_heater/hon_parser.h" +#include +#include +#include +#include +using namespace esphome::haier_water_heater::water_heater_protocol; +namespace esphome::haier_water_heater::testing { +std::vector bytes(const char *hex) { + std::istringstream stream(hex); + unsigned byte; + std::vector result; + while (stream >> std::hex >> byte) + result.push_back(byte); + return result; +} +unsigned feed(FrameParser &parser, const std::vector &wire) { + unsigned count = 0; + for (auto byte : wire) + count += parser.feed(byte); + return count; +} +unsigned feed_head(FrameParser &parser, const std::vector &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(wire, wire + count), bytes(expected)); + ASSERT_EQ(feed(parser, std::vector(wire, wire + count)), 1u); + EXPECT_EQ(parser.data()[FRAME_FLAGS_OFFSET], 0); + EXPECT_EQ(parser.data()[FRAME_TYPE_OFFSET], static_cast(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(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(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 frame(MAX_FRAME_LENGTH + 1, 0); + frame[0] = MAX_FRAME_LENGTH; + frame[FRAME_TYPE_OFFSET] = static_cast(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 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(FrameType::FRAME_TYPE_VERSION)); + wire.back() ^= 1; + EXPECT_EQ(feed(parser, wire), 0u); + EXPECT_TRUE(parser.complete()); +} +} // namespace esphome::haier_water_heater::testing diff --git a/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp b/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp new file mode 100644 index 00000000000..41b40687d48 --- /dev/null +++ b/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp @@ -0,0 +1,292 @@ +#include +#include +#include +#include +#include + +#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 rx; + std::vector> 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 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 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