diff --git a/CODEOWNERS b/CODEOWNERS index 8806489884..b235643539 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -100,6 +100,7 @@ esphome/components/script/* @esphome/core esphome/components/sdm_meter/* @jesserockz @polyfaces esphome/components/sdp3x/* @Azimath esphome/components/selec_meter/* @sourabhjaiswal +esphome/components/select/* @esphome/core esphome/components/sensor/* @esphome/core esphome/components/sgp40/* @SenexCrenshaw esphome/components/sht4x/* @sjtrny diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index c04e0a32f1..c0bbbaaeab 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 select_command (SelectCommandRequest) returns (void) {} } @@ -867,3 +868,39 @@ message NumberCommandRequest { fixed32 key = 1; float state = 2; } + +// ==================== SELECT ==================== +message ListEntitiesSelectResponse { + option (id) = 52; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_SELECT"; + + string object_id = 1; + fixed32 key = 2; + string name = 3; + string unique_id = 4; + + string icon = 5; + repeated string options = 6; +} +message SelectStateResponse { + option (id) = 53; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_SELECT"; + option (no_delay) = true; + + fixed32 key = 1; + string state = 2; + // If the select does not have a valid state yet. + // Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller + bool missing_state = 3; +} +message SelectCommandRequest { + option (id) = 54; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SELECT"; + 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 b92c67b042..94522a13be 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -609,6 +609,41 @@ void APIConnection::number_command(const NumberCommandRequest &msg) { } #endif +#ifdef USE_SELECT +bool APIConnection::send_select_state(select::Select *select, std::string state) { + if (!this->state_subscription_) + return false; + + SelectStateResponse resp{}; + resp.key = select->get_object_id_hash(); + resp.state = std::move(state); + resp.missing_state = !select->has_state(); + return this->send_select_state_response(resp); +} +bool APIConnection::send_select_info(select::Select *select) { + ListEntitiesSelectResponse msg; + msg.key = select->get_object_id_hash(); + msg.object_id = select->get_object_id(); + msg.name = select->get_name(); + msg.unique_id = get_default_unique_id("select", select); + msg.icon = select->traits.get_icon(); + + for (const auto &option : select->traits.get_options()) + msg.options.push_back(option); + + return this->send_list_entities_select_response(msg); +} +void APIConnection::select_command(const SelectCommandRequest &msg) { + select::Select *select = App.get_select_by_key(msg.key); + if (select == nullptr) + return; + + auto call = select->make_call(); + call.set_option(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 1d7fc48563..bc9839a423 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -67,6 +67,11 @@ class APIConnection : public APIServerConnection { bool send_number_state(number::Number *number, float state); bool send_number_info(number::Number *number); void number_command(const NumberCommandRequest &msg) override; +#endif +#ifdef USE_SELECT + bool send_select_state(select::Select *select, std::string state); + bool send_select_info(select::Select *select); + void select_command(const SelectCommandRequest &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 79092cb511..210ba49dfc 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -3574,6 +3574,172 @@ void NumberCommandRequest::dump_to(std::string &out) const { out.append("\n"); out.append("}"); } +bool ListEntitiesSelectResponse::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 6: { + this->options.push_back(value.as_string()); + return true; + } + default: + return false; + } +} +bool ListEntitiesSelectResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { + switch (field_id) { + case 2: { + this->key = value.as_fixed32(); + return true; + } + default: + return false; + } +} +void ListEntitiesSelectResponse::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); + for (auto &it : this->options) { + buffer.encode_string(6, it, true); + } +} +void ListEntitiesSelectResponse::dump_to(std::string &out) const { + char buffer[64]; + out.append("ListEntitiesSelectResponse {\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"); + + for (const auto &it : this->options) { + out.append(" options: "); + out.append("'").append(it).append("'"); + out.append("\n"); + } + out.append("}"); +} +bool SelectStateResponse::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 SelectStateResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 2: { + this->state = value.as_string(); + return true; + } + default: + return false; + } +} +bool SelectStateResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { + switch (field_id) { + case 1: { + this->key = value.as_fixed32(); + return true; + } + default: + return false; + } +} +void SelectStateResponse::encode(ProtoWriteBuffer buffer) const { + buffer.encode_fixed32(1, this->key); + buffer.encode_string(2, this->state); + buffer.encode_bool(3, this->missing_state); +} +void SelectStateResponse::dump_to(std::string &out) const { + char buffer[64]; + out.append("SelectStateResponse {\n"); + out.append(" key: "); + sprintf(buffer, "%u", 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("}"); +} +bool SelectCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 2: { + this->state = value.as_string(); + return true; + } + default: + return false; + } +} +bool SelectCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { + switch (field_id) { + case 1: { + this->key = value.as_fixed32(); + return true; + } + default: + return false; + } +} +void SelectCommandRequest::encode(ProtoWriteBuffer buffer) const { + buffer.encode_fixed32(1, this->key); + buffer.encode_string(2, this->state); +} +void SelectCommandRequest::dump_to(std::string &out) const { + char buffer[64]; + out.append("SelectCommandRequest {\n"); + out.append(" key: "); + sprintf(buffer, "%u", this->key); + out.append(buffer); + out.append("\n"); + + out.append(" state: "); + out.append("'").append(this->state).append("'"); + 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 4c92b7b00d..47b0229dd5 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -849,6 +849,45 @@ class NumberCommandRequest : public ProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; }; +class ListEntitiesSelectResponse : public ProtoMessage { + public: + std::string object_id{}; + uint32_t key{0}; + std::string name{}; + std::string unique_id{}; + std::string icon{}; + std::vector options{}; + 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 SelectStateResponse : public ProtoMessage { + public: + uint32_t key{0}; + std::string state{}; + 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_length(uint32_t field_id, ProtoLengthDelimited value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +class SelectCommandRequest : public ProtoMessage { + public: + uint32_t key{0}; + std::string state{}; + 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; +}; } // namespace api } // namespace esphome diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 440a5d0ab3..682317801a 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -198,6 +198,20 @@ bool APIServerConnectionBase::send_number_state_response(const NumberStateRespon #endif #ifdef USE_NUMBER #endif +#ifdef USE_SELECT +bool APIServerConnectionBase::send_list_entities_select_response(const ListEntitiesSelectResponse &msg) { + ESP_LOGVV(TAG, "send_list_entities_select_response: %s", msg.dump().c_str()); + return this->send_message_(msg, 52); +} +#endif +#ifdef USE_SELECT +bool APIServerConnectionBase::send_select_state_response(const SelectStateResponse &msg) { + ESP_LOGVV(TAG, "send_select_state_response: %s", msg.dump().c_str()); + return this->send_message_(msg, 53); +} +#endif +#ifdef USE_SELECT +#endif bool APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) { switch (msg_type) { case 1: { @@ -372,6 +386,15 @@ bool APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, 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; + } + case 54: { +#ifdef USE_SELECT + SelectCommandRequest msg; + msg.decode(msg_data, msg_size); + ESP_LOGVV(TAG, "on_select_command_request: %s", msg.dump().c_str()); + this->on_select_command_request(msg); #endif break; } @@ -583,6 +606,19 @@ void APIServerConnection::on_number_command_request(const NumberCommandRequest & this->number_command(msg); } #endif +#ifdef USE_SELECT +void APIServerConnection::on_select_command_request(const SelectCommandRequest &msg) { + if (!this->is_connection_setup()) { + this->on_no_setup_connection(); + return; + } + if (!this->is_authenticated()) { + this->on_unauthenticated_access(); + return; + } + this->select_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 398c10a811..1b8d990b05 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -120,6 +120,15 @@ class APIServerConnectionBase : public ProtoService { #endif #ifdef USE_NUMBER virtual void on_number_command_request(const NumberCommandRequest &value){}; +#endif +#ifdef USE_SELECT + bool send_list_entities_select_response(const ListEntitiesSelectResponse &msg); +#endif +#ifdef USE_SELECT + bool send_select_state_response(const SelectStateResponse &msg); +#endif +#ifdef USE_SELECT + virtual void on_select_command_request(const SelectCommandRequest &value){}; #endif protected: bool read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override; @@ -159,6 +168,9 @@ class APIServerConnection : public APIServerConnectionBase { #endif #ifdef USE_NUMBER virtual void number_command(const NumberCommandRequest &msg) = 0; +#endif +#ifdef USE_SELECT + virtual void select_command(const SelectCommandRequest &msg) = 0; #endif protected: void on_hello_request(const HelloRequest &msg) override; @@ -194,6 +206,9 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_NUMBER void on_number_command_request(const NumberCommandRequest &msg) override; #endif +#ifdef USE_SELECT + void on_select_command_request(const SelectCommandRequest &msg) override; +#endif }; } // namespace api diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 7434030565..d48c0a4fd8 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -206,6 +206,15 @@ void APIServer::on_number_update(number::Number *obj, float state) { } #endif +#ifdef USE_SELECT +void APIServer::on_select_update(select::Select *obj, const std::string &state) { + if (obj->is_internal()) + return; + for (auto *c : this->clients_) + c->send_select_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 68d1df2c1f..96b3192e9e 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -62,6 +62,9 @@ class APIServer : public Component, public Controller { #endif #ifdef USE_NUMBER void on_number_update(number::Number *obj, float state) override; +#endif +#ifdef USE_SELECT + void on_select_update(select::Select *obj, const std::string &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 8897758073..745dd92c89 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -55,5 +55,9 @@ 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_SELECT +bool ListEntitiesIterator::on_select(select::Select *select) { return this->client_->send_select_info(select); } +#endif + } // namespace api } // namespace esphome diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h index c55ba5089e..c728fb0a97 100644 --- a/esphome/components/api/list_entities.h +++ b/esphome/components/api/list_entities.h @@ -42,6 +42,9 @@ class ListEntitiesIterator : public ComponentIterator { #endif #ifdef USE_NUMBER bool on_number(number::Number *number) override; +#endif +#ifdef USE_SELECT + bool on_select(select::Select *select) override; #endif bool on_end() override; diff --git a/esphome/components/api/subscribe_state.cpp b/esphome/components/api/subscribe_state.cpp index 25aa7c8b31..1b8453f233 100644 --- a/esphome/components/api/subscribe_state.cpp +++ b/esphome/components/api/subscribe_state.cpp @@ -42,6 +42,11 @@ bool InitialStateIterator::on_number(number::Number *number) { return this->client_->send_number_state(number, number->state); } #endif +#ifdef USE_SELECT +bool InitialStateIterator::on_select(select::Select *select) { + return this->client_->send_select_state(select, select->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 f03322ac4a..beb9b947d4 100644 --- a/esphome/components/api/subscribe_state.h +++ b/esphome/components/api/subscribe_state.h @@ -39,6 +39,9 @@ class InitialStateIterator : public ComponentIterator { #endif #ifdef USE_NUMBER bool on_number(number::Number *number) override; +#endif +#ifdef USE_SELECT + bool on_select(select::Select *select) override; #endif protected: APIConnection *client_; diff --git a/esphome/components/api/util.cpp b/esphome/components/api/util.cpp index 6e05d49b74..5085994607 100644 --- a/esphome/components/api/util.cpp +++ b/esphome/components/api/util.cpp @@ -182,6 +182,21 @@ void ComponentIterator::advance() { } } break; +#endif +#ifdef USE_SELECT + case IteratorState::SELECT: + if (this->at_ >= App.get_selects().size()) { + advance_platform = true; + } else { + auto *select = App.get_selects()[this->at_]; + if (select->is_internal()) { + success = true; + break; + } else { + success = this->on_select(select); + } + } + break; #endif case IteratorState::MAX: if (this->on_end()) { diff --git a/esphome/components/api/util.h b/esphome/components/api/util.h index f8b248056b..e404a95619 100644 --- a/esphome/components/api/util.h +++ b/esphome/components/api/util.h @@ -50,6 +50,9 @@ class ComponentIterator { #endif #ifdef USE_NUMBER virtual bool on_number(number::Number *number) = 0; +#endif +#ifdef USE_SELECT + virtual bool on_select(select::Select *select) = 0; #endif virtual bool on_end(); @@ -87,6 +90,9 @@ class ComponentIterator { #endif #ifdef USE_NUMBER NUMBER, +#endif +#ifdef USE_SELECT + SELECT, #endif MAX, } state_{IteratorState::NONE}; diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 3559fce046..56ea9027af 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -92,6 +92,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) +MQTTSelectComponent = mqtt_ns.class_("MQTTSelectComponent", MQTTComponent) def validate_config(value): diff --git a/esphome/components/mqtt/mqtt_select.cpp b/esphome/components/mqtt/mqtt_select.cpp new file mode 100644 index 0000000000..c0ac472d46 --- /dev/null +++ b/esphome/components/mqtt/mqtt_select.cpp @@ -0,0 +1,58 @@ +#include "mqtt_select.h" +#include "esphome/core/log.h" + +#ifdef USE_SELECT + +namespace esphome { +namespace mqtt { + +static const char *const TAG = "mqtt.select"; + +using namespace esphome::select; + +MQTTSelectComponent::MQTTSelectComponent(Select *select) : MQTTComponent(), select_(select) {} + +void MQTTSelectComponent::setup() { + this->subscribe(this->get_command_topic_(), [this](const std::string &topic, const std::string &state) { + auto call = this->select_->make_call(); + call.set_option(state); + call.perform(); + }); + this->select_->add_on_state_callback([this](const std::string &state) { this->publish_state(state); }); +} + +void MQTTSelectComponent::dump_config() { + ESP_LOGCONFIG(TAG, "MQTT Select '%s':", this->select_->get_name().c_str()); + LOG_MQTT_COMPONENT(true, false) +} + +std::string MQTTSelectComponent::component_type() const { return "select"; } + +std::string MQTTSelectComponent::friendly_name() const { return this->select_->get_name(); } +void MQTTSelectComponent::send_discovery(JsonObject &root, mqtt::SendDiscoveryConfig &config) { + const auto &traits = select_->traits; + // https://www.home-assistant.io/integrations/select.mqtt/ + if (!traits.get_icon().empty()) + root["icon"] = traits.get_icon(); + JsonArray &options = root.createNestedArray("options"); + for (const auto &option : traits.get_options()) + options.add(option); + + config.command_topic = true; +} +bool MQTTSelectComponent::send_initial_state() { + if (this->select_->has_state()) { + return this->publish_state(this->select_->state); + } else { + return true; + } +} +bool MQTTSelectComponent::is_internal() { return this->select_->is_internal(); } +bool MQTTSelectComponent::publish_state(const std::string &value) { + return this->publish(this->get_state_topic_(), value); +} + +} // namespace mqtt +} // namespace esphome + +#endif diff --git a/esphome/components/mqtt/mqtt_select.h b/esphome/components/mqtt/mqtt_select.h new file mode 100644 index 0000000000..013e905ead --- /dev/null +++ b/esphome/components/mqtt/mqtt_select.h @@ -0,0 +1,46 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_SELECT + +#include "esphome/components/select/select.h" +#include "mqtt_component.h" + +namespace esphome { +namespace mqtt { + +class MQTTSelectComponent : public mqtt::MQTTComponent { + public: + /** Construct this MQTTSelectComponent instance with the provided friendly_name and select + * + * @param select The select. + */ + explicit MQTTSelectComponent(select::Select *select); + + // ========== 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(const std::string &value); + + protected: + /// Override for MQTTComponent, returns "select". + std::string component_type() const override; + + std::string friendly_name() const override; + + select::Select *select_; +}; + +} // namespace mqtt +} // namespace esphome + +#endif diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py new file mode 100644 index 0000000000..5be09b0832 --- /dev/null +++ b/esphome/components/select/__init__.py @@ -0,0 +1,102 @@ +from typing import List +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_ICON, + CONF_ID, + CONF_INTERNAL, + CONF_ON_VALUE, + CONF_OPTION, + CONF_TRIGGER_ID, + CONF_NAME, + CONF_MQTT_ID, + ICON_EMPTY, +) +from esphome.core import CORE, coroutine_with_priority + +CODEOWNERS = ["@esphome/core"] +IS_PLATFORM_COMPONENT = True + +select_ns = cg.esphome_ns.namespace("select") +Select = select_ns.class_("Select", cg.Nameable) +SelectPtr = Select.operator("ptr") + +# Triggers +SelectStateTrigger = select_ns.class_( + "SelectStateTrigger", automation.Trigger.template(cg.float_) +) + +# Actions +SelectSetAction = select_ns.class_("SelectSetAction", automation.Action) + +icon = cv.icon + + +SELECT_SCHEMA = cv.MQTT_COMPONENT_SCHEMA.extend( + { + cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSelectComponent), + cv.GenerateID(): cv.declare_id(Select), + cv.Optional(CONF_ICON, default=ICON_EMPTY): icon, + cv.Optional(CONF_ON_VALUE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SelectStateTrigger), + } + ), + } +) + + +async def setup_select_core_(var, config, *, options: List[str]): + cg.add(var.set_name(config[CONF_NAME])) + if CONF_INTERNAL in config: + cg.add(var.set_internal(config[CONF_INTERNAL])) + + cg.add(var.traits.set_icon(config[CONF_ICON])) + cg.add(var.traits.set_options(options)) + + 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_select(var, config, *, options: List[str]): + if not CORE.has_id(config[CONF_ID]): + var = cg.Pvariable(config[CONF_ID], var) + cg.add(cg.App.register_select(var)) + await setup_select_core_(var, config, options=options) + + +async def new_select(config, *, options: List[str]): + var = cg.new_Pvariable(config[CONF_ID]) + await register_select(var, config, options=options) + return var + + +@coroutine_with_priority(40.0) +async def to_code(config): + cg.add_define("USE_SELECT") + cg.add_global(select_ns.using) + + +@automation.register_action( + "select.set", + SelectSetAction, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(Select), + cv.Required(CONF_OPTION): cv.templatable(cv.string_strict), + } + ), +) +async def select_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_OPTION], args, str) + cg.add(var.set_option(template_)) + return var diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h new file mode 100644 index 0000000000..59525f879e --- /dev/null +++ b/esphome/components/select/automation.h @@ -0,0 +1,33 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "select.h" + +namespace esphome { +namespace select { + +class SelectStateTrigger : public Trigger { + public: + explicit SelectStateTrigger(Select *parent) { + parent->add_on_state_callback([this](std::string value) { this->trigger(std::move(value)); }); + } +}; + +template class SelectSetAction : public Action { + public: + SelectSetAction(Select *select) : select_(select) {} + TEMPLATABLE_VALUE(std::string, option) + + void play(Ts... x) override { + auto call = this->select_->make_call(); + call.set_option(this->option_.value(x...)); + call.perform(); + } + + protected: + Select *select_; +}; + +} // namespace select +} // namespace esphome diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp new file mode 100644 index 0000000000..14f4d9277d --- /dev/null +++ b/esphome/components/select/select.cpp @@ -0,0 +1,43 @@ +#include "select.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace select { + +static const char *const TAG = "select"; + +void SelectCall::perform() { + ESP_LOGD(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); + if (!this->option_.has_value()) { + ESP_LOGW(TAG, "No value set for SelectCall"); + return; + } + + const auto &traits = this->parent_->traits; + auto value = *this->option_; + auto options = traits.get_options(); + + if (std::find(options.begin(), options.end(), value) == options.end()) { + ESP_LOGW(TAG, " Option %s is not a valid option.", value.c_str()); + return; + } + + ESP_LOGD(TAG, " Option: %s", (*this->option_).c_str()); + this->parent_->control(*this->option_); +} + +void Select::publish_state(const std::string &state) { + this->has_state_ = true; + this->state = state; + ESP_LOGD(TAG, "'%s': Sending state %s", this->get_name().c_str(), state.c_str()); + this->state_callback_.call(state); +} + +void Select::add_on_state_callback(std::function &&callback) { + this->state_callback_.add(std::move(callback)); +} + +uint32_t Select::hash_base() { return 2812997003UL; } + +} // namespace select +} // namespace esphome diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h new file mode 100644 index 0000000000..414a8daabb --- /dev/null +++ b/esphome/components/select/select.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome { +namespace select { + +#define LOG_SELECT(prefix, type, obj) \ + if ((obj) != nullptr) { \ + ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, type, (obj)->get_name().c_str()); \ + if (!(obj)->traits.get_icon().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->traits.get_icon().c_str()); \ + } \ + } + +class Select; + +class SelectCall { + public: + explicit SelectCall(Select *parent) : parent_(parent) {} + void perform(); + + SelectCall &set_option(const std::string &option) { + option_ = option; + return *this; + } + const optional &get_option() const { return option_; } + + protected: + Select *const parent_; + optional option_; +}; + +class SelectTraits { + public: + void set_options(std::vector options) { this->options_ = std::move(options); } + const std::vector get_options() const { return this->options_; } + void set_icon(std::string icon) { icon_ = std::move(icon); } + const std::string &get_icon() const { return icon_; } + + protected: + std::vector options_; + std::string icon_; +}; + +/** Base-class for all selects. + * + * A select can use publish_state to send out a new value. + */ +class Select : public Nameable { + public: + std::string state; + + void publish_state(const std::string &state); + + SelectCall make_call() { return SelectCall(this); } + void set(const std::string &value) { make_call().set_option(value).perform(); } + + void add_on_state_callback(std::function &&callback); + + SelectTraits traits; + + /// Return whether this select has gotten a full state yet. + bool has_state() const { return has_state_; } + + protected: + friend class SelectCall; + + /** Set the value of the select, this is a virtual method that each select integration must implement. + * + * This method is called by the SelectCall. + * + * @param value The value as validated by the SelectCall. + */ + virtual void control(const std::string &value) = 0; + + uint32_t hash_base() override; + + CallbackManager state_callback_; + bool has_state_{false}; +}; + +} // namespace select +} // namespace esphome diff --git a/esphome/components/template/select/__init__.py b/esphome/components/template/select/__init__.py new file mode 100644 index 0000000000..4044a407f3 --- /dev/null +++ b/esphome/components/template/select/__init__.py @@ -0,0 +1,74 @@ +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import select +from esphome.const import ( + CONF_ID, + CONF_INITIAL_OPTION, + CONF_LAMBDA, + CONF_OPTIONS, + CONF_OPTIMISTIC, + CONF_RESTORE_VALUE, +) +from .. import template_ns + +TemplateSelect = template_ns.class_( + "TemplateSelect", select.Select, cg.PollingComponent +) + +CONF_SET_ACTION = "set_action" + + +def validate_initial_value_in_options(config): + if CONF_INITIAL_OPTION in config: + if config[CONF_INITIAL_OPTION] not in config[CONF_OPTIONS]: + raise cv.Invalid( + f"initial_option '{config[CONF_INITIAL_OPTION]}' is not a valid option [{', '.join(config[CONF_OPTIONS])}]" + ) + else: + config[CONF_INITIAL_OPTION] = config[CONF_OPTIONS][0] + return config + + +CONFIG_SCHEMA = cv.All( + select.SELECT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(TemplateSelect), + cv.Required(CONF_OPTIONS): cv.All( + cv.ensure_list(cv.string_strict), cv.Length(min=1) + ), + cv.Optional(CONF_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_OPTIMISTIC): cv.boolean, + cv.Optional(CONF_SET_ACTION): automation.validate_automation(single=True), + cv.Optional(CONF_INITIAL_OPTION): cv.string_strict, + cv.Optional(CONF_RESTORE_VALUE): cv.boolean, + } + ).extend(cv.polling_component_schema("60s")), + validate_initial_value_in_options, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await select.register_select(var, config, options=config[CONF_OPTIONS]) + + if CONF_LAMBDA in config: + template_ = await cg.process_lambda( + config[CONF_LAMBDA], [], return_type=cg.optional.template(str) + ) + cg.add(var.set_template(template_)) + + else: + if CONF_OPTIMISTIC in config: + cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) + + cg.add(var.set_initial_option(config[CONF_INITIAL_OPTION])) + + if CONF_RESTORE_VALUE in config: + cg.add(var.set_restore_value(config[CONF_RESTORE_VALUE])) + + 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/select/template_select.cpp b/esphome/components/template/select/template_select.cpp new file mode 100644 index 0000000000..782c0ee6f9 --- /dev/null +++ b/esphome/components/template/select/template_select.cpp @@ -0,0 +1,74 @@ +#include "template_select.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace template_ { + +static const char *const TAG = "template.select"; + +void TemplateSelect::setup() { + if (this->f_.has_value()) + return; + + std::string value; + ESP_LOGD(TAG, "Setting up Template Number"); + if (!this->restore_value_) { + value = this->initial_option_; + ESP_LOGD(TAG, "State from initial: %s", value.c_str()); + } else { + size_t index; + this->pref_ = global_preferences.make_preference(this->get_object_id_hash()); + if (!this->pref_.load(&index)) { + value = this->initial_option_; + ESP_LOGD(TAG, "State from initial (could not load): %s", value.c_str()); + } else { + value = this->traits.get_options().at(index); + ESP_LOGD(TAG, "State from restore: %s", value.c_str()); + } + } + + this->publish_state(value); +} + +void TemplateSelect::update() { + if (!this->f_.has_value()) + return; + + auto val = (*this->f_)(); + if (!val.has_value()) + return; + + auto options = this->traits.get_options(); + if (std::find(options.begin(), options.end(), *val) == options.end()) { + ESP_LOGE(TAG, "lambda returned an invalid option %s", (*val).c_str()); + return; + } + + this->publish_state(*val); +} + +void TemplateSelect::control(const std::string &value) { + this->set_trigger_->trigger(value); + + if (this->optimistic_) + this->publish_state(value); + + if (this->restore_value_) { + auto options = this->traits.get_options(); + size_t index = std::find(options.begin(), options.end(), value) - options.begin(); + + this->pref_.save(&index); + } +} +void TemplateSelect::dump_config() { + LOG_SELECT("", "Template Select", this); + LOG_UPDATE_INTERVAL(this); + if (this->f_.has_value()) + return; + ESP_LOGCONFIG(TAG, " Optimistic: %s", YESNO(this->optimistic_)); + ESP_LOGCONFIG(TAG, " Initial Option: %s", this->initial_option_.c_str()); + ESP_LOGCONFIG(TAG, " Restore Value: %s", YESNO(this->restore_value_)); +} + +} // namespace template_ +} // namespace esphome diff --git a/esphome/components/template/select/template_select.h b/esphome/components/template/select/template_select.h new file mode 100644 index 0000000000..e24eb6e880 --- /dev/null +++ b/esphome/components/template/select/template_select.h @@ -0,0 +1,37 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/preferences.h" + +namespace esphome { +namespace template_ { + +class TemplateSelect : public select::Select, 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_option(std::string initial_option) { this->initial_option_ = std::move(initial_option); } + void set_restore_value(bool restore_value) { this->restore_value_ = restore_value; } + + protected: + void control(const std::string &value) override; + bool optimistic_ = false; + std::string initial_option_; + bool restore_value_ = false; + Trigger *set_trigger_ = new Trigger(); + optional()>> f_; + + ESPPreferenceObject pref_; +}; + +} // namespace template_ +} // namespace esphome diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 5e45a87e26..9dad61bb5b 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -129,6 +129,12 @@ void WebServer::setup() { if (!obj->is_internal()) client->send(this->number_json(obj, obj->state).c_str(), "state"); #endif + +#ifdef USE_SELECT + for (auto *obj : App.get_selects()) + if (!obj->is_internal()) + client->send(this->select_json(obj, obj->state).c_str(), "state"); +#endif }); #ifdef USE_LOGGER @@ -211,6 +217,11 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { write_row(stream, obj, "number", ""); #endif +#ifdef USE_SELECT + for (auto *obj : App.get_selects()) + write_row(stream, obj, "select", ""); +#endif + stream->print(F("

See ESPHome Web API for " "REST API documentation.

" "

OTA Update

events_.send(this->select_json(obj, state).c_str(), "state"); +} +void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match) { + for (auto *obj : App.get_selects()) { + if (obj->is_internal()) + continue; + if (obj->get_object_id() != match.id) + continue; + std::string data = this->select_json(obj, obj->state); + request->send(200, "text/json", data.c_str()); + return; + } + request->send(404); +} +std::string WebServer::select_json(select::Select *obj, const std::string &value) { + return json::build_json([obj, value](JsonObject &root) { + root["id"] = "select-" + obj->get_object_id(); + root["state"] = value; + root["value"] = value; + }); +} +#endif + bool WebServer::canHandle(AsyncWebServerRequest *request) { if (request->url() == "/") return true; @@ -683,6 +719,11 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) { return true; #endif +#ifdef USE_SELECT + if (request->method() == HTTP_GET && match.domain == "select") + return true; +#endif + return false; } void WebServer::handleRequest(AsyncWebServerRequest *request) { @@ -765,6 +806,13 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { return; } #endif + +#ifdef USE_SELECT + if (match.domain == "select") { + this->handle_select_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 4789c6e1c0..54d7356ac9 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -163,6 +163,15 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { std::string number_json(number::Number *obj, float value); #endif +#ifdef USE_SELECT + void on_select_update(select::Select *obj, const std::string &state) override; + /// Handle a select request under '/select/'. + void handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match); + + /// Dump the number state with its value as a JSON string. + std::string select_json(select::Select *obj, const std::string &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 5c21836c7d..ae6c5716d9 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -278,6 +278,7 @@ CONF_INCLUDES = "includes" CONF_INDEX = "index" CONF_INDOOR = "indoor" CONF_INITIAL_MODE = "initial_mode" +CONF_INITIAL_OPTION = "initial_option" CONF_INITIAL_VALUE = "initial_value" CONF_INTEGRATION_TIME = "integration_time" CONF_INTENSITY = "intensity" @@ -407,6 +408,8 @@ CONF_OPEN_DRAIN_INTERRUPT = "open_drain_interrupt" CONF_OPEN_DURATION = "open_duration" CONF_OPEN_ENDSTOP = "open_endstop" CONF_OPTIMISTIC = "optimistic" +CONF_OPTION = "option" +CONF_OPTIONS = "options" CONF_OR = "or" CONF_OSCILLATING = "oscillating" CONF_OSCILLATION_COMMAND_TOPIC = "oscillation_command_topic" diff --git a/esphome/core/application.h b/esphome/core/application.h index e065552a74..edbfbd130b 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -35,6 +35,9 @@ #ifdef USE_NUMBER #include "esphome/components/number/number.h" #endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif namespace esphome { @@ -89,6 +92,10 @@ class Application { void register_number(number::Number *number) { this->numbers_.push_back(number); } #endif +#ifdef USE_SELECT + void register_select(select::Select *select) { this->selects_.push_back(select); } +#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"); @@ -224,6 +231,15 @@ class Application { 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) { + for (auto *obj : this->selects_) + if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) + return obj; + return nullptr; + } +#endif Scheduler scheduler; @@ -264,6 +280,9 @@ class Application { #ifdef USE_NUMBER std::vector numbers_{}; #endif +#ifdef USE_SELECT + std::vector selects_{}; +#endif std::string name_; std::string compilation_time_; diff --git a/esphome/core/controller.cpp b/esphome/core/controller.cpp index 305fe93532..1d25be41f2 100644 --- a/esphome/core/controller.cpp +++ b/esphome/core/controller.cpp @@ -59,6 +59,12 @@ void Controller::setup_controller() { obj->add_on_state_callback([this, obj](float state) { this->on_number_update(obj, state); }); } #endif +#ifdef USE_SELECT + for (auto *obj : App.get_selects()) { + if (!obj->is_internal()) + obj->add_on_state_callback([this, obj](const std::string &state) { this->on_select_update(obj, state); }); + } +#endif } } // namespace esphome diff --git a/esphome/core/controller.h b/esphome/core/controller.h index 746658075f..0de8f7ea19 100644 --- a/esphome/core/controller.h +++ b/esphome/core/controller.h @@ -28,6 +28,9 @@ #ifdef USE_NUMBER #include "esphome/components/number/number.h" #endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif namespace esphome { @@ -61,6 +64,9 @@ class Controller { #ifdef USE_NUMBER virtual void on_number_update(number::Number *obj, float state){}; #endif +#ifdef USE_SELECT + virtual void on_select_update(select::Select *obj, const std::string &state){}; +#endif }; } // namespace esphome diff --git a/esphome/core/defines.h b/esphome/core/defines.h index cac03fc703..5c176d1b33 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -14,6 +14,7 @@ #define USE_LIGHT #define USE_CLIMATE #define USE_NUMBER +#define USE_SELECT #define USE_MQTT #define USE_POWER_SUPPLY #define USE_HOMEASSISTANT_TIME diff --git a/script/ci-custom.py b/script/ci-custom.py index d79e5b5e2f..5dad3e2445 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -563,6 +563,7 @@ def lint_inclusive_language(fname, match): "esphome/components/output/binary_output.h", "esphome/components/output/float_output.h", "esphome/components/nextion/nextion_base.h", + "esphome/components/select/select.h", "esphome/components/sensor/sensor.h", "esphome/components/stepper/stepper.h", "esphome/components/switch/switch.h", diff --git a/tests/test5.yaml b/tests/test5.yaml index 1a2dde1010..6ccb83a11a 100644 --- a/tests/test5.yaml +++ b/tests/test5.yaml @@ -82,6 +82,29 @@ number: min_value: 0 step: 5 +select: + - platform: template + name: My template select + id: template_select_id + optimistic: true + initial_option: two + restore_value: true + on_value: + - logger.log: + format: "Select changed to %s" + args: ["x.c_str()"] + set_action: + - logger.log: + format: "Template Select set to %s" + args: ["x.c_str()"] + - select.set: + id: template_select_id + option: two + options: + - one + - two + - three + sensor: - platform: selec_meter total_active_energy: