# 开机时钟小时偏差问题修复说明 ## 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 : ""); 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 继续负责绝对时间校准。改动不新增常驻内存资源,主要影响仅为新增启动日志、启动阶段编号顺延和全局本地时区提前生效。