From e80bd8ed3d5dcbc1b7e07117e04d46b663c89162 Mon Sep 17 00:00:00 2001 From: Daniel Dunn Date: Wed, 25 Oct 2023 03:00:32 -0600 Subject: [PATCH] Add text component (#5336) Co-authored-by: Maurits Co-authored-by: mauritskorse Co-authored-by: Daniel Dunn Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/api/api.proto | 46 ++++ esphome/components/api/api_connection.cpp | 39 +++ esphome/components/api/api_connection.h | 5 + esphome/components/api/api_pb2.cpp | 233 ++++++++++++++++++ esphome/components/api/api_pb2.h | 55 +++++ esphome/components/api/api_pb2_service.cpp | 42 ++++ esphome/components/api/api_pb2_service.h | 15 ++ esphome/components/api/api_server.cpp | 9 + esphome/components/api/api_server.h | 3 + esphome/components/api/list_entities.cpp | 4 + esphome/components/api/list_entities.h | 3 + esphome/components/api/subscribe_state.cpp | 3 + esphome/components/api/subscribe_state.h | 3 + esphome/components/copy/text/__init__.py | 36 +++ esphome/components/copy/text/copy_text.cpp | 25 ++ esphome/components/copy/text/copy_text.h | 23 ++ esphome/components/mqtt/__init__.py | 1 + esphome/components/mqtt/mqtt_text.cpp | 63 +++++ esphome/components/mqtt/mqtt_text.h | 46 ++++ esphome/components/sprinkler/__init__.py | 2 +- .../components/template/number/__init__.py | 3 +- .../components/template/select/__init__.py | 3 +- esphome/components/template/text/__init__.py | 92 +++++++ .../template/text/template_text.cpp | 64 +++++ .../components/template/text/template_text.h | 87 +++++++ esphome/components/text/__init__.py | 138 +++++++++++ esphome/components/text/automation.h | 33 +++ esphome/components/text/text.cpp | 26 ++ esphome/components/text/text.h | 55 +++++ esphome/components/text/text_call.cpp | 56 +++++ esphome/components/text/text_call.h | 25 ++ esphome/components/text/text_traits.h | 39 +++ .../components/web_server/list_entities.cpp | 7 + esphome/components/web_server/list_entities.h | 3 + esphome/components/web_server/web_server.cpp | 89 +++++++ esphome/components/web_server/web_server.h | 9 + esphome/config_validation.py | 1 + esphome/const.py | 2 + esphome/core/application.h | 19 ++ esphome/core/component_iterator.cpp | 15 ++ esphome/core/component_iterator.h | 6 + esphome/core/controller.cpp | 6 + esphome/core/controller.h | 6 + esphome/core/defines.h | 1 + script/api_protobuf/api_protobuf.py | 2 +- script/ci-custom.py | 1 + tests/component_tests/text/test_text.py | 56 +++++ tests/component_tests/text/test_text.yaml | 33 +++ tests/test2.yaml | 18 +- 50 files changed, 1545 insertions(+), 7 deletions(-) create mode 100644 esphome/components/copy/text/__init__.py create mode 100644 esphome/components/copy/text/copy_text.cpp create mode 100644 esphome/components/copy/text/copy_text.h create mode 100644 esphome/components/mqtt/mqtt_text.cpp create mode 100644 esphome/components/mqtt/mqtt_text.h create mode 100644 esphome/components/template/text/__init__.py create mode 100644 esphome/components/template/text/template_text.cpp create mode 100644 esphome/components/template/text/template_text.h create mode 100644 esphome/components/text/__init__.py create mode 100644 esphome/components/text/automation.h create mode 100644 esphome/components/text/text.cpp create mode 100644 esphome/components/text/text.h create mode 100644 esphome/components/text/text_call.cpp create mode 100644 esphome/components/text/text_call.h create mode 100644 esphome/components/text/text_traits.h create mode 100644 tests/component_tests/text/test_text.py create mode 100644 tests/component_tests/text/test_text.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 6cc09bf245..eff8f28a53 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -303,6 +303,7 @@ esphome/components/tcl112/* @glmnet esphome/components/tee501/* @Stock-M esphome/components/teleinfo/* @0hax esphome/components/template/alarm_control_panel/* @grahambrown11 +esphome/components/text/* @mauritskorse esphome/components/thermostat/* @kbx81 esphome/components/time/* @OttoWinter esphome/components/tlc5947/* @rnauber diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 69765c7a94..ca3071d6d9 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -39,6 +39,7 @@ service APIConnection { rpc camera_image (CameraImageRequest) returns (void) {} rpc climate_command (ClimateCommandRequest) returns (void) {} rpc number_command (NumberCommandRequest) returns (void) {} + rpc text_command (TextCommandRequest) returns (void) {} rpc select_command (SelectCommandRequest) returns (void) {} rpc button_command (ButtonCommandRequest) returns (void) {} rpc lock_command (LockCommandRequest) returns (void) {} @@ -1536,3 +1537,48 @@ message AlarmControlPanelCommandRequest { AlarmControlPanelStateCommand command = 2; string code = 3; } + +// ===================== TEXT ===================== +enum TextMode { + TEXT_MODE_TEXT = 0; + TEXT_MODE_PASSWORD = 1; +} +message ListEntitiesTextResponse { + option (id) = 97; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_TEXT"; + + string object_id = 1; + fixed32 key = 2; + string name = 3; + string unique_id = 4; + string icon = 5; + bool disabled_by_default = 6; + EntityCategory entity_category = 7; + + uint32 min_length = 8; + uint32 max_length = 9; + string pattern = 10; + TextMode mode = 11; +} +message TextStateResponse { + option (id) = 98; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_TEXT"; + option (no_delay) = true; + + fixed32 key = 1; + string state = 2; + // If the Text does not have a valid state yet. + // Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller + bool missing_state = 3; +} +message TextCommandRequest { + option (id) = 99; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_TEXT"; + option (no_delay) = true; + + fixed32 key = 1; + string state = 2; +} diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 3172b71fa2..bc61271e93 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1,6 +1,7 @@ #include "api_connection.h" #include #include +#include #include "esphome/components/network/util.h" #include "esphome/core/entity_base.h" #include "esphome/core/hal.h" @@ -655,6 +656,44 @@ void APIConnection::number_command(const NumberCommandRequest &msg) { } #endif +#ifdef USE_TEXT +bool APIConnection::send_text_state(text::Text *text, std::string state) { + if (!this->state_subscription_) + return false; + + TextStateResponse resp{}; + resp.key = text->get_object_id_hash(); + resp.state = std::move(state); + resp.missing_state = !text->has_state(); + return this->send_text_state_response(resp); +} +bool APIConnection::send_text_info(text::Text *text) { + ListEntitiesTextResponse msg; + msg.key = text->get_object_id_hash(); + msg.object_id = text->get_object_id(); + msg.name = text->get_name(); + msg.icon = text->get_icon(); + msg.disabled_by_default = text->is_disabled_by_default(); + msg.entity_category = static_cast(text->get_entity_category()); + msg.mode = static_cast(text->traits.get_mode()); + + msg.min_length = text->traits.get_min_length(); + msg.max_length = text->traits.get_max_length(); + msg.pattern = text->traits.get_pattern(); + + return this->send_list_entities_text_response(msg); +} +void APIConnection::text_command(const TextCommandRequest &msg) { + text::Text *text = App.get_text_by_key(msg.key); + if (text == nullptr) + return; + + auto call = text->make_call(); + call.set_value(msg.state); + call.perform(); +} +#endif + #ifdef USE_SELECT bool APIConnection::send_select_state(select::Select *select, std::string state) { if (!this->state_subscription_) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 2a62c2faff..c17aaab611 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -72,6 +72,11 @@ class APIConnection : public APIServerConnection { bool send_number_info(number::Number *number); void number_command(const NumberCommandRequest &msg) override; #endif +#ifdef USE_TEXT + bool send_text_state(text::Text *text, std::string state); + bool send_text_info(text::Text *text); + void text_command(const TextCommandRequest &msg) override; +#endif #ifdef USE_SELECT bool send_select_state(select::Select *select, std::string state); bool send_select_info(select::Select *select); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 65df2312e1..c070b3c988 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -512,6 +512,18 @@ const char *proto_enum_to_string(enums::Al } } #endif +#ifdef HAS_PROTO_MESSAGE_DUMP +template<> const char *proto_enum_to_string(enums::TextMode value) { + switch (value) { + case enums::TEXT_MODE_TEXT: + return "TEXT_MODE_TEXT"; + case enums::TEXT_MODE_PASSWORD: + return "TEXT_MODE_PASSWORD"; + default: + return "UNKNOWN"; + } +} +#endif bool HelloRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { case 2: { @@ -6795,6 +6807,227 @@ void AlarmControlPanelCommandRequest::dump_to(std::string &out) const { out.append("}"); } #endif +bool ListEntitiesTextResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 6: { + this->disabled_by_default = value.as_bool(); + return true; + } + case 7: { + this->entity_category = value.as_enum(); + return true; + } + case 8: { + this->min_length = value.as_uint32(); + return true; + } + case 9: { + this->max_length = value.as_uint32(); + return true; + } + case 11: { + this->mode = value.as_enum(); + return true; + } + default: + return false; + } +} +bool ListEntitiesTextResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 1: { + this->object_id = value.as_string(); + return true; + } + case 3: { + this->name = value.as_string(); + return true; + } + case 4: { + this->unique_id = value.as_string(); + return true; + } + case 5: { + this->icon = value.as_string(); + return true; + } + case 10: { + this->pattern = value.as_string(); + return true; + } + default: + return false; + } +} +bool ListEntitiesTextResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { + switch (field_id) { + case 2: { + this->key = value.as_fixed32(); + return true; + } + default: + return false; + } +} +void ListEntitiesTextResponse::encode(ProtoWriteBuffer buffer) const { + buffer.encode_string(1, this->object_id); + buffer.encode_fixed32(2, this->key); + buffer.encode_string(3, this->name); + buffer.encode_string(4, this->unique_id); + buffer.encode_string(5, this->icon); + buffer.encode_bool(6, this->disabled_by_default); + buffer.encode_enum(7, this->entity_category); + buffer.encode_uint32(8, this->min_length); + buffer.encode_uint32(9, this->max_length); + buffer.encode_string(10, this->pattern); + buffer.encode_enum(11, this->mode); +} +#ifdef HAS_PROTO_MESSAGE_DUMP +void ListEntitiesTextResponse::dump_to(std::string &out) const { + __attribute__((unused)) char buffer[64]; + out.append("ListEntitiesTextResponse {\n"); + out.append(" object_id: "); + out.append("'").append(this->object_id).append("'"); + out.append("\n"); + + out.append(" key: "); + sprintf(buffer, "%" PRIu32, this->key); + out.append(buffer); + out.append("\n"); + + out.append(" name: "); + out.append("'").append(this->name).append("'"); + out.append("\n"); + + out.append(" unique_id: "); + out.append("'").append(this->unique_id).append("'"); + out.append("\n"); + + out.append(" icon: "); + out.append("'").append(this->icon).append("'"); + out.append("\n"); + + out.append(" disabled_by_default: "); + out.append(YESNO(this->disabled_by_default)); + out.append("\n"); + + out.append(" entity_category: "); + out.append(proto_enum_to_string(this->entity_category)); + out.append("\n"); + + out.append(" min_length: "); + sprintf(buffer, "%" PRIu32, this->min_length); + out.append(buffer); + out.append("\n"); + + out.append(" max_length: "); + sprintf(buffer, "%" PRIu32, this->max_length); + out.append(buffer); + out.append("\n"); + + out.append(" pattern: "); + out.append("'").append(this->pattern).append("'"); + out.append("\n"); + + out.append(" mode: "); + out.append(proto_enum_to_string(this->mode)); + out.append("\n"); + out.append("}"); +} +#endif +bool TextStateResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 3: { + this->missing_state = value.as_bool(); + return true; + } + default: + return false; + } +} +bool TextStateResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 2: { + this->state = value.as_string(); + return true; + } + default: + return false; + } +} +bool TextStateResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { + switch (field_id) { + case 1: { + this->key = value.as_fixed32(); + return true; + } + default: + return false; + } +} +void TextStateResponse::encode(ProtoWriteBuffer buffer) const { + buffer.encode_fixed32(1, this->key); + buffer.encode_string(2, this->state); + buffer.encode_bool(3, this->missing_state); +} +#ifdef HAS_PROTO_MESSAGE_DUMP +void TextStateResponse::dump_to(std::string &out) const { + __attribute__((unused)) char buffer[64]; + out.append("TextStateResponse {\n"); + out.append(" key: "); + sprintf(buffer, "%" PRIu32, this->key); + out.append(buffer); + out.append("\n"); + + out.append(" state: "); + out.append("'").append(this->state).append("'"); + out.append("\n"); + + out.append(" missing_state: "); + out.append(YESNO(this->missing_state)); + out.append("\n"); + out.append("}"); +} +#endif +bool TextCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 2: { + this->state = value.as_string(); + return true; + } + default: + return false; + } +} +bool TextCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { + switch (field_id) { + case 1: { + this->key = value.as_fixed32(); + return true; + } + default: + return false; + } +} +void TextCommandRequest::encode(ProtoWriteBuffer buffer) const { + buffer.encode_fixed32(1, this->key); + buffer.encode_string(2, this->state); +} +#ifdef HAS_PROTO_MESSAGE_DUMP +void TextCommandRequest::dump_to(std::string &out) const { + __attribute__((unused)) char buffer[64]; + out.append("TextCommandRequest {\n"); + out.append(" key: "); + sprintf(buffer, "%" PRIu32, this->key); + out.append(buffer); + out.append("\n"); + + out.append(" state: "); + out.append("'").append(this->state).append("'"); + out.append("\n"); + out.append("}"); +} +#endif } // namespace api } // namespace esphome diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 4c70facf3d..b935784831 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -208,6 +208,10 @@ enum AlarmControlPanelStateCommand : uint32_t { ALARM_CONTROL_PANEL_ARM_CUSTOM_BYPASS = 5, ALARM_CONTROL_PANEL_TRIGGER = 6, }; +enum TextMode : uint32_t { + TEXT_MODE_TEXT = 0, + TEXT_MODE_PASSWORD = 1, +}; } // namespace enums @@ -1778,6 +1782,57 @@ class AlarmControlPanelCommandRequest : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; +class ListEntitiesTextResponse : public ProtoMessage { + public: + std::string object_id{}; + uint32_t key{0}; + std::string name{}; + std::string unique_id{}; + std::string icon{}; + bool disabled_by_default{false}; + enums::EntityCategory entity_category{}; + uint32_t min_length{0}; + uint32_t max_length{0}; + std::string pattern{}; + enums::TextMode mode{}; + void encode(ProtoWriteBuffer buffer) const override; +#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_length(uint32_t field_id, ProtoLengthDelimited value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +class TextStateResponse : public ProtoMessage { + public: + uint32_t key{0}; + std::string state{}; + bool missing_state{false}; + void encode(ProtoWriteBuffer buffer) const override; +#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_length(uint32_t field_id, ProtoLengthDelimited value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +class TextCommandRequest : public ProtoMessage { + public: + uint32_t key{0}; + std::string state{}; + void encode(ProtoWriteBuffer buffer) const override; +#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_length(uint32_t field_id, ProtoLengthDelimited value) override; +}; } // namespace api } // namespace esphome diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 8752ae6cfd..4c49c09c8e 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -495,6 +495,24 @@ bool APIServerConnectionBase::send_alarm_control_panel_state_response(const Alar #endif #ifdef USE_ALARM_CONTROL_PANEL #endif +#ifdef USE_TEXT +bool APIServerConnectionBase::send_list_entities_text_response(const ListEntitiesTextResponse &msg) { +#ifdef HAS_PROTO_MESSAGE_DUMP + ESP_LOGVV(TAG, "send_list_entities_text_response: %s", msg.dump().c_str()); +#endif + return this->send_message_(msg, 97); +} +#endif +#ifdef USE_TEXT +bool APIServerConnectionBase::send_text_state_response(const TextStateResponse &msg) { +#ifdef HAS_PROTO_MESSAGE_DUMP + ESP_LOGVV(TAG, "send_text_state_response: %s", msg.dump().c_str()); +#endif + return this->send_message_(msg, 98); +} +#endif +#ifdef USE_TEXT +#endif bool APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) { switch (msg_type) { case 1: { @@ -913,6 +931,17 @@ bool APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, ESP_LOGVV(TAG, "on_alarm_control_panel_command_request: %s", msg.dump().c_str()); #endif this->on_alarm_control_panel_command_request(msg); +#endif + break; + } + case 99: { +#ifdef USE_TEXT + TextCommandRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + ESP_LOGVV(TAG, "on_text_command_request: %s", msg.dump().c_str()); +#endif + this->on_text_command_request(msg); #endif break; } @@ -1124,6 +1153,19 @@ void APIServerConnection::on_number_command_request(const NumberCommandRequest & this->number_command(msg); } #endif +#ifdef USE_TEXT +void APIServerConnection::on_text_command_request(const TextCommandRequest &msg) { + if (!this->is_connection_setup()) { + this->on_no_setup_connection(); + return; + } + if (!this->is_authenticated()) { + this->on_unauthenticated_access(); + return; + } + this->text_command(msg); +} +#endif #ifdef USE_SELECT void APIServerConnection::on_select_command_request(const SelectCommandRequest &msg) { if (!this->is_connection_setup()) { diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 2864e303c0..20639fc139 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -248,6 +248,15 @@ class APIServerConnectionBase : public ProtoService { #endif #ifdef USE_ALARM_CONTROL_PANEL virtual void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &value){}; +#endif +#ifdef USE_TEXT + bool send_list_entities_text_response(const ListEntitiesTextResponse &msg); +#endif +#ifdef USE_TEXT + bool send_text_state_response(const TextStateResponse &msg); +#endif +#ifdef USE_TEXT + virtual void on_text_command_request(const TextCommandRequest &value){}; #endif protected: bool read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override; @@ -288,6 +297,9 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_NUMBER virtual void number_command(const NumberCommandRequest &msg) = 0; #endif +#ifdef USE_TEXT + virtual void text_command(const TextCommandRequest &msg) = 0; +#endif #ifdef USE_SELECT virtual void select_command(const SelectCommandRequest &msg) = 0; #endif @@ -371,6 +383,9 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_NUMBER void on_number_command_request(const NumberCommandRequest &msg) override; #endif +#ifdef USE_TEXT + void on_text_command_request(const TextCommandRequest &msg) override; +#endif #ifdef USE_SELECT void on_select_command_request(const SelectCommandRequest &msg) override; #endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 54266ff0f0..c4edddc92b 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -254,6 +254,15 @@ void APIServer::on_number_update(number::Number *obj, float state) { } #endif +#ifdef USE_TEXT +void APIServer::on_text_update(text::Text *obj, const std::string &state) { + if (obj->is_internal()) + return; + for (auto &c : this->clients_) + c->send_text_state(obj, state); +} +#endif + #ifdef USE_SELECT void APIServer::on_select_update(select::Select *obj, const std::string &state, size_t index) { if (obj->is_internal()) diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index a4454d4b84..4d359ebb79 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -65,6 +65,9 @@ class APIServer : public Component, public Controller { #ifdef USE_NUMBER void on_number_update(number::Number *obj, float state) override; #endif +#ifdef USE_TEXT + void on_text_update(text::Text *obj, const std::string &state) override; +#endif #ifdef USE_SELECT void on_select_update(select::Select *obj, const std::string &state, size_t index) override; #endif diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index cd73d1ef5d..fe359143b1 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -60,6 +60,10 @@ bool ListEntitiesIterator::on_climate(climate::Climate *climate) { return this-> bool ListEntitiesIterator::on_number(number::Number *number) { return this->client_->send_number_info(number); } #endif +#ifdef USE_TEXT +bool ListEntitiesIterator::on_text(text::Text *text) { return this->client_->send_text_info(text); } +#endif + #ifdef USE_SELECT bool ListEntitiesIterator::on_select(select::Select *select) { return this->client_->send_select_info(select); } #endif diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h index b40d77e841..e19c0d99f0 100644 --- a/esphome/components/api/list_entities.h +++ b/esphome/components/api/list_entities.h @@ -46,6 +46,9 @@ class ListEntitiesIterator : public ComponentIterator { #ifdef USE_NUMBER bool on_number(number::Number *number) override; #endif +#ifdef USE_TEXT + bool on_text(text::Text *text) override; +#endif #ifdef USE_SELECT bool on_select(select::Select *select) override; #endif diff --git a/esphome/components/api/subscribe_state.cpp b/esphome/components/api/subscribe_state.cpp index 66b5f40928..aff156cafd 100644 --- a/esphome/components/api/subscribe_state.cpp +++ b/esphome/components/api/subscribe_state.cpp @@ -42,6 +42,9 @@ bool InitialStateIterator::on_number(number::Number *number) { return this->client_->send_number_state(number, number->state); } #endif +#ifdef USE_TEXT +bool InitialStateIterator::on_text(text::Text *text) { return this->client_->send_text_state(text, text->state); } +#endif #ifdef USE_SELECT bool InitialStateIterator::on_select(select::Select *select) { return this->client_->send_select_state(select, select->state); diff --git a/esphome/components/api/subscribe_state.h b/esphome/components/api/subscribe_state.h index 0597b9f384..1f27387cf2 100644 --- a/esphome/components/api/subscribe_state.h +++ b/esphome/components/api/subscribe_state.h @@ -43,6 +43,9 @@ class InitialStateIterator : public ComponentIterator { #ifdef USE_NUMBER bool on_number(number::Number *number) override; #endif +#ifdef USE_TEXT + bool on_text(text::Text *text) override; +#endif #ifdef USE_SELECT bool on_select(select::Select *select) override; #endif diff --git a/esphome/components/copy/text/__init__.py b/esphome/components/copy/text/__init__.py new file mode 100644 index 0000000000..7593789250 --- /dev/null +++ b/esphome/components/copy/text/__init__.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import text +from esphome.const import ( + CONF_ENTITY_CATEGORY, + CONF_ICON, + CONF_MODE, + CONF_SOURCE_ID, +) +from esphome.core.entity_helpers import inherit_property_from + +from .. import copy_ns + +CopyText = copy_ns.class_("CopyText", text.Text, cg.Component) + + +CONFIG_SCHEMA = text.TEXT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(CopyText), + cv.Required(CONF_SOURCE_ID): cv.use_id(text.Text), + } +).extend(cv.COMPONENT_SCHEMA) + +FINAL_VALIDATE_SCHEMA = cv.All( + inherit_property_from(CONF_ICON, CONF_SOURCE_ID), + inherit_property_from(CONF_ENTITY_CATEGORY, CONF_SOURCE_ID), + inherit_property_from(CONF_MODE, CONF_SOURCE_ID), +) + + +async def to_code(config): + var = await text.new_text(config) + await cg.register_component(var, config) + + source = await cg.get_variable(config[CONF_SOURCE_ID]) + cg.add(var.set_source(source)) diff --git a/esphome/components/copy/text/copy_text.cpp b/esphome/components/copy/text/copy_text.cpp new file mode 100644 index 0000000000..cb74201383 --- /dev/null +++ b/esphome/components/copy/text/copy_text.cpp @@ -0,0 +1,25 @@ +#include "copy_text.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace copy { + +static const char *const TAG = "copy.text"; + +void CopyText::setup() { + source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); }); + + if (source_->has_state()) + this->publish_state(source_->state); +} + +void CopyText::dump_config() { LOG_TEXT("", "Copy Text", this); } + +void CopyText::control(const std::string &value) { + auto call2 = source_->make_call(); + call2.set_value(value); + call2.perform(); +} + +} // namespace copy +} // namespace esphome diff --git a/esphome/components/copy/text/copy_text.h b/esphome/components/copy/text/copy_text.h new file mode 100644 index 0000000000..beb8610dfe --- /dev/null +++ b/esphome/components/copy/text/copy_text.h @@ -0,0 +1,23 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/text/text.h" + +namespace esphome { +namespace copy { + +class CopyText : public text::Text, public Component { + public: + void set_source(text::Text *source) { source_ = source; } + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + protected: + void control(const std::string &value) override; + + text::Text *source_; +}; + +} // namespace copy +} // namespace esphome diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 2d1a3dc5fd..6d0846edad 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -111,6 +111,7 @@ MQTTSensorComponent = mqtt_ns.class_("MQTTSensorComponent", MQTTComponent) MQTTSwitchComponent = mqtt_ns.class_("MQTTSwitchComponent", MQTTComponent) MQTTTextSensor = mqtt_ns.class_("MQTTTextSensor", MQTTComponent) MQTTNumberComponent = mqtt_ns.class_("MQTTNumberComponent", MQTTComponent) +MQTTTextComponent = mqtt_ns.class_("MQTTTextComponent", MQTTComponent) MQTTSelectComponent = mqtt_ns.class_("MQTTSelectComponent", MQTTComponent) MQTTButtonComponent = mqtt_ns.class_("MQTTButtonComponent", MQTTComponent) MQTTLockComponent = mqtt_ns.class_("MQTTLockComponent", MQTTComponent) diff --git a/esphome/components/mqtt/mqtt_text.cpp b/esphome/components/mqtt/mqtt_text.cpp new file mode 100644 index 0000000000..58978faead --- /dev/null +++ b/esphome/components/mqtt/mqtt_text.cpp @@ -0,0 +1,63 @@ +#include "mqtt_text.h" +#include "esphome/core/log.h" + +#include "mqtt_const.h" + +#ifdef USE_MQTT +#ifdef USE_TEXT + +namespace esphome { +namespace mqtt { + +static const char *const TAG = "mqtt.text"; + +using namespace esphome::text; + +MQTTTextComponent::MQTTTextComponent(Text *text) : text_(text) {} + +void MQTTTextComponent::setup() { + this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &state) { + auto call = this->text_->make_call(); + call.set_value(state); + call.perform(); + }); + + this->text_->add_on_state_callback([this](const std::string &state) { this->publish_state(state); }); +} + +void MQTTTextComponent::dump_config() { + ESP_LOGCONFIG(TAG, "MQTT text '%s':", this->text_->get_name().c_str()); + LOG_MQTT_COMPONENT(true, false) +} + +std::string MQTTTextComponent::component_type() const { return "text"; } +const EntityBase *MQTTTextComponent::get_entity() const { return this->text_; } + +void MQTTTextComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) { + switch (this->text_->traits.get_mode()) { + case TEXT_MODE_TEXT: + root[MQTT_MODE] = "text"; + break; + case TEXT_MODE_PASSWORD: + root[MQTT_MODE] = "password"; + break; + } + + config.command_topic = true; +} +bool MQTTTextComponent::send_initial_state() { + if (this->text_->has_state()) { + return this->publish_state(this->text_->state); + } else { + return true; + } +} +bool MQTTTextComponent::publish_state(const std::string &value) { + return this->publish(this->get_state_topic_(), value); +} + +} // namespace mqtt +} // namespace esphome + +#endif +#endif // USE_MQTT diff --git a/esphome/components/mqtt/mqtt_text.h b/esphome/components/mqtt/mqtt_text.h new file mode 100644 index 0000000000..d9486fcbf8 --- /dev/null +++ b/esphome/components/mqtt/mqtt_text.h @@ -0,0 +1,46 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_MQTT +#ifdef USE_TEXT + +#include "esphome/components/text/text.h" +#include "mqtt_component.h" + +namespace esphome { +namespace mqtt { + +class MQTTTextComponent : public mqtt::MQTTComponent { + public: + /** Construct this MQTTTextComponent instance with the provided friendly_name and text + * + * @param text The text input. + */ + explicit MQTTTextComponent(text::Text *text); + + // ========== INTERNAL METHODS ========== + // (In most use cases you won't need these) + /// Override setup. + void setup() override; + void dump_config() override; + + void send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) override; + + bool send_initial_state() override; + + bool publish_state(const std::string &value); + + protected: + /// Override for MQTTComponent, returns "text". + std::string component_type() const override; + const EntityBase *get_entity() const override; + + text::Text *text_; +}; + +} // namespace mqtt +} // namespace esphome + +#endif +#endif // USE_MQTT diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index 02c80ad5a0..e5bcf681f4 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -19,6 +19,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, UNIT_MINUTE, UNIT_SECOND, + CONF_SET_ACTION, ) AUTO_LOAD = ["number", "switch"] @@ -46,7 +47,6 @@ CONF_QUEUE_ENABLE_SWITCH = "queue_enable_switch" CONF_REPEAT_NUMBER = "repeat_number" CONF_REVERSE_SWITCH = "reverse_switch" CONF_RUN_DURATION_NUMBER = "run_duration_number" -CONF_SET_ACTION = "set_action" CONF_STANDBY_SWITCH = "standby_switch" CONF_VALVE_NUMBER = "valve_number" CONF_VALVE_OPEN_DELAY = "valve_open_delay" diff --git a/esphome/components/template/number/__init__.py b/esphome/components/template/number/__init__.py index b9a507c7e9..c6ed25adda 100644 --- a/esphome/components/template/number/__init__.py +++ b/esphome/components/template/number/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_OPTIMISTIC, CONF_RESTORE_VALUE, CONF_STEP, + CONF_SET_ACTION, ) from .. import template_ns @@ -18,8 +19,6 @@ TemplateNumber = template_ns.class_( "TemplateNumber", number.Number, cg.PollingComponent ) -CONF_SET_ACTION = "set_action" - def validate_min_max(config): if config[CONF_MAX_VALUE] <= config[CONF_MIN_VALUE]: diff --git a/esphome/components/template/select/__init__.py b/esphome/components/template/select/__init__.py index d116cbb8ae..75dbd4f5c5 100644 --- a/esphome/components/template/select/__init__.py +++ b/esphome/components/template/select/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_OPTIONS, CONF_OPTIMISTIC, CONF_RESTORE_VALUE, + CONF_SET_ACTION, ) from .. import template_ns @@ -16,8 +17,6 @@ TemplateSelect = template_ns.class_( "TemplateSelect", select.Select, cg.PollingComponent ) -CONF_SET_ACTION = "set_action" - def validate(config): if CONF_LAMBDA in config: diff --git a/esphome/components/template/text/__init__.py b/esphome/components/template/text/__init__.py new file mode 100644 index 0000000000..a82664ee15 --- /dev/null +++ b/esphome/components/template/text/__init__.py @@ -0,0 +1,92 @@ +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import text +from esphome.const import ( + CONF_INITIAL_VALUE, + CONF_LAMBDA, + CONF_OPTIMISTIC, + CONF_RESTORE_VALUE, + CONF_MAX_LENGTH, + CONF_MIN_LENGTH, + CONF_PATTERN, + CONF_SET_ACTION, +) +from .. import template_ns + +TemplateText = template_ns.class_("TemplateText", text.Text, cg.PollingComponent) + +TextSaverBase = template_ns.class_("TemplateTextSaverBase") +TextSaverTemplate = template_ns.class_("TextSaver", TextSaverBase) + +CONF_MAX_RESTORE_DATA_LENGTH = "max_restore_data_length" + + +def validate(config): + if CONF_LAMBDA in config: + if config[CONF_OPTIMISTIC]: + raise cv.Invalid("optimistic cannot be used with lambda") + if CONF_INITIAL_VALUE in config: + raise cv.Invalid("initial_value cannot be used with lambda") + if CONF_RESTORE_VALUE in config: + raise cv.Invalid("restore_value cannot be used with lambda") + elif CONF_INITIAL_VALUE not in config: + config[CONF_INITIAL_VALUE] = "" + + if not config[CONF_OPTIMISTIC] and CONF_SET_ACTION not in config: + raise cv.Invalid( + "Either optimistic mode must be enabled, or set_action must be set, to handle the text input being set." + ) + + with cv.prepend_path(CONF_MIN_LENGTH): + if config[CONF_MIN_LENGTH] >= config[CONF_MAX_LENGTH]: + raise cv.Invalid("min_length must be less than max_length") + return config + + +CONFIG_SCHEMA = cv.All( + text.TEXT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(TemplateText), + cv.Optional(CONF_MIN_LENGTH, default=0): cv.int_range(min=0, max=255), + cv.Optional(CONF_MAX_LENGTH, default=255): cv.int_range(min=0, max=255), + cv.Optional(CONF_PATTERN): cv.string, + cv.Optional(CONF_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, + cv.Optional(CONF_SET_ACTION): automation.validate_automation(single=True), + cv.Optional(CONF_INITIAL_VALUE): cv.string_strict, + cv.Optional(CONF_RESTORE_VALUE, default=False): cv.boolean, + } + ).extend(cv.polling_component_schema("60s")), + validate, +) + + +async def to_code(config): + var = await text.new_text( + config, + min_length=config[CONF_MIN_LENGTH], + max_length=config[CONF_MAX_LENGTH], + pattern=config.get(CONF_PATTERN), + ) + await cg.register_component(var, config) + + if CONF_LAMBDA in config: + template_ = await cg.process_lambda( + config[CONF_LAMBDA], [], return_type=cg.optional.template(cg.std_string) + ) + cg.add(var.set_template(template_)) + + else: + cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) + if initial_value_config := config.get(CONF_INITIAL_VALUE): + cg.add(var.set_initial_value(initial_value_config)) + if config[CONF_RESTORE_VALUE]: + args = cg.TemplateArguments(config[CONF_MAX_LENGTH]) + saver = TextSaverTemplate.template(args).new() + cg.add(var.set_value_saver(saver)) + + if CONF_SET_ACTION in config: + await automation.build_automation( + var.get_set_trigger(), [(cg.std_string, "x")], config[CONF_SET_ACTION] + ) diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp new file mode 100644 index 0000000000..fb0208e198 --- /dev/null +++ b/esphome/components/template/text/template_text.cpp @@ -0,0 +1,64 @@ +#include "template_text.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace template_ { + +static const char *const TAG = "template.text"; + +void TemplateText::setup() { + if (!(this->f_ == nullptr)) { + if (this->f_.has_value()) + return; + } + + std::string value; + ESP_LOGD(TAG, "Setting up Template Text Input"); + value = this->initial_value_; + if (!this->pref_) { + ESP_LOGD(TAG, "State from initial: %s", value.c_str()); + } else { + uint32_t key = this->get_object_id_hash(); + key += this->traits.get_min_length() << 2; + key += this->traits.get_max_length() << 4; + key += fnv1_hash(this->traits.get_pattern()) << 6; + this->pref_->setup(key, value); + } + if (!value.empty()) + this->publish_state(value); +} + +void TemplateText::update() { + if (this->f_ == nullptr) + return; + + if (!this->f_.has_value()) + return; + + auto val = (*this->f_)(); + if (!val.has_value()) + return; + + this->publish_state(*val); +} + +void TemplateText::control(const std::string &value) { + this->set_trigger_->trigger(value); + + if (this->optimistic_) + this->publish_state(value); + + if (this->pref_) { + if (!this->pref_->save(value)) { + ESP_LOGW(TAG, "Text value too long to save"); + } + } +} +void TemplateText::dump_config() { + LOG_TEXT("", "Template Text Input", this); + ESP_LOGCONFIG(TAG, " Optimistic: %s", YESNO(this->optimistic_)); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace template_ +} // namespace esphome diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h new file mode 100644 index 0000000000..bcfc54a2ba --- /dev/null +++ b/esphome/components/template/text/template_text.h @@ -0,0 +1,87 @@ +#pragma once + +#include "esphome/components/text/text.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/preferences.h" + +namespace esphome { +namespace template_ { + +// We keep this separate so we don't have to template and duplicate +// the text input for each different size flash allocation. +class TemplateTextSaverBase { + public: + virtual bool save(const std::string &value) { return true; } + + virtual void setup(uint32_t id, std::string &value) {} + + protected: + ESPPreferenceObject pref_; + std::string prev_; +}; + +template class TextSaver : public TemplateTextSaverBase { + public: + bool save(const std::string &value) override { + int diff = value.compare(this->prev_); + if (diff != 0) { + // If string is bigger than the allocation, do not save it. + // We don't need to waste ram setting prev_value either. + int size = value.size(); + if (size <= SZ) { + // Make it into a length prefixed thing + unsigned char temp[SZ + 1]; + memcpy(temp + 1, value.c_str(), size); + // SZ should be pre checked at the schema level, it can't go past the char range. + temp[0] = ((unsigned char) size); + this->pref_.save(&temp); + this->prev_.assign(value); + return true; + } + } + return false; + } + + // Make the preference object. Fill the provided location with the saved data + // If it is available, else leave it alone + void setup(uint32_t id, std::string &value) override { + this->pref_ = global_preferences->make_preference(id); + + char temp[SZ + 1]; + bool hasdata = this->pref_.load(&temp); + + if (hasdata) { + value.assign(temp + 1, (size_t) temp[0]); + } + + this->prev_.assign(value); + } +}; + +class TemplateText : public text::Text, public PollingComponent { + public: + void set_template(std::function()> &&f) { this->f_ = f; } + + void setup() override; + void update() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + Trigger *get_set_trigger() const { return this->set_trigger_; } + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } + void set_initial_value(const std::string &initial_value) { this->initial_value_ = initial_value; } + void set_value_saver(TemplateTextSaverBase *restore_value_saver) { this->pref_ = restore_value_saver; } + + protected: + void control(const std::string &value) override; + bool optimistic_ = false; + std::string initial_value_; + Trigger *set_trigger_ = new Trigger(); + optional()>> f_{nullptr}; + + TemplateTextSaverBase *pref_ = nullptr; +}; + +} // namespace template_ +} // namespace esphome diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py new file mode 100644 index 0000000000..21c23ce73b --- /dev/null +++ b/esphome/components/text/__init__.py @@ -0,0 +1,138 @@ +from typing import Optional +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import automation +from esphome.components import mqtt +from esphome.const import ( + CONF_ID, + CONF_MODE, + CONF_ON_VALUE, + CONF_TRIGGER_ID, + CONF_MQTT_ID, + CONF_VALUE, +) + +from esphome.core import CORE, coroutine_with_priority +from esphome.cpp_helpers import setup_entity + +CODEOWNERS = ["@mauritskorse"] +IS_PLATFORM_COMPONENT = True + +text_ns = cg.esphome_ns.namespace("text") +Text = text_ns.class_("Text", cg.EntityBase) +TextPtr = Text.operator("ptr") + +# Triggers +TextStateTrigger = text_ns.class_( + "TextStateTrigger", automation.Trigger.template(cg.std_string) +) + +# Actions +TextSetAction = text_ns.class_("TextSetAction", automation.Action) + +# Conditions +TextMode = text_ns.enum("TextMode") + +TEXT_MODES = { + "TEXT": TextMode.TEXT_MODE_TEXT, + "PASSWORD": TextMode.TEXT_MODE_PASSWORD, # to be implemented for keys, passwords, etc. +} + +TEXT_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend(cv.MQTT_COMPONENT_SCHEMA).extend( + { + cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTextComponent), + cv.GenerateID(): cv.declare_id(Text), + cv.Optional(CONF_ON_VALUE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TextStateTrigger), + } + ), + cv.Required(CONF_MODE): cv.enum(TEXT_MODES, upper=True), + } +) + + +async def setup_text_core_( + var, + config, + *, + min_length: Optional[int], + max_length: Optional[int], + pattern: Optional[str], +): + await setup_entity(var, config) + + cg.add(var.traits.set_min_length(min_length)) + cg.add(var.traits.set_max_length(max_length)) + if pattern is not None: + cg.add(var.traits.set_pattern(pattern)) + + cg.add(var.traits.set_mode(config[CONF_MODE])) + + for conf in config.get(CONF_ON_VALUE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [(cg.std_string, "x")], conf) + + if CONF_MQTT_ID in config: + mqtt_ = cg.new_Pvariable(config[CONF_MQTT_ID], var) + await mqtt.register_mqtt_component(mqtt_, config) + + +async def register_text( + var, + config, + *, + min_length: Optional[int] = 0, + max_length: Optional[int] = 255, + pattern: Optional[str] = None, +): + if not CORE.has_id(config[CONF_ID]): + var = cg.Pvariable(config[CONF_ID], var) + cg.add(cg.App.register_text(var)) + await setup_text_core_( + var, config, min_length=min_length, max_length=max_length, pattern=pattern + ) + + +async def new_text( + config, + *, + min_length: Optional[int] = 0, + max_length: Optional[int] = 255, + pattern: Optional[str] = None, +): + var = cg.new_Pvariable(config[CONF_ID]) + await register_text( + var, config, min_length=min_length, max_length=max_length, pattern=pattern + ) + return var + + +@coroutine_with_priority(40.0) +async def to_code(config): + cg.add_define("USE_TEXT") + cg.add_global(text_ns.using) + + +OPERATION_BASE_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(Text), + } +) + + +@automation.register_action( + "text.set", + TextSetAction, + OPERATION_BASE_SCHEMA.extend( + { + cv.Required(CONF_VALUE): cv.templatable(cv.string_strict), + } + ), +) +async def text_set_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.std_string) + cg.add(var.set_value(template_)) + return var diff --git a/esphome/components/text/automation.h b/esphome/components/text/automation.h new file mode 100644 index 0000000000..f20a4f433b --- /dev/null +++ b/esphome/components/text/automation.h @@ -0,0 +1,33 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "text.h" + +namespace esphome { +namespace text { + +class TextStateTrigger : public Trigger { + public: + explicit TextStateTrigger(Text *parent) { + parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); + } +}; + +template class TextSetAction : public Action { + public: + explicit TextSetAction(Text *text) : text_(text) {} + TEMPLATABLE_VALUE(std::string, value) + + void play(Ts... x) override { + auto call = this->text_->make_call(); + call.set_value(this->value_.value(x...)); + call.perform(); + } + + protected: + Text *text_; +}; + +} // namespace text +} // namespace esphome diff --git a/esphome/components/text/text.cpp b/esphome/components/text/text.cpp new file mode 100644 index 0000000000..8f0242e747 --- /dev/null +++ b/esphome/components/text/text.cpp @@ -0,0 +1,26 @@ +#include "text.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace text { + +static const char *const TAG = "text"; + +void Text::publish_state(const std::string &state) { + this->has_state_ = true; + this->state = state; + if (this->traits.get_mode() == TEXT_MODE_PASSWORD) { + ESP_LOGD(TAG, "'%s': Sending state " LOG_SECRET("'%s'"), this->get_name().c_str(), state.c_str()); + + } else { + ESP_LOGD(TAG, "'%s': Sending state %s", this->get_name().c_str(), state.c_str()); + } + this->state_callback_.call(state); +} + +void Text::add_on_state_callback(std::function &&callback) { + this->state_callback_.add(std::move(callback)); +} + +} // namespace text +} // namespace esphome diff --git a/esphome/components/text/text.h b/esphome/components/text/text.h new file mode 100644 index 0000000000..f71dde69ba --- /dev/null +++ b/esphome/components/text/text.h @@ -0,0 +1,55 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/entity_base.h" +#include "esphome/core/helpers.h" +#include "text_call.h" +#include "text_traits.h" + +namespace esphome { +namespace text { + +#define LOG_TEXT(prefix, type, obj) \ + if ((obj) != nullptr) { \ + ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ + if (!(obj)->get_icon().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + } \ + } + +/** Base-class for all text inputs. + * + * A text input can use publish_state to send out a new value. + */ +class Text : public EntityBase { + public: + std::string state; + TextTraits traits; + + void publish_state(const std::string &state); + + /// Return whether this text input has gotten a full state yet. + bool has_state() const { return has_state_; } + + /// Instantiate a TextCall object to modify this text component's state. + TextCall make_call() { return TextCall(this); } + + void add_on_state_callback(std::function &&callback); + + protected: + friend class TextCall; + + /** Set the value of the text input, this is a virtual method that each text input integration must implement. + * + * This method is called by the TextCall. + * + * @param value The value as validated by the TextCall. + */ + virtual void control(const std::string &value) = 0; + + CallbackManager state_callback_; + bool has_state_{false}; +}; + +} // namespace text +} // namespace esphome diff --git a/esphome/components/text/text_call.cpp b/esphome/components/text/text_call.cpp new file mode 100644 index 0000000000..0d0a1d228d --- /dev/null +++ b/esphome/components/text/text_call.cpp @@ -0,0 +1,56 @@ +#include "text_call.h" +#include "esphome/core/log.h" +#include "text.h" + +namespace esphome { +namespace text { + +static const char *const TAG = "text"; + +TextCall &TextCall::set_value(const std::string &value) { + this->value_ = value; + return *this; +} + +void TextCall::validate_() { + const auto *name = this->parent_->get_name().c_str(); + + if (!this->value_.has_value()) { + ESP_LOGW(TAG, "'%s' - No value set for TextCall", name); + return; + } + + int sz = this->value_.value().size(); + + if (sz > this->parent_->traits.get_max_length()) { + ESP_LOGW(TAG, "'%s' - Value set for TextCall is too long", name); + this->value_.reset(); + return; + } + + if (sz < this->parent_->traits.get_min_length()) { + ESP_LOGW(TAG, "'%s' - Value set for TextCall is too short", name); + this->value_.reset(); + return; + } +} + +void TextCall::perform() { + this->validate_(); + if (!this->value_.has_value()) { + ESP_LOGW(TAG, "'%s' - No value set for TextCall", this->parent_->get_name().c_str()); + return; + } + std::string target_value = this->value_.value(); + + if (this->parent_->traits.get_mode() == TEXT_MODE_PASSWORD) { + ESP_LOGD(TAG, "'%s' - Setting password value: " LOG_SECRET("'%s'"), this->parent_->get_name().c_str(), + target_value.c_str()); + } else { + ESP_LOGD(TAG, "'%s' - Setting text value: %s", this->parent_->get_name().c_str(), target_value.c_str()); + } + this->parent_->control(target_value); +} + +} // namespace text +} // namespace esphome diff --git a/esphome/components/text/text_call.h b/esphome/components/text/text_call.h new file mode 100644 index 0000000000..9f75a25c6b --- /dev/null +++ b/esphome/components/text/text_call.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/core/helpers.h" +#include "text_traits.h" + +namespace esphome { +namespace text { + +class Text; + +class TextCall { + public: + explicit TextCall(Text *parent) : parent_(parent) {} + void perform(); + + TextCall &set_value(const std::string &value); + + protected: + Text *const parent_; + optional value_; + void validate_(); +}; + +} // namespace text +} // namespace esphome diff --git a/esphome/components/text/text_traits.h b/esphome/components/text/text_traits.h new file mode 100644 index 0000000000..952afa70c7 --- /dev/null +++ b/esphome/components/text/text_traits.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +#include "esphome/core/helpers.h" + +namespace esphome { +namespace text { + +enum TextMode : uint8_t { + TEXT_MODE_TEXT = 0, + TEXT_MODE_PASSWORD = 1, +}; + +class TextTraits { + public: + // Set/get the number value boundaries. + void set_min_length(int min_length) { this->min_length_ = min_length; } + int get_min_length() const { return this->min_length_; } + void set_max_length(int max_length) { this->max_length_ = max_length; } + int get_max_length() const { return this->max_length_; } + + // Set/get the pattern. + void set_pattern(std::string pattern) { this->pattern_ = std::move(pattern); } + std::string get_pattern() const { return this->pattern_; } + + // Set/get the frontend mode. + void set_mode(TextMode mode) { this->mode_ = mode; } + TextMode get_mode() const { return this->mode_; } + + protected: + int min_length_; + int max_length_; + std::string pattern_; + TextMode mode_{TEXT_MODE_TEXT}; +}; + +} // namespace text +} // namespace esphome diff --git a/esphome/components/web_server/list_entities.cpp b/esphome/components/web_server/list_entities.cpp index 016dd37dd9..5c9009c5da 100644 --- a/esphome/components/web_server/list_entities.cpp +++ b/esphome/components/web_server/list_entities.cpp @@ -82,6 +82,13 @@ bool ListEntitiesIterator::on_number(number::Number *number) { } #endif +#ifdef USE_TEXT +bool ListEntitiesIterator::on_text(text::Text *text) { + this->web_server_->events_.send(this->web_server_->text_json(text, text->state, DETAIL_ALL).c_str(), "state"); + return true; +} +#endif + #ifdef USE_SELECT bool ListEntitiesIterator::on_select(select::Select *select) { this->web_server_->events_.send(this->web_server_->select_json(select, select->state, DETAIL_ALL).c_str(), "state"); diff --git a/esphome/components/web_server/list_entities.h b/esphome/components/web_server/list_entities.h index 1569c8ac57..7da5b3fe2c 100644 --- a/esphome/components/web_server/list_entities.h +++ b/esphome/components/web_server/list_entities.h @@ -41,6 +41,9 @@ class ListEntitiesIterator : public ComponentIterator { #ifdef USE_NUMBER bool on_number(number::Number *number) override; #endif +#ifdef USE_TEXT + bool on_text(text::Text *text) override; +#endif #ifdef USE_SELECT bool on_select(select::Select *select) override; #endif diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index dec8fb8e21..ccc86e5e53 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -264,6 +264,32 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { } #endif +#ifdef USE_TEXT + for (auto *obj : App.get_texts()) { + if (this->include_internal_ || !obj->is_internal()) { + write_row(stream, obj, "text", "", [](AsyncResponseStream &stream, EntityBase *obj) { + text::Text *text = (text::Text *) obj; + auto mode = (int) text->traits.get_mode(); + stream.print(R"(traits.get_min_length()); + stream.print(R"(" maxlength=")"); + stream.print(text->traits.get_max_length()); + stream.print(R"(" pattern=")"); + stream.print(text->traits.get_pattern().c_str()); + stream.print(R"(" value=")"); + stream.print(text->state.c_str()); + stream.print(R"("/>)"); + }); + } + } +#endif + #ifdef USE_SELECT for (auto *obj : App.get_selects()) { if (this->include_internal_ || !obj->is_internal()) { @@ -795,6 +821,57 @@ std::string WebServer::number_json(number::Number *obj, float value, JsonDetail } #endif +#ifdef USE_TEXT +void WebServer::on_text_update(text::Text *obj, const std::string &state) { + this->events_.send(this->text_json(obj, state, DETAIL_STATE).c_str(), "state"); +} +void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match) { + for (auto *obj : App.get_texts()) { + if (obj->get_object_id() != match.id) + continue; + + if (request->method() == HTTP_GET) { + std::string data = this->text_json(obj, obj->state, DETAIL_STATE); + request->send(200, "text/json", data.c_str()); + return; + } + if (match.method != "set") { + request->send(404); + return; + } + + auto call = obj->make_call(); + if (request->hasParam("value")) { + String value = request->getParam("value")->value(); + call.set_value(value.c_str()); + } + + this->defer([call]() mutable { call.perform(); }); + request->send(200); + return; + } + request->send(404); +} + +std::string WebServer::text_json(text::Text *obj, const std::string &value, JsonDetail start_config) { + return json::build_json([obj, value, start_config](JsonObject root) { + set_json_id(root, obj, "text-" + obj->get_object_id(), start_config); + if (start_config == DETAIL_ALL) { + root["mode"] = (int) obj->traits.get_mode(); + } + root["min_length"] = obj->traits.get_min_length(); + root["max_length"] = obj->traits.get_max_length(); + root["pattern"] = obj->traits.get_pattern(); + if (obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD) { + root["state"] = "********"; + } else { + root["state"] = value; + } + root["value"] = value; + }); +} +#endif + #ifdef USE_SELECT void WebServer::on_select_update(select::Select *obj, const std::string &state, size_t index) { this->events_.send(this->select_json(obj, state, DETAIL_STATE).c_str(), "state"); @@ -1116,6 +1193,11 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) { return true; #endif +#ifdef USE_TEXT + if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain == "text") + return true; +#endif + #ifdef USE_SELECT if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain == "select") return true; @@ -1222,6 +1304,13 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { } #endif +#ifdef USE_TEXT + if (match.domain == "text") { + this->handle_text_request(request, match); + return; + } +#endif + #ifdef USE_SELECT if (match.domain == "select") { this->handle_select_request(request, match); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 788e30ccf2..45b99d4eba 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -216,6 +216,15 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { std::string number_json(number::Number *obj, float value, JsonDetail start_config); #endif +#ifdef USE_TEXT + void on_text_update(text::Text *obj, const std::string &state) override; + /// Handle a text input request under '/text/'. + void handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match); + + /// Dump the text state with its value as a JSON string. + std::string text_json(text::Text *obj, const std::string &value, JsonDetail start_config); +#endif + #ifdef USE_SELECT void on_select_update(select::Select *obj, const std::string &state, size_t index) override; /// Handle a select request under '/select/'. diff --git a/esphome/config_validation.py b/esphome/config_validation.py index d699a2647b..1b95370fdf 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -182,6 +182,7 @@ RESERVED_IDS = [ "struct", "switch", "template", + "text", "this", "thread_local", "throw", diff --git a/esphome/const.py b/esphome/const.py index a06c75ae44..9e0bd4da0a 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -542,6 +542,7 @@ CONF_PANASONIC = "panasonic" CONF_PARAMETERS = "parameters" CONF_PASSWORD = "password" CONF_PATH = "path" +CONF_PATTERN = "pattern" CONF_PAYLOAD = "payload" CONF_PAYLOAD_AVAILABLE = "payload_available" CONF_PAYLOAD_NOT_AVAILABLE = "payload_not_available" @@ -679,6 +680,7 @@ CONF_SERVERS = "servers" CONF_SERVICE = "service" CONF_SERVICE_UUID = "service_uuid" CONF_SERVICES = "services" +CONF_SET_ACTION = "set_action" CONF_SET_POINT_MINIMUM_DIFFERENTIAL = "set_point_minimum_differential" CONF_SETUP_MODE = "setup_mode" CONF_SETUP_PRIORITY = "setup_priority" diff --git a/esphome/core/application.h b/esphome/core/application.h index 054f2ea648..f2dbaa4db5 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -39,6 +39,9 @@ #ifdef USE_NUMBER #include "esphome/components/number/number.h" #endif +#ifdef USE_TEXT +#include "esphome/components/text/text.h" +#endif #ifdef USE_SELECT #include "esphome/components/select/select.h" #endif @@ -117,6 +120,10 @@ class Application { void register_number(number::Number *number) { this->numbers_.push_back(number); } #endif +#ifdef USE_TEXT + void register_text(text::Text *text) { this->texts_.push_back(text); } +#endif + #ifdef USE_SELECT void register_select(select::Select *select) { this->selects_.push_back(select); } #endif @@ -277,6 +284,15 @@ class Application { return nullptr; } #endif +#ifdef USE_TEXT + const std::vector &get_texts() { return this->texts_; } + text::Text *get_text_by_key(uint32_t key, bool include_internal = false) { + for (auto *obj : this->texts_) + if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) + return obj; + return nullptr; + } +#endif #ifdef USE_SELECT const std::vector &get_selects() { return this->selects_; } select::Select *get_select_by_key(uint32_t key, bool include_internal = false) { @@ -364,6 +380,9 @@ class Application { #ifdef USE_SELECT std::vector selects_{}; #endif +#ifdef USE_TEXT + std::vector texts_{}; +#endif #ifdef USE_LOCK std::vector locks_{}; #endif diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index 871f6d6c0e..0bf8fb6f83 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -202,6 +202,21 @@ void ComponentIterator::advance() { } break; #endif +#ifdef USE_TEXT + case IteratorState::TEXT: + if (this->at_ >= App.get_texts().size()) { + advance_platform = true; + } else { + auto *text = App.get_texts()[this->at_]; + if (text->is_internal() && !this->include_internal_) { + success = true; + break; + } else { + success = this->on_text(text); + } + } + break; +#endif #ifdef USE_SELECT case IteratorState::SELECT: if (this->at_ >= App.get_selects().size()) { diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 8b2da6218c..646c39705f 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -57,6 +57,9 @@ class ComponentIterator { #ifdef USE_NUMBER virtual bool on_number(number::Number *number) = 0; #endif +#ifdef USE_TEXT + virtual bool on_text(text::Text *text) = 0; +#endif #ifdef USE_SELECT virtual bool on_select(select::Select *select) = 0; #endif @@ -111,6 +114,9 @@ class ComponentIterator { #ifdef USE_NUMBER NUMBER, #endif +#ifdef USE_TEXT + TEXT, +#endif #ifdef USE_SELECT SELECT, #endif diff --git a/esphome/core/controller.cpp b/esphome/core/controller.cpp index 18d427b40c..95f63b6224 100644 --- a/esphome/core/controller.cpp +++ b/esphome/core/controller.cpp @@ -59,6 +59,12 @@ void Controller::setup_controller(bool include_internal) { obj->add_on_state_callback([this, obj](float state) { this->on_number_update(obj, state); }); } #endif +#ifdef USE_TEXT + for (auto *obj : App.get_texts()) { + if (include_internal || !obj->is_internal()) + obj->add_on_state_callback([this, obj](const std::string &state) { this->on_text_update(obj, state); }); + } +#endif #ifdef USE_SELECT for (auto *obj : App.get_selects()) { if (include_internal || !obj->is_internal()) { diff --git a/esphome/core/controller.h b/esphome/core/controller.h index 25a4acb36e..f977d8a36a 100644 --- a/esphome/core/controller.h +++ b/esphome/core/controller.h @@ -31,6 +31,9 @@ #ifdef USE_NUMBER #include "esphome/components/number/number.h" #endif +#ifdef USE_TEXT +#include "esphome/components/text/text.h" +#endif #ifdef USE_SELECT #include "esphome/components/select/select.h" #endif @@ -76,6 +79,9 @@ class Controller { #ifdef USE_NUMBER virtual void on_number_update(number::Number *obj, float state){}; #endif +#ifdef USE_TEXT + virtual void on_text_update(text::Text *obj, const std::string &state){}; +#endif #ifdef USE_SELECT virtual void on_select_update(select::Select *obj, const std::string &state, size_t index){}; #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 71493119c0..598b08063b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -44,6 +44,7 @@ #define USE_SENSOR #define USE_STATUS_LED #define USE_SWITCH +#define USE_TEXT #define USE_TEXT_SENSOR #define USE_TIME #define USE_TOUCHSCREEN diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 3c19034e96..b1292095d8 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -4,7 +4,7 @@ It's pretty crappy spaghetti code, but it works. you need to install protobuf-compiler: -running protc --version should return +running protoc --version should return libprotoc 3.6.1 then run this script with python3 and the files diff --git a/script/ci-custom.py b/script/ci-custom.py index da4da50d7e..d8c2f3053f 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -617,6 +617,7 @@ def lint_trailing_whitespace(fname, match): "esphome/components/lock/lock.h", "esphome/components/mqtt/mqtt_component.h", "esphome/components/number/number.h", + "esphome/components/text/text.h", "esphome/components/output/binary_output.h", "esphome/components/output/float_output.h", "esphome/components/nextion/nextion_base.h", diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py new file mode 100644 index 0000000000..43f4ef2592 --- /dev/null +++ b/tests/component_tests/text/test_text.py @@ -0,0 +1,56 @@ +"""Tests for the binary sensor component.""" + + +def test_text_is_setup(generate_main): + """ + When the binary sensor is set in the yaml file, it should be registered in main + """ + # Given + + # When + main_cpp = generate_main("tests/component_tests/text/test_text.yaml") + + # Then + assert "new template_::TemplateText();" in main_cpp + assert "App.register_text" in main_cpp + + +def test_text_sets_mandatory_fields(generate_main): + """ + When the mandatory fields are set in the yaml, they should be set in main + """ + # Given + + # When + main_cpp = generate_main("tests/component_tests/text/test_text.yaml") + + # Then + assert 'it_1->set_name("test 1 text");' in main_cpp + + +def test_text_config_value_internal_set(generate_main): + """ + Test that the "internal" config value is correctly set + """ + # Given + + # When + main_cpp = generate_main("tests/component_tests/text/test_text.yaml") + + # Then + assert "it_2->set_internal(false);" in main_cpp + assert "it_3->set_internal(true);" in main_cpp + + +def test_text_config_value_mode_set(generate_main): + """ + Test that the "internal" config value is correctly set + """ + # Given + + # When + main_cpp = generate_main("tests/component_tests/text/test_text.yaml") + + # Then + assert "it_1->traits.set_mode(text::TEXT_MODE_TEXT);" in main_cpp + assert "it_3->traits.set_mode(text::TEXT_MODE_PASSWORD);" in main_cpp diff --git a/tests/component_tests/text/test_text.yaml b/tests/component_tests/text/test_text.yaml new file mode 100644 index 0000000000..d0fdf5303f --- /dev/null +++ b/tests/component_tests/text/test_text.yaml @@ -0,0 +1,33 @@ +esphome: + name: test + +esp32: + board: esp32dev + +text: + - platform: template + name: "test 1 text" + id: "it_1" + optimistic: true + mode: text + + - platform: template + name: "test 2 text" + id: "it_2" + icon: "mdi:text" + optimistic: true + min_length: 5 + max_length: 255 + internal: false + initial_value: "Welcome ESPHOME" + restore_value: true + mode: text + + - platform: template + name: "test 3 key" + id: "it_3" + icon: "mdi:text" + mode: "password" + optimistic: true + internal: true + max_length: 255 diff --git a/tests/test2.yaml b/tests/test2.yaml index fe1ad91f8d..bfc886eaa4 100644 --- a/tests/test2.yaml +++ b/tests/test2.yaml @@ -795,7 +795,23 @@ switch: value: !lambda |- return {0x13, 0x37}; - esp32_ble_server: id: ble manufacturer_data: [0x72, 0x4, 0x00, 0x23] + +text: + - platform: template + name: My Text + id: my_text + min_length: 0 + max_length: 20 + mode: text + pattern: "[a-z]+" + optimistic: true + restore_value: true + initial_value: "Hello World" + - platform: copy + name: My Text Copy + id: my_text_copy + source_id: my_text + mode: password