Files
lucky-cat/docs/knowledge vault/bugfix-wall-clock-timezone-before-lcd.zh-CN.md
T

290 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 开机时钟小时偏差问题修复说明
## 1. 提交信息
- 提交:`f33ad70 fix(time): apply timezone before lcd startup`
- 现象:设备每次开机后,LCD 显示的分钟正确,但小时错误;网络连接并完成时间服务初始化后,小时恢复正确。
- 复现场景:普通重启、Deep Sleep 唤醒、开机后暂时没有网络。
## 2. 问题原因
ESP32 的系统时间和 RTC 时间使用 UTC 时间戳保存绝对时间。LCD 将时间戳转换成年、月、日、小时和分钟时,会通过 `localtime_r()` 使用当前进程的 `TZ` 环境变量进行本地时区换算。
旧实现只在 Wi-Fi 连接成功、准备启动 SNTP 时执行:
```c
setenv("TZ", posix_tz, 1);
tzset();
```
但 LCD 的初始化和渲染发生在 Wi-Fi 连接及 SNTP 启动之前。因此,设备刚启动时虽然 RTC 中保留的 UTC 时间戳仍然有效,但当前进程尚未设置中国标准时间时区,`localtime_r()` 会按 UTC 解释时间。这会产生以下特征:
- 分钟不受 UTC 与中国标准时间时差影响,所以分钟显示正确。
- 小时少 8 小时,因此小时显示错误。
- Wi-Fi 连接成功后,SNTP 启动流程设置 `TZ=CST-8`,小时随即恢复正确。
Deep Sleep 也会复现该问题,是因为 RTC 可以继续保留绝对时间,而应用进程重新启动后,进程级的 `TZ` 环境状态需要重新设置。也就是说,保留 RTC 时间并不等于保留本地时区配置。
修复前的启动顺序为:
```text
存储准备
-> 音频初始化
-> LCD 初始化及时间渲染
-> Wi-Fi 连接
-> 运行时任务启动
-> SNTP 启动并设置时区
```
问题本质不是 RTC 或 SNTP 保存了错误的小时,而是本地时区设置得太晚。
## 3. 解决方式
将“应用本地时区”和“通过网络同步绝对时间”拆分为两个可以独立执行的操作:
1. 新增不依赖网络的时区设置接口,只负责设置 `TZ` 并调用 `tzset()`。
2. 按照现有分层,通过 `platform -> domain -> control_plane -> app_composition` 逐层暴露该能力。
3. 在存储准备完成后、LCD 初始化之前增加 `wall_clock` 启动阶段,立即应用配置项 `CONFIG_TQ_TIME_POSIX_TZ`。
4. SNTP 仍在网络连接成功后启动,用于校准 UTC 绝对时间;启动 SNTP 时再次应用时区,作为后续保护。
5. 如果开机阶段设置时区失败,记录警告并继续启动,避免时区异常阻断整个设备启动流程。
修复后的启动顺序为:
```text
存储准备
-> 本地应用 CST-8 时区
-> 电池启动保护及音频初始化
-> LCD 初始化及时间渲染
-> Wi-Fi 连接
-> SNTP 校准绝对时间
```
其中,`CST-8` 是 POSIX 时区字符串。POSIX 的符号约定与通常书写习惯相反,`CST-8` 表示 UTC+8。
## 4. 具体代码
### 4.1 Platform 层:新增独立时区设置接口
公开接口声明:
```c
/**
* @brief Wall-clock time synchronization (SNTP) primitives.
*
* - `platform_time_set_timezone()` applies the process-local POSIX time zone
* without starting SNTP. Call it during boot before local time is rendered.
* - `platform_time_sync_start()` is idempotent: it sets the process time zone
* (`posix_tz`, e.g. "CST-8"; ignored when NULL/empty) on every call and
* starts the SNTP client once. Must be called after the network is up.
* - `platform_time_is_synced()` reports whether the system clock has advanced
* past a sane epoch lower bound (i.e. SNTP has set the time at least once).
*/
esp_err_t platform_time_set_timezone(const char *posix_tz);
esp_err_t platform_time_sync_start(const char *sntp_server, const char *posix_tz);
bool platform_time_is_synced(void);
```
接口实现:
```c
esp_err_t platform_time_set_timezone(const char *posix_tz)
{
if (posix_tz == NULL || posix_tz[0] == '\0') {
return ESP_ERR_INVALID_ARG;
}
if (setenv("TZ", posix_tz, 1) != 0) {
ESP_LOGE(TAG, "Set time zone failed (tz=%s)", posix_tz);
return ESP_FAIL;
}
tzset();
ESP_LOGI(TAG, "Local time zone applied (tz=%s)", posix_tz);
return ESP_OK;
}
```
该实现先校验参数,再更新进程级 `TZ` 环境变量并刷新 C 运行库的时区换算规则。此过程不访问网络,也不修改 RTC 中的 UTC 时间戳。
### 4.2 Platform 层:SNTP 启动复用时区接口
```c
esp_err_t platform_time_sync_start(const char *sntp_server, const char *posix_tz)
{
if (posix_tz != NULL && posix_tz[0] != '\0') {
esp_err_t timezone_err = platform_time_set_timezone(posix_tz);
if (timezone_err != ESP_OK) {
return timezone_err;
}
}
portENTER_CRITICAL(&s_time_lock);
if (s_sntp_started) {
portEXIT_CRITICAL(&s_time_lock);
return ESP_OK;
}
s_sntp_started = true;
portEXIT_CRITICAL(&s_time_lock);
const char *server = (sntp_server != NULL && sntp_server[0] != '\0')
? sntp_server
: "pool.ntp.org";
esp_sntp_config_t config = ESP_NETIF_SNTP_DEFAULT_CONFIG(server);
config.start = true;
config.server_from_dhcp = false;
esp_err_t err = esp_netif_sntp_init(&config);
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
portENTER_CRITICAL(&s_time_lock);
s_sntp_started = false;
portEXIT_CRITICAL(&s_time_lock);
ESP_LOGE(TAG, "SNTP init failed: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "SNTP started (server=%s, tz=%s)",
server, (posix_tz != NULL && posix_tz[0] != '\0') ? posix_tz : "<unset>");
return ESP_OK;
}
```
SNTP 启动仍保持幂等。每次调用都会先应用有效的时区配置,但 SNTP 客户端只初始化一次。
### 4.3 Domain 层:桥接时区能力
公开接口声明:
```c
/**
* @brief Wall-clock time synchronization, bridged to the platform SNTP
* primitive. `set_timezone` applies local-time conversion without
* network access. `start` is idempotent and also applies the process
* time zone (`posix_tz`, e.g. "CST-8"); `is_synced` reports whether the
* clock has been set. Call `start` after the network is up.
*/
esp_err_t domain_runtime_time_set_timezone(const char *posix_tz);
esp_err_t domain_runtime_time_sync_start(const char *sntp_server, const char *posix_tz);
bool domain_runtime_time_is_synced(void);
```
桥接实现:
```c
esp_err_t domain_runtime_time_set_timezone(const char *posix_tz)
{
return platform_time_set_timezone(posix_tz);
}
```
### 4.4 Control Plane 层:使用项目配置准备本地时钟
时区配置缺省值:
```c
#ifndef CONFIG_TQ_TIME_POSIX_TZ
#define CONFIG_TQ_TIME_POSIX_TZ "CST-8"
#endif
```
公开接口声明:
```c
/** Apply the configured local time zone before any local time is rendered. */
esp_err_t control_plane_prepare_wall_clock(void);
```
接口实现:
```c
esp_err_t control_plane_prepare_wall_clock(void)
{
return domain_runtime_time_set_timezone(CONFIG_TQ_TIME_POSIX_TZ);
}
```
### 4.5 App Composition 层:增加开机阶段
启动阶段枚举中增加 `APP_COMPOSITION_BOOT_STAGE_WALL_CLOCK`:
```c
typedef enum {
APP_COMPOSITION_BOOT_STAGE_POWER_HOLD = 1,
APP_COMPOSITION_BOOT_STAGE_STORAGE_PREPARE,
APP_COMPOSITION_BOOT_STAGE_WALL_CLOCK,
APP_COMPOSITION_BOOT_STAGE_AUDIO_INIT,
APP_COMPOSITION_BOOT_STAGE_BOOT_SOUND,
APP_COMPOSITION_BOOT_STAGE_AUDIO_READY,
APP_COMPOSITION_BOOT_STAGE_LCD_DEVICE,
APP_COMPOSITION_BOOT_STAGE_LCD_RENDER_LOOP,
APP_COMPOSITION_BOOT_STAGE_PLATFORM_DOMAIN,
APP_COMPOSITION_BOOT_STAGE_TURNTABLE_CALIBRATION,
APP_COMPOSITION_BOOT_STAGE_WIFI_BOOTSTRAP,
APP_COMPOSITION_BOOT_STAGE_RUNTIME_WORKERS,
APP_COMPOSITION_BOOT_STAGE_NETWORK_SERVICES,
} app_composition_boot_stage_t;
```
在存储准备完成之后、LCD 和网络初始化之前调用:
```c
app_composition_log_stage(APP_COMPOSITION_BOOT_STAGE_WALL_CLOCK,
"wall_clock",
"control_plane_prepare_wall_clock");
esp_err_t wall_clock_err = control_plane_prepare_wall_clock();
app_composition_log_stage_result(APP_COMPOSITION_BOOT_STAGE_WALL_CLOCK,
"wall_clock",
wall_clock_err);
if (wall_clock_err != ESP_OK) {
ESP_LOGW(TAG,
"Local time zone setup failed, continue boot: %s",
esp_err_to_name(wall_clock_err));
}
```
## 5. 解决成果
- 代码路径已确保在 LCD 首次渲染时间前应用 `CST-8`;RTC 时间有效时,开机阶段即可显示正确的中国标准时间。
- 从启动顺序上消除了普通重启和 Deep Sleep 唤醒后等待 Wi-Fi 才能修正小时的问题。
- 从实现上支持离线启动时根据 RTC 中保留的 UTC 时间换算本地小时。
- 分钟显示逻辑和原有 LCD 更新时间隔保持不变。
- SNTP 职责保持不变,网络连接后仍负责校准绝对时间。
- 已使用 ESP-IDF v5.5.4 完成编译验证,构建成功;普通重启、Deep Sleep 和断网启动仍建议按第 6.6 节进行实机回归。
- 固件大小为 `0x217f20`,应用分区剩余 `0x1680e0`,约 40%。
## 6. 后续是否有其他影响
### 6.1 内存和性能影响
本次修改没有新增任务、定时器、帧缓冲、动态内存分配或 PSRAM 分配。新增内容主要是函数调用、少量代码和日志字符串,对内部 RAM 的持续占用基本无可感知变化,也不需要为了该修复将内部 RAM 改为 PSRAM。
开机时只增加一次 `setenv()`、`tzset()` 和日志输出,执行耗时相对于完整启动流程可以忽略。SNTP 启动时会再次调用时区设置接口,但不会重复启动 SNTP 客户端。
### 6.2 全局时区影响
`TZ` 是进程级全局配置。设置为 `CST-8` 后,项目内所有使用 `localtime()` 或 `localtime_r()` 的模块都会采用同一时区。这符合设备当前统一使用中国标准时间的预期;如果未来需要同时显示多个地区的时间,不能依赖反复修改全局 `TZ`,需要增加独立的时区换算方案。
### 6.3 启动日志和阶段编号
启动日志会新增一个 `wall_clock` 阶段以及一条时区应用成功日志。由于枚举中插入了新的阶段,后续启动阶段的数字编号会顺延一位;阶段名称和启动行为不受影响。如果外部日志分析程序依赖数字编号,需要同步调整。
### 6.4 异常降级行为
若时区字符串为空或设置失败,启动流程不会被中断,而是输出警告后继续。此时 LCD 可能暂时按 UTC 显示;网络连接后 SNTP 启动流程会再次尝试应用时区。
### 6.5 RTC 无有效时间的情况
本次修复只解决“UTC 时间有效,但本地时区尚未应用”的问题,不负责在完全断电且没有可靠 RTC 时间来源时生成正确绝对时间。如果冷启动时系统时间无效,LCD 应继续显示 `--:--`,直到网络连接并由 SNTP 完成校时。时区设置不能替代 SNTP 校时。
### 6.6 建议的硬件回归验证
建议在实机上继续覆盖以下场景:
- RTC 时间有效时进行普通软件重启,确认首屏小时和分钟均正确。
- 进入 Deep Sleep 后唤醒,确认联网前的小时正确。
- 断开网络启动,确认 RTC 有效时仍能按 UTC+8 显示。
- 网络恢复并完成 SNTP 同步,确认时间连续且没有异常跳变。
- 完全断电导致 RTC 时间无效时,确认同步前显示 `--:--`,同步后恢复正确时间。
## 7. 结论
该问题由启动阶段未及时恢复进程级时区引起,与分钟计算、LCD 渲染或 RTC 走时精度无关。修复通过在 LCD 启动前独立应用 `CST-8`,使设备不依赖网络也能将有效的 UTC 时间换算为正确本地时间;网络连接后的 SNTP 继续负责绝对时间校准。改动不新增常驻内存资源,主要影响仅为新增启动日志、启动阶段编号顺延和全局本地时区提前生效。