[tuya] Add forced minute boundary time sync (#18603)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Georg Seema
2026-10-11 00:32:39 -10:00
committed by GitHub
co-authored by J. Nick Koston pre-commit-ci-lite[bot] J. Nick Koston
parent 2681126986
commit 1402cb7b64
6 changed files with 104 additions and 13 deletions
+21 -4
View File
@@ -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_))
+38 -8
View File
@@ -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<uint8_t> 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<uint8_t>{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
+6 -1
View File
@@ -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
+23
View File
@@ -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})
@@ -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