From 39926909af216486cc42edd6e3b1de53a8a98e13 Mon Sep 17 00:00:00 2001 From: Douwe <61123717+dhoeben@users.noreply.github.com> Date: Sun, 21 Dec 2025 22:36:34 +0100 Subject: [PATCH] [water_heater] (1/4) Implement API/Core/component for new water_heater component (#12498) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/api/api.proto | 79 +++++ esphome/components/api/api_connection.cpp | 54 ++++ esphome/components/api/api_connection.h | 11 + esphome/components/api/api_pb2.cpp | 108 +++++++ esphome/components/api/api_pb2.h | 83 ++++++ esphome/components/api/api_pb2_dump.cpp | 90 ++++++ esphome/components/api/api_pb2_includes.h | 4 + esphome/components/api/api_pb2_service.cpp | 11 + esphome/components/api/api_pb2_service.h | 4 + esphome/components/api/api_server.cpp | 4 + esphome/components/api/api_server.h | 3 + esphome/components/api/list_entities.cpp | 3 + esphome/components/api/list_entities.h | 3 + esphome/components/api/subscribe_state.cpp | 3 + esphome/components/api/subscribe_state.h | 3 + esphome/components/water_heater/__init__.py | 111 +++++++ .../components/water_heater/water_heater.cpp | 281 ++++++++++++++++++ .../components/water_heater/water_heater.h | 259 ++++++++++++++++ .../components/web_server/list_entities.cpp | 7 + esphome/components/web_server/list_entities.h | 3 + esphome/const.py | 2 + esphome/core/application.h | 15 + esphome/core/component_iterator.cpp | 6 + esphome/core/component_iterator.h | 6 + esphome/core/controller.h | 6 + esphome/core/controller_registry.cpp | 4 + esphome/core/controller_registry.h | 10 + esphome/core/defines.h | 3 + 29 files changed, 1177 insertions(+) create mode 100644 esphome/components/water_heater/__init__.py create mode 100644 esphome/components/water_heater/water_heater.cpp create mode 100644 esphome/components/water_heater/water_heater.h diff --git a/CODEOWNERS b/CODEOWNERS index 21be3e36d1..941c2e2849 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -537,6 +537,7 @@ esphome/components/version/* @esphome/core esphome/components/voice_assistant/* @jesserockz @kahrendt esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher +esphome/components/water_heater/* @dhoeben esphome/components/waveshare_epaper/* @clydebarrow esphome/components/web_server/ota/* @esphome/core esphome/components/web_server_base/* @esphome/core diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index bf39f0b14b..c351bc8c9c 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1101,6 +1101,85 @@ message ClimateCommandRequest { uint32 device_id = 24 [(field_ifdef) = "USE_DEVICES"]; } +// ==================== WATER_HEATER ==================== +enum WaterHeaterMode { + WATER_HEATER_MODE_OFF = 0; + WATER_HEATER_MODE_ECO = 1; + WATER_HEATER_MODE_ELECTRIC = 2; + WATER_HEATER_MODE_PERFORMANCE = 3; + WATER_HEATER_MODE_HIGH_DEMAND = 4; + WATER_HEATER_MODE_HEAT_PUMP = 5; + WATER_HEATER_MODE_GAS = 6; +} + +message ListEntitiesWaterHeaterResponse { + option (id) = 132; + option (base_class) = "InfoResponseProtoMessage"; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_WATER_HEATER"; + + string object_id = 1; + fixed32 key = 2; + string name = 3; + string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON"]; + bool disabled_by_default = 5; + EntityCategory entity_category = 6; + uint32 device_id = 7 [(field_ifdef) = "USE_DEVICES"]; + float min_temperature = 8; + float max_temperature = 9; + float target_temperature_step = 10; + repeated WaterHeaterMode supported_modes = 11 [(container_pointer_no_template) = "water_heater::WaterHeaterModeMask"]; + // Bitmask of WaterHeaterFeature flags + uint32 supported_features = 12; +} + +message WaterHeaterStateResponse { + option (id) = 133; + option (base_class) = "StateResponseProtoMessage"; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_WATER_HEATER"; + option (no_delay) = true; + + fixed32 key = 1; + float current_temperature = 2; + float target_temperature = 3; + WaterHeaterMode mode = 4; + uint32 device_id = 5 [(field_ifdef) = "USE_DEVICES"]; + // Bitmask of current state flags (bit 0 = away, bit 1 = on) + uint32 state = 6; + float target_temperature_low = 7; + float target_temperature_high = 8; +} + +// Bitmask for WaterHeaterCommandRequest.has_fields +enum WaterHeaterCommandHasField { + WATER_HEATER_COMMAND_HAS_NONE = 0; + WATER_HEATER_COMMAND_HAS_MODE = 1; + WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE = 2; + WATER_HEATER_COMMAND_HAS_STATE = 4; + WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_LOW = 8; + WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_HIGH = 16; +} + +message WaterHeaterCommandRequest { + option (id) = 134; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_WATER_HEATER"; + option (no_delay) = true; + option (base_class) = "CommandProtoMessage"; + + fixed32 key = 1; + // Bitmask of which fields are set (see WaterHeaterCommandHasField) + uint32 has_fields = 2; + WaterHeaterMode mode = 3; + float target_temperature = 4; + uint32 device_id = 5 [(field_ifdef) = "USE_DEVICES"]; + // State flags bitmask (bit 0 = away, bit 1 = on) + uint32 state = 6; + float target_temperature_low = 7; + float target_temperature_high = 8; +} + // ==================== NUMBER ==================== enum NumberMode { NUMBER_MODE_AUTO = 0; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 1bcb90b0b0..28970a321c 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -42,6 +42,9 @@ #ifdef USE_ZWAVE_PROXY #include "esphome/components/zwave_proxy/zwave_proxy.h" #endif +#ifdef USE_WATER_HEATER +#include "esphome/components/water_heater/water_heater.h" +#endif namespace esphome::api { @@ -1306,6 +1309,57 @@ void APIConnection::alarm_control_panel_command(const AlarmControlPanelCommandRe } #endif +#ifdef USE_WATER_HEATER +bool APIConnection::send_water_heater_state(water_heater::WaterHeater *water_heater) { + return this->send_message_smart_(water_heater, &APIConnection::try_send_water_heater_state, + WaterHeaterStateResponse::MESSAGE_TYPE, WaterHeaterStateResponse::ESTIMATED_SIZE); +} +uint16_t APIConnection::try_send_water_heater_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, + bool is_single) { + auto *wh = static_cast(entity); + WaterHeaterStateResponse resp; + resp.mode = static_cast(wh->get_mode()); + resp.current_temperature = wh->get_current_temperature(); + resp.target_temperature = wh->get_target_temperature(); + resp.target_temperature_low = wh->get_target_temperature_low(); + resp.target_temperature_high = wh->get_target_temperature_high(); + resp.state = wh->get_state(); + resp.key = wh->get_object_id_hash(); + + return encode_message_to_buffer(resp, WaterHeaterStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); +} +uint16_t APIConnection::try_send_water_heater_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, + bool is_single) { + auto *wh = static_cast(entity); + ListEntitiesWaterHeaterResponse msg; + auto traits = wh->get_traits(); + msg.min_temperature = traits.get_min_temperature(); + msg.max_temperature = traits.get_max_temperature(); + msg.target_temperature_step = traits.get_target_temperature_step(); + msg.supported_modes = &traits.get_supported_modes(); + msg.supported_features = traits.get_feature_flags(); + return fill_and_encode_entity_info(wh, msg, ListEntitiesWaterHeaterResponse::MESSAGE_TYPE, conn, remaining_size, + is_single); +} + +void APIConnection::on_water_heater_command_request(const WaterHeaterCommandRequest &msg) { + ENTITY_COMMAND_MAKE_CALL(water_heater::WaterHeater, water_heater, water_heater) + if (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_MODE) + call.set_mode(static_cast(msg.mode)); + if (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE) + call.set_target_temperature(msg.target_temperature); + if (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_LOW) + call.set_target_temperature_low(msg.target_temperature_low); + if (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_HIGH) + call.set_target_temperature_high(msg.target_temperature_high); + if (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_STATE) { + call.set_away((msg.state & water_heater::WATER_HEATER_STATE_AWAY) != 0); + call.set_on((msg.state & water_heater::WATER_HEATER_STATE_ON) != 0); + } + call.perform(); +} +#endif + #ifdef USE_EVENT void APIConnection::send_event(event::Event *event, const char *event_type) { this->send_message_smart_(event, MessageCreator(event_type), EventResponse::MESSAGE_TYPE, diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index b50be5d0d4..7351b5082f 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -176,6 +176,11 @@ class APIConnection final : public APIServerConnection { void alarm_control_panel_command(const AlarmControlPanelCommandRequest &msg) override; #endif +#ifdef USE_WATER_HEATER + bool send_water_heater_state(water_heater::WaterHeater *water_heater); + void on_water_heater_command_request(const WaterHeaterCommandRequest &msg) override; +#endif + #ifdef USE_EVENT void send_event(event::Event *event, const char *event_type); #endif @@ -456,6 +461,12 @@ class APIConnection final : public APIServerConnection { static uint16_t try_send_alarm_control_panel_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single); #endif +#ifdef USE_WATER_HEATER + static uint16_t try_send_water_heater_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, + bool is_single); + static uint16_t try_send_water_heater_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, + bool is_single); +#endif #ifdef USE_EVENT static uint16_t try_send_event_response(event::Event *event, const char *event_type, APIConnection *conn, uint32_t remaining_size, bool is_single); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 6a2d902f8f..3376b022c5 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1447,6 +1447,114 @@ bool ClimateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { return true; } #endif +#ifdef USE_WATER_HEATER +void ListEntitiesWaterHeaterResponse::encode(ProtoWriteBuffer buffer) const { + buffer.encode_string(1, this->object_id_ref_); + buffer.encode_fixed32(2, this->key); + buffer.encode_string(3, this->name_ref_); +#ifdef USE_ENTITY_ICON + buffer.encode_string(4, this->icon_ref_); +#endif + buffer.encode_bool(5, this->disabled_by_default); + buffer.encode_uint32(6, static_cast(this->entity_category)); +#ifdef USE_DEVICES + buffer.encode_uint32(7, this->device_id); +#endif + buffer.encode_float(8, this->min_temperature); + buffer.encode_float(9, this->max_temperature); + buffer.encode_float(10, this->target_temperature_step); + for (const auto &it : *this->supported_modes) { + buffer.encode_uint32(11, static_cast(it), true); + } + buffer.encode_uint32(12, this->supported_features); +} +void ListEntitiesWaterHeaterResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); +#ifdef USE_ENTITY_ICON + size.add_length(1, this->icon_ref_.size()); +#endif + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); +#ifdef USE_DEVICES + size.add_uint32(1, this->device_id); +#endif + size.add_float(1, this->min_temperature); + size.add_float(1, this->max_temperature); + size.add_float(1, this->target_temperature_step); + if (!this->supported_modes->empty()) { + for (const auto &it : *this->supported_modes) { + size.add_uint32_force(1, static_cast(it)); + } + } + size.add_uint32(1, this->supported_features); +} +void WaterHeaterStateResponse::encode(ProtoWriteBuffer buffer) const { + buffer.encode_fixed32(1, this->key); + buffer.encode_float(2, this->current_temperature); + buffer.encode_float(3, this->target_temperature); + buffer.encode_uint32(4, static_cast(this->mode)); +#ifdef USE_DEVICES + buffer.encode_uint32(5, this->device_id); +#endif + buffer.encode_uint32(6, this->state); + buffer.encode_float(7, this->target_temperature_low); + buffer.encode_float(8, this->target_temperature_high); +} +void WaterHeaterStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_float(1, this->current_temperature); + size.add_float(1, this->target_temperature); + size.add_uint32(1, static_cast(this->mode)); +#ifdef USE_DEVICES + size.add_uint32(1, this->device_id); +#endif + size.add_uint32(1, this->state); + size.add_float(1, this->target_temperature_low); + size.add_float(1, this->target_temperature_high); +} +bool WaterHeaterCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 2: + this->has_fields = value.as_uint32(); + break; + case 3: + this->mode = static_cast(value.as_uint32()); + break; +#ifdef USE_DEVICES + case 5: + this->device_id = value.as_uint32(); + break; +#endif + case 6: + this->state = value.as_uint32(); + break; + default: + return false; + } + return true; +} +bool WaterHeaterCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { + switch (field_id) { + case 1: + this->key = value.as_fixed32(); + break; + case 4: + this->target_temperature = value.as_float(); + break; + case 7: + this->target_temperature_low = value.as_float(); + break; + case 8: + this->target_temperature_high = value.as_float(); + break; + default: + return false; + } + return true; +} +#endif #ifdef USE_NUMBER void ListEntitiesNumberResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->object_id_ref_); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 22deb19be8..2111c2a895 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -129,6 +129,25 @@ enum ClimatePreset : uint32_t { CLIMATE_PRESET_ACTIVITY = 7, }; #endif +#ifdef USE_WATER_HEATER +enum WaterHeaterMode : uint32_t { + WATER_HEATER_MODE_OFF = 0, + WATER_HEATER_MODE_ECO = 1, + WATER_HEATER_MODE_ELECTRIC = 2, + WATER_HEATER_MODE_PERFORMANCE = 3, + WATER_HEATER_MODE_HIGH_DEMAND = 4, + WATER_HEATER_MODE_HEAT_PUMP = 5, + WATER_HEATER_MODE_GAS = 6, +}; +#endif +enum WaterHeaterCommandHasField : uint32_t { + WATER_HEATER_COMMAND_HAS_NONE = 0, + WATER_HEATER_COMMAND_HAS_MODE = 1, + WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE = 2, + WATER_HEATER_COMMAND_HAS_STATE = 4, + WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_LOW = 8, + WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_HIGH = 16, +}; #ifdef USE_NUMBER enum NumberMode : uint32_t { NUMBER_MODE_AUTO = 0, @@ -1516,6 +1535,70 @@ class ClimateCommandRequest final : public CommandProtoMessage { bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; #endif +#ifdef USE_WATER_HEATER +class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 132; + static constexpr uint8_t ESTIMATED_SIZE = 63; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "list_entities_water_heater_response"; } +#endif + float min_temperature{0.0f}; + float max_temperature{0.0f}; + float target_temperature_step{0.0f}; + const water_heater::WaterHeaterModeMask *supported_modes{}; + uint32_t supported_features{0}; + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(ProtoSize &size) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: +}; +class WaterHeaterStateResponse final : public StateResponseProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 133; + static constexpr uint8_t ESTIMATED_SIZE = 35; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "water_heater_state_response"; } +#endif + float current_temperature{0.0f}; + float target_temperature{0.0f}; + enums::WaterHeaterMode mode{}; + uint32_t state{0}; + float target_temperature_low{0.0f}; + float target_temperature_high{0.0f}; + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(ProtoSize &size) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: +}; +class WaterHeaterCommandRequest final : public CommandProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 134; + static constexpr uint8_t ESTIMATED_SIZE = 34; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "water_heater_command_request"; } +#endif + uint32_t has_fields{0}; + enums::WaterHeaterMode mode{}; + float target_temperature{0.0f}; + uint32_t state{0}; + float target_temperature_low{0.0f}; + float target_temperature_high{0.0f}; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: + bool decode_32bit(uint32_t field_id, Proto32Bit value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +#endif #ifdef USE_NUMBER class ListEntitiesNumberResponse final : public InfoResponseProtoMessage { public: diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 7815eb73e4..9faf39e29e 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -348,6 +348,47 @@ template<> const char *proto_enum_to_string(enums::Climate } } #endif +#ifdef USE_WATER_HEATER +template<> const char *proto_enum_to_string(enums::WaterHeaterMode value) { + switch (value) { + case enums::WATER_HEATER_MODE_OFF: + return "WATER_HEATER_MODE_OFF"; + case enums::WATER_HEATER_MODE_ECO: + return "WATER_HEATER_MODE_ECO"; + case enums::WATER_HEATER_MODE_ELECTRIC: + return "WATER_HEATER_MODE_ELECTRIC"; + case enums::WATER_HEATER_MODE_PERFORMANCE: + return "WATER_HEATER_MODE_PERFORMANCE"; + case enums::WATER_HEATER_MODE_HIGH_DEMAND: + return "WATER_HEATER_MODE_HIGH_DEMAND"; + case enums::WATER_HEATER_MODE_HEAT_PUMP: + return "WATER_HEATER_MODE_HEAT_PUMP"; + case enums::WATER_HEATER_MODE_GAS: + return "WATER_HEATER_MODE_GAS"; + default: + return "UNKNOWN"; + } +} +#endif +template<> +const char *proto_enum_to_string(enums::WaterHeaterCommandHasField value) { + switch (value) { + case enums::WATER_HEATER_COMMAND_HAS_NONE: + return "WATER_HEATER_COMMAND_HAS_NONE"; + case enums::WATER_HEATER_COMMAND_HAS_MODE: + return "WATER_HEATER_COMMAND_HAS_MODE"; + case enums::WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE: + return "WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE"; + case enums::WATER_HEATER_COMMAND_HAS_STATE: + return "WATER_HEATER_COMMAND_HAS_STATE"; + case enums::WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_LOW: + return "WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_LOW"; + case enums::WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_HIGH: + return "WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_HIGH"; + default: + return "UNKNOWN"; + } +} #ifdef USE_NUMBER template<> const char *proto_enum_to_string(enums::NumberMode value) { switch (value) { @@ -1398,6 +1439,55 @@ void ClimateCommandRequest::dump_to(std::string &out) const { #endif } #endif +#ifdef USE_WATER_HEATER +void ListEntitiesWaterHeaterResponse::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "ListEntitiesWaterHeaterResponse"); + dump_field(out, "object_id", this->object_id_ref_); + dump_field(out, "key", this->key); + dump_field(out, "name", this->name_ref_); +#ifdef USE_ENTITY_ICON + dump_field(out, "icon", this->icon_ref_); +#endif + dump_field(out, "disabled_by_default", this->disabled_by_default); + dump_field(out, "entity_category", static_cast(this->entity_category)); +#ifdef USE_DEVICES + dump_field(out, "device_id", this->device_id); +#endif + dump_field(out, "min_temperature", this->min_temperature); + dump_field(out, "max_temperature", this->max_temperature); + dump_field(out, "target_temperature_step", this->target_temperature_step); + for (const auto &it : *this->supported_modes) { + dump_field(out, "supported_modes", static_cast(it), 4); + } + dump_field(out, "supported_features", this->supported_features); +} +void WaterHeaterStateResponse::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "WaterHeaterStateResponse"); + dump_field(out, "key", this->key); + dump_field(out, "current_temperature", this->current_temperature); + dump_field(out, "target_temperature", this->target_temperature); + dump_field(out, "mode", static_cast(this->mode)); +#ifdef USE_DEVICES + dump_field(out, "device_id", this->device_id); +#endif + dump_field(out, "state", this->state); + dump_field(out, "target_temperature_low", this->target_temperature_low); + dump_field(out, "target_temperature_high", this->target_temperature_high); +} +void WaterHeaterCommandRequest::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "WaterHeaterCommandRequest"); + dump_field(out, "key", this->key); + dump_field(out, "has_fields", this->has_fields); + dump_field(out, "mode", static_cast(this->mode)); + dump_field(out, "target_temperature", this->target_temperature); +#ifdef USE_DEVICES + dump_field(out, "device_id", this->device_id); +#endif + dump_field(out, "state", this->state); + dump_field(out, "target_temperature_low", this->target_temperature_low); + dump_field(out, "target_temperature_high", this->target_temperature_high); +} +#endif #ifdef USE_NUMBER void ListEntitiesNumberResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "ListEntitiesNumberResponse"); diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h index 55d95304b1..f45e091c6f 100644 --- a/esphome/components/api/api_pb2_includes.h +++ b/esphome/components/api/api_pb2_includes.h @@ -10,6 +10,10 @@ #include "esphome/components/climate/climate_traits.h" #endif +#ifdef USE_WATER_HEATER +#include "esphome/components/water_heater/water_heater.h" +#endif + #ifdef USE_LIGHT #include "esphome/components/light/light_traits.h" #endif diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 45f6ecd30e..984cb0bb6e 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -621,6 +621,17 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_homeassistant_action_response(msg); break; } +#endif +#ifdef USE_WATER_HEATER + case WaterHeaterCommandRequest::MESSAGE_TYPE: { + WaterHeaterCommandRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + ESP_LOGVV(TAG, "on_water_heater_command_request: %s", msg.dump().c_str()); +#endif + this->on_water_heater_command_request(msg); + break; + } #endif default: break; diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 6d94046a23..261d9fbd27 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -91,6 +91,10 @@ class APIServerConnectionBase : public ProtoService { virtual void on_climate_command_request(const ClimateCommandRequest &value){}; #endif +#ifdef USE_WATER_HEATER + virtual void on_water_heater_command_request(const WaterHeaterCommandRequest &value){}; +#endif + #ifdef USE_NUMBER virtual void on_number_command_request(const NumberCommandRequest &value){}; #endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index b1a5ee5d57..7a03d8f8ad 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -335,6 +335,10 @@ API_DISPATCH_UPDATE(valve::Valve, valve) API_DISPATCH_UPDATE(media_player::MediaPlayer, media_player) #endif +#ifdef USE_WATER_HEATER +API_DISPATCH_UPDATE(water_heater::WaterHeater, water_heater) +#endif + #ifdef USE_EVENT // Event is a special case - unlike other entities with simple state fields, // events store their state in a member accessed via obj->get_last_event_type() diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index ad7d8bf63d..96c56fd08a 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -133,6 +133,9 @@ class APIServer : public Component, #ifdef USE_MEDIA_PLAYER void on_media_player_update(media_player::MediaPlayer *obj) override; #endif +#ifdef USE_WATER_HEATER + void on_water_heater_update(water_heater::WaterHeater *obj) override; +#endif #ifdef USE_API_HOMEASSISTANT_SERVICES void send_homeassistant_action(const HomeassistantActionRequest &call); diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index b4d1454153..2470899c93 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -73,6 +73,9 @@ LIST_ENTITIES_HANDLER(media_player, media_player::MediaPlayer, ListEntitiesMedia LIST_ENTITIES_HANDLER(alarm_control_panel, alarm_control_panel::AlarmControlPanel, ListEntitiesAlarmControlPanelResponse) #endif +#ifdef USE_WATER_HEATER +LIST_ENTITIES_HANDLER(water_heater, water_heater::WaterHeater, ListEntitiesWaterHeaterResponse) +#endif #ifdef USE_EVENT LIST_ENTITIES_HANDLER(event, event::Event, ListEntitiesEventResponse) #endif diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h index 4c90dbbad8..04e6525eb0 100644 --- a/esphome/components/api/list_entities.h +++ b/esphome/components/api/list_entities.h @@ -82,6 +82,9 @@ class ListEntitiesIterator : public ComponentIterator { #ifdef USE_ALARM_CONTROL_PANEL bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *entity) override; #endif +#ifdef USE_WATER_HEATER + bool on_water_heater(water_heater::WaterHeater *entity) override; +#endif #ifdef USE_EVENT bool on_event(event::Event *entity) override; #endif diff --git a/esphome/components/api/subscribe_state.cpp b/esphome/components/api/subscribe_state.cpp index 3a563f2221..4bbc17018e 100644 --- a/esphome/components/api/subscribe_state.cpp +++ b/esphome/components/api/subscribe_state.cpp @@ -60,6 +60,9 @@ INITIAL_STATE_HANDLER(media_player, media_player::MediaPlayer) #ifdef USE_ALARM_CONTROL_PANEL INITIAL_STATE_HANDLER(alarm_control_panel, alarm_control_panel::AlarmControlPanel) #endif +#ifdef USE_WATER_HEATER +INITIAL_STATE_HANDLER(water_heater, water_heater::WaterHeater) +#endif #ifdef USE_UPDATE INITIAL_STATE_HANDLER(update, update::UpdateEntity) #endif diff --git a/esphome/components/api/subscribe_state.h b/esphome/components/api/subscribe_state.h index 2c22c322ec..9230000ace 100644 --- a/esphome/components/api/subscribe_state.h +++ b/esphome/components/api/subscribe_state.h @@ -76,6 +76,9 @@ class InitialStateIterator : public ComponentIterator { #ifdef USE_ALARM_CONTROL_PANEL bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *entity) override; #endif +#ifdef USE_WATER_HEATER + bool on_water_heater(water_heater::WaterHeater *entity) override; +#endif #ifdef USE_EVENT bool on_event(event::Event *event) override { return true; }; #endif diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py new file mode 100644 index 0000000000..5420e7c435 --- /dev/null +++ b/esphome/components/water_heater/__init__.py @@ -0,0 +1,111 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import ( + CONF_ENTITY_CATEGORY, + CONF_ICON, + CONF_ID, + CONF_MAX_TEMPERATURE, + CONF_MIN_TEMPERATURE, + CONF_VISUAL, +) +from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.cpp_generator import MockObjClass +from esphome.types import ConfigType + +CODEOWNERS = ["@dhoeben"] + +IS_PLATFORM_COMPONENT = True + +water_heater_ns = cg.esphome_ns.namespace("water_heater") +WaterHeater = water_heater_ns.class_("WaterHeater", cg.EntityBase, cg.Component) +WaterHeaterCall = water_heater_ns.class_("WaterHeaterCall") +WaterHeaterTraits = water_heater_ns.class_("WaterHeaterTraits") + +CONF_TARGET_TEMPERATURE_STEP = "target_temperature_step" + +WaterHeaterMode = water_heater_ns.enum("WaterHeaterMode") +WATER_HEATER_MODES = { + "OFF": WaterHeaterMode.WATER_HEATER_MODE_OFF, + "ECO": WaterHeaterMode.WATER_HEATER_MODE_ECO, + "ELECTRIC": WaterHeaterMode.WATER_HEATER_MODE_ELECTRIC, + "PERFORMANCE": WaterHeaterMode.WATER_HEATER_MODE_PERFORMANCE, + "HIGH_DEMAND": WaterHeaterMode.WATER_HEATER_MODE_HIGH_DEMAND, + "HEAT_PUMP": WaterHeaterMode.WATER_HEATER_MODE_HEAT_PUMP, + "GAS": WaterHeaterMode.WATER_HEATER_MODE_GAS, +} +validate_water_heater_mode = cv.enum(WATER_HEATER_MODES, upper=True) + +_WATER_HEATER_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( + { + cv.Optional(CONF_VISUAL, default={}): cv.Schema( + { + cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature, + cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature, + cv.Optional(CONF_TARGET_TEMPERATURE_STEP): cv.float_, + } + ), + } +).extend(cv.COMPONENT_SCHEMA) + +_WATER_HEATER_SCHEMA.add_extra(entity_duplicate_validator("water_heater")) + + +def water_heater_schema( + class_: MockObjClass, + *, + icon: str = cv.UNDEFINED, + entity_category: str = cv.UNDEFINED, +) -> cv.Schema: + schema = {cv.GenerateID(): cv.declare_id(class_)} + + for key, default, validator in [ + (CONF_ICON, icon, cv.icon), + (CONF_ENTITY_CATEGORY, entity_category, cv.entity_category), + ]: + if default is not cv.UNDEFINED: + schema[cv.Optional(key, default=default)] = validator + + return _WATER_HEATER_SCHEMA.extend(schema) + + +async def setup_water_heater_core_(var: cg.Pvariable, config: ConfigType) -> None: + """Set up the core water heater properties in C++.""" + await setup_entity(var, config, "water_heater") + + visual = config[CONF_VISUAL] + if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: + cg.add_define("USE_WATER_HEATER_VISUAL_OVERRIDES") + cg.add(var.set_visual_min_temperature_override(min_temp)) + if (max_temp := visual.get(CONF_MAX_TEMPERATURE)) is not None: + cg.add_define("USE_WATER_HEATER_VISUAL_OVERRIDES") + cg.add(var.set_visual_max_temperature_override(max_temp)) + if (temp_step := visual.get(CONF_TARGET_TEMPERATURE_STEP)) is not None: + cg.add_define("USE_WATER_HEATER_VISUAL_OVERRIDES") + cg.add(var.set_visual_target_temperature_step_override(temp_step)) + + +async def register_water_heater(var: cg.Pvariable, config: ConfigType) -> cg.Pvariable: + if not CORE.has_id(config[CONF_ID]): + var = cg.Pvariable(config[CONF_ID], var) + + cg.add_define("USE_WATER_HEATER") + + await cg.register_component(var, config) + + cg.add(cg.App.register_water_heater(var)) + + CORE.register_platform_component("water_heater", var) + await setup_water_heater_core_(var, config) + return var + + +async def new_water_heater(config: ConfigType, *args) -> cg.Pvariable: + var = cg.new_Pvariable(config[CONF_ID], *args) + await register_water_heater(var, config) + return var + + +@coroutine_with_priority(CoroPriority.CORE) +async def to_code(config: ConfigType) -> None: + cg.add_global(water_heater_ns.using) diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp new file mode 100644 index 0000000000..441872ec00 --- /dev/null +++ b/esphome/components/water_heater/water_heater.cpp @@ -0,0 +1,281 @@ +#include "water_heater.h" +#include "esphome/core/log.h" +#include "esphome/core/application.h" +#include "esphome/core/controller_registry.h" + +#include + +namespace esphome::water_heater { + +static const char *const TAG = "water_heater"; + +void log_water_heater(const char *tag, const char *prefix, const char *type, WaterHeater *obj) { + if (obj != nullptr) { + ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); + } +} + +WaterHeaterCall::WaterHeaterCall(WaterHeater *parent) : parent_(parent) {} + +WaterHeaterCall &WaterHeaterCall::set_mode(WaterHeaterMode mode) { + this->mode_ = mode; + return *this; +} + +WaterHeaterCall &WaterHeaterCall::set_mode(const std::string &mode) { + if (str_equals_case_insensitive(mode, "OFF")) { + this->set_mode(WATER_HEATER_MODE_OFF); + } else if (str_equals_case_insensitive(mode, "ECO")) { + this->set_mode(WATER_HEATER_MODE_ECO); + } else if (str_equals_case_insensitive(mode, "ELECTRIC")) { + this->set_mode(WATER_HEATER_MODE_ELECTRIC); + } else if (str_equals_case_insensitive(mode, "PERFORMANCE")) { + this->set_mode(WATER_HEATER_MODE_PERFORMANCE); + } else if (str_equals_case_insensitive(mode, "HIGH_DEMAND")) { + this->set_mode(WATER_HEATER_MODE_HIGH_DEMAND); + } else if (str_equals_case_insensitive(mode, "HEAT_PUMP")) { + this->set_mode(WATER_HEATER_MODE_HEAT_PUMP); + } else if (str_equals_case_insensitive(mode, "GAS")) { + this->set_mode(WATER_HEATER_MODE_GAS); + } else { + ESP_LOGW(TAG, "'%s' - Unrecognized mode %s", this->parent_->get_name().c_str(), mode.c_str()); + } + return *this; +} + +WaterHeaterCall &WaterHeaterCall::set_target_temperature(float temperature) { + this->target_temperature_ = temperature; + return *this; +} + +WaterHeaterCall &WaterHeaterCall::set_target_temperature_low(float temperature) { + this->target_temperature_low_ = temperature; + return *this; +} + +WaterHeaterCall &WaterHeaterCall::set_target_temperature_high(float temperature) { + this->target_temperature_high_ = temperature; + return *this; +} + +WaterHeaterCall &WaterHeaterCall::set_away(bool away) { + if (away) { + this->state_ |= WATER_HEATER_STATE_AWAY; + } else { + this->state_ &= ~WATER_HEATER_STATE_AWAY; + } + return *this; +} + +WaterHeaterCall &WaterHeaterCall::set_on(bool on) { + if (on) { + this->state_ |= WATER_HEATER_STATE_ON; + } else { + this->state_ &= ~WATER_HEATER_STATE_ON; + } + return *this; +} + +void WaterHeaterCall::perform() { + ESP_LOGD(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); + this->validate_(); + if (this->mode_.has_value()) { + ESP_LOGD(TAG, " Mode: %s", LOG_STR_ARG(water_heater_mode_to_string(*this->mode_))); + } + if (!std::isnan(this->target_temperature_)) { + ESP_LOGD(TAG, " Target Temperature: %.2f", this->target_temperature_); + } + if (!std::isnan(this->target_temperature_low_)) { + ESP_LOGD(TAG, " Target Temperature Low: %.2f", this->target_temperature_low_); + } + if (!std::isnan(this->target_temperature_high_)) { + ESP_LOGD(TAG, " Target Temperature High: %.2f", this->target_temperature_high_); + } + if (this->state_ & WATER_HEATER_STATE_AWAY) { + ESP_LOGD(TAG, " Away: YES"); + } + if (this->state_ & WATER_HEATER_STATE_ON) { + ESP_LOGD(TAG, " On: YES"); + } + this->parent_->control(*this); +} + +void WaterHeaterCall::validate_() { + auto traits = this->parent_->get_traits(); + if (this->mode_.has_value()) { + if (!traits.supports_mode(*this->mode_)) { + ESP_LOGW(TAG, "'%s' - Mode %d not supported", this->parent_->get_name().c_str(), *this->mode_); + this->mode_.reset(); + } + } + if (!std::isnan(this->target_temperature_)) { + if (traits.get_supports_two_point_target_temperature()) { + ESP_LOGW(TAG, "'%s' - Cannot set target temperature for device with two-point target temperature", + this->parent_->get_name().c_str()); + this->target_temperature_ = NAN; + } else if (this->target_temperature_ < traits.get_min_temperature() || + this->target_temperature_ > traits.get_max_temperature()) { + ESP_LOGW(TAG, "'%s' - Target temperature %.1f is out of range [%.1f - %.1f]", this->parent_->get_name().c_str(), + this->target_temperature_, traits.get_min_temperature(), traits.get_max_temperature()); + this->target_temperature_ = + std::max(traits.get_min_temperature(), std::min(this->target_temperature_, traits.get_max_temperature())); + } + } + if (!std::isnan(this->target_temperature_low_) || !std::isnan(this->target_temperature_high_)) { + if (!traits.get_supports_two_point_target_temperature()) { + ESP_LOGW(TAG, "'%s' - Cannot set low/high target temperature", this->parent_->get_name().c_str()); + this->target_temperature_low_ = NAN; + this->target_temperature_high_ = NAN; + } + } + if (!std::isnan(this->target_temperature_low_) && !std::isnan(this->target_temperature_high_)) { + if (this->target_temperature_low_ > this->target_temperature_high_) { + ESP_LOGW(TAG, "'%s' - Target temperature low %.2f must be less than high %.2f", this->parent_->get_name().c_str(), + this->target_temperature_low_, this->target_temperature_high_); + this->target_temperature_low_ = NAN; + this->target_temperature_high_ = NAN; + } + } + if ((this->state_ & WATER_HEATER_STATE_AWAY) && !traits.get_supports_away_mode()) { + ESP_LOGW(TAG, "'%s' - Away mode not supported", this->parent_->get_name().c_str()); + this->state_ &= ~WATER_HEATER_STATE_AWAY; + } + // If ON/OFF not supported, device is always on - clear the flag silently + if (!traits.has_feature_flags(WATER_HEATER_SUPPORTS_ON_OFF)) { + this->state_ &= ~WATER_HEATER_STATE_ON; + } +} + +void WaterHeater::setup() { + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); +} + +void WaterHeater::publish_state() { + auto traits = this->get_traits(); + ESP_LOGD(TAG, "'%s' - Sending state:", this->name_.c_str()); + ESP_LOGD(TAG, " Mode: %s", LOG_STR_ARG(water_heater_mode_to_string(this->mode_))); + if (!std::isnan(this->current_temperature_)) { + ESP_LOGD(TAG, " Current Temperature: %.2f°C", this->current_temperature_); + } + if (traits.get_supports_two_point_target_temperature()) { + ESP_LOGD(TAG, " Target Temperature: Low: %.2f°C High: %.2f°C", this->target_temperature_low_, + this->target_temperature_high_); + } else if (!std::isnan(this->target_temperature_)) { + ESP_LOGD(TAG, " Target Temperature: %.2f°C", this->target_temperature_); + } + if (this->state_ & WATER_HEATER_STATE_AWAY) { + ESP_LOGD(TAG, " Away: YES"); + } + if (traits.has_feature_flags(WATER_HEATER_SUPPORTS_ON_OFF)) { + ESP_LOGD(TAG, " On: %s", (this->state_ & WATER_HEATER_STATE_ON) ? "YES" : "NO"); + } + +#if defined(USE_WATER_HEATER) && defined(USE_CONTROLLER_REGISTRY) + ControllerRegistry::notify_water_heater_update(this); +#endif + + SavedWaterHeaterState saved{}; + saved.mode = this->mode_; + if (traits.get_supports_two_point_target_temperature()) { + saved.target_temperature_low = this->target_temperature_low_; + saved.target_temperature_high = this->target_temperature_high_; + } else { + saved.target_temperature = this->target_temperature_; + } + saved.state = this->state_; + this->pref_.save(&saved); +} + +optional WaterHeater::restore_state() { + SavedWaterHeaterState recovered{}; + if (!this->pref_.load(&recovered)) + return {}; + + auto traits = this->get_traits(); + auto call = this->make_call(); + call.set_mode(recovered.mode); + if (traits.get_supports_two_point_target_temperature()) { + call.set_target_temperature_low(recovered.target_temperature_low); + call.set_target_temperature_high(recovered.target_temperature_high); + } else { + call.set_target_temperature(recovered.target_temperature); + } + call.set_away((recovered.state & WATER_HEATER_STATE_AWAY) != 0); + call.set_on((recovered.state & WATER_HEATER_STATE_ON) != 0); + return call; +} + +WaterHeaterTraits WaterHeater::get_traits() { + auto traits = this->traits(); +#ifdef USE_WATER_HEATER_VISUAL_OVERRIDES + if (!std::isnan(this->visual_min_temperature_override_)) { + traits.set_min_temperature(this->visual_min_temperature_override_); + } + if (!std::isnan(this->visual_max_temperature_override_)) { + traits.set_max_temperature(this->visual_max_temperature_override_); + } + if (!std::isnan(this->visual_target_temperature_step_override_)) { + traits.set_target_temperature_step(this->visual_target_temperature_step_override_); + } +#endif + return traits; +} + +#ifdef USE_WATER_HEATER_VISUAL_OVERRIDES +void WaterHeater::set_visual_min_temperature_override(float min_temperature_override) { + this->visual_min_temperature_override_ = min_temperature_override; +} +void WaterHeater::set_visual_max_temperature_override(float max_temperature_override) { + this->visual_max_temperature_override_ = max_temperature_override; +} +void WaterHeater::set_visual_target_temperature_step_override(float visual_target_temperature_step_override) { + this->visual_target_temperature_step_override_ = visual_target_temperature_step_override; +} +#endif + +const LogString *water_heater_mode_to_string(WaterHeaterMode mode) { + switch (mode) { + case WATER_HEATER_MODE_OFF: + return LOG_STR("OFF"); + case WATER_HEATER_MODE_ECO: + return LOG_STR("ECO"); + case WATER_HEATER_MODE_ELECTRIC: + return LOG_STR("ELECTRIC"); + case WATER_HEATER_MODE_PERFORMANCE: + return LOG_STR("PERFORMANCE"); + case WATER_HEATER_MODE_HIGH_DEMAND: + return LOG_STR("HIGH_DEMAND"); + case WATER_HEATER_MODE_HEAT_PUMP: + return LOG_STR("HEAT_PUMP"); + case WATER_HEATER_MODE_GAS: + return LOG_STR("GAS"); + default: + return LOG_STR("UNKNOWN"); + } +} + +void WaterHeater::dump_traits_(const char *tag) { + auto traits = this->get_traits(); + ESP_LOGCONFIG(tag, + " Min Temperature: %.1f°C\n" + " Max Temperature: %.1f°C\n" + " Temperature Step: %.1f", + traits.get_min_temperature(), traits.get_max_temperature(), traits.get_target_temperature_step()); + if (traits.get_supports_two_point_target_temperature()) { + ESP_LOGCONFIG(tag, " Supports Two-Point Target Temperature: YES"); + } + if (traits.get_supports_away_mode()) { + ESP_LOGCONFIG(tag, " Supports Away Mode: YES"); + } + if (traits.has_feature_flags(WATER_HEATER_SUPPORTS_ON_OFF)) { + ESP_LOGCONFIG(tag, " Supports On/Off: YES"); + } + if (!traits.get_supported_modes().empty()) { + ESP_LOGCONFIG(tag, " Supported Modes:"); + for (WaterHeaterMode m : traits.get_supported_modes()) { + ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(water_heater_mode_to_string(m))); + } + } +} + +} // namespace esphome::water_heater diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h new file mode 100644 index 0000000000..e223dd59b2 --- /dev/null +++ b/esphome/components/water_heater/water_heater.h @@ -0,0 +1,259 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/entity_base.h" +#include "esphome/core/finite_set_mask.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/preferences.h" + +namespace esphome::water_heater { + +class WaterHeater; +struct WaterHeaterCallInternal; + +void log_water_heater(const char *tag, const char *prefix, const char *type, WaterHeater *obj); +#define LOG_WATER_HEATER(prefix, type, obj) log_water_heater(TAG, prefix, LOG_STR_LITERAL(type), obj) + +enum WaterHeaterMode : uint32_t { + WATER_HEATER_MODE_OFF = 0, + WATER_HEATER_MODE_ECO = 1, + WATER_HEATER_MODE_ELECTRIC = 2, + WATER_HEATER_MODE_PERFORMANCE = 3, + WATER_HEATER_MODE_HIGH_DEMAND = 4, + WATER_HEATER_MODE_HEAT_PUMP = 5, + WATER_HEATER_MODE_GAS = 6, +}; + +// Type alias for water heater mode bitmask +// Replaces std::set to eliminate red-black tree overhead +using WaterHeaterModeMask = + FiniteSetMask>; + +/// Feature flags for water heater capabilities (matches Home Assistant WaterHeaterEntityFeature) +enum WaterHeaterFeature : uint32_t { + /// The water heater supports reporting the current temperature. + WATER_HEATER_SUPPORTS_CURRENT_TEMPERATURE = 1 << 0, + /// The water heater supports a target temperature. + WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE = 1 << 1, + /// The water heater supports operation mode selection. + WATER_HEATER_SUPPORTS_OPERATION_MODE = 1 << 2, + /// The water heater supports an away/vacation mode. + WATER_HEATER_SUPPORTS_AWAY_MODE = 1 << 3, + /// The water heater can be turned on/off. + WATER_HEATER_SUPPORTS_ON_OFF = 1 << 4, + /// The water heater supports two-point target temperature (low/high range). + WATER_HEATER_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE = 1 << 5, +}; + +/// State flags for water heater current state (bitmask) +enum WaterHeaterStateFlag : uint32_t { + /// Away/vacation mode is currently active + WATER_HEATER_STATE_AWAY = 1 << 0, + /// Water heater is on (not in standby) + WATER_HEATER_STATE_ON = 1 << 1, +}; + +struct SavedWaterHeaterState { + WaterHeaterMode mode; + union { + float target_temperature; + struct { + float target_temperature_low; + float target_temperature_high; + }; + } __attribute__((packed)); + uint32_t state; +} __attribute__((packed)); + +class WaterHeaterCall { + friend struct WaterHeaterCallInternal; + + public: + WaterHeaterCall() : parent_(nullptr) {} + + WaterHeaterCall(WaterHeater *parent); + + WaterHeaterCall &set_mode(WaterHeaterMode mode); + WaterHeaterCall &set_mode(const std::string &mode); + WaterHeaterCall &set_target_temperature(float temperature); + WaterHeaterCall &set_target_temperature_low(float temperature); + WaterHeaterCall &set_target_temperature_high(float temperature); + WaterHeaterCall &set_away(bool away); + WaterHeaterCall &set_on(bool on); + + void perform(); + + const optional &get_mode() const { return this->mode_; } + float get_target_temperature() const { return this->target_temperature_; } + float get_target_temperature_low() const { return this->target_temperature_low_; } + float get_target_temperature_high() const { return this->target_temperature_high_; } + /// Get state flags value + uint32_t get_state() const { return this->state_; } + + protected: + void validate_(); + WaterHeater *parent_; + optional mode_; + float target_temperature_{NAN}; + float target_temperature_low_{NAN}; + float target_temperature_high_{NAN}; + uint32_t state_{0}; +}; + +struct WaterHeaterCallInternal : public WaterHeaterCall { + WaterHeaterCallInternal(WaterHeater *parent) : WaterHeaterCall(parent) {} + + WaterHeaterCallInternal &set_from_restore(const WaterHeaterCall &restore) { + this->mode_ = restore.mode_; + this->target_temperature_ = restore.target_temperature_; + this->target_temperature_low_ = restore.target_temperature_low_; + this->target_temperature_high_ = restore.target_temperature_high_; + this->state_ = restore.state_; + return *this; + } +}; + +class WaterHeaterTraits { + public: + /// Get/set feature flags (see WaterHeaterFeature enum) + void add_feature_flags(uint32_t flags) { this->feature_flags_ |= flags; } + void clear_feature_flags(uint32_t flags) { this->feature_flags_ &= ~flags; } + bool has_feature_flags(uint32_t flags) const { return (this->feature_flags_ & flags) == flags; } + uint32_t get_feature_flags() const { return this->feature_flags_; } + + bool get_supports_current_temperature() const { + return this->has_feature_flags(WATER_HEATER_SUPPORTS_CURRENT_TEMPERATURE); + } + void set_supports_current_temperature(bool supports) { + if (supports) { + this->add_feature_flags(WATER_HEATER_SUPPORTS_CURRENT_TEMPERATURE); + } else { + this->clear_feature_flags(WATER_HEATER_SUPPORTS_CURRENT_TEMPERATURE); + } + } + + bool get_supports_away_mode() const { return this->has_feature_flags(WATER_HEATER_SUPPORTS_AWAY_MODE); } + void set_supports_away_mode(bool supports) { + if (supports) { + this->add_feature_flags(WATER_HEATER_SUPPORTS_AWAY_MODE); + } else { + this->clear_feature_flags(WATER_HEATER_SUPPORTS_AWAY_MODE); + } + } + + bool get_supports_two_point_target_temperature() const { + return this->has_feature_flags(WATER_HEATER_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE); + } + void set_supports_two_point_target_temperature(bool supports) { + if (supports) { + this->add_feature_flags(WATER_HEATER_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE); + } else { + this->clear_feature_flags(WATER_HEATER_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE); + } + } + + void set_min_temperature(float min_temperature) { this->min_temperature_ = min_temperature; } + float get_min_temperature() const { return this->min_temperature_; } + + void set_max_temperature(float max_temperature) { this->max_temperature_ = max_temperature; } + float get_max_temperature() const { return this->max_temperature_; } + + void set_target_temperature_step(float target_temperature_step) { + this->target_temperature_step_ = target_temperature_step; + } + float get_target_temperature_step() const { return this->target_temperature_step_; } + + void set_supported_modes(WaterHeaterModeMask modes) { this->supported_modes_ = modes; } + const WaterHeaterModeMask &get_supported_modes() const { return this->supported_modes_; } + bool supports_mode(WaterHeaterMode mode) const { return this->supported_modes_.count(mode); } + + protected: + // Ordered to minimize padding: 4-byte members first + uint32_t feature_flags_{0}; + float min_temperature_{0.0f}; + float max_temperature_{0.0f}; + float target_temperature_step_{0.0f}; + WaterHeaterModeMask supported_modes_; +}; + +class WaterHeater : public EntityBase, public Component { + public: + WaterHeaterMode get_mode() const { return this->mode_; } + float get_current_temperature() const { return this->current_temperature_; } + float get_target_temperature() const { return this->target_temperature_; } + float get_target_temperature_low() const { return this->target_temperature_low_; } + float get_target_temperature_high() const { return this->target_temperature_high_; } + /// Get the current state flags bitmask + uint32_t get_state() const { return this->state_; } + /// Check if away mode is currently active + bool is_away() const { return (this->state_ & WATER_HEATER_STATE_AWAY) != 0; } + /// Check if the water heater is on + bool is_on() const { return (this->state_ & WATER_HEATER_STATE_ON) != 0; } + + void set_current_temperature(float current_temperature) { this->current_temperature_ = current_temperature; } + + virtual void publish_state(); + virtual WaterHeaterTraits get_traits(); + virtual WaterHeaterCallInternal make_call() = 0; + +#ifdef USE_WATER_HEATER_VISUAL_OVERRIDES + void set_visual_min_temperature_override(float min_temperature_override); + void set_visual_max_temperature_override(float max_temperature_override); + void set_visual_target_temperature_step_override(float visual_target_temperature_step_override); +#endif + virtual void control(const WaterHeaterCall &call) = 0; + + void setup() override; + + optional restore_state(); + + protected: + virtual WaterHeaterTraits traits() = 0; + + /// Log the traits of this water heater for dump_config(). + void dump_traits_(const char *tag); + + /// Set the mode of the water heater. Should only be called from control(). + void set_mode_(WaterHeaterMode mode) { this->mode_ = mode; } + /// Set the target temperature of the water heater. Should only be called from control(). + void set_target_temperature_(float target_temperature) { this->target_temperature_ = target_temperature; } + /// Set the low target temperature (for two-point control). Should only be called from control(). + void set_target_temperature_low_(float target_temperature_low) { + this->target_temperature_low_ = target_temperature_low; + } + /// Set the high target temperature (for two-point control). Should only be called from control(). + void set_target_temperature_high_(float target_temperature_high) { + this->target_temperature_high_ = target_temperature_high; + } + /// Set the state flags. Should only be called from control(). + void set_state_(uint32_t state) { this->state_ = state; } + /// Set or clear a state flag. Should only be called from control(). + void set_state_flag_(uint32_t flag, bool value) { + if (value) { + this->state_ |= flag; + } else { + this->state_ &= ~flag; + } + } + + WaterHeaterMode mode_{WATER_HEATER_MODE_OFF}; + float current_temperature_{NAN}; + float target_temperature_{NAN}; + float target_temperature_low_{NAN}; + float target_temperature_high_{NAN}; + uint32_t state_{0}; // Bitmask of WaterHeaterStateFlag + +#ifdef USE_WATER_HEATER_VISUAL_OVERRIDES + float visual_min_temperature_override_{NAN}; + float visual_max_temperature_override_{NAN}; + float visual_target_temperature_step_override_{NAN}; +#endif + + ESPPreferenceObject pref_; +}; + +/// Convert the given WaterHeaterMode to a human-readable string for logging. +const LogString *water_heater_mode_to_string(WaterHeaterMode mode); + +} // namespace esphome::water_heater diff --git a/esphome/components/web_server/list_entities.cpp b/esphome/components/web_server/list_entities.cpp index 6b27545549..16b1d1e797 100644 --- a/esphome/components/web_server/list_entities.cpp +++ b/esphome/components/web_server/list_entities.cpp @@ -135,6 +135,13 @@ bool ListEntitiesIterator::on_alarm_control_panel(alarm_control_panel::AlarmCont } #endif +#ifdef USE_WATER_HEATER +bool ListEntitiesIterator::on_water_heater(water_heater::WaterHeater *obj) { + // Water heater web_server support not yet implemented - this stub acknowledges the entity + return true; +} +#endif + #ifdef USE_EVENT bool ListEntitiesIterator::on_event(event::Event *obj) { // Null event type, since we are just iterating over entities diff --git a/esphome/components/web_server/list_entities.h b/esphome/components/web_server/list_entities.h index 43e1cc2544..5d9049b082 100644 --- a/esphome/components/web_server/list_entities.h +++ b/esphome/components/web_server/list_entities.h @@ -79,6 +79,9 @@ class ListEntitiesIterator : public ComponentIterator { #ifdef USE_ALARM_CONTROL_PANEL bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *obj) override; #endif +#ifdef USE_WATER_HEATER + bool on_water_heater(water_heater::WaterHeater *obj) override; +#endif #ifdef USE_EVENT bool on_event(event::Event *obj) override; #endif diff --git a/esphome/const.py b/esphome/const.py index f43204fd9f..1d46e81f9d 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1086,6 +1086,7 @@ CONF_WARM_WHITE = "warm_white" CONF_WARM_WHITE_COLOR_TEMPERATURE = "warm_white_color_temperature" CONF_WATCHDOG_THRESHOLD = "watchdog_threshold" CONF_WATCHDOG_TIMEOUT = "watchdog_timeout" +CONF_WATER_HEATER = "water_heater" CONF_WEB_SERVER = "web_server" CONF_WEB_SERVER_ID = "web_server_id" CONF_WEIGHT = "weight" @@ -1179,6 +1180,7 @@ ICON_TIMELAPSE = "mdi:timelapse" ICON_TIMER = "mdi:timer-outline" ICON_VIBRATE = "mdi:vibrate" ICON_WATER = "mdi:water" +ICON_WATER_HEATER = "mdi:water-boiler" ICON_WATER_PERCENT = "mdi:water-percent" ICON_WEATHER_SUNSET = "mdi:weather-sunset" ICON_WEATHER_SUNSET_DOWN = "mdi:weather-sunset-down" diff --git a/esphome/core/application.h b/esphome/core/application.h index f462553a81..d2146a6c16 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -87,6 +87,9 @@ #ifdef USE_ALARM_CONTROL_PANEL #include "esphome/components/alarm_control_panel/alarm_control_panel.h" #endif +#ifdef USE_WATER_HEATER +#include "esphome/components/water_heater/water_heater.h" +#endif #ifdef USE_EVENT #include "esphome/components/event/event.h" #endif @@ -217,6 +220,10 @@ class Application { } #endif +#ifdef USE_WATER_HEATER + void register_water_heater(water_heater::WaterHeater *water_heater) { this->water_heaters_.push_back(water_heater); } +#endif + #ifdef USE_EVENT void register_event(event::Event *event) { this->events_.push_back(event); } #endif @@ -437,6 +444,11 @@ class Application { GET_ENTITY_METHOD(alarm_control_panel::AlarmControlPanel, alarm_control_panel, alarm_control_panels) #endif +#ifdef USE_WATER_HEATER + auto &get_water_heaters() const { return this->water_heaters_; } + GET_ENTITY_METHOD(water_heater::WaterHeater, water_heater, water_heaters) +#endif + #ifdef USE_EVENT auto &get_events() const { return this->events_; } GET_ENTITY_METHOD(event::Event, event, events) @@ -634,6 +646,9 @@ class Application { StaticVector alarm_control_panels_{}; #endif +#ifdef USE_WATER_HEATER + StaticVector water_heaters_{}; +#endif #ifdef USE_UPDATE StaticVector updates_{}; #endif diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index 8c6a7b95b5..4015d8ec60 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -163,6 +163,12 @@ void ComponentIterator::advance() { break; #endif +#ifdef USE_WATER_HEATER + case IteratorState::WATER_HEATER: + this->process_platform_item_(App.get_water_heaters(), &ComponentIterator::on_water_heater); + break; +#endif + #ifdef USE_EVENT case IteratorState::EVENT: this->process_platform_item_(App.get_events(), &ComponentIterator::on_event); diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 1b1bd80ac5..37d1960601 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -84,6 +84,9 @@ class ComponentIterator { #ifdef USE_ALARM_CONTROL_PANEL virtual bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) = 0; #endif +#ifdef USE_WATER_HEATER + virtual bool on_water_heater(water_heater::WaterHeater *water_heater) = 0; +#endif #ifdef USE_EVENT virtual bool on_event(event::Event *event) = 0; #endif @@ -161,6 +164,9 @@ class ComponentIterator { #ifdef USE_ALARM_CONTROL_PANEL ALARM_CONTROL_PANEL, #endif +#ifdef USE_WATER_HEATER + WATER_HEATER, +#endif #ifdef USE_EVENT EVENT, #endif diff --git a/esphome/core/controller.h b/esphome/core/controller.h index 697017217d..632b46c893 100644 --- a/esphome/core/controller.h +++ b/esphome/core/controller.h @@ -58,6 +58,9 @@ #ifdef USE_ALARM_CONTROL_PANEL #include "esphome/components/alarm_control_panel/alarm_control_panel.h" #endif +#ifdef USE_WATER_HEATER +#include "esphome/components/water_heater/water_heater.h" +#endif #ifdef USE_EVENT #include "esphome/components/event/event.h" #endif @@ -123,6 +126,9 @@ class Controller { #ifdef USE_ALARM_CONTROL_PANEL virtual void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj){}; #endif +#ifdef USE_WATER_HEATER + virtual void on_water_heater_update(water_heater::WaterHeater *obj){}; +#endif #ifdef USE_EVENT virtual void on_event(event::Event *obj){}; #endif diff --git a/esphome/core/controller_registry.cpp b/esphome/core/controller_registry.cpp index 0a84bb0d0d..13b505e8e9 100644 --- a/esphome/core/controller_registry.cpp +++ b/esphome/core/controller_registry.cpp @@ -98,6 +98,10 @@ CONTROLLER_REGISTRY_NOTIFY(media_player::MediaPlayer, media_player) CONTROLLER_REGISTRY_NOTIFY(alarm_control_panel::AlarmControlPanel, alarm_control_panel) #endif +#ifdef USE_WATER_HEATER +CONTROLLER_REGISTRY_NOTIFY(water_heater::WaterHeater, water_heater) +#endif + #ifdef USE_EVENT CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(event::Event, event) #endif diff --git a/esphome/core/controller_registry.h b/esphome/core/controller_registry.h index 640a276a0a..d6452d8827 100644 --- a/esphome/core/controller_registry.h +++ b/esphome/core/controller_registry.h @@ -119,6 +119,12 @@ class AlarmControlPanel; } #endif +#ifdef USE_WATER_HEATER +namespace water_heater { +class WaterHeater; +} +#endif + #ifdef USE_EVENT namespace event { class Event; @@ -228,6 +234,10 @@ class ControllerRegistry { static void notify_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj); #endif +#ifdef USE_WATER_HEATER + static void notify_water_heater_update(water_heater::WaterHeater *obj); +#endif + #ifdef USE_EVENT static void notify_event(event::Event *obj); #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0c12b29eb7..11c5062140 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -113,6 +113,8 @@ #define USE_UART_WAKE_LOOP_ON_RX #define USE_UPDATE #define USE_VALVE +#define USE_WATER_HEATER +#define USE_WATER_HEATER_VISUAL_OVERRIDES #define USE_ZWAVE_PROXY // Feature flags which do not work for zephyr @@ -337,3 +339,4 @@ #define ESPHOME_ENTITY_TIME_COUNT 1 #define ESPHOME_ENTITY_UPDATE_COUNT 1 #define ESPHOME_ENTITY_VALVE_COUNT 1 +#define ESPHOME_ENTITY_WATER_HEATER_COUNT 1