From 1402cb7b64a1f1d3adfe4f62b04abdbfd321efb8 Mon Sep 17 00:00:00 2001 From: Georg Seema Date: Sun, 11 Oct 2026 13:32:39 +0300 Subject: [PATCH] [tuya] Add forced minute boundary time sync (#18603) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/tuya/__init__.py | 25 ++++++++-- esphome/components/tuya/tuya.cpp | 46 +++++++++++++++---- esphome/components/tuya/tuya.h | 7 ++- tests/component_tests/tuya/__init__.py | 0 tests/component_tests/tuya/test_init.py | 23 ++++++++++ .../tuya/test-force-time-sync.esp32-idf.yaml | 16 +++++++ 6 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 tests/component_tests/tuya/__init__.py create mode 100644 tests/component_tests/tuya/test_init.py create mode 100644 tests/components/tuya/test-force-time-sync.esp32-idf.yaml diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index bf392c7826..c05db51cdf 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -4,6 +4,7 @@ from esphome.components import time, uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_SENSOR_DATAPOINT, CONF_TIME_ID from esphome.cpp_generator import MockObj +from esphome.types import ConfigType DEPENDENCIES = ["uart"] DOMAIN = "tuya" @@ -13,6 +14,7 @@ CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS = "ignore_mcu_update_on_datapoints" CONF_ON_DATAPOINT_UPDATE = "on_datapoint_update" CONF_DATAPOINT_TYPE = "datapoint_type" CONF_STATUS_PIN = "status_pin" +CONF_FORCE_TIME_SYNC = "force_time_sync" tuya_ns = cg.esphome_ns.namespace("tuya") TuyaDatapointType = tuya_ns.enum("TuyaDatapointType", is_class=True) @@ -43,11 +45,23 @@ DATAPOINT_TYPES = { CONF_TUYA_ID = "tuya_id" -CONFIG_SCHEMA = ( + + +def _validate_time_options(config: ConfigType) -> ConfigType: + if config[CONF_FORCE_TIME_SYNC] and CONF_TIME_ID not in config: + raise cv.Invalid( + f"{CONF_FORCE_TIME_SYNC} requires {CONF_TIME_ID}", + path=[CONF_FORCE_TIME_SYNC], + ) + return config + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(Tuya), cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), + cv.Optional(CONF_FORCE_TIME_SYNC, default=False): cv.boolean, cv.Optional(CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS): cv.ensure_list( cv.uint8_t ), @@ -63,7 +77,8 @@ CONFIG_SCHEMA = ( } ) .extend(cv.COMPONENT_SCHEMA) - .extend(uart.UART_DEVICE_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + _validate_time_options, ) @@ -71,9 +86,11 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - if CONF_TIME_ID in config: - time_ = await cg.get_variable(config[CONF_TIME_ID]) + if (time_id := config.get(CONF_TIME_ID)) is not None: + time_ = await cg.get_variable(time_id) cg.add(var.set_time_id(time_)) + if config[CONF_FORCE_TIME_SYNC]: + cg.add(var.set_force_time_sync(True)) if CONF_STATUS_PIN in config: status_pin_ = await cg.gpio_pin_expression(config[CONF_STATUS_PIN]) cg.add(var.set_status_pin(status_pin_)) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index b23dcc946b..c6316f59fc 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -23,11 +23,13 @@ ESPHOME_LOG_TAG(TAG, "tuya"); static constexpr uint32_t HEARTBEAT_INTERVAL_ID = 0; static constexpr uint32_t WIFI_STATUS_INTERVAL_ID = 1; static constexpr uint32_t DATAPOINT_DUMP_TIMEOUT_ID = 2; +static constexpr uint32_t FORCE_TIME_SYNC_INTERVAL_ID = 3; static const int COMMAND_DELAY = 10; static const int RECEIVE_TIMEOUT = 300; static const int MAX_RETRIES = 5; // Max bytes to log for datapoint values (larger values are truncated) static constexpr size_t MAX_DATAPOINT_LOG_BYTES = 16; +static constexpr uint32_t FORCE_TIME_SYNC_CHECK_INTERVAL = 1000; static bool network_is_connected() { #ifdef USE_NETWORK @@ -39,6 +41,13 @@ static bool network_is_connected() { void Tuya::setup() { this->set_interval(HEARTBEAT_INTERVAL_ID, 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); +#ifdef USE_TIME + if (this->force_time_sync_) { + this->register_local_time_sync_callback_(); + this->set_interval(FORCE_TIME_SYNC_INTERVAL_ID, FORCE_TIME_SYNC_CHECK_INTERVAL, + [this] { this->check_force_time_sync_(); }); + } +#endif if (this->status_pin_ != nullptr) { this->status_pin_->digital_write(false); } @@ -99,6 +108,11 @@ void Tuya::dump_config() { } LOG_PIN(" Status Pin: ", this->status_pin_); ESP_LOGCONFIG(TAG, " Product: '%s'", this->product_.c_str()); +#ifdef USE_TIME + if (this->time_id_ != nullptr) { + ESP_LOGCONFIG(TAG, " Force time sync: %s", YESNO(this->force_time_sync_)); + } +#endif } bool Tuya::validate_message_() { @@ -306,12 +320,7 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff #ifdef USE_TIME if (this->time_id_ != nullptr) { this->send_local_time_(); - - if (!this->time_sync_callback_registered_) { - // tuya mcu supports time, so we let them know when our time changed - this->time_id_->add_on_time_sync_callback([this] { this->send_local_time_(); }); - this->time_sync_callback_registered_ = true; - } + this->register_local_time_sync_callback_(); } else #endif { @@ -616,7 +625,23 @@ void Tuya::send_wifi_status_() { } #ifdef USE_TIME -void Tuya::send_local_time_() { +void Tuya::register_local_time_sync_callback_() { + if (this->time_sync_callback_registered_) { + return; + } + this->time_id_->add_on_time_sync_callback([this] { this->send_local_time_(); }); + this->time_sync_callback_registered_ = true; +} + +void Tuya::check_force_time_sync_() { + ESPTime now = this->time_id_->now(); + if (!now.is_valid() || now.minute == this->last_force_time_sync_minute_) + return; + this->last_force_time_sync_minute_ = now.minute; + this->send_local_time_(true); +} + +void Tuya::send_local_time_(bool periodic) { std::vector payload; ESPTime now = this->time_id_->now(); if (now.is_valid()) { @@ -631,7 +656,12 @@ void Tuya::send_local_time_() { if (day_of_week == 0) { day_of_week = 7; } - ESP_LOGD(TAG, "Sending local time"); + // The forced send repeats every minute, so it stays out of the debug log + if (periodic) { + ESP_LOGV(TAG, "Sending local time"); + } else { + ESP_LOGD(TAG, "Sending local time"); + } payload = std::vector{0x01, year, month, day_of_month, hour, minute, second, day_of_week}; } else { // By spec we need to notify MCU that the time was not obtained if this is a response to a query diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index f074d30162..1e2e12200d 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -113,6 +113,7 @@ class Tuya final : public Component, public uart::UARTDevice { TuyaInitState get_init_state(); #ifdef USE_TIME void set_time_id(time::RealTimeClock *time_id) { this->time_id_ = time_id; } + void set_force_time_sync(bool force_time_sync) { this->force_time_sync_ = force_time_sync; } #endif void add_ignore_mcu_update_on_datapoints(uint8_t ignore_mcu_update_on_datapoints) { this->ignore_mcu_update_on_datapoints_.push_back(ignore_mcu_update_on_datapoints); @@ -144,9 +145,13 @@ class Tuya final : public Component, public uart::UARTDevice { uint8_t get_wifi_rssi_(); #ifdef USE_TIME - void send_local_time_(); + void register_local_time_sync_callback_(); + void check_force_time_sync_(); + void send_local_time_(bool periodic = false); void send_gmt_time_(); time::RealTimeClock *time_id_{nullptr}; + bool force_time_sync_{false}; + uint8_t last_force_time_sync_minute_{0xFF}; // 0xFF: no minute seen yet bool time_sync_callback_registered_{false}; bool gmt_time_sync_callback_registered_{false}; #endif diff --git a/tests/component_tests/tuya/__init__.py b/tests/component_tests/tuya/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/tuya/test_init.py b/tests/component_tests/tuya/test_init.py new file mode 100644 index 0000000000..6984b9aa1b --- /dev/null +++ b/tests/component_tests/tuya/test_init.py @@ -0,0 +1,23 @@ +"""Tests for Tuya time synchronization configuration validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.tuya import CONF_FORCE_TIME_SYNC, CONFIG_SCHEMA +from esphome.const import CONF_TIME_ID + + +def test_force_time_sync_accepts_time_id() -> None: + config = CONFIG_SCHEMA( + { + CONF_FORCE_TIME_SYNC: True, + CONF_TIME_ID: "tuya_time", + } + ) + + assert config[CONF_FORCE_TIME_SYNC] is True + + +def test_force_time_sync_requires_time_id() -> None: + with pytest.raises(cv.Invalid, match="force_time_sync requires time_id"): + CONFIG_SCHEMA({CONF_FORCE_TIME_SYNC: True}) diff --git a/tests/components/tuya/test-force-time-sync.esp32-idf.yaml b/tests/components/tuya/test-force-time-sync.esp32-idf.yaml new file mode 100644 index 0000000000..df0fe110b4 --- /dev/null +++ b/tests/components/tuya/test-force-time-sync.esp32-idf.yaml @@ -0,0 +1,16 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + status_pin: GPIO12 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + common: !include common.yaml + +time: + - platform: sntp + id: tuya_time + +tuya: + time_id: tuya_time + force_time_sync: true