From 76c53379877c0c8f40200ec0b1253ba0e5d39151 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 9 Apr 2024 13:46:35 +1200 Subject: [PATCH] Add support for time entities (#6399) * Add time entities * Add tests * Add myself to datetime codeowners * Fix publishing times with 0 values * Log performing TimeCall * Implement `on_time` trigger * Rename var * Fix initial value for time * Add arg name for clarity * Remove useless checks --- CODEOWNERS | 2 +- esphome/components/api/api.proto | 42 ++++ esphome/components/api/api_connection.cpp | 37 +++ esphome/components/api/api_connection.h | 5 + esphome/components/api/api_pb2.cpp | 219 ++++++++++++++++++ esphome/components/api/api_pb2.h | 50 ++++ esphome/components/api/api_pb2_service.cpp | 42 ++++ esphome/components/api/api_pb2_service.h | 15 ++ esphome/components/api/api_server.cpp | 9 + esphome/components/api/api_server.h | 3 + esphome/components/api/list_entities.cpp | 4 + esphome/components/api/list_entities.h | 3 + esphome/components/api/subscribe_state.cpp | 3 + esphome/components/api/subscribe_state.h | 3 + esphome/components/datetime/__init__.py | 83 ++++++- esphome/components/datetime/time_entity.cpp | 156 +++++++++++++ esphome/components/datetime/time_entity.h | 137 +++++++++++ esphome/components/mqtt/__init__.py | 3 +- esphome/components/mqtt/mqtt_date.h | 4 +- esphome/components/mqtt/mqtt_time.cpp | 68 ++++++ esphome/components/mqtt/mqtt_time.h | 45 ++++ .../components/template/datetime/__init__.py | 38 ++- .../template/datetime/template_time.cpp | 111 +++++++++ .../template/datetime/template_time.h | 46 ++++ .../components/web_server/list_entities.cpp | 7 + esphome/components/web_server/list_entities.h | 3 + esphome/components/web_server/web_server.cpp | 58 +++++ esphome/components/web_server/web_server.h | 11 +- esphome/core/application.h | 19 ++ esphome/core/component_iterator.cpp | 15 ++ esphome/core/component_iterator.h | 6 + esphome/core/controller.cpp | 6 + esphome/core/controller.h | 6 + esphome/core/defines.h | 1 + script/ci-custom.py | 1 + .../datetime/{date.all.yaml => test.all.yaml} | 0 tests/components/template/test.all.yaml | 13 ++ 37 files changed, 1251 insertions(+), 23 deletions(-) create mode 100644 esphome/components/datetime/time_entity.cpp create mode 100644 esphome/components/datetime/time_entity.h create mode 100644 esphome/components/mqtt/mqtt_time.cpp create mode 100644 esphome/components/mqtt/mqtt_time.h create mode 100644 esphome/components/template/datetime/template_time.cpp create mode 100644 esphome/components/template/datetime/template_time.h rename tests/components/datetime/{date.all.yaml => test.all.yaml} (100%) diff --git a/CODEOWNERS b/CODEOWNERS index d2a86cd3d9..4a3205acf0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -91,7 +91,7 @@ esphome/components/daikin_arc/* @MagicBear esphome/components/daikin_brc/* @hagak esphome/components/daly_bms/* @s1lvi0 esphome/components/dashboard_import/* @esphome/core -esphome/components/datetime/* @rfdarter +esphome/components/datetime/* @jesserockz @rfdarter esphome/components/debug/* @OttoWinter esphome/components/delonghi/* @grob6000 esphome/components/dfplayer/* @glmnet diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 6b685b8974..8d5459e717 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -45,6 +45,7 @@ service APIConnection { rpc lock_command (LockCommandRequest) returns (void) {} rpc media_player_command (MediaPlayerCommandRequest) returns (void) {} rpc date_command (DateCommandRequest) returns (void) {} + rpc time_command (TimeCommandRequest) returns (void) {} rpc subscribe_bluetooth_le_advertisements(SubscribeBluetoothLEAdvertisementsRequest) returns (void) {} rpc bluetooth_device_request(BluetoothDeviceRequest) returns (void) {} @@ -1658,3 +1659,44 @@ message DateCommandRequest { uint32 month = 3; uint32 day = 4; } + +// ==================== DATETIME TIME ==================== +message ListEntitiesTimeResponse { + option (id) = 103; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_DATETIME_TIME"; + + string object_id = 1; + fixed32 key = 2; + string name = 3; + string unique_id = 4; + + string icon = 5; + bool disabled_by_default = 6; + EntityCategory entity_category = 7; +} +message TimeStateResponse { + option (id) = 104; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_DATETIME_TIME"; + option (no_delay) = true; + + fixed32 key = 1; + // If the time does not have a valid state yet. + // Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller + bool missing_state = 2; + uint32 hour = 3; + uint32 minute = 4; + uint32 second = 5; +} +message TimeCommandRequest { + option (id) = 105; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_DATETIME_TIME"; + option (no_delay) = true; + + fixed32 key = 1; + uint32 hour = 2; + uint32 minute = 3; + uint32 second = 4; +} diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index e9607f7f77..e51fa8c154 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -735,6 +735,43 @@ void APIConnection::date_command(const DateCommandRequest &msg) { } #endif +#ifdef USE_DATETIME_TIME +bool APIConnection::send_time_state(datetime::TimeEntity *time) { + if (!this->state_subscription_) + return false; + + TimeStateResponse resp{}; + resp.key = time->get_object_id_hash(); + resp.missing_state = !time->has_state(); + resp.hour = time->hour; + resp.minute = time->minute; + resp.second = time->second; + return this->send_time_state_response(resp); +} +bool APIConnection::send_time_info(datetime::TimeEntity *time) { + ListEntitiesTimeResponse msg; + msg.key = time->get_object_id_hash(); + msg.object_id = time->get_object_id(); + if (time->has_own_name()) + msg.name = time->get_name(); + msg.unique_id = get_default_unique_id("time", time); + msg.icon = time->get_icon(); + msg.disabled_by_default = time->is_disabled_by_default(); + msg.entity_category = static_cast(time->get_entity_category()); + + return this->send_list_entities_time_response(msg); +} +void APIConnection::time_command(const TimeCommandRequest &msg) { + datetime::TimeEntity *time = App.get_time_by_key(msg.key); + if (time == nullptr) + return; + + auto call = time->make_call(); + call.set_time(msg.hour, msg.minute, msg.second); + call.perform(); +} +#endif + #ifdef USE_TEXT bool APIConnection::send_text_state(text::Text *text, std::string state) { if (!this->state_subscription_) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index c19c209969..5c0a78015d 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -77,6 +77,11 @@ class APIConnection : public APIServerConnection { bool send_date_info(datetime::DateEntity *date); void date_command(const DateCommandRequest &msg) override; #endif +#ifdef USE_DATETIME_TIME + bool send_time_state(datetime::TimeEntity *time); + bool send_time_info(datetime::TimeEntity *time); + void time_command(const TimeCommandRequest &msg) override; +#endif #ifdef USE_TEXT bool send_text_state(text::Text *text, std::string state); bool send_text_info(text::Text *text); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index f6325d0854..884396bda3 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -7476,6 +7476,225 @@ void DateCommandRequest::dump_to(std::string &out) const { out.append("}"); } #endif +bool ListEntitiesTimeResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 6: { + this->disabled_by_default = value.as_bool(); + return true; + } + case 7: { + this->entity_category = value.as_enum(); + return true; + } + default: + return false; + } +} +bool ListEntitiesTimeResponse::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 ListEntitiesTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { + switch (field_id) { + case 2: { + this->key = value.as_fixed32(); + return true; + } + default: + return false; + } +} +void ListEntitiesTimeResponse::encode(ProtoWriteBuffer buffer) const { + buffer.encode_string(1, this->object_id); + buffer.encode_fixed32(2, this->key); + buffer.encode_string(3, this->name); + buffer.encode_string(4, this->unique_id); + buffer.encode_string(5, this->icon); + buffer.encode_bool(6, this->disabled_by_default); + buffer.encode_enum(7, this->entity_category); +} +#ifdef HAS_PROTO_MESSAGE_DUMP +void ListEntitiesTimeResponse::dump_to(std::string &out) const { + __attribute__((unused)) char buffer[64]; + out.append("ListEntitiesTimeResponse {\n"); + out.append(" object_id: "); + out.append("'").append(this->object_id).append("'"); + out.append("\n"); + + out.append(" key: "); + sprintf(buffer, "%" PRIu32, this->key); + out.append(buffer); + out.append("\n"); + + out.append(" name: "); + out.append("'").append(this->name).append("'"); + out.append("\n"); + + out.append(" unique_id: "); + out.append("'").append(this->unique_id).append("'"); + out.append("\n"); + + out.append(" icon: "); + out.append("'").append(this->icon).append("'"); + out.append("\n"); + + out.append(" disabled_by_default: "); + out.append(YESNO(this->disabled_by_default)); + out.append("\n"); + + out.append(" entity_category: "); + out.append(proto_enum_to_string(this->entity_category)); + out.append("\n"); + out.append("}"); +} +#endif +bool TimeStateResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 2: { + this->missing_state = value.as_bool(); + return true; + } + case 3: { + this->hour = value.as_uint32(); + return true; + } + case 4: { + this->minute = value.as_uint32(); + return true; + } + case 5: { + this->second = value.as_uint32(); + return true; + } + default: + return false; + } +} +bool TimeStateResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { + switch (field_id) { + case 1: { + this->key = value.as_fixed32(); + return true; + } + default: + return false; + } +} +void TimeStateResponse::encode(ProtoWriteBuffer buffer) const { + buffer.encode_fixed32(1, this->key); + buffer.encode_bool(2, this->missing_state); + buffer.encode_uint32(3, this->hour); + buffer.encode_uint32(4, this->minute); + buffer.encode_uint32(5, this->second); +} +#ifdef HAS_PROTO_MESSAGE_DUMP +void TimeStateResponse::dump_to(std::string &out) const { + __attribute__((unused)) char buffer[64]; + out.append("TimeStateResponse {\n"); + out.append(" key: "); + sprintf(buffer, "%" PRIu32, this->key); + out.append(buffer); + out.append("\n"); + + out.append(" missing_state: "); + out.append(YESNO(this->missing_state)); + out.append("\n"); + + out.append(" hour: "); + sprintf(buffer, "%" PRIu32, this->hour); + out.append(buffer); + out.append("\n"); + + out.append(" minute: "); + sprintf(buffer, "%" PRIu32, this->minute); + out.append(buffer); + out.append("\n"); + + out.append(" second: "); + sprintf(buffer, "%" PRIu32, this->second); + out.append(buffer); + out.append("\n"); + out.append("}"); +} +#endif +bool TimeCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 2: { + this->hour = value.as_uint32(); + return true; + } + case 3: { + this->minute = value.as_uint32(); + return true; + } + case 4: { + this->second = value.as_uint32(); + return true; + } + default: + return false; + } +} +bool TimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { + switch (field_id) { + case 1: { + this->key = value.as_fixed32(); + return true; + } + default: + return false; + } +} +void TimeCommandRequest::encode(ProtoWriteBuffer buffer) const { + buffer.encode_fixed32(1, this->key); + buffer.encode_uint32(2, this->hour); + buffer.encode_uint32(3, this->minute); + buffer.encode_uint32(4, this->second); +} +#ifdef HAS_PROTO_MESSAGE_DUMP +void TimeCommandRequest::dump_to(std::string &out) const { + __attribute__((unused)) char buffer[64]; + out.append("TimeCommandRequest {\n"); + out.append(" key: "); + sprintf(buffer, "%" PRIu32, this->key); + out.append(buffer); + out.append("\n"); + + out.append(" hour: "); + sprintf(buffer, "%" PRIu32, this->hour); + out.append(buffer); + out.append("\n"); + + out.append(" minute: "); + sprintf(buffer, "%" PRIu32, this->minute); + out.append(buffer); + out.append("\n"); + + out.append(" second: "); + sprintf(buffer, "%" PRIu32, this->second); + out.append(buffer); + out.append("\n"); + out.append("}"); +} +#endif } // namespace api } // namespace esphome diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index e361e6b8e1..2ae6fd2bb6 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1919,6 +1919,56 @@ class DateCommandRequest : public ProtoMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; +class ListEntitiesTimeResponse : public ProtoMessage { + public: + std::string object_id{}; + uint32_t key{0}; + std::string name{}; + std::string unique_id{}; + std::string icon{}; + bool disabled_by_default{false}; + enums::EntityCategory entity_category{}; + void encode(ProtoWriteBuffer buffer) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: + bool decode_32bit(uint32_t field_id, Proto32Bit value) override; + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +class TimeStateResponse : public ProtoMessage { + public: + uint32_t key{0}; + bool missing_state{false}; + uint32_t hour{0}; + uint32_t minute{0}; + uint32_t second{0}; + void encode(ProtoWriteBuffer buffer) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: + bool decode_32bit(uint32_t field_id, Proto32Bit value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +class TimeCommandRequest : public ProtoMessage { + public: + uint32_t key{0}; + uint32_t hour{0}; + uint32_t minute{0}; + uint32_t second{0}; + void encode(ProtoWriteBuffer buffer) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: + bool decode_32bit(uint32_t field_id, Proto32Bit value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt 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 2067f530ce..7a97df1ce0 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -539,6 +539,24 @@ bool APIServerConnectionBase::send_date_state_response(const DateStateResponse & #endif #ifdef USE_DATETIME_DATE #endif +#ifdef USE_DATETIME_TIME +bool APIServerConnectionBase::send_list_entities_time_response(const ListEntitiesTimeResponse &msg) { +#ifdef HAS_PROTO_MESSAGE_DUMP + ESP_LOGVV(TAG, "send_list_entities_time_response: %s", msg.dump().c_str()); +#endif + return this->send_message_(msg, 103); +} +#endif +#ifdef USE_DATETIME_TIME +bool APIServerConnectionBase::send_time_state_response(const TimeStateResponse &msg) { +#ifdef HAS_PROTO_MESSAGE_DUMP + ESP_LOGVV(TAG, "send_time_state_response: %s", msg.dump().c_str()); +#endif + return this->send_message_(msg, 104); +} +#endif +#ifdef USE_DATETIME_TIME +#endif bool APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) { switch (msg_type) { case 1: { @@ -979,6 +997,17 @@ bool APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, ESP_LOGVV(TAG, "on_date_command_request: %s", msg.dump().c_str()); #endif this->on_date_command_request(msg); +#endif + break; + } + case 105: { +#ifdef USE_DATETIME_TIME + TimeCommandRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + ESP_LOGVV(TAG, "on_time_command_request: %s", msg.dump().c_str()); +#endif + this->on_time_command_request(msg); #endif break; } @@ -1279,6 +1308,19 @@ void APIServerConnection::on_date_command_request(const DateCommandRequest &msg) this->date_command(msg); } #endif +#ifdef USE_DATETIME_TIME +void APIServerConnection::on_time_command_request(const TimeCommandRequest &msg) { + if (!this->is_connection_setup()) { + this->on_no_setup_connection(); + return; + } + if (!this->is_authenticated()) { + this->on_unauthenticated_access(); + return; + } + this->time_command(msg); +} +#endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_subscribe_bluetooth_le_advertisements_request( const SubscribeBluetoothLEAdvertisementsRequest &msg) { diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index effcfc30f4..095ce51b0f 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -270,6 +270,15 @@ class APIServerConnectionBase : public ProtoService { #endif #ifdef USE_DATETIME_DATE virtual void on_date_command_request(const DateCommandRequest &value){}; +#endif +#ifdef USE_DATETIME_TIME + bool send_list_entities_time_response(const ListEntitiesTimeResponse &msg); +#endif +#ifdef USE_DATETIME_TIME + bool send_time_state_response(const TimeStateResponse &msg); +#endif +#ifdef USE_DATETIME_TIME + virtual void on_time_command_request(const TimeCommandRequest &value){}; #endif protected: bool read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override; @@ -328,6 +337,9 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_DATETIME_DATE virtual void date_command(const DateCommandRequest &msg) = 0; #endif +#ifdef USE_DATETIME_TIME + virtual void time_command(const TimeCommandRequest &msg) = 0; +#endif #ifdef USE_BLUETOOTH_PROXY virtual void subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) = 0; #endif @@ -417,6 +429,9 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_DATETIME_DATE void on_date_command_request(const DateCommandRequest &msg) override; #endif +#ifdef USE_DATETIME_TIME + void on_time_command_request(const TimeCommandRequest &msg) override; +#endif #ifdef USE_BLUETOOTH_PROXY void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg) override; #endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index b17555bb49..4c809126e6 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -264,6 +264,15 @@ void APIServer::on_date_update(datetime::DateEntity *obj) { } #endif +#ifdef USE_DATETIME_TIME +void APIServer::on_time_update(datetime::TimeEntity *obj) { + if (obj->is_internal()) + return; + for (auto &c : this->clients_) + c->send_time_state(obj); +} +#endif + #ifdef USE_TEXT void APIServer::on_text_update(text::Text *obj, const std::string &state) { if (obj->is_internal()) diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index c12355cc8b..8a9c26af73 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -69,6 +69,9 @@ class APIServer : public Component, public Controller { #ifdef USE_DATETIME_DATE void on_date_update(datetime::DateEntity *obj) override; #endif +#ifdef USE_DATETIME_TIME + void on_time_update(datetime::TimeEntity *obj) override; +#endif #ifdef USE_TEXT void on_text_update(text::Text *obj, const std::string &state) override; #endif diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index cd1841de5e..18685ee4d1 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -64,6 +64,10 @@ bool ListEntitiesIterator::on_number(number::Number *number) { return this->clie bool ListEntitiesIterator::on_date(datetime::DateEntity *date) { return this->client_->send_date_info(date); } #endif +#ifdef USE_DATETIME_TIME +bool ListEntitiesIterator::on_time(datetime::TimeEntity *time) { return this->client_->send_time_info(time); } +#endif + #ifdef USE_TEXT bool ListEntitiesIterator::on_text(text::Text *text) { return this->client_->send_text_info(text); } #endif diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h index b49867048d..95a09fc25b 100644 --- a/esphome/components/api/list_entities.h +++ b/esphome/components/api/list_entities.h @@ -49,6 +49,9 @@ class ListEntitiesIterator : public ComponentIterator { #ifdef USE_DATETIME_DATE bool on_date(datetime::DateEntity *date) override; #endif +#ifdef USE_DATETIME_TIME + bool on_time(datetime::TimeEntity *time) override; +#endif #ifdef USE_TEXT bool on_text(text::Text *text) override; #endif diff --git a/esphome/components/api/subscribe_state.cpp b/esphome/components/api/subscribe_state.cpp index 4e7216ddef..5eb40cfa7e 100644 --- a/esphome/components/api/subscribe_state.cpp +++ b/esphome/components/api/subscribe_state.cpp @@ -45,6 +45,9 @@ bool InitialStateIterator::on_number(number::Number *number) { #ifdef USE_DATETIME_DATE bool InitialStateIterator::on_date(datetime::DateEntity *date) { return this->client_->send_date_state(date); } #endif +#ifdef USE_DATETIME_TIME +bool InitialStateIterator::on_time(datetime::TimeEntity *time) { return this->client_->send_time_state(time); } +#endif #ifdef USE_TEXT bool InitialStateIterator::on_text(text::Text *text) { return this->client_->send_text_state(text, text->state); } #endif diff --git a/esphome/components/api/subscribe_state.h b/esphome/components/api/subscribe_state.h index 4a96659b76..447f1707d2 100644 --- a/esphome/components/api/subscribe_state.h +++ b/esphome/components/api/subscribe_state.h @@ -46,6 +46,9 @@ class InitialStateIterator : public ComponentIterator { #ifdef USE_DATETIME_DATE bool on_date(datetime::DateEntity *date) override; #endif +#ifdef USE_DATETIME_TIME + bool on_time(datetime::TimeEntity *time) override; +#endif #ifdef USE_TEXT bool on_text(text::Text *text) override; #endif diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 3ae99cfff6..b255a27303 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -3,38 +3,50 @@ import esphome.codegen as cg # import cpp_generator as cpp import esphome.config_validation as cv from esphome import automation -from esphome.components import mqtt +from esphome.components import mqtt, time from esphome.const import ( CONF_ID, + CONF_ON_TIME, CONF_ON_VALUE, + CONF_TIME_ID, CONF_TRIGGER_ID, CONF_TYPE, CONF_MQTT_ID, CONF_DATE, + CONF_TIME, CONF_YEAR, CONF_MONTH, CONF_DAY, + CONF_SECOND, + CONF_HOUR, + CONF_MINUTE, ) from esphome.core import CORE, coroutine_with_priority from esphome.cpp_generator import MockObjClass from esphome.cpp_helpers import setup_entity -CODEOWNERS = ["@rfdarter"] +CODEOWNERS = ["@rfdarter", "@jesserockz"] IS_PLATFORM_COMPONENT = True datetime_ns = cg.esphome_ns.namespace("datetime") DateTimeBase = datetime_ns.class_("DateTimeBase", cg.EntityBase) DateEntity = datetime_ns.class_("DateEntity", DateTimeBase) +TimeEntity = datetime_ns.class_("TimeEntity", DateTimeBase) # Actions DateSetAction = datetime_ns.class_("DateSetAction", automation.Action) +TimeSetAction = datetime_ns.class_("TimeSetAction", automation.Action) DateTimeStateTrigger = datetime_ns.class_( "DateTimeStateTrigger", automation.Trigger.template(cg.ESPTime) ) +OnTimeTrigger = datetime_ns.class_( + "OnTimeTrigger", automation.Trigger, cg.Component, cg.Parented.template(TimeEntity) +) + DATETIME_MODES = [ "DATE", "TIME", @@ -44,7 +56,6 @@ DATETIME_MODES = [ _DATETIME_SCHEMA = cv.Schema( { - cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTDatetimeComponent), cv.Optional(CONF_ON_VALUE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DateTimeStateTrigger), @@ -57,6 +68,7 @@ _DATETIME_SCHEMA = cv.Schema( def date_schema(class_: MockObjClass) -> cv.Schema: schema = { cv.GenerateID(): cv.declare_id(class_), + cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTDateComponent), cv.Optional(CONF_TYPE, default="DATE"): cv.one_of("DATE", upper=True), } return _DATETIME_SCHEMA.extend(schema) @@ -65,7 +77,20 @@ def date_schema(class_: MockObjClass) -> cv.Schema: def time_schema(class_: MockObjClass) -> cv.Schema: schema = { cv.GenerateID(): cv.declare_id(class_), + cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTimeComponent), cv.Optional(CONF_TYPE, default="TIME"): cv.one_of("TIME", upper=True), + cv.Inclusive( + CONF_ON_TIME, + group_of_inclusion=CONF_ON_TIME, + msg="`on_time` and `time_id` must both be specified", + ): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnTimeTrigger), + } + ), + cv.Inclusive(CONF_TIME_ID, group_of_inclusion=CONF_ON_TIME): cv.use_id( + time.RealTimeClock + ), } return _DATETIME_SCHEMA.extend(schema) @@ -88,6 +113,17 @@ async def setup_datetime_core_(var, config): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [(cg.ESPTime, "x")], conf) + rtc_id = config.get(CONF_TIME_ID) + rtc = None + if rtc_id is not None: + rtc = await cg.get_variable(rtc_id) + for conf in config.get(CONF_ON_TIME, []): + assert rtc is not None + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], rtc) + await automation.build_automation(trigger, [], conf) + await cg.register_component(trigger, conf) + await cg.register_parented(trigger, var) + async def register_datetime(var, config): if not CORE.has_id(config[CONF_ID]): @@ -109,18 +145,12 @@ async def to_code(config): cg.add_global(datetime_ns.using) -OPERATION_BASE_SCHEMA = cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(DateEntity), - } -) - - @automation.register_action( "datetime.date.set", DateSetAction, - OPERATION_BASE_SCHEMA.extend( + cv.Schema( { + cv.Required(CONF_ID): cv.use_id(DateEntity), cv.Required(CONF_DATE): cv.Any( cv.returning_lambda, cv.date_time(allowed_time=False) ), @@ -144,3 +174,34 @@ async def datetime_date_set_to_code(config, action_id, template_arg, args): ) cg.add(action_var.set_date(date_struct)) return action_var + + +@automation.register_action( + "datetime.time.set", + TimeSetAction, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(TimeEntity), + cv.Required(CONF_TIME): cv.Any( + cv.returning_lambda, cv.date_time(allowed_date=False) + ), + } + ), +) +async def datetime_time_set_to_code(config, action_id, template_arg, args): + action_var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(action_var, config[CONF_ID]) + + time_config = config[CONF_TIME] + if cg.is_template(time_config): + template_ = await cg.templatable(config[CONF_TIME], [], cg.ESPTime) + cg.add(action_var.set_time(template_)) + else: + time_struct = cg.StructInitializer( + cg.ESPTime, + ("second", time_config[CONF_SECOND]), + ("minute", time_config[CONF_MINUTE]), + ("hour", time_config[CONF_HOUR]), + ) + cg.add(action_var.set_time(time_struct)) + return action_var diff --git a/esphome/components/datetime/time_entity.cpp b/esphome/components/datetime/time_entity.cpp new file mode 100644 index 0000000000..98558152d7 --- /dev/null +++ b/esphome/components/datetime/time_entity.cpp @@ -0,0 +1,156 @@ +#include "time_entity.h" + +#ifdef USE_DATETIME_TIME + +#include "esphome/core/log.h" + +namespace esphome { +namespace datetime { + +static const char *const TAG = "datetime.time_entity"; + +void TimeEntity::publish_state() { + if (this->hour_ > 23) { + this->has_state_ = false; + ESP_LOGE(TAG, "Hour must be between 0 and 23"); + return; + } + if (this->minute_ > 59) { + this->has_state_ = false; + ESP_LOGE(TAG, "Minute must be between 0 and 59"); + return; + } + if (this->second_ > 59) { + this->has_state_ = false; + ESP_LOGE(TAG, "Second must be between 0 and 59"); + return; + } + this->has_state_ = true; + ESP_LOGD(TAG, "'%s': Sending time %02d:%02d:%02d", this->get_name().c_str(), this->hour_, this->minute_, + this->second_); + this->state_callback_.call(); +} + +TimeCall TimeEntity::make_call() { return TimeCall(this); } + +void TimeCall::validate_() { + if (this->hour_.has_value() && this->hour_ > 23) { + ESP_LOGE(TAG, "Hour must be between 0 and 23"); + this->hour_.reset(); + } + if (this->minute_.has_value() && this->minute_ > 59) { + ESP_LOGE(TAG, "Minute must be between 0 and 59"); + this->minute_.reset(); + } + if (this->second_.has_value() && this->second_ > 59) { + ESP_LOGE(TAG, "Second must be between 0 and 59"); + this->second_.reset(); + } +} + +void TimeCall::perform() { + this->validate_(); + ESP_LOGD(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); + if (this->hour_.has_value()) { + ESP_LOGD(TAG, " Hour: %d", *this->hour_); + } + if (this->minute_.has_value()) { + ESP_LOGD(TAG, " Minute: %d", *this->minute_); + } + if (this->second_.has_value()) { + ESP_LOGD(TAG, " Second: %d", *this->second_); + } + this->parent_->control(*this); +} + +TimeCall &TimeCall::set_time(uint8_t hour, uint8_t minute, uint8_t second) { + this->hour_ = hour; + this->minute_ = minute; + this->second_ = second; + return *this; +}; + +TimeCall &TimeCall::set_time(ESPTime time) { return this->set_time(time.hour, time.minute, time.second); }; + +TimeCall &TimeCall::set_time(const std::string &time) { + ESPTime val{}; + if (!ESPTime::strptime(time, val)) { + ESP_LOGE(TAG, "Could not convert the time string to an ESPTime object"); + return *this; + } + return this->set_time(val); +} + +TimeCall TimeEntityRestoreState::to_call(TimeEntity *time) { + TimeCall call = time->make_call(); + call.set_time(this->hour, this->minute, this->second); + return call; +} + +void TimeEntityRestoreState::apply(TimeEntity *time) { + time->hour_ = this->hour; + time->minute_ = this->minute; + time->second_ = this->second; + time->publish_state(); +} + +#ifdef USE_TIME + +static const int MAX_TIMESTAMP_DRIFT = 900; // how far can the clock drift before we consider + // there has been a drastic time synchronization + +void OnTimeTrigger::loop() { + if (!this->parent_->has_state()) { + return; + } + ESPTime time = this->rtc_->now(); + if (!time.is_valid()) { + return; + } + if (this->last_check_.has_value()) { + if (*this->last_check_ > time && this->last_check_->timestamp - time.timestamp > MAX_TIMESTAMP_DRIFT) { + // We went back in time (a lot), probably caused by time synchronization + ESP_LOGW(TAG, "Time has jumped back!"); + } else if (*this->last_check_ >= time) { + // already handled this one + return; + } else if (time > *this->last_check_ && time.timestamp - this->last_check_->timestamp > MAX_TIMESTAMP_DRIFT) { + // We went ahead in time (a lot), probably caused by time synchronization + ESP_LOGW(TAG, "Time has jumped ahead!"); + this->last_check_ = time; + return; + } + + while (true) { + this->last_check_->increment_second(); + if (*this->last_check_ >= time) + break; + + if (this->matches_(*this->last_check_)) { + this->trigger(); + break; + } + } + } + + this->last_check_ = time; + if (!time.fields_in_range()) { + ESP_LOGW(TAG, "Time is out of range!"); + ESP_LOGD(TAG, "Second=%02u Minute=%02u Hour=%02u", time.second, time.minute, time.hour); + } + + if (this->matches_(time)) + this->trigger(); +} + +bool OnTimeTrigger::matches_(const ESPTime &time) const { + return time.is_valid() && time.hour == this->parent_->hour && time.minute == this->parent_->minute && + time.second == this->parent_->second; +} + +#endif + +} // namespace datetime +} // namespace esphome + +#endif // USE_DATETIME_TIME diff --git a/esphome/components/datetime/time_entity.h b/esphome/components/datetime/time_entity.h new file mode 100644 index 0000000000..956c09e2b4 --- /dev/null +++ b/esphome/components/datetime/time_entity.h @@ -0,0 +1,137 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_DATETIME_TIME + +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" +#include "esphome/core/time.h" + +#include "datetime_base.h" + +#ifdef USE_TIME +#include "esphome/components/time/real_time_clock.h" +#endif + +namespace esphome { +namespace datetime { + +#define LOG_DATETIME_TIME(prefix, type, obj) \ + if ((obj) != nullptr) { \ + ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ + if (!(obj)->get_icon().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + } \ + } + +class TimeCall; +class TimeEntity; + +struct TimeEntityRestoreState { + uint8_t hour; + uint8_t minute; + uint8_t second; + + TimeCall to_call(TimeEntity *time); + void apply(TimeEntity *time); +} __attribute__((packed)); + +class TimeEntity : public DateTimeBase { + protected: + uint8_t hour_; + uint8_t minute_; + uint8_t second_; + + public: + void publish_state(); + TimeCall make_call(); + + ESPTime state_as_esptime() const override { + ESPTime obj; + obj.hour = this->hour_; + obj.minute = this->minute_; + obj.second = this->second_; + return obj; + } + + const uint8_t &hour = hour_; + const uint8_t &minute = minute_; + const uint8_t &second = second_; + + protected: + friend class TimeCall; + friend struct TimeEntityRestoreState; + + virtual void control(const TimeCall &call) = 0; +}; + +class TimeCall { + public: + explicit TimeCall(TimeEntity *parent) : parent_(parent) {} + void perform(); + TimeCall &set_time(uint8_t hour, uint8_t minute, uint8_t second); + TimeCall &set_time(ESPTime time); + TimeCall &set_time(const std::string &time); + + TimeCall &set_hour(uint8_t hour) { + this->hour_ = hour; + return *this; + } + TimeCall &set_minute(uint8_t minute) { + this->minute_ = minute; + return *this; + } + TimeCall &set_second(uint8_t second) { + this->second_ = second; + return *this; + } + + optional get_hour() const { return this->hour_; } + optional get_minute() const { return this->minute_; } + optional get_second() const { return this->second_; } + + protected: + void validate_(); + + TimeEntity *parent_; + + optional hour_; + optional minute_; + optional second_; +}; + +template class TimeSetAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(ESPTime, time) + + void play(Ts... x) override { + auto call = this->parent_->make_call(); + + if (this->time_.has_value()) { + call.set_time(this->time_.value(x...)); + } + call.perform(); + } +}; + +#ifdef USE_TIME + +class OnTimeTrigger : public Trigger<>, public Component, public Parented { + public: + explicit OnTimeTrigger(time::RealTimeClock *rtc) : rtc_(rtc) {} + void loop() override; + + protected: + bool matches_(const ESPTime &time) const; + + time::RealTimeClock *rtc_; + optional last_check_; +}; + +#endif + +} // namespace datetime +} // namespace esphome + +#endif // USE_DATETIME_TIME diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index e442eb9146..b2c03c1546 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -113,7 +113,8 @@ MQTTSensorComponent = mqtt_ns.class_("MQTTSensorComponent", MQTTComponent) MQTTSwitchComponent = mqtt_ns.class_("MQTTSwitchComponent", MQTTComponent) MQTTTextSensor = mqtt_ns.class_("MQTTTextSensor", MQTTComponent) MQTTNumberComponent = mqtt_ns.class_("MQTTNumberComponent", MQTTComponent) -MQTTDatetimeComponent = mqtt_ns.class_("MQTTDatetimeComponent", MQTTComponent) +MQTTDateComponent = mqtt_ns.class_("MQTTDateComponent", MQTTComponent) +MQTTTimeComponent = mqtt_ns.class_("MQTTTimeComponent", MQTTComponent) MQTTTextComponent = mqtt_ns.class_("MQTTTextComponent", MQTTComponent) MQTTSelectComponent = mqtt_ns.class_("MQTTSelectComponent", MQTTComponent) MQTTButtonComponent = mqtt_ns.class_("MQTTButtonComponent", MQTTComponent) diff --git a/esphome/components/mqtt/mqtt_date.h b/esphome/components/mqtt/mqtt_date.h index 2776893d32..5147afe7e7 100644 --- a/esphome/components/mqtt/mqtt_date.h +++ b/esphome/components/mqtt/mqtt_date.h @@ -13,9 +13,9 @@ namespace mqtt { class MQTTDateComponent : public mqtt::MQTTComponent { public: - /** Construct this MQTTDatetimeComponent instance with the provided friendly_name and datetime + /** Construct this MQTTDateComponent instance with the provided friendly_name and date * - * @param datetime The datetime component. + * @param date The date component. */ explicit MQTTDateComponent(datetime::DateEntity *date); diff --git a/esphome/components/mqtt/mqtt_time.cpp b/esphome/components/mqtt/mqtt_time.cpp new file mode 100644 index 0000000000..332ef53cbc --- /dev/null +++ b/esphome/components/mqtt/mqtt_time.cpp @@ -0,0 +1,68 @@ +#include "mqtt_time.h" + +#include +#include "esphome/core/log.h" + +#include "mqtt_const.h" + +#ifdef USE_MQTT +#ifdef USE_DATETIME_TIME + +namespace esphome { +namespace mqtt { + +static const char *const TAG = "mqtt.datetime.time"; + +using namespace esphome::datetime; + +MQTTTimeComponent::MQTTTimeComponent(TimeEntity *time) : time_(time) {} + +void MQTTTimeComponent::setup() { + this->subscribe_json(this->get_command_topic_(), [this](const std::string &topic, JsonObject root) { + auto call = this->time_->make_call(); + if (root.containsKey("hour")) { + call.set_hour(root["hour"]); + } + if (root.containsKey("minute")) { + call.set_minute(root["minute"]); + } + if (root.containsKey("second")) { + call.set_second(root["second"]); + } + call.perform(); + }); + this->time_->add_on_state_callback( + [this]() { this->publish_state(this->time_->hour, this->time_->minute, this->time_->second); }); +} + +void MQTTTimeComponent::dump_config() { + ESP_LOGCONFIG(TAG, "MQTT Time '%s':", this->time_->get_name().c_str()); + LOG_MQTT_COMPONENT(true, true) +} + +std::string MQTTTimeComponent::component_type() const { return "time"; } +const EntityBase *MQTTTimeComponent::get_entity() const { return this->time_; } + +void MQTTTimeComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) { + // Nothing extra to add here +} +bool MQTTTimeComponent::send_initial_state() { + if (this->time_->has_state()) { + return this->publish_state(this->time_->hour, this->time_->minute, this->time_->second); + } else { + return true; + } +} +bool MQTTTimeComponent::publish_state(uint8_t hour, uint8_t minute, uint8_t second) { + return this->publish_json(this->get_state_topic_(), [hour, minute, second](JsonObject root) { + root["hour"] = hour; + root["minute"] = minute; + root["second"] = second; + }); +} + +} // namespace mqtt +} // namespace esphome + +#endif // USE_DATETIME_TIME +#endif // USE_MQTT diff --git a/esphome/components/mqtt/mqtt_time.h b/esphome/components/mqtt/mqtt_time.h new file mode 100644 index 0000000000..b9dd822a73 --- /dev/null +++ b/esphome/components/mqtt/mqtt_time.h @@ -0,0 +1,45 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_MQTT +#ifdef USE_DATETIME_TIME + +#include "esphome/components/datetime/time_entity.h" +#include "mqtt_component.h" + +namespace esphome { +namespace mqtt { + +class MQTTTimeComponent : public mqtt::MQTTComponent { + public: + /** Construct this MQTTTimeComponent instance with the provided friendly_name and time + * + * @param time The time entity. + */ + explicit MQTTTimeComponent(datetime::TimeEntity *time); + + // ========== INTERNAL METHODS ========== + // (In most use cases you won't need these) + /// Override setup. + void setup() override; + void dump_config() override; + + void send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) override; + + bool send_initial_state() override; + + bool publish_state(uint8_t hour, uint8_t minute, uint8_t second); + + protected: + std::string component_type() const override; + const EntityBase *get_entity() const override; + + datetime::TimeEntity *time_; +}; + +} // namespace mqtt +} // namespace esphome + +#endif // USE_DATETIME_DATE +#endif // USE_MQTT diff --git a/esphome/components/template/datetime/__init__.py b/esphome/components/template/datetime/__init__.py index 16f341301e..53d9d1b9d3 100644 --- a/esphome/components/template/datetime/__init__.py +++ b/esphome/components/template/datetime/__init__.py @@ -9,7 +9,11 @@ from esphome.const import ( CONF_RESTORE_VALUE, CONF_SET_ACTION, CONF_DAY, + CONF_HOUR, + CONF_MINUTE, CONF_MONTH, + CONF_SECOND, + CONF_TYPE, CONF_YEAR, ) @@ -23,6 +27,10 @@ TemplateDate = template_ns.class_( "TemplateDate", datetime.DateEntity, cg.PollingComponent ) +TemplateTime = template_ns.class_( + "TemplateTime", datetime.TimeEntity, cg.PollingComponent +) + def validate(config): config = config.copy() @@ -63,6 +71,13 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_INITIAL_VALUE): cv.date_time(allowed_time=False), } ), + "TIME": datetime.time_schema(TemplateTime) + .extend(_BASE_SCHEMA) + .extend( + { + cv.Optional(CONF_INITIAL_VALUE): cv.date_time(allowed_date=False), + } + ), }, upper=True, ), @@ -85,13 +100,22 @@ async def to_code(config): cg.add(var.set_restore_value(config[CONF_RESTORE_VALUE])) if initial_value := config.get(CONF_INITIAL_VALUE): - date_struct = cg.StructInitializer( - cg.ESPTime, - ("day_of_month", initial_value[CONF_DAY]), - ("month", initial_value[CONF_MONTH]), - ("year", initial_value[CONF_YEAR]), - ) - cg.add(var.set_initial_value(date_struct)) + if config[CONF_TYPE] == "DATE": + date_struct = cg.StructInitializer( + cg.ESPTime, + ("day_of_month", initial_value[CONF_DAY]), + ("month", initial_value[CONF_MONTH]), + ("year", initial_value[CONF_YEAR]), + ) + cg.add(var.set_initial_value(date_struct)) + elif config[CONF_TYPE] == "TIME": + time_struct = cg.StructInitializer( + cg.ESPTime, + ("second", initial_value[CONF_SECOND]), + ("minute", initial_value[CONF_MINUTE]), + ("hour", initial_value[CONF_HOUR]), + ) + cg.add(var.set_initial_value(time_struct)) if CONF_SET_ACTION in config: await automation.build_automation( diff --git a/esphome/components/template/datetime/template_time.cpp b/esphome/components/template/datetime/template_time.cpp new file mode 100644 index 0000000000..0e4d734d16 --- /dev/null +++ b/esphome/components/template/datetime/template_time.cpp @@ -0,0 +1,111 @@ +#include "template_time.h" + +#ifdef USE_DATETIME_TIME + +#include "esphome/core/log.h" + +namespace esphome { +namespace template_ { + +static const char *const TAG = "template.time"; + +void TemplateTime::setup() { + if (this->f_.has_value()) + return; + + ESPTime state{}; + + if (!this->restore_value_) { + state = this->initial_value_; + } else { + datetime::TimeEntityRestoreState temp; + this->pref_ = + global_preferences->make_preference(194434060U ^ this->get_object_id_hash()); + if (this->pref_.load(&temp)) { + temp.apply(this); + return; + } else { + // set to inital value if loading from pref failed + state = this->initial_value_; + } + } + + this->hour_ = state.hour; + this->minute_ = state.minute; + this->second_ = state.second; + this->publish_state(); +} + +void TemplateTime::update() { + if (!this->f_.has_value()) + return; + + auto val = (*this->f_)(); + if (!val.has_value()) + return; + + this->hour_ = val->hour; + this->minute_ = val->minute; + this->second_ = val->second; + this->publish_state(); +} + +void TemplateTime::control(const datetime::TimeCall &call) { + bool has_hour = call.get_hour().has_value(); + bool has_minute = call.get_minute().has_value(); + bool has_second = call.get_second().has_value(); + + ESPTime value = {}; + if (has_hour) + value.hour = *call.get_hour(); + + if (has_minute) + value.minute = *call.get_minute(); + + if (has_second) + value.second = *call.get_second(); + + this->set_trigger_->trigger(value); + + if (this->optimistic_) { + if (has_hour) + this->hour_ = *call.get_hour(); + if (has_minute) + this->minute_ = *call.get_minute(); + if (has_second) + this->second_ = *call.get_second(); + this->publish_state(); + } + + if (this->restore_value_) { + datetime::TimeEntityRestoreState temp = {}; + if (has_hour) { + temp.hour = *call.get_hour(); + } else { + temp.hour = this->hour_; + } + if (has_minute) { + temp.minute = *call.get_minute(); + } else { + temp.minute = this->minute_; + } + if (has_second) { + temp.second = *call.get_second(); + } else { + temp.second = this->second_; + } + + this->pref_.save(&temp); + } +} + +void TemplateTime::dump_config() { + LOG_DATETIME_TIME("", "Template Time", this); + ESP_LOGCONFIG(TAG, " Optimistic: %s", YESNO(this->optimistic_)); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace template_ +} // namespace esphome + +#endif // USE_DATETIME_TIME diff --git a/esphome/components/template/datetime/template_time.h b/esphome/components/template/datetime/template_time.h new file mode 100644 index 0000000000..4a7c0098ec --- /dev/null +++ b/esphome/components/template/datetime/template_time.h @@ -0,0 +1,46 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_DATETIME_TIME + +#include "esphome/components/datetime/time_entity.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/preferences.h" +#include "esphome/core/time.h" + +namespace esphome { +namespace template_ { + +class TemplateTime : public datetime::TimeEntity, public PollingComponent { + public: + void set_template(std::function()> &&f) { this->f_ = f; } + + void setup() override; + void update() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + Trigger *get_set_trigger() const { return this->set_trigger_; } + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } + + void set_initial_value(ESPTime initial_value) { this->initial_value_ = initial_value; } + void set_restore_value(bool restore_value) { this->restore_value_ = restore_value; } + + protected: + void control(const datetime::TimeCall &call) override; + + bool optimistic_{false}; + ESPTime initial_value_{}; + bool restore_value_{false}; + Trigger *set_trigger_ = new Trigger(); + optional()>> f_; + + ESPPreferenceObject pref_; +}; + +} // namespace template_ +} // namespace esphome + +#endif // USE_DATETIME_TIME diff --git a/esphome/components/web_server/list_entities.cpp b/esphome/components/web_server/list_entities.cpp index 2252f55008..dd9fd4afe4 100644 --- a/esphome/components/web_server/list_entities.cpp +++ b/esphome/components/web_server/list_entities.cpp @@ -113,6 +113,13 @@ bool ListEntitiesIterator::on_date(datetime::DateEntity *date) { } #endif +#ifdef USE_DATETIME_TIME +bool ListEntitiesIterator::on_time(datetime::TimeEntity *time) { + this->web_server_->events_.send(this->web_server_->time_json(time, DETAIL_ALL).c_str(), "state"); + return true; +} +#endif + #ifdef USE_TEXT bool ListEntitiesIterator::on_text(text::Text *text) { if (this->web_server_->events_.count() == 0) diff --git a/esphome/components/web_server/list_entities.h b/esphome/components/web_server/list_entities.h index cd7c9099d6..fc48186b32 100644 --- a/esphome/components/web_server/list_entities.h +++ b/esphome/components/web_server/list_entities.h @@ -44,6 +44,9 @@ class ListEntitiesIterator : public ComponentIterator { #ifdef USE_DATETIME_DATE bool on_date(datetime::DateEntity *date) override; #endif +#ifdef USE_DATETIME_TIME + bool on_time(datetime::TimeEntity *time) override; +#endif #ifdef USE_TEXT bool on_text(text::Text *text) override; #endif diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 6c3e4e5eec..b48a39cbcb 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -921,6 +921,52 @@ std::string WebServer::date_json(datetime::DateEntity *obj, JsonDetail start_con } #endif // USE_DATETIME_DATE +#ifdef USE_DATETIME_TIME +void WebServer::on_time_update(datetime::TimeEntity *obj) { + this->events_.send(this->time_json(obj, DETAIL_STATE).c_str(), "state"); +} +void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match) { + for (auto *obj : App.get_times()) { + if (obj->get_object_id() != match.id) + continue; + if (request->method() == HTTP_GET && match.method.empty()) { + std::string data = this->time_json(obj, DETAIL_STATE); + request->send(200, "application/json", data.c_str()); + return; + } + if (match.method != "set") { + request->send(404); + return; + } + + auto call = obj->make_call(); + + if (!request->hasParam("value")) { + request->send(409); + return; + } + + if (request->hasParam("value")) { + std::string value = request->getParam("value")->value().c_str(); + call.set_time(value); + } + + this->schedule_([call]() mutable { call.perform(); }); + request->send(200); + return; + } + request->send(404); +} +std::string WebServer::time_json(datetime::TimeEntity *obj, JsonDetail start_config) { + return json::build_json([obj, start_config](JsonObject root) { + set_json_id(root, obj, "time-" + obj->get_object_id(), start_config); + std::string value = str_sprintf("%02d:%02d:%02d", obj->hour, obj->minute, obj->second); + root["value"] = value; + root["state"] = value; + }); +} +#endif // USE_DATETIME_TIME + #ifdef USE_TEXT void WebServer::on_text_update(text::Text *obj, const std::string &state) { if (this->events_.count() == 0) @@ -1320,6 +1366,11 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) { return true; #endif +#ifdef USE_DATETIME_TIME + if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain == "time") + return true; +#endif + #ifdef USE_TEXT if ((request->method() == HTTP_POST || request->method() == HTTP_GET) && match.domain == "text") return true; @@ -1445,6 +1496,13 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { } #endif +#ifdef USE_DATETIME_TIME + if (match.domain == "time") { + this->handle_time_request(request, match); + return; + } +#endif + #ifdef USE_TEXT if (match.domain == "text") { this->handle_text_request(request, match); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 57cbbe1339..1935f8d076 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -8,9 +8,9 @@ #include #ifdef USE_ESP32 -#include #include #include +#include #endif #if USE_WEBSERVER_VERSION >= 2 @@ -230,6 +230,15 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { std::string date_json(datetime::DateEntity *obj, JsonDetail start_config); #endif +#ifdef USE_DATETIME_TIME + void on_time_update(datetime::TimeEntity *obj) override; + /// Handle a time request under '/time/'. + void handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match); + + /// Dump the time state with its value as a JSON string. + std::string time_json(datetime::TimeEntity *obj, JsonDetail start_config); +#endif + #ifdef USE_TEXT void on_text_update(text::Text *obj, const std::string &state) override; /// Handle a text input request under '/text/'. diff --git a/esphome/core/application.h b/esphome/core/application.h index 26125dd935..73330d27e3 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -42,6 +42,9 @@ #ifdef USE_DATETIME_DATE #include "esphome/components/datetime/date_entity.h" #endif +#ifdef USE_DATETIME_TIME +#include "esphome/components/datetime/time_entity.h" +#endif #ifdef USE_TEXT #include "esphome/components/text/text.h" #endif @@ -128,6 +131,10 @@ class Application { void register_date(datetime::DateEntity *date) { this->dates_.push_back(date); } #endif +#ifdef USE_DATETIME_TIME + void register_time(datetime::TimeEntity *time) { this->times_.push_back(time); } +#endif + #ifdef USE_TEXT void register_text(text::Text *text) { this->texts_.push_back(text); } #endif @@ -305,6 +312,15 @@ class Application { return nullptr; } #endif +#ifdef USE_DATETIME_TIME + const std::vector &get_times() { return this->times_; } + datetime::TimeEntity *get_time_by_key(uint32_t key, bool include_internal = false) { + for (auto *obj : this->times_) + if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) + return obj; + return nullptr; + } +#endif #ifdef USE_TEXT const std::vector &get_texts() { return this->texts_; } text::Text *get_text_by_key(uint32_t key, bool include_internal = false) { @@ -401,6 +417,9 @@ class Application { #ifdef USE_DATETIME_DATE std::vector dates_{}; #endif +#ifdef USE_DATETIME_TIME + std::vector times_{}; +#endif #ifdef USE_SELECT std::vector selects_{}; #endif diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index 1e06221af6..228cf64d54 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -217,6 +217,21 @@ void ComponentIterator::advance() { } break; #endif +#ifdef USE_DATETIME_TIME + case IteratorState::DATETIME_TIME: + if (this->at_ >= App.get_times().size()) { + advance_platform = true; + } else { + auto *time = App.get_times()[this->at_]; + if (time->is_internal() && !this->include_internal_) { + success = true; + break; + } else { + success = this->on_time(time); + } + } + break; +#endif #ifdef USE_TEXT case IteratorState::TEXT: if (this->at_ >= App.get_texts().size()) { diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 02c6dddacb..d7f19f2850 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -60,6 +60,9 @@ class ComponentIterator { #ifdef USE_DATETIME_DATE virtual bool on_date(datetime::DateEntity *date) = 0; #endif +#ifdef USE_DATETIME_TIME + virtual bool on_time(datetime::TimeEntity *time) = 0; +#endif #ifdef USE_TEXT virtual bool on_text(text::Text *text) = 0; #endif @@ -120,6 +123,9 @@ class ComponentIterator { #ifdef USE_DATETIME_DATE DATETIME_DATE, #endif +#ifdef USE_DATETIME_TIME + DATETIME_TIME, +#endif #ifdef USE_TEXT TEXT, #endif diff --git a/esphome/core/controller.cpp b/esphome/core/controller.cpp index 43b8fea50c..db5818d455 100644 --- a/esphome/core/controller.cpp +++ b/esphome/core/controller.cpp @@ -65,6 +65,12 @@ void Controller::setup_controller(bool include_internal) { obj->add_on_state_callback([this, obj]() { this->on_date_update(obj); }); } #endif +#ifdef USE_DATETIME_TIME + for (auto *obj : App.get_times()) { + if (include_internal || !obj->is_internal()) + obj->add_on_state_callback([this, obj]() { this->on_time_update(obj); }); + } +#endif #ifdef USE_TEXT for (auto *obj : App.get_texts()) { if (include_internal || !obj->is_internal()) diff --git a/esphome/core/controller.h b/esphome/core/controller.h index c31cd22d07..9b1cfd93c6 100644 --- a/esphome/core/controller.h +++ b/esphome/core/controller.h @@ -34,6 +34,9 @@ #ifdef USE_DATETIME_DATE #include "esphome/components/datetime/date_entity.h" #endif +#ifdef USE_DATETIME_TIME +#include "esphome/components/datetime/time_entity.h" +#endif #ifdef USE_TEXT #include "esphome/components/text/text.h" #endif @@ -85,6 +88,9 @@ class Controller { #ifdef USE_DATETIME_DATE virtual void on_date_update(datetime::DateEntity *obj){}; #endif +#ifdef USE_DATETIME_TIME + virtual void on_time_update(datetime::TimeEntity *obj){}; +#endif #ifdef USE_TEXT virtual void on_text_update(text::Text *obj, const std::string &state){}; #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 501dccc6fa..22153de5de 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -36,6 +36,7 @@ #define USE_NUMBER #define USE_DATETIME #define USE_DATETIME_DATE +#define USE_DATETIME_TIME #define USE_OTA #define USE_OTA_PASSWORD #define USE_OTA_STATE_CALLBACK diff --git a/script/ci-custom.py b/script/ci-custom.py index 1ad44dc930..3be7be76a2 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -622,6 +622,7 @@ def lint_trailing_whitespace(fname, match): "esphome/components/climate/climate.h", "esphome/components/cover/cover.h", "esphome/components/datetime/date_entity.h", + "esphome/components/datetime/time_entity.h", "esphome/components/display/display.h", "esphome/components/fan/fan.h", "esphome/components/i2c/i2c.h", diff --git a/tests/components/datetime/date.all.yaml b/tests/components/datetime/test.all.yaml similarity index 100% rename from tests/components/datetime/date.all.yaml rename to tests/components/datetime/test.all.yaml diff --git a/tests/components/template/test.all.yaml b/tests/components/template/test.all.yaml index e50ffd7f67..29dc83b649 100644 --- a/tests/components/template/test.all.yaml +++ b/tests/components/template/test.all.yaml @@ -153,3 +153,16 @@ datetime: - x.year - x.month - x.day_of_month + - platform: template + name: Time + id: test_time + type: time + set_action: + - logger.log: "set_value" + on_value: + - logger.log: + format: "Time: %02d:%02d:%02d" + args: + - x.hour + - x.minute + - x.second