Merge branch 'dev' into wh_template

This commit is contained in:
J. Nick Koston
2025-12-21 11:36:44 -10:00
committed by GitHub
40 changed files with 1710 additions and 94 deletions

4
.gitignore vendored
View File

@@ -91,6 +91,10 @@ venv-*/
# mypy
.mypy_cache/
# nix
/default.nix
/shell.nix
.pioenvs
.piolibdeps
.pio

View File

@@ -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

View File

@@ -824,9 +824,9 @@ message HomeAssistantStateResponse {
option (no_delay) = true;
option (ifdef) = "USE_API_HOMEASSISTANT_STATES";
string entity_id = 1;
string state = 2;
string attribute = 3;
string entity_id = 1 [(pointer_to_buffer) = true];
string state = 2 [(pointer_to_buffer) = true];
string attribute = 3 [(pointer_to_buffer) = true];
}
// ==================== IMPORT TIME ====================
@@ -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;

View File

@@ -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<water_heater::WaterHeater *>(entity);
WaterHeaterStateResponse resp;
resp.mode = static_cast<enums::WaterHeaterMode>(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<water_heater::WaterHeater *>(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<water_heater::WaterHeaterMode>(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,
@@ -1582,15 +1636,29 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) {
#ifdef USE_API_HOMEASSISTANT_STATES
void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) {
for (auto &it : this->parent_->get_state_subs()) {
// Compare entity_id and attribute with message fields
bool entity_match = (strcmp(it.entity_id, msg.entity_id.c_str()) == 0);
bool attribute_match = (it.attribute != nullptr && strcmp(it.attribute, msg.attribute.c_str()) == 0) ||
(it.attribute == nullptr && msg.attribute.empty());
// Skip if entity_id is empty (invalid message)
if (msg.entity_id_len == 0) {
return;
}
if (entity_match && attribute_match) {
it.callback(msg.state);
for (auto &it : this->parent_->get_state_subs()) {
// Compare entity_id: check length matches and content matches
size_t entity_id_len = strlen(it.entity_id);
if (entity_id_len != msg.entity_id_len || memcmp(it.entity_id, msg.entity_id, msg.entity_id_len) != 0) {
continue;
}
// Compare attribute: either both have matching attribute, or both have none
size_t sub_attr_len = it.attribute != nullptr ? strlen(it.attribute) : 0;
if (sub_attr_len != msg.attribute_len ||
(sub_attr_len > 0 && memcmp(it.attribute, msg.attribute, sub_attr_len) != 0)) {
continue;
}
// Create temporary string for callback (callback takes const std::string &)
// Handle empty state (nullptr with len=0)
std::string state(msg.state_len > 0 ? reinterpret_cast<const char *>(msg.state) : "", msg.state_len);
it.callback(state);
}
}
#endif

View File

@@ -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);

View File

@@ -966,15 +966,24 @@ void SubscribeHomeAssistantStateResponse::calculate_size(ProtoSize &size) const
}
bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) {
switch (field_id) {
case 1:
this->entity_id = value.as_string();
case 1: {
// Use raw data directly to avoid allocation
this->entity_id = value.data();
this->entity_id_len = value.size();
break;
case 2:
this->state = value.as_string();
}
case 2: {
// Use raw data directly to avoid allocation
this->state = value.data();
this->state_len = value.size();
break;
case 3:
this->attribute = value.as_string();
}
case 3: {
// Use raw data directly to avoid allocation
this->attribute = value.data();
this->attribute_len = value.size();
break;
}
default:
return false;
}
@@ -1438,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<uint32_t>(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<uint32_t>(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<uint32_t>(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<uint32_t>(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<uint32_t>(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<uint32_t>(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<enums::WaterHeaterMode>(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_);

View File

@@ -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,
@@ -1203,13 +1222,16 @@ class SubscribeHomeAssistantStateResponse final : public ProtoMessage {
class HomeAssistantStateResponse final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 40;
static constexpr uint8_t ESTIMATED_SIZE = 27;
static constexpr uint8_t ESTIMATED_SIZE = 57;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *message_name() const override { return "home_assistant_state_response"; }
#endif
std::string entity_id{};
std::string state{};
std::string attribute{};
const uint8_t *entity_id{nullptr};
uint16_t entity_id_len{0};
const uint8_t *state{nullptr};
uint16_t state_len{0};
const uint8_t *attribute{nullptr};
uint16_t attribute_len{0};
#ifdef HAS_PROTO_MESSAGE_DUMP
void dump_to(std::string &out) const override;
#endif
@@ -1513,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:

View File

@@ -348,6 +348,47 @@ template<> const char *proto_enum_to_string<enums::ClimatePreset>(enums::Climate
}
}
#endif
#ifdef USE_WATER_HEATER
template<> const char *proto_enum_to_string<enums::WaterHeaterMode>(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>(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>(enums::NumberMode value) {
switch (value) {
@@ -1184,9 +1225,15 @@ void SubscribeHomeAssistantStateResponse::dump_to(std::string &out) const {
}
void HomeAssistantStateResponse::dump_to(std::string &out) const {
MessageDumpHelper helper(out, "HomeAssistantStateResponse");
dump_field(out, "entity_id", this->entity_id);
dump_field(out, "state", this->state);
dump_field(out, "attribute", this->attribute);
out.append(" entity_id: ");
out.append(format_hex_pretty(this->entity_id, this->entity_id_len));
out.append("\n");
out.append(" state: ");
out.append(format_hex_pretty(this->state, this->state_len));
out.append("\n");
out.append(" attribute: ");
out.append(format_hex_pretty(this->attribute, this->attribute_len));
out.append("\n");
}
#endif
void GetTimeRequest::dump_to(std::string &out) const { out.append("GetTimeRequest {}"); }
@@ -1392,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<enums::EntityCategory>(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<enums::WaterHeaterMode>(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<enums::WaterHeaterMode>(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<enums::WaterHeaterMode>(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");

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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()

View File

@@ -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);

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -160,41 +160,63 @@ HYST_LEVEL = {
"High": HystLevel.HYST_LEVEL_HIGH,
}
# Config key -> Validator mapping
# Optional settings to generate setter calls for
CONFIG_MAP = {
CONF_OUTPUT_POWER: cv.float_range(min=-30.0, max=11.0),
CONF_RX_ATTENUATION: cv.enum(RX_ATTENUATION, upper=False),
CONF_DC_BLOCKING_FILTER: cv.boolean,
CONF_FREQUENCY: cv.All(cv.frequency, cv.float_range(min=300.0e6, max=928.0e6)),
CONF_IF_FREQUENCY: cv.All(cv.frequency, cv.float_range(min=25000, max=788000)),
CONF_FILTER_BANDWIDTH: cv.All(cv.frequency, cv.float_range(min=58000, max=812000)),
CONF_CHANNEL: cv.uint8_t,
CONF_CHANNEL_SPACING: cv.All(cv.frequency, cv.float_range(min=25000, max=405000)),
CONF_FSK_DEVIATION: cv.All(cv.frequency, cv.float_range(min=1500, max=381000)),
CONF_MSK_DEVIATION: cv.int_range(min=1, max=8),
CONF_SYMBOL_RATE: cv.float_range(min=600, max=500000),
CONF_SYNC_MODE: cv.enum(SYNC_MODE, upper=False),
CONF_CARRIER_SENSE_ABOVE_THRESHOLD: cv.boolean,
CONF_MODULATION_TYPE: cv.enum(MODULATION, upper=False),
CONF_MANCHESTER: cv.boolean,
CONF_NUM_PREAMBLE: cv.int_range(min=0, max=7),
CONF_SYNC1: cv.hex_uint8_t,
CONF_SYNC0: cv.hex_uint8_t,
CONF_MAGN_TARGET: cv.enum(MAGN_TARGET, upper=False),
CONF_MAX_LNA_GAIN: cv.enum(MAX_LNA_GAIN, upper=False),
CONF_MAX_DVGA_GAIN: cv.enum(MAX_DVGA_GAIN, upper=False),
CONF_CARRIER_SENSE_ABS_THR: cv.int_range(min=-8, max=7),
CONF_CARRIER_SENSE_REL_THR: cv.enum(CARRIER_SENSE_REL_THR, upper=False),
CONF_LNA_PRIORITY: cv.boolean,
CONF_FILTER_LENGTH_FSK_MSK: cv.enum(FILTER_LENGTH_FSK_MSK, upper=False),
CONF_FILTER_LENGTH_ASK_OOK: cv.enum(FILTER_LENGTH_ASK_OOK, upper=False),
CONF_FREEZE: cv.enum(FREEZE, upper=False),
CONF_WAIT_TIME: cv.enum(WAIT_TIME, upper=False),
CONF_HYST_LEVEL: cv.enum(HYST_LEVEL, upper=False),
CONF_PACKET_MODE: cv.boolean,
CONF_PACKET_LENGTH: cv.uint8_t,
CONF_CRC_ENABLE: cv.boolean,
CONF_WHITENING: cv.boolean,
cv.Optional(CONF_OUTPUT_POWER, default=10): cv.float_range(min=-30.0, max=11.0),
cv.Optional(CONF_RX_ATTENUATION, default="0dB"): cv.enum(
RX_ATTENUATION, upper=False
),
cv.Optional(CONF_DC_BLOCKING_FILTER, default=True): cv.boolean,
cv.Optional(CONF_FREQUENCY, default="433.92MHz"): cv.All(
cv.frequency, cv.float_range(min=300.0e6, max=928.0e6)
),
cv.Optional(CONF_IF_FREQUENCY, default="153kHz"): cv.All(
cv.frequency, cv.float_range(min=25000, max=788000)
),
cv.Optional(CONF_FILTER_BANDWIDTH, default="203kHz"): cv.All(
cv.frequency, cv.float_range(min=58000, max=812000)
),
cv.Optional(CONF_CHANNEL, default=0): cv.uint8_t,
cv.Optional(CONF_CHANNEL_SPACING, default="200kHz"): cv.All(
cv.frequency, cv.float_range(min=25000, max=405000)
),
cv.Optional(CONF_FSK_DEVIATION): cv.All(
cv.frequency, cv.float_range(min=1500, max=381000)
),
cv.Optional(CONF_MSK_DEVIATION): cv.int_range(min=1, max=8),
cv.Optional(CONF_SYMBOL_RATE, default=5000): cv.float_range(min=600, max=500000),
cv.Optional(CONF_SYNC_MODE, default="16/16"): cv.enum(SYNC_MODE, upper=False),
cv.Optional(CONF_CARRIER_SENSE_ABOVE_THRESHOLD, default=False): cv.boolean,
cv.Optional(CONF_MODULATION_TYPE, default="ASK/OOK"): cv.enum(
MODULATION, upper=False
),
cv.Optional(CONF_MANCHESTER, default=False): cv.boolean,
cv.Optional(CONF_NUM_PREAMBLE, default=2): cv.int_range(min=0, max=7),
cv.Optional(CONF_SYNC1, default=0xD3): cv.hex_uint8_t,
cv.Optional(CONF_SYNC0, default=0x91): cv.hex_uint8_t,
cv.Optional(CONF_MAGN_TARGET, default="42dB"): cv.enum(MAGN_TARGET, upper=False),
cv.Optional(CONF_MAX_LNA_GAIN, default="Default"): cv.enum(
MAX_LNA_GAIN, upper=False
),
cv.Optional(CONF_MAX_DVGA_GAIN, default="-3"): cv.enum(MAX_DVGA_GAIN, upper=False),
cv.Optional(CONF_CARRIER_SENSE_ABS_THR): cv.int_range(min=-8, max=7),
cv.Optional(CONF_CARRIER_SENSE_REL_THR): cv.enum(
CARRIER_SENSE_REL_THR, upper=False
),
cv.Optional(CONF_LNA_PRIORITY, default=False): cv.boolean,
cv.Optional(CONF_FILTER_LENGTH_FSK_MSK): cv.enum(
FILTER_LENGTH_FSK_MSK, upper=False
),
cv.Optional(CONF_FILTER_LENGTH_ASK_OOK): cv.enum(
FILTER_LENGTH_ASK_OOK, upper=False
),
cv.Optional(CONF_FREEZE): cv.enum(FREEZE, upper=False),
cv.Optional(CONF_WAIT_TIME, default="32"): cv.enum(WAIT_TIME, upper=False),
cv.Optional(CONF_HYST_LEVEL): cv.enum(HYST_LEVEL, upper=False),
cv.Optional(CONF_PACKET_MODE, default=False): cv.boolean,
cv.Optional(CONF_PACKET_LENGTH): cv.uint8_t,
cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean,
cv.Optional(CONF_WHITENING, default=False): cv.boolean,
}
@@ -217,7 +239,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True),
}
)
.extend({cv.Optional(key): validator for key, validator in CONFIG_MAP.items()})
.extend(CONFIG_MAP)
.extend(cv.COMPONENT_SCHEMA)
.extend(spi.spi_device_schema(cs_pin_required=True)),
_validate_packet_mode,
@@ -229,7 +251,8 @@ async def to_code(config):
await cg.register_component(var, config)
await spi.register_spi_device(var, config)
for key in CONFIG_MAP:
for opt in CONFIG_MAP:
key = opt.schema
if key in config:
cg.add(getattr(var, f"set_{key}")(config[key]))

View File

@@ -98,25 +98,8 @@ CC1101Component::CC1101Component() {
this->state_.LENGTH_CONFIG = 2;
this->state_.FS_AUTOCAL = 1;
// Default Settings
this->set_frequency(433920000);
this->set_if_frequency(153000);
this->set_filter_bandwidth(203000);
this->set_channel(0);
this->set_channel_spacing(200000);
this->set_symbol_rate(5000);
this->set_sync_mode(SyncMode::SYNC_MODE_NONE);
this->set_carrier_sense_above_threshold(true);
this->set_modulation_type(Modulation::MODULATION_ASK_OOK);
this->set_magn_target(MagnTarget::MAGN_TARGET_42DB);
this->set_max_lna_gain(MaxLnaGain::MAX_LNA_GAIN_DEFAULT);
this->set_max_dvga_gain(MaxDvgaGain::MAX_DVGA_GAIN_MINUS_3);
this->set_lna_priority(false);
this->set_wait_time(WaitTime::WAIT_TIME_32_SAMPLES);
// CRITICAL: Initialize PA Table to avoid transmitting 0 power (Silence)
memset(this->pa_table_, 0, sizeof(this->pa_table_));
this->set_output_power(10.0f);
}
void CC1101Component::setup() {

View File

@@ -11,6 +11,9 @@ namespace esphome {
namespace esp32_camera {
static const char *const TAG = "esp32_camera";
#if ESPHOME_LOG_LEVEL < ESPHOME_LOG_LEVEL_VERBOSE
static constexpr uint32_t FRAME_LOG_INTERVAL_MS = 60000;
#endif
/* ---------------- public API (derivated) ---------------- */
void ESP32Camera::setup() {
@@ -204,7 +207,20 @@ void ESP32Camera::loop() {
}
this->current_image_ = std::make_shared<ESP32CameraImage>(fb, this->single_requesters_ | this->stream_requesters_);
ESP_LOGD(TAG, "Got Image: len=%u", fb->len);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
ESP_LOGV(TAG, "Got Image: len=%u", fb->len);
#else
// Initialize log time on first frame to ensure accurate interval measurement
if (this->frame_count_ == 0) {
this->last_log_time_ = now;
}
this->frame_count_++;
if (now - this->last_log_time_ >= FRAME_LOG_INTERVAL_MS) {
ESP_LOGD(TAG, "Received %u images in last %us", this->frame_count_, FRAME_LOG_INTERVAL_MS / 1000);
this->last_log_time_ = now;
this->frame_count_ = 0;
}
#endif
for (auto *listener : this->listeners_) {
listener->on_camera_image(this->current_image_);
}

View File

@@ -213,6 +213,10 @@ class ESP32Camera : public camera::Camera {
uint32_t last_idle_request_{0};
uint32_t last_update_{0};
#if ESPHOME_LOG_LEVEL < ESPHOME_LOG_LEVEL_VERBOSE
uint32_t last_log_time_{0};
uint16_t frame_count_{0};
#endif
#ifdef USE_I2C
i2c::InternalI2CBus *i2c_bus_{nullptr};
#endif // USE_I2C

View File

@@ -33,15 +33,7 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t
severity = LOG_LEVEL_TO_SYSLOG_SEVERITY[level];
}
int pri = this->facility_ * 8 + severity;
auto now = this->time_->now();
std::string timestamp;
if (now.is_valid()) {
timestamp = now.strftime("%b %e %H:%M:%S");
} else {
// RFC 5424: A syslog application MUST use the NILVALUE as TIMESTAMP if the syslog application is incapable of
// obtaining system time.
timestamp = "-";
}
size_t len = message_len;
// remove color formatting
if (this->strip_ && message[0] == 0x1B && len > 11) {
@@ -49,8 +41,40 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t
len -= 11;
}
auto data = str_sprintf("<%d>%s %s %s: %.*s", pri, timestamp.c_str(), App.get_name().c_str(), tag, len, message);
this->parent_->send_packet((const uint8_t *) data.data(), data.size());
// Build syslog packet on stack (508 bytes chosen as practical limit for syslog over UDP)
char packet[508];
size_t offset = 0;
size_t remaining = sizeof(packet);
// Write PRI - abort if this fails as packet would be malformed
int ret = snprintf(packet, remaining, "<%d>", pri);
if (ret <= 0 || static_cast<size_t>(ret) >= remaining) {
return;
}
offset = ret;
remaining -= ret;
// Write timestamp directly into packet (RFC 5424: use "-" if time not valid or strftime fails)
auto now = this->time_->now();
size_t ts_written = now.is_valid() ? now.strftime(packet + offset, remaining, "%b %e %H:%M:%S") : 0;
if (ts_written > 0) {
offset += ts_written;
remaining -= ts_written;
} else if (remaining > 0) {
packet[offset++] = '-';
remaining--;
}
// Write hostname, tag, and message
ret = snprintf(packet + offset, remaining, " %s %s: %.*s", App.get_name().c_str(), tag, (int) len, message);
if (ret > 0) {
// snprintf returns chars that would be written; clamp to actual buffer space
offset += std::min(static_cast<size_t>(ret), remaining > 0 ? remaining - 1 : 0);
}
if (offset > 0) {
this->parent_->send_packet(reinterpret_cast<const uint8_t *>(packet), offset);
}
}
} // namespace esphome::syslog

View File

@@ -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)

View File

@@ -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 <cmath>
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<SavedWaterHeaterState>(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<WaterHeaterCall> 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

View File

@@ -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<WaterHeaterMode> to eliminate red-black tree overhead
using WaterHeaterModeMask =
FiniteSetMask<WaterHeaterMode, DefaultBitPolicy<WaterHeaterMode, WATER_HEATER_MODE_GAS + 1>>;
/// 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<WaterHeaterMode> &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<WaterHeaterMode> 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<WaterHeaterCall> 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

View File

@@ -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

View File

@@ -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

View File

@@ -1042,7 +1042,13 @@ std::string WebServer::date_json(datetime::DateEntity *obj, JsonDetail start_con
json::JsonBuilder builder;
JsonObject root = builder.root();
std::string value = str_sprintf("%d-%02d-%02d", obj->year, obj->month, obj->day);
// Format: YYYY-MM-DD (max 10 chars + null)
char value[12];
#ifdef USE_ESP8266
snprintf_P(value, sizeof(value), PSTR("%d-%02d-%02d"), obj->year, obj->month, obj->day);
#else
snprintf(value, sizeof(value), "%d-%02d-%02d", obj->year, obj->month, obj->day);
#endif
set_json_icon_state_value(root, obj, "date", value, value, start_config);
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);
@@ -1098,7 +1104,13 @@ std::string WebServer::time_json(datetime::TimeEntity *obj, JsonDetail start_con
json::JsonBuilder builder;
JsonObject root = builder.root();
std::string value = str_sprintf("%02d:%02d:%02d", obj->hour, obj->minute, obj->second);
// Format: HH:MM:SS (8 chars + null)
char value[12];
#ifdef USE_ESP8266
snprintf_P(value, sizeof(value), PSTR("%02d:%02d:%02d"), obj->hour, obj->minute, obj->second);
#else
snprintf(value, sizeof(value), "%02d:%02d:%02d", obj->hour, obj->minute, obj->second);
#endif
set_json_icon_state_value(root, obj, "time", value, value, start_config);
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);
@@ -1154,8 +1166,15 @@ std::string WebServer::datetime_json(datetime::DateTimeEntity *obj, JsonDetail s
json::JsonBuilder builder;
JsonObject root = builder.root();
std::string value =
str_sprintf("%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute, obj->second);
// Format: YYYY-MM-DD HH:MM:SS (max 19 chars + null)
char value[24];
#ifdef USE_ESP8266
snprintf_P(value, sizeof(value), PSTR("%d-%02d-%02d %02d:%02d:%02d"), obj->year, obj->month, obj->day, obj->hour,
obj->minute, obj->second);
#else
snprintf(value, sizeof(value), "%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute,
obj->second);
#endif
set_json_icon_state_value(root, obj, "datetime", value, value, start_config);
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);

View File

@@ -343,8 +343,9 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw
void AsyncWebServerRequest::requestAuthentication(const char *realm) const {
httpd_resp_set_hdr(*this, "Connection", "keep-alive");
auto auth_val = str_sprintf("Basic realm=\"%s\"", realm ? realm : "Login Required");
httpd_resp_set_hdr(*this, "WWW-Authenticate", auth_val.c_str());
// Note: realm is never configured in ESPHome, always nullptr -> "Login Required"
(void) realm; // Unused - always use default
httpd_resp_set_hdr(*this, "WWW-Authenticate", "Basic realm=\"Login Required\"");
httpd_resp_send_err(*this, HTTPD_401_UNAUTHORIZED, nullptr);
}
#endif

View File

@@ -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"

View File

@@ -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_panel::AlarmControlPanel *, ESPHOME_ENTITY_ALARM_CONTROL_PANEL_COUNT>
alarm_control_panels_{};
#endif
#ifdef USE_WATER_HEATER
StaticVector<water_heater::WaterHeater *, ESPHOME_ENTITY_WATER_HEATER_COUNT> water_heaters_{};
#endif
#ifdef USE_UPDATE
StaticVector<update::UpdateEntity *, ESPHOME_ENTITY_UPDATE_COUNT> updates_{};
#endif

View File

@@ -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);

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,43 @@
esphome:
name: syslog-test
host:
api:
services:
- service: log_long_message
then:
- lambda: |-
// Log a message that exceeds 508 bytes to test truncation
ESP_LOGI("trunctest", "START|%s|END",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"
"DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"
"EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE"
"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF");
- service: log_short_message
then:
- lambda: |-
// Log a short message that should arrive complete (not truncated)
ESP_LOGI("shorttest", "BEGIN|SHORT_MESSAGE_CONTENT|FINISH");
logger:
level: DEBUG
time:
- platform: host
id: host_time
udp:
- id: syslog_udp
addresses:
- "127.0.0.1"
syslog:
udp_id: syslog_udp
time_id: host_time
port: SYSLOG_PORT_PLACEHOLDER
level: DEBUG
strip: true
facility: 16

View File

@@ -179,6 +179,12 @@ async def test_api_homeassistant(
client.send_home_assistant_state("binary_sensor.external_motion", "", "ON")
client.send_home_assistant_state("weather.home", "condition", "sunny")
# Test edge cases for zero-copy implementation safety
# Empty entity_id should be silently ignored (no crash)
client.send_home_assistant_state("", "", "should_be_ignored")
# Empty state with valid entity should work (use different entity to not interfere with test)
client.send_home_assistant_state("sensor.edge_case_empty_state", "", "")
# List entities and services
_, services = await client.list_entities_services()

View File

@@ -0,0 +1,284 @@
"""Integration test for syslog component."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
import contextlib
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
import re
import socket
from typing import TypedDict
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
class ParsedSyslogMessage(TypedDict):
"""Parsed syslog message components."""
pri: int
facility: int
severity: int
timestamp: str
hostname: str
tag: str
message: str
# RFC 3164 syslog message pattern:
# <PRI>TIMESTAMP HOSTNAME TAG: MESSAGE
# Example: <134>Dec 20 14:30:45 syslog-test app: [D][app:029]: Running...
SYSLOG_PATTERN = re.compile(
r"<(\d+)>" # PRI (priority = facility * 8 + severity)
r"(\S+ +\d+ \d+:\d+:\d+|-)" # TIMESTAMP (BSD-style "%b %e %H:%M:%S", e.g. "Dec 20 14:30:45", or NILVALUE "-")
r" (\S+)" # HOSTNAME
r" (\S+):" # TAG
r" (.*)" # MESSAGE
)
@dataclass
class SyslogReceiver:
"""Collects syslog messages received over UDP."""
messages: list[str] = field(default_factory=list)
message_received: asyncio.Event = field(default_factory=asyncio.Event)
_waiters: list[tuple[re.Pattern, asyncio.Event]] = field(default_factory=list)
def on_message(self, msg: str) -> None:
"""Called when a message is received."""
self.messages.append(msg)
self.message_received.set()
# Check pattern waiters
for pattern, event in self._waiters:
if pattern.search(msg):
event.set()
async def wait_for_messages(self, timeout: float = 10.0) -> None:
"""Wait for at least one message to be received."""
await asyncio.wait_for(self.message_received.wait(), timeout=timeout)
async def wait_for_pattern(self, pattern: str, timeout: float = 5.0) -> str:
"""Wait for a message matching the pattern."""
compiled = re.compile(pattern)
event = asyncio.Event()
self._waiters.append((compiled, event))
try:
# Check existing messages first
for msg in self.messages:
if compiled.search(msg):
return msg
# Wait for new message
await asyncio.wait_for(event.wait(), timeout=timeout)
# Find and return the matching message
for msg in reversed(self.messages):
if compiled.search(msg):
return msg
raise RuntimeError("Event set but no matching message found")
finally:
self._waiters.remove((compiled, event))
@asynccontextmanager
async def syslog_udp_listener() -> AsyncGenerator[tuple[int, SyslogReceiver]]:
"""Async context manager that listens for syslog UDP messages.
Yields:
Tuple of (port, SyslogReceiver) where port is the UDP port to send to
and SyslogReceiver contains the received messages.
"""
# Create and bind UDP socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", 0))
sock.setblocking(False)
port = sock.getsockname()[1]
receiver = SyslogReceiver()
async def receive_messages() -> None:
"""Background task to receive syslog messages."""
loop = asyncio.get_running_loop()
while True:
try:
data = await loop.sock_recv(sock, 4096)
if data:
msg = data.decode("utf-8", errors="replace")
receiver.on_message(msg)
except BlockingIOError:
await asyncio.sleep(0.01)
except Exception:
break
task = asyncio.create_task(receive_messages())
try:
yield port, receiver
finally:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
sock.close()
def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None:
"""Parse a syslog message and return its components."""
match = SYSLOG_PATTERN.match(msg)
if not match:
return None
pri, timestamp, hostname, tag, message = match.groups()
pri_val = int(pri)
# PRI = facility * 8 + severity
facility = pri_val // 8
severity = pri_val % 8
return ParsedSyslogMessage(
pri=pri_val,
facility=facility,
severity=severity,
timestamp=timestamp,
hostname=hostname,
tag=tag,
message=message,
)
@pytest.mark.asyncio
async def test_syslog(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test syslog component sends properly formatted messages."""
async with syslog_udp_listener() as (udp_port, receiver):
# Replace the placeholder port in the config
config = yaml_config.replace("SYSLOG_PORT_PLACEHOLDER", str(udp_port))
async with run_compiled(config), api_client_connected() as client:
# Verify device is running
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "syslog-test"
# Wait for syslog messages (ESPHome logs during startup)
try:
await receiver.wait_for_messages(timeout=10.0)
except TimeoutError:
pytest.fail("No syslog messages received within timeout")
# Give it a moment to collect more messages
await asyncio.sleep(0.5)
# Verify we received messages
assert len(receiver.messages) > 0, "No syslog messages received"
# Parse and validate all messages
parsed_messages: list[ParsedSyslogMessage] = []
for msg in receiver.messages:
parsed = parse_syslog_message(msg)
if parsed:
parsed_messages.append(parsed)
assert len(parsed_messages) > 0, (
f"No valid syslog messages found. Received: {receiver.messages[:5]}"
)
# Validate message format for all parsed messages
for parsed in parsed_messages:
# Validate PRI is in valid range (0-191)
assert 0 <= parsed["pri"] <= 191, f"Invalid PRI: {parsed['pri']}"
# Validate facility matches config (16 = local0)
assert parsed["facility"] == 16, (
f"Expected facility 16, got {parsed['facility']}"
)
# Validate severity is in valid range (0-7)
assert 0 <= parsed["severity"] <= 7, (
f"Invalid severity: {parsed['severity']}"
)
# Validate hostname matches device name
assert parsed["hostname"] == "syslog-test", (
f"Unexpected hostname: {parsed['hostname']}"
)
# Validate timestamp format (BSD or NILVALUE)
if parsed["timestamp"] != "-":
assert re.match(
r"[A-Z][a-z]{2} +\d+ \d{2}:\d{2}:\d{2}",
parsed["timestamp"],
), f"Invalid timestamp format: {parsed['timestamp']}"
# Verify we see different severity levels in the logs
severities_seen = {p["severity"] for p in parsed_messages}
# ESPHome startup logs should include at least INFO (5) or DEBUG (7)
assert len(severities_seen) >= 1, "Expected to see at least one severity"
# Verify messages don't contain ANSI color codes (strip=true)
for parsed in parsed_messages:
assert "\x1b[" not in parsed["message"], (
f"Color codes not stripped: {parsed['message'][:50]}"
)
# Verify message content is not empty for most messages
non_empty_messages = [p for p in parsed_messages if p["message"].strip()]
assert len(non_empty_messages) > 0, "All messages are empty"
# Verify tag format (should be component name like "app", "wifi", etc.)
for parsed in parsed_messages:
assert len(parsed["tag"]) > 0, "Empty tag"
# Tag should not contain spaces or colons
assert " " not in parsed["tag"], f"Tag contains space: {parsed['tag']}"
# Test message truncation - call service that logs a very long message
_, services = await client.list_entities_services()
log_service = next(
(s for s in services if s.name == "log_long_message"), None
)
assert log_service is not None, "log_long_message service not found"
# Call the service to trigger a long log message
await client.execute_service(log_service, {})
# Wait specifically for the truncation test message
try:
trunc_msg = await receiver.wait_for_pattern(r"trunctest.*START\|")
except TimeoutError:
pytest.fail(
f"Truncation test message not received. Got: {receiver.messages}"
)
# Verify message is truncated to max 508 bytes
assert len(trunc_msg) <= 508, f"Message exceeds 508 bytes: {len(trunc_msg)}"
# Verify the message starts correctly but is truncated (no "|END")
assert "START|" in trunc_msg, "Message should contain START marker"
assert "|END" not in trunc_msg, (
"Message should be truncated before END marker"
)
# Test short message - should arrive complete (not truncated)
short_service = next(
(s for s in services if s.name == "log_short_message"), None
)
assert short_service is not None, "log_short_message service not found"
await client.execute_service(short_service, {})
try:
short_msg = await receiver.wait_for_pattern(r"shorttest.*BEGIN\|")
except TimeoutError:
pytest.fail(
f"Short test message not received. Got: {receiver.messages[-10:]}"
)
# Verify short message arrived complete with both markers
assert "BEGIN|" in short_msg, "Short message missing BEGIN marker"
assert "|FINISH" in short_msg, (
f"Short message truncated unexpectedly: {short_msg}"
)
assert "SHORT_MESSAGE_CONTENT" in short_msg, (
f"Short message content missing: {short_msg}"
)