mirror of
https://github.com/esphome/esphome.git
synced 2026-10-17 11:42:51 +00:00
[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:
co-authored by
pre-commit-ci-lite[bot]
J. Nick Koston
J. Nick Koston
parent
cd6a34401b
commit
ab0c32c2aa
@@ -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
|
||||
Reference in New Issue
Block a user