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

12 KiB
Raw Permalink Blame History

开机时钟小时偏差问题修复说明

1. 提交信息

  • 提交:f33ad70 fix(time): apply timezone before lcd startup
  • 现象:设备每次开机后,LCD 显示的分钟正确,但小时错误;网络连接并完成时间服务初始化后,小时恢复正确。
  • 复现场景:普通重启、Deep Sleep 唤醒、开机后暂时没有网络。

2. 问题原因

ESP32 的系统时间和 RTC 时间使用 UTC 时间戳保存绝对时间。LCD 将时间戳转换成年、月、日、小时和分钟时,会通过 localtime_r() 使用当前进程的 TZ 环境变量进行本地时区换算。

旧实现只在 Wi-Fi 连接成功、准备启动 SNTP 时执行:

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 时间并不等于保留本地时区配置。

修复前的启动顺序为:

存储准备
-> 音频初始化
-> 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. 如果开机阶段设置时区失败,记录警告并继续启动,避免时区异常阻断整个设备启动流程。

修复后的启动顺序为:

存储准备
-> 本地应用 CST-8 时区
-> 电池启动保护及音频初始化
-> LCD 初始化及时间渲染
-> Wi-Fi 连接
-> SNTP 校准绝对时间

其中,CST-8 是 POSIX 时区字符串。POSIX 的符号约定与通常书写习惯相反,CST-8 表示 UTC+8。

4. 具体代码

4.1 Platform 层:新增独立时区设置接口

公开接口声明:

/**
 * @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);

接口实现:

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 启动复用时区接口

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 层:桥接时区能力

公开接口声明:

/**
 * @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);

桥接实现:

esp_err_t domain_runtime_time_set_timezone(const char *posix_tz)
{
    return platform_time_set_timezone(posix_tz);
}

4.4 Control Plane 层:使用项目配置准备本地时钟

时区配置缺省值:

#ifndef CONFIG_TQ_TIME_POSIX_TZ
#define CONFIG_TQ_TIME_POSIX_TZ "CST-8"
#endif

公开接口声明:

/** Apply the configured local time zone before any local time is rendered. */
esp_err_t control_plane_prepare_wall_clock(void);

接口实现:

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:

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 和网络初始化之前调用:

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 继续负责绝对时间校准。改动不新增常驻内存资源,主要影响仅为新增启动日志、启动阶段编号顺延和全局本地时区提前生效。