From dd37a4e04c8e1220f934de89a97c326381d0fdda Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 13 Jul 2021 07:20:12 +1200 Subject: [PATCH] Add Number entities (from Home Assistant) (#1971) Co-authored-by: Oxan van Leeuwen --- CODEOWNERS | 1 + esphome/components/api/api.proto | 39 ++++ esphome/components/api/api_connection.cpp | 36 ++++ esphome/components/api/api_connection.h | 5 + esphome/components/api/api_pb2.cpp | 173 ++++++++++++++++++ esphome/components/api/api_pb2.h | 39 ++++ esphome/components/api/api_pb2_service.cpp | 36 ++++ 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 | 5 + esphome/components/api/subscribe_state.h | 3 + esphome/components/api/util.cpp | 15 ++ esphome/components/api/util.h | 6 + esphome/components/mqtt/__init__.py | 1 + esphome/components/mqtt/mqtt_number.cpp | 61 ++++++ esphome/components/mqtt/mqtt_number.h | 46 +++++ esphome/components/number/__init__.py | 154 ++++++++++++++++ esphome/components/number/automation.cpp | 47 +++++ esphome/components/number/automation.h | 76 ++++++++ esphome/components/number/number.cpp | 76 ++++++++ esphome/components/number/number.h | 112 ++++++++++++ .../components/template/number/__init__.py | 63 +++++++ .../template/number/template_number.cpp | 39 ++++ .../template/number/template_number.h | 30 +++ esphome/components/web_server/web_server.cpp | 49 +++++ esphome/components/web_server/web_server.h | 9 + esphome/const.py | 1 + esphome/core/application.h | 19 ++ esphome/core/controller.cpp | 6 + esphome/core/controller.h | 6 + esphome/core/defines.h | 1 + script/ci-custom.py | 1 + tests/test5.yaml | 17 ++ 36 files changed, 1206 insertions(+) create mode 100644 esphome/components/mqtt/mqtt_number.cpp create mode 100644 esphome/components/mqtt/mqtt_number.h create mode 100644 esphome/components/number/__init__.py create mode 100644 esphome/components/number/automation.cpp create mode 100644 esphome/components/number/automation.h create mode 100644 esphome/components/number/number.cpp create mode 100644 esphome/components/number/number.h create mode 100644 esphome/components/template/number/__init__.py create mode 100644 esphome/components/template/number/template_number.cpp create mode 100644 esphome/components/template/number/template_number.h diff --git a/CODEOWNERS b/CODEOWNERS index a1c7bf9bfd..2a57e5d81a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -73,6 +73,7 @@ esphome/components/midea_dongle/* @dudanov esphome/components/mitsubishi/* @RubyBailey esphome/components/network/* @esphome/core esphome/components/nfc/* @jesserockz +esphome/components/number/* @esphome/core esphome/components/ota/* @esphome/core esphome/components/output/* @esphome/core esphome/components/pid/* @OttoWinter diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 8034c980a4..40be1fd0db 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -38,6 +38,7 @@ service APIConnection { rpc switch_command (SwitchCommandRequest) returns (void) {} rpc camera_image (CameraImageRequest) returns (void) {} rpc climate_command (ClimateCommandRequest) returns (void) {} + rpc number_command (NumberCommandRequest) returns (void) {} } @@ -798,3 +799,41 @@ message ClimateCommandRequest { bool has_custom_preset = 20; string custom_preset = 21; } + +// ==================== NUMBER ==================== +message ListEntitiesNumberResponse { + option (id) = 49; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_NUMBER"; + + string object_id = 1; + fixed32 key = 2; + string name = 3; + string unique_id = 4; + + string icon = 5; + float min_value = 6; + float max_value = 7; + float step = 8; +} +message NumberStateResponse { + option (id) = 50; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_NUMBER"; + option (no_delay) = true; + + fixed32 key = 1; + float state = 2; + // If the number does not have a valid state yet. + // Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller + bool missing_state = 3; +} +message NumberCommandRequest { + option (id) = 51; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_NUMBER"; + option (no_delay) = true; + + fixed32 key = 1; + float state = 2; +} diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c36d36a159..8c76583fc7 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -553,6 +553,42 @@ void APIConnection::climate_command(const ClimateCommandRequest &msg) { } #endif +#ifdef USE_NUMBER +bool APIConnection::send_number_state(number::Number *number, float state) { + if (!this->state_subscription_) + return false; + + NumberStateResponse resp{}; + resp.key = number->get_object_id_hash(); + resp.state = state; + resp.missing_state = !number->has_state(); + return this->send_number_state_response(resp); +} +bool APIConnection::send_number_info(number::Number *number) { + ListEntitiesNumberResponse msg; + msg.key = number->get_object_id_hash(); + msg.object_id = number->get_object_id(); + msg.name = number->get_name(); + msg.unique_id = get_default_unique_id("number", number); + msg.icon = number->get_icon(); + + msg.min_value = number->get_min_value(); + msg.max_value = number->get_max_value(); + msg.step = number->get_step(); + + return this->send_list_entities_number_response(msg); +} +void APIConnection::number_command(const NumberCommandRequest &msg) { + number::Number *number = App.get_number_by_key(msg.key); + if (number == nullptr) + return; + + auto call = number->make_call(); + call.set_value(msg.state); + call.perform(); +} +#endif + #ifdef USE_ESP32_CAMERA void APIConnection::send_camera_state(std::shared_ptr image) { if (!this->state_subscription_) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 3e91ead52c..1d7fc48563 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -62,6 +62,11 @@ class APIConnection : public APIServerConnection { bool send_climate_state(climate::Climate *climate); bool send_climate_info(climate::Climate *climate); void climate_command(const ClimateCommandRequest &msg) override; +#endif +#ifdef USE_NUMBER + bool send_number_state(number::Number *number, float state); + bool send_number_info(number::Number *number); + void number_command(const NumberCommandRequest &msg) override; #endif bool send_log_message(int level, const char *tag, const char *line); void send_homeassistant_service_call(const HomeassistantServiceResponse &call) { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 83ebdd8b68..c3cfc8cd76 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -3256,6 +3256,179 @@ void ClimateCommandRequest::dump_to(std::string &out) const { out.append("\n"); out.append("}"); } +bool ListEntitiesNumberResponse::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; + } + default: + return false; + } +} +bool ListEntitiesNumberResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { + switch (field_id) { + case 2: { + this->key = value.as_fixed32(); + return true; + } + case 6: { + this->min_value = value.as_float(); + return true; + } + case 7: { + this->max_value = value.as_float(); + return true; + } + case 8: { + this->step = value.as_float(); + return true; + } + default: + return false; + } +} +void ListEntitiesNumberResponse::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_float(6, this->min_value); + buffer.encode_float(7, this->max_value); + buffer.encode_float(8, this->step); +} +void ListEntitiesNumberResponse::dump_to(std::string &out) const { + char buffer[64]; + out.append("ListEntitiesNumberResponse {\n"); + out.append(" object_id: "); + out.append("'").append(this->object_id).append("'"); + out.append("\n"); + + out.append(" key: "); + sprintf(buffer, "%u", 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(" min_value: "); + sprintf(buffer, "%g", this->min_value); + out.append(buffer); + out.append("\n"); + + out.append(" max_value: "); + sprintf(buffer, "%g", this->max_value); + out.append(buffer); + out.append("\n"); + + out.append(" step: "); + sprintf(buffer, "%g", this->step); + out.append(buffer); + out.append("\n"); + out.append("}"); +} +bool NumberStateResponse::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 NumberStateResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { + switch (field_id) { + case 1: { + this->key = value.as_fixed32(); + return true; + } + case 2: { + this->state = value.as_float(); + return true; + } + default: + return false; + } +} +void NumberStateResponse::encode(ProtoWriteBuffer buffer) const { + buffer.encode_fixed32(1, this->key); + buffer.encode_float(2, this->state); + buffer.encode_bool(3, this->missing_state); +} +void NumberStateResponse::dump_to(std::string &out) const { + char buffer[64]; + out.append("NumberStateResponse {\n"); + out.append(" key: "); + sprintf(buffer, "%u", this->key); + out.append(buffer); + out.append("\n"); + + out.append(" state: "); + sprintf(buffer, "%g", this->state); + out.append(buffer); + out.append("\n"); + + out.append(" missing_state: "); + out.append(YESNO(this->missing_state)); + out.append("\n"); + out.append("}"); +} +bool NumberCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { + switch (field_id) { + case 1: { + this->key = value.as_fixed32(); + return true; + } + case 2: { + this->state = value.as_float(); + return true; + } + default: + return false; + } +} +void NumberCommandRequest::encode(ProtoWriteBuffer buffer) const { + buffer.encode_fixed32(1, this->key); + buffer.encode_float(2, this->state); +} +void NumberCommandRequest::dump_to(std::string &out) const { + char buffer[64]; + out.append("NumberCommandRequest {\n"); + out.append(" key: "); + sprintf(buffer, "%u", this->key); + out.append(buffer); + out.append("\n"); + + out.append(" state: "); + sprintf(buffer, "%g", this->state); + out.append(buffer); + out.append("\n"); + out.append("}"); +} } // namespace api } // namespace esphome diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 6873e0d54c..e3bb1d9106 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -782,6 +782,45 @@ class ClimateCommandRequest : public ProtoMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; +class ListEntitiesNumberResponse : public ProtoMessage { + public: + std::string object_id{}; + uint32_t key{0}; + std::string name{}; + std::string unique_id{}; + std::string icon{}; + float min_value{0.0f}; + float max_value{0.0f}; + float step{0.0f}; + void encode(ProtoWriteBuffer buffer) const override; + void dump_to(std::string &out) const override; + + protected: + bool decode_32bit(uint32_t field_id, Proto32Bit value) override; + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; +}; +class NumberStateResponse : public ProtoMessage { + public: + uint32_t key{0}; + float state{0.0f}; + bool missing_state{false}; + void encode(ProtoWriteBuffer buffer) const override; + void dump_to(std::string &out) const override; + + protected: + bool decode_32bit(uint32_t field_id, Proto32Bit value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +class NumberCommandRequest : public ProtoMessage { + public: + uint32_t key{0}; + float state{0.0f}; + void encode(ProtoWriteBuffer buffer) const override; + void dump_to(std::string &out) const override; + + protected: + bool decode_32bit(uint32_t field_id, Proto32Bit 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 4fade19787..440a5d0ab3 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -184,6 +184,20 @@ bool APIServerConnectionBase::send_climate_state_response(const ClimateStateResp #endif #ifdef USE_CLIMATE #endif +#ifdef USE_NUMBER +bool APIServerConnectionBase::send_list_entities_number_response(const ListEntitiesNumberResponse &msg) { + ESP_LOGVV(TAG, "send_list_entities_number_response: %s", msg.dump().c_str()); + return this->send_message_(msg, 49); +} +#endif +#ifdef USE_NUMBER +bool APIServerConnectionBase::send_number_state_response(const NumberStateResponse &msg) { + ESP_LOGVV(TAG, "send_number_state_response: %s", msg.dump().c_str()); + return this->send_message_(msg, 50); +} +#endif +#ifdef USE_NUMBER +#endif bool APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) { switch (msg_type) { case 1: { @@ -349,6 +363,15 @@ bool APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, msg.decode(msg_data, msg_size); ESP_LOGVV(TAG, "on_climate_command_request: %s", msg.dump().c_str()); this->on_climate_command_request(msg); +#endif + break; + } + case 51: { +#ifdef USE_NUMBER + NumberCommandRequest msg; + msg.decode(msg_data, msg_size); + ESP_LOGVV(TAG, "on_number_command_request: %s", msg.dump().c_str()); + this->on_number_command_request(msg); #endif break; } @@ -547,6 +570,19 @@ void APIServerConnection::on_climate_command_request(const ClimateCommandRequest this->climate_command(msg); } #endif +#ifdef USE_NUMBER +void APIServerConnection::on_number_command_request(const NumberCommandRequest &msg) { + if (!this->is_connection_setup()) { + this->on_no_setup_connection(); + return; + } + if (!this->is_authenticated()) { + this->on_unauthenticated_access(); + return; + } + this->number_command(msg); +} +#endif } // namespace api } // namespace esphome diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index afbe39e314..398c10a811 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -111,6 +111,15 @@ class APIServerConnectionBase : public ProtoService { #endif #ifdef USE_CLIMATE virtual void on_climate_command_request(const ClimateCommandRequest &value){}; +#endif +#ifdef USE_NUMBER + bool send_list_entities_number_response(const ListEntitiesNumberResponse &msg); +#endif +#ifdef USE_NUMBER + bool send_number_state_response(const NumberStateResponse &msg); +#endif +#ifdef USE_NUMBER + virtual void on_number_command_request(const NumberCommandRequest &value){}; #endif protected: bool read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override; @@ -147,6 +156,9 @@ class APIServerConnection : public APIServerConnectionBase { #endif #ifdef USE_CLIMATE virtual void climate_command(const ClimateCommandRequest &msg) = 0; +#endif +#ifdef USE_NUMBER + virtual void number_command(const NumberCommandRequest &msg) = 0; #endif protected: void on_hello_request(const HelloRequest &msg) override; @@ -179,6 +191,9 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_CLIMATE void on_climate_command_request(const ClimateCommandRequest &msg) override; #endif +#ifdef USE_NUMBER + void on_number_command_request(const NumberCommandRequest &msg) override; +#endif }; } // namespace api diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 1373533ae2..7434030565 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -197,6 +197,15 @@ void APIServer::on_climate_update(climate::Climate *obj) { } #endif +#ifdef USE_NUMBER +void APIServer::on_number_update(number::Number *obj, float state) { + if (obj->is_internal()) + return; + for (auto *c : this->clients_) + c->send_number_state(obj, state); +} +#endif + float APIServer::get_setup_priority() const { return setup_priority::AFTER_WIFI; } void APIServer::set_port(uint16_t port) { this->port_ = port; } APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index eb6c91d01c..add22e121e 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -60,6 +60,9 @@ class APIServer : public Component, public Controller { #endif #ifdef USE_CLIMATE void on_climate_update(climate::Climate *obj) override; +#endif +#ifdef USE_NUMBER + void on_number_update(number::Number *obj, float state) override; #endif void send_homeassistant_service_call(const HomeassistantServiceResponse &call); void register_user_service(UserServiceDescriptor *descriptor) { this->user_services_.push_back(descriptor); } diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index d4245136ae..8897758073 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -51,5 +51,9 @@ bool ListEntitiesIterator::on_camera(esp32_camera::ESP32Camera *camera) { bool ListEntitiesIterator::on_climate(climate::Climate *climate) { return this->client_->send_climate_info(climate); } #endif +#ifdef USE_NUMBER +bool ListEntitiesIterator::on_number(number::Number *number) { return this->client_->send_number_info(number); } +#endif + } // namespace api } // namespace esphome diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h index 6b10a72fdf..c55ba5089e 100644 --- a/esphome/components/api/list_entities.h +++ b/esphome/components/api/list_entities.h @@ -39,6 +39,9 @@ class ListEntitiesIterator : public ComponentIterator { #endif #ifdef USE_CLIMATE bool on_climate(climate::Climate *climate) override; +#endif +#ifdef USE_NUMBER + bool on_number(number::Number *number) override; #endif bool on_end() override; diff --git a/esphome/components/api/subscribe_state.cpp b/esphome/components/api/subscribe_state.cpp index 2612a852d3..25aa7c8b31 100644 --- a/esphome/components/api/subscribe_state.cpp +++ b/esphome/components/api/subscribe_state.cpp @@ -37,6 +37,11 @@ bool InitialStateIterator::on_text_sensor(text_sensor::TextSensor *text_sensor) #ifdef USE_CLIMATE bool InitialStateIterator::on_climate(climate::Climate *climate) { return this->client_->send_climate_state(climate); } #endif +#ifdef USE_NUMBER +bool InitialStateIterator::on_number(number::Number *number) { + return this->client_->send_number_state(number, number->state); +} +#endif InitialStateIterator::InitialStateIterator(APIServer *server, APIConnection *client) : ComponentIterator(server), client_(client) {} diff --git a/esphome/components/api/subscribe_state.h b/esphome/components/api/subscribe_state.h index 51b9c695e4..f03322ac4a 100644 --- a/esphome/components/api/subscribe_state.h +++ b/esphome/components/api/subscribe_state.h @@ -36,6 +36,9 @@ class InitialStateIterator : public ComponentIterator { #endif #ifdef USE_CLIMATE bool on_climate(climate::Climate *climate) override; +#endif +#ifdef USE_NUMBER + bool on_number(number::Number *number) override; #endif protected: APIConnection *client_; diff --git a/esphome/components/api/util.cpp b/esphome/components/api/util.cpp index f929db5d6a..6e05d49b74 100644 --- a/esphome/components/api/util.cpp +++ b/esphome/components/api/util.cpp @@ -167,6 +167,21 @@ void ComponentIterator::advance() { } } break; +#endif +#ifdef USE_NUMBER + case IteratorState::NUMBER: + if (this->at_ >= App.get_numbers().size()) { + advance_platform = true; + } else { + auto *number = App.get_numbers()[this->at_]; + if (number->is_internal()) { + success = true; + break; + } else { + success = this->on_number(number); + } + } + break; #endif case IteratorState::MAX: if (this->on_end()) { diff --git a/esphome/components/api/util.h b/esphome/components/api/util.h index 5a29a48cbe..f8b248056b 100644 --- a/esphome/components/api/util.h +++ b/esphome/components/api/util.h @@ -47,6 +47,9 @@ class ComponentIterator { #endif #ifdef USE_CLIMATE virtual bool on_climate(climate::Climate *climate) = 0; +#endif +#ifdef USE_NUMBER + virtual bool on_number(number::Number *number) = 0; #endif virtual bool on_end(); @@ -81,6 +84,9 @@ class ComponentIterator { #endif #ifdef USE_CLIMATE CLIMATE, +#endif +#ifdef USE_NUMBER + NUMBER, #endif MAX, } state_{IteratorState::NONE}; diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 906c570b17..3559fce046 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -91,6 +91,7 @@ MQTTJSONLightComponent = mqtt_ns.class_("MQTTJSONLightComponent", MQTTComponent) MQTTSensorComponent = mqtt_ns.class_("MQTTSensorComponent", MQTTComponent) MQTTSwitchComponent = mqtt_ns.class_("MQTTSwitchComponent", MQTTComponent) MQTTTextSensor = mqtt_ns.class_("MQTTTextSensor", MQTTComponent) +MQTTNumberComponent = mqtt_ns.class_("MQTTNumberComponent", MQTTComponent) def validate_config(value): diff --git a/esphome/components/mqtt/mqtt_number.cpp b/esphome/components/mqtt/mqtt_number.cpp new file mode 100644 index 0000000000..bb67a225fd --- /dev/null +++ b/esphome/components/mqtt/mqtt_number.cpp @@ -0,0 +1,61 @@ +#include "mqtt_number.h" +#include "esphome/core/log.h" + +#ifdef USE_NUMBER + +#ifdef USE_DEEP_SLEEP +#include "esphome/components/deep_sleep/deep_sleep_component.h" +#endif + +namespace esphome { +namespace mqtt { + +static const char *const TAG = "mqtt.number"; + +using namespace esphome::number; + +MQTTNumberComponent::MQTTNumberComponent(Number *number) : MQTTComponent(), number_(number) {} + +void MQTTNumberComponent::setup() { + this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &state) { + auto val = parse_float(state); + if (!val.has_value()) { + ESP_LOGE(TAG, "Can't convert '%s' to number!", state.c_str()); + return; + } + auto call = this->number_->make_call(); + call.set_value(*val); + call.perform(); + }); + this->number_->add_on_state_callback([this](float state) { this->publish_state(state); }); +} + +void MQTTNumberComponent::dump_config() { + ESP_LOGCONFIG(TAG, "MQTT Number '%s':", this->number_->get_name().c_str()); + LOG_MQTT_COMPONENT(true, false) +} + +std::string MQTTNumberComponent::component_type() const { return "number"; } + +std::string MQTTNumberComponent::friendly_name() const { return this->number_->get_name(); } +void MQTTNumberComponent::send_discovery(JsonObject &root, mqtt::SendDiscoveryConfig &config) { + if (!this->number_->get_icon().empty()) + root["icon"] = this->number_->get_icon(); +} +bool MQTTNumberComponent::send_initial_state() { + if (this->number_->has_state()) { + return this->publish_state(this->number_->state); + } else { + return true; + } +} +bool MQTTNumberComponent::is_internal() { return this->number_->is_internal(); } +bool MQTTNumberComponent::publish_state(float value) { + int8_t accuracy = this->number_->get_accuracy_decimals(); + return this->publish(this->get_state_topic_(), value_accuracy_to_string(value, accuracy)); +} + +} // namespace mqtt +} // namespace esphome + +#endif diff --git a/esphome/components/mqtt/mqtt_number.h b/esphome/components/mqtt/mqtt_number.h new file mode 100644 index 0000000000..f44de91435 --- /dev/null +++ b/esphome/components/mqtt/mqtt_number.h @@ -0,0 +1,46 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_NUMBER + +#include "esphome/components/number/number.h" +#include "mqtt_component.h" + +namespace esphome { +namespace mqtt { + +class MQTTNumberComponent : public mqtt::MQTTComponent { + public: + /** Construct this MQTTNumberComponent instance with the provided friendly_name and number + * + * @param number The number. + */ + explicit MQTTNumberComponent(number::Number *number); + + // ========== 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 is_internal() override; + + bool publish_state(float value); + + protected: + /// Override for MQTTComponent, returns "number". + std::string component_type() const override; + + std::string friendly_name() const override; + + number::Number *number_; +}; + +} // namespace mqtt +} // namespace esphome + +#endif diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py new file mode 100644 index 0000000000..ed33931d8b --- /dev/null +++ b/esphome/components/number/__init__.py @@ -0,0 +1,154 @@ +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_ABOVE, + CONF_BELOW, + CONF_ICON, + CONF_ID, + CONF_INTERNAL, + CONF_ON_VALUE, + CONF_ON_VALUE_RANGE, + CONF_TRIGGER_ID, + CONF_NAME, + CONF_MQTT_ID, + CONF_VALUE, + ICON_EMPTY, +) +from esphome.core import CORE, coroutine_with_priority + +CODEOWNERS = ["@esphome/core"] +IS_PLATFORM_COMPONENT = True + +number_ns = cg.esphome_ns.namespace("number") +Number = number_ns.class_("Number", cg.Nameable) +NumberPtr = Number.operator("ptr") + +# Triggers +NumberStateTrigger = number_ns.class_( + "NumberStateTrigger", automation.Trigger.template(cg.float_) +) +ValueRangeTrigger = number_ns.class_( + "ValueRangeTrigger", automation.Trigger.template(cg.float_), cg.Component +) + +# Actions +NumberSetAction = number_ns.class_("NumberSetAction", automation.Action) + +# Conditions +NumberInRangeCondition = number_ns.class_( + "NumberInRangeCondition", automation.Condition +) + +icon = cv.icon + + +NUMBER_SCHEMA = cv.MQTT_COMPONENT_SCHEMA.extend( + { + cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTNumberComponent), + cv.GenerateID(): cv.declare_id(Number), + cv.Optional(CONF_ICON, default=ICON_EMPTY): icon, + cv.Optional(CONF_ON_VALUE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(NumberStateTrigger), + } + ), + cv.Optional(CONF_ON_VALUE_RANGE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ValueRangeTrigger), + cv.Optional(CONF_ABOVE): cv.float_, + cv.Optional(CONF_BELOW): cv.float_, + }, + cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW), + ), + } +) + + +async def setup_number_core_(var, config): + cg.add(var.set_name(config[CONF_NAME])) + if CONF_INTERNAL in config: + cg.add(var.set_internal(config[CONF_INTERNAL])) + + cg.add(var.set_icon(config[CONF_ICON])) + + for conf in config.get(CONF_ON_VALUE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [(float, "x")], conf) + for conf in config.get(CONF_ON_VALUE_RANGE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await cg.register_component(trigger, conf) + if CONF_ABOVE in conf: + template_ = await cg.templatable(conf[CONF_ABOVE], [(float, "x")], float) + cg.add(trigger.set_min(template_)) + if CONF_BELOW in conf: + template_ = await cg.templatable(conf[CONF_BELOW], [(float, "x")], float) + cg.add(trigger.set_max(template_)) + await automation.build_automation(trigger, [(float, "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_number(var, config): + if not CORE.has_id(config[CONF_ID]): + var = cg.Pvariable(config[CONF_ID], var) + cg.add(cg.App.register_number(var)) + await setup_number_core_(var, config) + + +async def new_number(config): + var = cg.new_Pvariable(config[CONF_ID]) + await register_number(var, config) + return var + + +NUMBER_IN_RANGE_CONDITION_SCHEMA = cv.All( + { + cv.Required(CONF_ID): cv.use_id(Number), + cv.Optional(CONF_ABOVE): cv.float_, + cv.Optional(CONF_BELOW): cv.float_, + }, + cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW), +) + + +@automation.register_condition( + "number.in_range", NumberInRangeCondition, NUMBER_IN_RANGE_CONDITION_SCHEMA +) +async def number_in_range_to_code(config, condition_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(condition_id, template_arg, paren) + + if CONF_ABOVE in config: + cg.add(var.set_min(config[CONF_ABOVE])) + if CONF_BELOW in config: + cg.add(var.set_max(config[CONF_BELOW])) + + return var + + +@coroutine_with_priority(40.0) +async def to_code(config): + cg.add_define("USE_NUMBER") + cg.add_global(number_ns.using) + + +@automation.register_action( + "number.set", + NumberSetAction, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(Number), + cv.Required(CONF_VALUE): cv.templatable(cv.float_), + } + ), +) +async def number_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, float) + cg.add(var.set_value(template_)) + return var diff --git a/esphome/components/number/automation.cpp b/esphome/components/number/automation.cpp new file mode 100644 index 0000000000..a0b169427f --- /dev/null +++ b/esphome/components/number/automation.cpp @@ -0,0 +1,47 @@ +#include "automation.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace number { + +static const char *const TAG = "number.automation"; + +void ValueRangeTrigger::setup() { + this->rtc_ = global_preferences.make_preference(this->parent_->get_object_id_hash()); + bool initial_state; + if (this->rtc_.load(&initial_state)) { + this->previous_in_range_ = initial_state; + } + + this->parent_->add_on_state_callback([this](float state) { this->on_state_(state); }); +} +float ValueRangeTrigger::get_setup_priority() const { return setup_priority::HARDWARE; } + +void ValueRangeTrigger::on_state_(float state) { + if (isnan(state)) + return; + + float local_min = this->min_.value(state); + float local_max = this->max_.value(state); + + bool in_range; + if (isnan(local_min) && isnan(local_max)) { + in_range = this->previous_in_range_; + } else if (isnan(local_min)) { + in_range = state <= local_max; + } else if (isnan(local_max)) { + in_range = state >= local_min; + } else { + in_range = local_min <= state && state <= local_max; + } + + if (in_range != this->previous_in_range_ && in_range) { + this->trigger(state); + } + + this->previous_in_range_ = in_range; + this->rtc_.save(&in_range); +} + +} // namespace number +} // namespace esphome diff --git a/esphome/components/number/automation.h b/esphome/components/number/automation.h new file mode 100644 index 0000000000..9e812f8c49 --- /dev/null +++ b/esphome/components/number/automation.h @@ -0,0 +1,76 @@ +#pragma once + +#include "esphome/components/number/number.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" + +namespace esphome { +namespace number { + +class NumberStateTrigger : public Trigger { + public: + explicit NumberStateTrigger(Number *parent) { + parent->add_on_state_callback([this](float value) { this->trigger(value); }); + } +}; + +template class NumberSetAction : public Action { + public: + NumberSetAction(Number *number) : number_(number) {} + TEMPLATABLE_VALUE(float, value) + + void play(Ts... x) override { + auto call = this->number_->make_call(); + call.set_value(this->value_.value(x...)); + call.perform(); + } + + protected: + Number *number_; +}; + +class ValueRangeTrigger : public Trigger, public Component { + public: + explicit ValueRangeTrigger(Number *parent) : parent_(parent) {} + + template void set_min(V min) { this->min_ = min; } + template void set_max(V max) { this->max_ = max; } + + void setup() override; + float get_setup_priority() const override; + + protected: + void on_state_(float state); + + Number *parent_; + ESPPreferenceObject rtc_; + bool previous_in_range_{false}; + TemplatableValue min_{NAN}; + TemplatableValue max_{NAN}; +}; + +template class NumberInRangeCondition : public Condition { + public: + NumberInRangeCondition(Number *parent) : parent_(parent) {} + + void set_min(float min) { this->min_ = min; } + void set_max(float max) { this->max_ = max; } + bool check(Ts... x) override { + const float state = this->parent_->state; + if (isnan(this->min_)) { + return state <= this->max_; + } else if (isnan(this->max_)) { + return state >= this->min_; + } else { + return this->min_ <= state && state <= this->max_; + } + } + + protected: + Number *parent_; + float min_{NAN}; + float max_{NAN}; +}; + +} // namespace number +} // namespace esphome diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp new file mode 100644 index 0000000000..eaee5d4e69 --- /dev/null +++ b/esphome/components/number/number.cpp @@ -0,0 +1,76 @@ +#include "number.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace number { + +static const char *const TAG = "number"; + +void NumberCall::perform() { + ESP_LOGD(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); + if (this->value_.has_value()) { + auto value = *this->value_; + uint8_t accuracy = this->parent_->get_accuracy_decimals(); + float min_value = this->parent_->get_min_value(); + if (value < min_value) { + ESP_LOGW(TAG, " Value %s must not be less than minimum %s", value_accuracy_to_string(value, accuracy).c_str(), + value_accuracy_to_string(min_value, accuracy).c_str()); + this->value_.reset(); + return; + } + float max_value = this->parent_->get_max_value(); + if (value > max_value) { + ESP_LOGW(TAG, " Value %s must not be larger than maximum %s", value_accuracy_to_string(value, accuracy).c_str(), + value_accuracy_to_string(max_value, accuracy).c_str()); + this->value_.reset(); + return; + } + ESP_LOGD(TAG, " Value: %s", value_accuracy_to_string(*this->value_, accuracy).c_str()); + this->parent_->set(*this->value_); + } +} + +NumberCall &NumberCall::set_value(float value) { + this->value_ = value; + return *this; +} + +const optional &NumberCall::get_value() const { return this->value_; } + +NumberCall Number::make_call() { return NumberCall(this); } + +void Number::publish_state(float state) { + this->has_state_ = true; + this->state = state; + ESP_LOGD(TAG, "'%s': Sending state %.5f", this->get_name().c_str(), state); + this->state_callback_.call(state); +} + +uint32_t Number::update_interval() { return 0; } +Number::Number(const std::string &name) : Nameable(name), state(NAN) {} +Number::Number() : Number("") {} + +void Number::add_on_state_callback(std::function &&callback) { + this->state_callback_.add(std::move(callback)); +} +void Number::set_icon(const std::string &icon) { this->icon_ = icon; } +std::string Number::get_icon() { return *this->icon_; } +int8_t Number::get_accuracy_decimals() { + // use printf %g to find number of digits based on step + char buf[32]; + sprintf(buf, "%.5g", this->step_); + std::string str{buf}; + size_t dot_pos = str.find('.'); + if (dot_pos == std::string::npos) + return 0; + + return str.length() - dot_pos - 1; +} +float Number::get_state() const { return this->state; } + +bool Number::has_state() const { return this->has_state_; } + +uint32_t Number::hash_base() { return 2282307003UL; } + +} // namespace number +} // namespace esphome diff --git a/esphome/components/number/number.h b/esphome/components/number/number.h new file mode 100644 index 0000000000..4fe9692a6b --- /dev/null +++ b/esphome/components/number/number.h @@ -0,0 +1,112 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome { +namespace number { + +#define LOG_NUMBER(prefix, type, obj) \ + if ((obj) != nullptr) { \ + ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, type, (obj)->get_name().c_str()); \ + if (!(obj)->get_icon().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + } \ + } + +class Number; + +class NumberCall { + public: + explicit NumberCall(Number *parent) : parent_(parent) {} + NumberCall &set_value(float value); + void perform(); + + const optional &get_value() const; + + protected: + Number *const parent_; + optional value_; +}; + +/** Base-class for all numbers. + * + * A number can use publish_state to send out a new value. + */ +class Number : public Nameable { + public: + explicit Number(); + explicit Number(const std::string &name); + + /** Manually set the icon of this number. By default the number's default defined by icon() is used. + * + * @param icon The icon, for example "mdi:flash". "" to disable. + */ + void set_icon(const std::string &icon); + /// Get the Home Assistant Icon. Uses the manual override if specified or the default value instead. + std::string get_icon(); + + /// Getter-syntax for .state. + float get_state() const; + + /// Get the accuracy in decimals. Based on the step value. + int8_t get_accuracy_decimals(); + + /** Publish the current state to the front-end. + */ + void publish_state(float state); + + NumberCall make_call(); + + // ========== INTERNAL METHODS ========== + // (In most use cases you won't need these) + /// Add a callback that will be called every time the state changes. + void add_on_state_callback(std::function &&callback); + + /** This member variable stores the last state. + * + * On startup, when no state is available yet, this is NAN (not-a-number) and the validity + * can be checked using has_state(). + * + * This is exposed through a member variable for ease of use in esphome lambdas. + */ + float state; + + /// Return whether this number has gotten a full state yet. + bool has_state() const; + + /// Return with which interval the number is polled. Return 0 for non-polling mode. + virtual uint32_t update_interval(); + + void set_min_value(float min_value) { this->min_value_ = min_value; } + void set_max_value(float max_value) { this->max_value_ = max_value; } + void set_step(float step) { this->step_ = step; } + + float get_min_value() const { return this->min_value_; } + float get_max_value() const { return this->max_value_; } + float get_step() const { return this->step_; } + + protected: + friend class NumberCall; + + /** Set the value of the number, this is a virtual method that each number integration must implement. + * + * This method is called by the NumberCall. + * + * @param value The value as validated by the NumberCall. + */ + virtual void set(float value) = 0; + + uint32_t hash_base() override; + + CallbackManager state_callback_; + /// Override the icon advertised to Home Assistant, otherwise number's icon will be used. + optional icon_; + bool has_state_{false}; + float step_{1.0}; + float min_value_{0}; + float max_value_{100}; +}; + +} // namespace number +} // namespace esphome diff --git a/esphome/components/template/number/__init__.py b/esphome/components/template/number/__init__.py new file mode 100644 index 0000000000..cf70a48c4d --- /dev/null +++ b/esphome/components/template/number/__init__.py @@ -0,0 +1,63 @@ +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import number +from esphome.const import ( + CONF_ID, + CONF_LAMBDA, + CONF_MAX_VALUE, + CONF_MIN_VALUE, + CONF_OPTIMISTIC, + CONF_STEP, +) +from .. import template_ns + +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]: + raise cv.Invalid("max_value must be greater than min_value") + return config + + +CONFIG_SCHEMA = cv.All( + number.NUMBER_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(TemplateNumber), + cv.Required(CONF_MAX_VALUE): cv.float_, + cv.Required(CONF_MIN_VALUE): cv.float_, + cv.Required(CONF_STEP): cv.positive_float, + 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), + } + ).extend(cv.polling_component_schema("60s")), + validate_min_max, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await number.register_number(var, config) + + if CONF_LAMBDA in config: + template_ = await cg.process_lambda( + config[CONF_LAMBDA], [], return_type=cg.optional.template(float) + ) + cg.add(var.set_template(template_)) + if CONF_SET_ACTION in config: + await automation.build_automation( + var.get_set_trigger(), [(float, "x")], config[CONF_SET_ACTION] + ) + + cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) + + cg.add(var.set_min_value(config[CONF_MIN_VALUE])) + cg.add(var.set_max_value(config[CONF_MAX_VALUE])) + cg.add(var.set_step(config[CONF_STEP])) diff --git a/esphome/components/template/number/template_number.cpp b/esphome/components/template/number/template_number.cpp new file mode 100644 index 0000000000..69c5d62684 --- /dev/null +++ b/esphome/components/template/number/template_number.cpp @@ -0,0 +1,39 @@ +#include "template_number.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace template_ { + +static const char *const TAG = "template.number"; + +TemplateNumber::TemplateNumber() : set_trigger_(new Trigger()) {} + +void TemplateNumber::update() { + if (!this->f_.has_value()) + return; + + auto val = (*this->f_)(); + if (val.has_value()) { + this->publish_state(*val); + } +} + +void TemplateNumber::set(float value) { + this->set_trigger_->trigger(value); + + if (this->optimistic_) + this->publish_state(value); +} +float TemplateNumber::get_setup_priority() const { return setup_priority::HARDWARE; } +void TemplateNumber::set_template(std::function()> &&f) { this->f_ = f; } +void TemplateNumber::dump_config() { + LOG_NUMBER("", "Template Number", this); + ESP_LOGCONFIG(TAG, " Optimistic: %s", YESNO(this->optimistic_)); + LOG_UPDATE_INTERVAL(this); +} + +void TemplateNumber::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } +Trigger *TemplateNumber::get_set_trigger() const { return this->set_trigger_; }; + +} // namespace template_ +} // namespace esphome diff --git a/esphome/components/template/number/template_number.h b/esphome/components/template/number/template_number.h new file mode 100644 index 0000000000..4c633e3b53 --- /dev/null +++ b/esphome/components/template/number/template_number.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/number/number.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" + +namespace esphome { +namespace template_ { + +class TemplateNumber : public number::Number, public PollingComponent { + public: + TemplateNumber(); + void set_template(std::function()> &&f); + + void update() override; + void dump_config() override; + float get_setup_priority() const override; + + Trigger *get_set_trigger() const; + void set_optimistic(bool optimistic); + + protected: + void set(float value) override; + bool optimistic_{false}; + Trigger *set_trigger_; + optional()>> f_; +}; + +} // namespace template_ +} // namespace esphome diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 7a6d877d8f..57eef7a946 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -119,6 +119,12 @@ void WebServer::setup() { if (!obj->is_internal()) client->send(this->cover_json(obj).c_str(), "state"); #endif + +#ifdef USE_NUMBER + for (auto *obj : App.get_numbers()) + if (!obj->is_internal()) + client->send(this->number_json(obj, obj->state).c_str(), "state"); +#endif }); #ifdef USE_LOGGER @@ -196,6 +202,11 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { write_row(stream, obj, "cover", ""); #endif +#ifdef USE_NUMBER + for (auto *obj : App.get_numbers()) + write_row(stream, obj, "number", ""); +#endif + stream->print(F("

See ESPHome Web API for " "REST API documentation.

" "

OTA Update

events_.send(this->number_json(obj, state).c_str(), "state"); +} +void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match) { + for (auto *obj : App.get_numbers()) { + if (obj->is_internal()) + continue; + if (obj->get_object_id() != match.id) + continue; + std::string data = this->number_json(obj, obj->state); + request->send(200, "text/json", data.c_str()); + return; + } + request->send(404); +} +std::string WebServer::number_json(number::Number *obj, float value) { + return json::build_json([obj, value](JsonObject &root) { + root["id"] = "number-" + obj->get_object_id(); + std::string state = value_accuracy_to_string(value, obj->get_accuracy_decimals()); + root["state"] = state; + root["value"] = value; + }); +} +#endif + bool WebServer::canHandle(AsyncWebServerRequest *request) { if (request->url() == "/") return true; @@ -636,6 +673,11 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) { return true; #endif +#ifdef USE_NUMBER + if (request->method() == HTTP_GET && match.domain == "number") + return true; +#endif + return false; } void WebServer::handleRequest(AsyncWebServerRequest *request) { @@ -711,6 +753,13 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { return; } #endif + +#ifdef USE_NUMBER + if (match.domain == "number") { + this->handle_number_request(request, match); + return; + } +#endif } bool WebServer::isRequestHandlerTrivial() { return false; } diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 89e23b7071..4789c6e1c0 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -154,6 +154,15 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { std::string cover_json(cover::Cover *obj); #endif +#ifdef USE_NUMBER + void on_number_update(number::Number *obj, float state) override; + /// Handle a number request under '/number/'. + void handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match); + + /// Dump the number state with its value as a JSON string. + std::string number_json(number::Number *obj, float value); +#endif + /// Override the web handler's canHandle method. bool canHandle(AsyncWebServerRequest *request) override; /// Override the web handler's handleRequest method. diff --git a/esphome/const.py b/esphome/const.py index 9b7490878d..e82b66ee37 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -553,6 +553,7 @@ CONF_STATE_CLASS = "state_class" CONF_STATE_TOPIC = "state_topic" CONF_STATIC_IP = "static_ip" CONF_STATUS = "status" +CONF_STEP = "step" CONF_STEP_MODE = "step_mode" CONF_STEP_PIN = "step_pin" CONF_STOP = "stop" diff --git a/esphome/core/application.h b/esphome/core/application.h index 774f6e3aa8..e065552a74 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -32,6 +32,9 @@ #ifdef USE_COVER #include "esphome/components/cover/cover.h" #endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif namespace esphome { @@ -82,6 +85,10 @@ class Application { void register_light(light::LightState *light) { this->lights_.push_back(light); } #endif +#ifdef USE_NUMBER + void register_number(number::Number *number) { this->numbers_.push_back(number); } +#endif + /// Register the component in this Application instance. template C *register_component(C *c) { static_assert(std::is_base_of::value, "Only Component subclasses can be registered"); @@ -208,6 +215,15 @@ class Application { return nullptr; } #endif +#ifdef USE_NUMBER + const std::vector &get_numbers() { return this->numbers_; } + number::Number *get_number_by_key(uint32_t key, bool include_internal = false) { + for (auto *obj : this->numbers_) + if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) + return obj; + return nullptr; + } +#endif Scheduler scheduler; @@ -245,6 +261,9 @@ class Application { #ifdef USE_LIGHT std::vector lights_{}; #endif +#ifdef USE_NUMBER + std::vector numbers_{}; +#endif std::string name_; std::string compilation_time_; diff --git a/esphome/core/controller.cpp b/esphome/core/controller.cpp index f3d10b23ff..305fe93532 100644 --- a/esphome/core/controller.cpp +++ b/esphome/core/controller.cpp @@ -53,6 +53,12 @@ void Controller::setup_controller() { obj->add_on_state_callback([this, obj]() { this->on_climate_update(obj); }); } #endif +#ifdef USE_NUMBER + for (auto *obj : App.get_numbers()) { + if (!obj->is_internal()) + obj->add_on_state_callback([this, obj](float state) { this->on_number_update(obj, state); }); + } +#endif } } // namespace esphome diff --git a/esphome/core/controller.h b/esphome/core/controller.h index 0e94a43c4c..746658075f 100644 --- a/esphome/core/controller.h +++ b/esphome/core/controller.h @@ -25,6 +25,9 @@ #ifdef USE_CLIMATE #include "esphome/components/climate/climate.h" #endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif namespace esphome { @@ -55,6 +58,9 @@ class Controller { #ifdef USE_CLIMATE virtual void on_climate_update(climate::Climate *obj){}; #endif +#ifdef USE_NUMBER + virtual void on_number_update(number::Number *obj, float state){}; +#endif }; } // namespace esphome diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 90562510b9..cac03fc703 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -13,6 +13,7 @@ #define USE_COVER #define USE_LIGHT #define USE_CLIMATE +#define USE_NUMBER #define USE_MQTT #define USE_POWER_SUPPLY #define USE_HOMEASSISTANT_TIME diff --git a/script/ci-custom.py b/script/ci-custom.py index 4ec7c664a4..02f193c6e0 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -559,6 +559,7 @@ def lint_inclusive_language(fname, match): "esphome/components/display/display_buffer.h", "esphome/components/i2c/i2c.h", "esphome/components/mqtt/mqtt_component.h", + "esphome/components/number/number.h", "esphome/components/output/binary_output.h", "esphome/components/output/float_output.h", "esphome/components/sensor/sensor.h", diff --git a/tests/test5.yaml b/tests/test5.yaml index ba047721e2..35225402a3 100644 --- a/tests/test5.yaml +++ b/tests/test5.yaml @@ -38,3 +38,20 @@ esp32_improv: authorizer: io0_button authorized_duration: 1min status_indicator: built_in_led + +number: + - platform: template + name: My template number + id: template_number_id + optimistic: true + on_value: + - logger.log: + format: "Number changed to %f" + args: ["x"] + set_action: + - logger.log: + format: "Template Number set to %f" + args: ["x"] + max_value: 100 + min_value: 0 + step: 5