From 37b78ad46a4a76ad930d58b4105a98d358dc2e5b Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:24:57 +0200 Subject: [PATCH] [zigbee][time] Add zigbee time component on esp32 (#18656) Co-authored-by: J. Nick Koston --- esphome/components/zigbee/const_esp32.py | 3 + esphome/components/zigbee/time/__init__.py | 48 +++++-- .../zigbee/time/zigbee_time_esp32.cpp | 118 ++++++++++++++++++ .../zigbee/time/zigbee_time_esp32.h | 34 +++++ esphome/components/zigbee/zigbee_ep_esp32.py | 53 ++++++-- .../components/zigbee/zigbee_helpers_esp32.c | 4 + tests/components/zigbee/common.yaml | 3 + tests/components/zigbee/common_nrf52.yaml | 3 - .../zigbee/test-router.esp32-c6-idf.yaml | 3 + 9 files changed, 249 insertions(+), 20 deletions(-) create mode 100644 esphome/components/zigbee/time/zigbee_time_esp32.cpp create mode 100644 esphome/components/zigbee/time/zigbee_time_esp32.h diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index bfc4d93d5b..2e1b09fb22 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -9,6 +9,7 @@ SCALE = "scale" CONF_ATTRIBUTE_ID = "attribute_id" KEY_ZIGBEE_EP = "zigbee_ep" KEY_ZIGBEE_EP_NO_NUM = "zigbee_ep_no_num" +KEY_ZIGBEE_FIRST_EP_CL = "zigbee_first_ep_cl" DEVICE_ID = { "RANGE_EXTENDER": cg.RawExpression("EZB_ZHA_RANGE_EXTENDER_DEVICE_ID"), @@ -18,11 +19,13 @@ DEVICE_ID = { cluster_id = cg.esphome_ns.enum("ezb_zcl_cluster_id_e") CLUSTER_ID = { "BASIC": cluster_id.EZB_ZCL_CLUSTER_ID_BASIC, + "TIME": cluster_id.EZB_ZCL_CLUSTER_ID_TIME, "BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT, "ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT, } CLUSTER_ROLE = { "SERVER": cg.RawExpression("EZB_ZCL_CLUSTER_SERVER"), + "CLIENT": cg.RawExpression("EZB_ZCL_CLUSTER_CLIENT"), } attr_type = cg.esphome_ns.enum("ezb_zcl_attr_type_e") ATTR_TYPE = { diff --git a/esphome/components/zigbee/time/__init__.py b/esphome/components/zigbee/time/__init__.py index 3acab0076f..74f81df2e9 100644 --- a/esphome/components/zigbee/time/__init__.py +++ b/esphome/components/zigbee/time/__init__.py @@ -1,13 +1,15 @@ import esphome.codegen as cg from esphome.components import time as time_ import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, CONF_UPDATE_INTERVAL from esphome.core import CORE from esphome.types import ConfigType from .. import consume_endpoint from ..const import zigbee_ns +from ..const_esp32 import ROLE from ..const_zephyr import CONF_ZIGBEE_ID +from ..zigbee_ep_esp32 import add_clusters_to_first_ep, get_first_ep_num from ..zigbee_zephyr import ( ZigbeeClusterDesc, ZigbeeComponent, @@ -22,26 +24,52 @@ DEPENDENCIES = ["zigbee"] ZigbeeTime = zigbee_ns.class_("ZigbeeTime", time_.RealTimeClock) + +def _validate_zigbee_time(config: ConfigType) -> ConfigType: + if CORE.is_nrf52: + return consume_endpoint(config) + if CORE.is_esp32: + cl = [ + { + CONF_ID: "TIME", + ROLE: "CLIENT", + }, + { + CONF_ID: "TIME", + ROLE: "SERVER", + }, + ] + add_clusters_to_first_ep(cl) + return config + + CONFIG_SCHEMA = cv.All( time_.TIME_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(ZigbeeTime), - cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id( - ZigbeeComponent - ), + cv.GenerateID(CONF_ZIGBEE_ID): cv.use_id(ZigbeeComponent), + cv.SplitDefault( + CONF_UPDATE_INTERVAL, + nrf52="1s", + esp32="15min", + ): cv.update_interval, # override default from TIME_SCHEMA. Remove once nrf52 implementation is aligned. } - ) - .extend(cv.COMPONENT_SCHEMA) - .extend(cv.polling_component_schema("1s")), - consume_endpoint, + ).extend(cv.COMPONENT_SCHEMA), + _validate_zigbee_time, ) async def to_code(config: ConfigType) -> None: - CORE.add_job(_add_time, config) + if CORE.using_zephyr: + CORE.add_job(_add_time_zephyr, config) + if CORE.is_esp32: + zb = await cg.get_variable(config[CONF_ZIGBEE_ID]) + var = cg.new_Pvariable(config[CONF_ID], zb, get_first_ep_num()) + await cg.register_component(var, config) + await time_.register_time(var, config) -async def _add_time(config: ConfigType) -> None: +async def _add_time_zephyr(config: ConfigType) -> None: slot_index = get_slot_index() # Create unique names for this sensor's variables based on slot index diff --git a/esphome/components/zigbee/time/zigbee_time_esp32.cpp b/esphome/components/zigbee/time/zigbee_time_esp32.cpp new file mode 100644 index 0000000000..5567295782 --- /dev/null +++ b/esphome/components/zigbee/time/zigbee_time_esp32.cpp @@ -0,0 +1,118 @@ +#include "zigbee_time_esp32.h" +#if defined(USE_ZIGBEE) && defined(USE_ESP32) && defined(USE_TIME) +#include "esphome/core/log.h" +#include "esphome/core/application.h" + +namespace esphome::zigbee { + +static const char *const TAG = "zigbee.time"; + +// This time standard is the number of +// seconds since 0 hrs 0 mins 0 sec on 1st January 2000 UTC (Universal Coordinated Time). +constexpr time_t EPOCH_2000 = 946684800; + +static ZigbeeTime *global_time = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +void ZigbeeTime::setup() { + global_time = this; + if (this->parent_->is_started()) { + this->register_zb_time_(); + } else { + this->parent_->add_on_start_callback([this]() { this->register_zb_time_(); }); + } +} + +void ZigbeeTime::register_zb_time_() { + ezb_zcl_time_interface_t time_interface = { + .get_utc_time = esphome::zigbee::ZigbeeTime::get_utc_time, + .set_utc_time = esphome::zigbee::ZigbeeTime::set_utc_time, + }; + ezb_err_t ret; + if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { + this->set_timeout("zb_time_register", 100, [this]() { this->register_zb_time_(); }); + return; + } + ret = ezb_zcl_time_server_interface_register(this->endpoint_, time_interface); + esp_zigbee_lock_release(); + if (ret != EZB_ERR_NONE) { + ESP_LOGW(TAG, "Setup failed: %d", ret); + this->mark_failed(); + return; + } + this->registered_ = true; + this->parent_->add_on_join_callback([this](bool x) { this->update(); }); + if (this->parent_->is_joined()) { + this->update(); + } +} + +void ZigbeeTime::status_cb(ezb_err_t status) { + if (status == EZB_ERR_NONE) { + ESP_LOGV(TAG, "Time synchronization successful"); + } else if (status == EZB_ERR_TIMEOUT) { + ESP_LOGW(TAG, "Time synchronization timed out"); + } else { + ESP_LOGW(TAG, "Time synchronization failed with error: %d", status); + } +} + +void ZigbeeTime::update() { + if (this->parent_->is_joined() && this->registered_) { + if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { + ESP_LOGV(TAG, "Updating time sync from Zigbee network..."); + ezb_zcl_time_server_synchronize_time(this->endpoint_, 10, esphome::zigbee::ZigbeeTime::status_cb, + EZB_ZCL_TIME_SERVER_RANK_MASTER); + esp_zigbee_lock_release(); + this->retry_count_ = 0; + } else { + if (this->retry_count_ == 0) { + ESP_LOGW(TAG, "Could not acquire Zigbee lock to synchronize time, will retry maximum 3 times"); + } + if (this->retry_count_ < 3) { + this->set_timeout("zb_time_sync", 100, [this]() { this->update(); }); + this->retry_count_++; + } else { + ESP_LOGW(TAG, "Could not acquire Zigbee lock to synchronize time"); + this->retry_count_ = 0; + } + } + } else { + ESP_LOGD(TAG, "Not connected to Zigbee network, cannot synchronize time"); + } +} + +uint32_t ZigbeeTime::get_utc_time() { + const time_t now = global_time->timestamp_now(); + if (now < EPOCH_2000) { + return 0xFFFFFFFF; // ZCL invalid UTCTime + } + return (uint32_t) (now - EPOCH_2000); +} + +void ZigbeeTime::set_utc_time(uint32_t utc) { + // prevent overflow + if (utc <= (std::numeric_limits::max() - EPOCH_2000)) { + global_time->set_epoch_time(utc + EPOCH_2000); + } +} + +void ZigbeeTime::set_epoch_time(uint32_t utc) { + // called from zigbee task, defer to main loop + this->defer([this, utc]() { + ESP_LOGV(TAG, "Setting device time to UTC: %u", static_cast(utc)); + this->synchronize_epoch_(utc); + }); + App.wake_loop_threadsafe(); +} + +void ZigbeeTime::dump_config() { + ESP_LOGCONFIG(TAG, + "Zigbee Time\n" + " Endpoint: %u", + this->endpoint_); + RealTimeClock::dump_config(); +} + +} // namespace esphome::zigbee + +#endif diff --git a/esphome/components/zigbee/time/zigbee_time_esp32.h b/esphome/components/zigbee/time/zigbee_time_esp32.h new file mode 100644 index 0000000000..4a47137745 --- /dev/null +++ b/esphome/components/zigbee/time/zigbee_time_esp32.h @@ -0,0 +1,34 @@ +#pragma once +#include "esphome/core/defines.h" +#if defined(USE_ZIGBEE) && defined(USE_ESP32) && defined(USE_TIME) + +#include "esphome/core/component.h" +#include "esphome/components/time/real_time_clock.h" +#include "../zigbee_esp32.h" + +namespace esphome::zigbee { + +class ZigbeeComponent; + +class ZigbeeTime final : public time::RealTimeClock { + public: + ZigbeeTime(ZigbeeComponent *parent, uint8_t ep) : parent_(parent), endpoint_(ep) {} + void setup() override; + void update() override; + void dump_config() override; + void set_epoch_time(uint32_t utc); + + protected: + void register_zb_time_(); + static void set_utc_time(uint32_t utc); + static uint32_t get_utc_time(); + static void status_cb(ezb_err_t status); + + ZigbeeComponent *parent_; + uint8_t endpoint_; + uint8_t retry_count_{0}; + bool registered_{false}; +}; + +} // namespace esphome::zigbee +#endif diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index c2001c66d6..700267ef50 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -18,6 +18,7 @@ from .const_esp32 import ( DEVICE_TYPE, KEY_ZIGBEE_EP, KEY_ZIGBEE_EP_NO_NUM, + KEY_ZIGBEE_FIRST_EP_CL, ROLE, ) @@ -95,11 +96,11 @@ def _get_next_ep_num(eps: list[int]) -> int: def _compare_clusters( - existing_ep: dict[str, Any], - ep: dict[str, Any], + existing_cl_list: list[dict[str, Any]], + cl_list: list[dict[str, Any]], ) -> tuple[str | int, str] | None: - existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_ep[CONF_CLUSTERS]] - for cl in [(cl[CONF_ID], cl[ROLE]) for cl in ep[CONF_CLUSTERS]]: + existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_cl_list] + for cl in [(cl[CONF_ID], cl[ROLE]) for cl in cl_list]: if cl in existing_clusters: return cl return None @@ -110,7 +111,7 @@ def _merge_endpoints( ep: dict[str, Any], use_type: bool | None, ) -> bool: - if _compare_clusters(existing_ep, ep): + if _compare_clusters(existing_ep.get(CONF_CLUSTERS, []), ep.get(CONF_CLUSTERS, [])): return False if ( ep.get(DEVICE_TYPE) @@ -200,6 +201,17 @@ def create_ep(router: bool) -> None: # clear list so that it is not processed again del zb_data[KEY_ZIGBEE_EP_NO_NUM] + # Add clusters to first ep + cl_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_FIRST_EP_CL, []) + if cl_list: + first_ep = ep_dict[get_first_ep_num()] + first_ep.setdefault(CONF_CLUSTERS, []) + if cl := _compare_clusters(first_ep[CONF_CLUSTERS], cl_list): + raise cv.Invalid( + f"Endpoint {get_first_ep_num()} has more than one cluster with cluster id {cl[0]} and role {cl[1]}." + ) + first_ep[CONF_CLUSTERS] += cl_list + del zb_data[KEY_ZIGBEE_FIRST_EP_CL] # Add default device type to endpoints that have none for ep in ep_dict.values(): @@ -207,6 +219,15 @@ def create_ep(router: bool) -> None: ep[DEVICE_TYPE] = "CUSTOM_ATTR" +def get_first_ep_num() -> int | None: + """Return the number of the first endpoint.""" + zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {}) + if ep_dict: + return min(ep_dict.keys()) + return None + + def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> None: """Add a Zigbee endpoint configuration to CORE.data. @@ -230,8 +251,8 @@ def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> Non # check if the existing endpoint has same clusters existing_ep = ep_dict[ep_num] if cl := _compare_clusters( - existing_ep, - ep, + existing_ep.get(CONF_CLUSTERS, []), + ep.get(CONF_CLUSTERS, []), ): raise cv.Invalid( f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}." @@ -245,3 +266,21 @@ def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> Non if use_type or ep.get(DEVICE_TYPE): ep[CONF_USE_DEVICE_TYPE] = {ep.get(DEVICE_TYPE): use_type} ep_dict[ep_num] = ep + + +def add_clusters_to_first_ep(cl: list[dict[str, Any]]) -> None: + """Add a list of Zigbee clusters to CORE.data. + + Args: + cl: list of cluster dictonaries. + """ + zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + cl_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_FIRST_EP_CL, []) + if cluster := _compare_clusters( + cl_list, + cl, + ): + raise cv.Invalid( + f"Only one cluster with cluster id {cluster[0]} and role {cluster[1]} can be added to first endpoint." + ) + cl_list += cl diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.c b/esphome/components/zigbee/zigbee_helpers_esp32.c index 150be612f6..0793669955 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.c +++ b/esphome/components/zigbee/zigbee_helpers_esp32.c @@ -30,6 +30,8 @@ ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_i return ezb_zcl_basic_create_cluster_desc(NULL, role_mask); case EZB_ZCL_CLUSTER_ID_IDENTIFY: return ezb_zcl_identify_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_TIME: + return ezb_zcl_time_create_cluster_desc(NULL, role_mask); case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT: return ezb_zcl_analog_input_create_cluster_desc(NULL, role_mask); case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: @@ -49,6 +51,8 @@ ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_ return ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, attr_id, value_p); case EZB_ZCL_CLUSTER_ID_IDENTIFY: return ezb_zcl_identify_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_TIME: + return ezb_zcl_time_cluster_desc_add_attr(cluster_desc, attr_id, value_p); case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT: return ezb_zcl_analog_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index cc0d28ea61..4518e39060 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -41,3 +41,6 @@ number: min_value: 2 max_value: 100 step: 1 + +time: + - platform: zigbee diff --git a/tests/components/zigbee/common_nrf52.yaml b/tests/components/zigbee/common_nrf52.yaml index c05c4053a5..2da6570618 100644 --- a/tests/components/zigbee/common_nrf52.yaml +++ b/tests/components/zigbee/common_nrf52.yaml @@ -10,6 +10,3 @@ zigbee: on_start: then: - logger.log: "Started zigbee stack" - -time: - - platform: zigbee diff --git a/tests/components/zigbee/test-router.esp32-c6-idf.yaml b/tests/components/zigbee/test-router.esp32-c6-idf.yaml index 228fe331e5..557cc08f04 100644 --- a/tests/components/zigbee/test-router.esp32-c6-idf.yaml +++ b/tests/components/zigbee/test-router.esp32-c6-idf.yaml @@ -5,3 +5,6 @@ zigbee: on_join: then: - logger.log: "Joined network" + +time: + - platform: zigbee