diff --git a/CMakeLists.txt b/CMakeLists.txt index ff6e066..4d73e29 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.16) -set(PROJECT_VER "0.1.91") +set(PROJECT_VER "0.1.92") include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(weclawbot) diff --git a/README.md b/README.md index 5d8bf73..0ac6872 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ BUILD_DIR="$PWD/build-note4" OUT_DIR="$PWD/web/firmware-note4" ./scripts/prepare ``` Both hardware targets and both build channels render the same versioned -upper-left title, for example `微笺 v0.1.91`. The title is derived from +upper-left title, for example `微笺 v0.1.92`. The title is derived from `WEC_FIRMWARE_VERSION`; there is no separate development-build label. ## Repository Map diff --git a/docs/current-progress.md b/docs/current-progress.md index 0ee9c05..4cc0d77 100644 --- a/docs/current-progress.md +++ b/docs/current-progress.md @@ -68,7 +68,7 @@ WeClawBot / 微笺屏是基于 Waveshare ESP32-S3-RLCD-4.2 的开源固件项目 已知版本问题: -- 公开 `main` 的当前固件源码版本为 `0.1.91`,正式 Release/安装资产仍为 +- 公开 `main` 的当前固件源码版本为 `0.1.92`,正式 Release/安装资产仍为 `v0.1.78`;Web 安装页连接真机后显示设备上报版本。 - `scripts/prepare_web_firmware.sh` 已重新生成 `web/firmware/`。 - 下一次正式发布前仍必须核对 release 产物和安装页 manifest。 @@ -1309,3 +1309,25 @@ weclawbotctl clear 清除强制下载位并以看门狗复位回应用态后,`WEC:GET` 确认板型、版本、日期时间和 Wi-Fi 正确,官方二维码日历页处于活动状态,MQTT 已恢复 `online`。右侧月历的最终 物理画面仍由现场目视确认。 + +## 2026-07-27 双硬件供电状态显示统一 + +- 用户报告 RLCD 与 Note 4 墨水屏的电源/插电状态显示不一致。真机修复前日志确认: + 微雪目标没有启用电池监测,Note 4 则可读取已安装电池和 USB 主机连接状态。UI 同时 + 存在两套位置、尺寸不同的插头图标;Note 4 的被动日历首帧还会先于 USB/电池采样, + 随后的低频刷新路径又跳过图标更新,因此相同插电状态可能显示为不同图标或不显示。 +- `0.1.92` 将两款硬件收敛到同一状态语义和渲染路径:电池图标只表示已安装电池及 + 电量,插头图标只表示已确认外部供电,不声明正在充电;两款硬件共用同一图标、位置 + 和清理逻辑。启动在首个产品页前采样 USB 与电池状态,Note 4 被动日历把后续变化记为 + 延迟刷新脏状态,由常规低频 tick 应用,不再永久跳过。 +- 供电事件增加 `external_power_connected`;无电池监测的微雪配置会明确记录 + `battery_monitor_enabled=false`,不再把符合板型配置的无读数误记为失败。回归契约 + 覆盖共享渲染、首帧采样、墨水屏延迟刷新和独立旧图标移除。 +- 固件回归检查与 ESP-IDF 5.5.2 双目标隔离公开构建通过:微雪 `0x6232e0` + (应用分区余 23%),Note 4 `0x633b10`(余 22%)。两台真机均保留 NVS 自动刷入 + `0.1.92`,全部镜像哈希校验通过,并通过无需按键的 USB-JTAG 看门狗复位回到应用态。 + `WEC:GET` 确认两台运行版本均为 `0.1.92`;微雪供电事件为 + `battery_monitor_enabled=false / usb_host_connected=true / + external_power_connected=true / ok=true`,Note 4 为 + `present=true / percent=69~70 / usb_host_connected=true / + external_power_connected=true / ok=true`。最终图标的物理像素外观仍需现场目视确认。 diff --git a/docs/hardware.md b/docs/hardware.md index afc0f3d..10baaf3 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -67,7 +67,7 @@ This startup retry rule is shared firmware behavior, while boards without an SHTC3 continue to use their board-specific environment source. Both hardware targets render the firmware version in the upper-left header, -for example `微笺 v0.1.90`. Development and public builds use the same title; +for example `微笺 v0.1.92`. Development and public builds use the same title; the visible version comes from `WEC_FIRMWARE_VERSION`. Battery sensing: @@ -76,6 +76,16 @@ Battery sensing: | --- | --- | --- | --- | | Battery voltage | GPIO4 / ADC1_CH3 | Board example documents battery voltage through a 3x divider. USB power can still make this rail look like a lithium cell, so `CONFIG_WEC_BATTERY_INSTALLED` gates whether the UI treats it as a real battery. | Top status battery/no-battery icon | +The top status bar uses one power model on both hardware targets. The battery +glyph means that an installed battery is available and its fill means battery +level. The plug glyph means confirmed external power and uses the same shape +and position on both displays; it does not claim that the battery is charging. +USB Serial/JTAG SOF packets positively identify a computer host. When no +battery is available, a running board is necessarily treated as externally +powered. A charge-only USB supply cannot be distinguished from battery power +on a battery-equipped board without an additional VBUS or charger-status +signal. + Development flashing: Once a board is running firmware with the serial bootloader command, the host diff --git a/main/app_config.h b/main/app_config.h index d76f3f7..e753f79 100644 --- a/main/app_config.h +++ b/main/app_config.h @@ -3,7 +3,7 @@ #include #include -#define WEC_FIRMWARE_VERSION "0.1.91" +#define WEC_FIRMWARE_VERSION "0.1.92" #define WEC_PRODUCT_URL "https://weclawbot.link" #define WEC_PRODUCT_HOST "weclawbot.link" diff --git a/main/app_main.cc b/main/app_main.cc index 369a852..b3450ac 100644 --- a/main/app_main.cc +++ b/main/app_main.cc @@ -424,21 +424,34 @@ void EnvironmentTask(void*) { } if (tick % 60 == 0) { const BatteryReading battery_reading = battery.Read(); + const bool external_power_connected = + usb_host_connected || + (battery_reading.valid && !battery_reading.present) || + !WEC_BOARD_HAS_INSTALLED_BATTERY; if (battery_reading.valid) { ui.SetBatteryStatus(battery_reading.present, battery_reading.percent); std::printf( "WEC:{\"scope\":\"power\",\"stage\":\"battery_read\"," "\"present\":%s,\"percent\":%d,\"voltage\":%.2f," "\"usb_host_connected\":%s," + "\"external_power_connected\":%s," "\"ok\":true,\"type\":\"event\"}\n", battery_reading.present ? "true" : "false", battery_reading.percent, battery_reading.voltage, - usb_host_connected ? "true" : "false"); + usb_host_connected ? "true" : "false", + external_power_connected ? "true" : "false"); } else { std::printf( "WEC:{\"scope\":\"power\",\"stage\":\"battery_read\"," - "\"ok\":false,\"type\":\"event\"}\n"); + "\"battery_monitor_enabled\":%s," + "\"usb_host_connected\":%s," + "\"external_power_connected\":%s," + "\"ok\":%s,\"type\":\"event\"}\n", + WEC_BOARD_HAS_INSTALLED_BATTERY ? "true" : "false", + usb_host_connected ? "true" : "false", + external_power_connected ? "true" : "false", + WEC_BOARD_HAS_INSTALLED_BATTERY ? "false" : "true"); } bot.RetryTimeSync(); @@ -519,6 +532,14 @@ extern "C" void app_main(void) { return; } + // Prime the shared power model before the first page is constructed so the + // slow EPD target does not need a second refresh just to show host power. + ui.SetUsbHostPowerStatus(UsbHostPowerConnected()); + const BatteryReading startup_battery = battery.Read(); + if (startup_battery.valid) { + ui.SetBatteryStatus(startup_battery.present, startup_battery.percent); + } + notes.Load(); bot.PrepareStartupUi(); buttons.Init(); diff --git a/main/ui.cc b/main/ui.cc index 4a80092..865f637 100644 --- a/main/ui.cc +++ b/main/ui.cc @@ -963,12 +963,12 @@ void Ui::SetBatteryStatus(bool present, int percent) { battery_present_ = present; battery_percent_ = clamped_percent; if (PassiveEpdCalendarLocked()) { + power_indicator_dirty_ = true; TraceUiCalendarPassiveSkip("battery"); display_.Unlock(); return; } - UpdateBatteryIndicatorLocked(); - UpdateUsbHostPowerIndicatorLocked(); + UpdatePowerIndicatorsLocked(); display_.Unlock(); } @@ -977,11 +977,12 @@ void Ui::SetUsbHostPowerStatus(bool connected) { if (usb_host_power_connected_ != connected) { usb_host_power_connected_ = connected; if (PassiveEpdCalendarLocked()) { + power_indicator_dirty_ = true; TraceUiCalendarPassiveSkip("usb_host_power"); display_.Unlock(); return; } - UpdateUsbHostPowerIndicatorLocked(); + UpdatePowerIndicatorsLocked(); } display_.Unlock(); } @@ -1022,6 +1023,9 @@ void Ui::Tick() { calendar_date_key_ = date_key; UpdateStatusLabelsLocked(); } + if (power_indicator_dirty_) { + UpdatePowerIndicatorsLocked(); + } display_.Unlock(); } @@ -1371,6 +1375,12 @@ void Ui::UpdateNetworkIndicatorLocked() { } } +void Ui::UpdatePowerIndicatorsLocked() { + UpdateBatteryIndicatorLocked(); + UpdatePowerPlugIndicatorLocked(); + power_indicator_dirty_ = false; +} + void Ui::UpdateBatteryIndicatorLocked() { if (!battery_outline_ || !battery_nub_) { return; @@ -1384,7 +1394,6 @@ void Ui::UpdateBatteryIndicatorLocked() { lv_obj_delete(battery_mark_label_); battery_mark_label_ = nullptr; } - ClearPlugIndicatorLocked(); if (!battery_status_known_ && WEC_BOARD_HAS_INSTALLED_BATTERY) { lv_obj_clear_flag(battery_outline_, LV_OBJ_FLAG_HIDDEN); @@ -1395,11 +1404,6 @@ void Ui::UpdateBatteryIndicatorLocked() { if (!battery_status_known_ || !battery_present_) { lv_obj_add_flag(battery_outline_, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(battery_nub_, LV_OBJ_FLAG_HIDDEN); - plug_parts_[0] = RectObj(lv_screen_active(), 362, 18, 10, 8, White(), Black(), 0, 2); - plug_parts_[1] = RectObj(lv_screen_active(), 364, 14, 2, 5, Black(), Black(), 0, 0); - plug_parts_[2] = RectObj(lv_screen_active(), 369, 14, 2, 5, Black(), Black(), 0, 0); - plug_parts_[3] = RectObj(lv_screen_active(), 372, 21, 9, 2, Black(), Black(), 0, 0); - plug_parts_[4] = RectObj(lv_screen_active(), 380, 17, 2, 10, Black(), Black(), 0, 0); return; } @@ -1410,34 +1414,36 @@ void Ui::UpdateBatteryIndicatorLocked() { Black(), Black(), 0, 0); } -void Ui::UpdateUsbHostPowerIndicatorLocked() { - ClearUsbHostPowerIndicatorLocked(); +void Ui::UpdatePowerPlugIndicatorLocked() { + ClearPowerPlugIndicatorLocked(); + if (!battery_outline_) { + return; + } const bool battery_available = (battery_status_known_ && battery_present_) || (!battery_status_known_ && WEC_BOARD_HAS_INSTALLED_BATTERY); - if (!battery_available || !usb_host_power_connected_) { + const bool external_power_connected = + usb_host_power_connected_ || !battery_available; + if (!external_power_connected) { return; } - // USB Serial/JTAG confirms a real data host, so this means the battery is - // present while the board is externally powered. It is not a charge-state claim. - usb_power_parts_[0] = RectObj(lv_screen_active(), 349, 18, 9, 7, White(), Black(), 0, 1); - usb_power_parts_[1] = RectObj(lv_screen_active(), 351, 14, 2, 4, Black(), Black(), 0, 0); - usb_power_parts_[2] = RectObj(lv_screen_active(), 355, 14, 2, 4, Black(), Black(), 0, 0); - usb_power_parts_[3] = RectObj(lv_screen_active(), 352, 25, 3, 3, Black(), Black(), 0, 0); + // Both hardware targets use this one glyph and position for external power. + // USB Serial/JTAG positively identifies a real data host; a running board + // with no available battery must also be externally powered. This is not a + // charging-state claim. + power_plug_parts_[0] = + RectObj(lv_screen_active(), 349, 18, 9, 7, White(), Black(), 0, 1); + power_plug_parts_[1] = + RectObj(lv_screen_active(), 351, 14, 2, 4, Black(), Black(), 0, 0); + power_plug_parts_[2] = + RectObj(lv_screen_active(), 355, 14, 2, 4, Black(), Black(), 0, 0); + power_plug_parts_[3] = + RectObj(lv_screen_active(), 352, 25, 3, 3, Black(), Black(), 0, 0); } -void Ui::ClearPlugIndicatorLocked() { - for (auto& part : plug_parts_) { - if (part) { - lv_obj_delete(part); - part = nullptr; - } - } -} - -void Ui::ClearUsbHostPowerIndicatorLocked() { - for (auto& part : usb_power_parts_) { +void Ui::ClearPowerPlugIndicatorLocked() { + for (auto& part : power_plug_parts_) { if (part) { lv_obj_delete(part); part = nullptr; @@ -1608,10 +1614,7 @@ void Ui::ClearLocked() { battery_nub_ = nullptr; battery_fill_ = nullptr; battery_mark_label_ = nullptr; - for (auto& part : plug_parts_) { - part = nullptr; - } - for (auto& part : usb_power_parts_) { + for (auto& part : power_plug_parts_) { part = nullptr; } qr_status_label_ = nullptr; @@ -1683,8 +1686,7 @@ void Ui::HeaderLocked(const char* left, const char* right) { battery_nub_ = RectObj(lv_screen_active(), 384, 19, 3, 4, Black(), Black(), 0, 0); UpdateStatusLabelsLocked(); - UpdateBatteryIndicatorLocked(); - UpdateUsbHostPowerIndicatorLocked(); + UpdatePowerIndicatorsLocked(); lv_obj_t* line = lv_obj_create(lv_screen_active()); lv_obj_remove_style_all(line); diff --git a/main/ui.h b/main/ui.h index 48628d0..45f06ee 100644 --- a/main/ui.h +++ b/main/ui.h @@ -111,10 +111,10 @@ class Ui { lv_obj_t* LabelLocked(lv_obj_t* parent, const char* text, const lv_font_t* font, lv_color_t color, int x, int y, int w); std::string NowString(const char* fallback = "--:--") const; + void UpdatePowerIndicatorsLocked(); void UpdateBatteryIndicatorLocked(); - void UpdateUsbHostPowerIndicatorLocked(); - void ClearPlugIndicatorLocked(); - void ClearUsbHostPowerIndicatorLocked(); + void UpdatePowerPlugIndicatorLocked(); + void ClearPowerPlugIndicatorLocked(); bool PassiveEpdCalendarLocked() const; DisplayInterface& display_; @@ -127,6 +127,7 @@ class Ui { bool battery_present_ = false; bool battery_status_known_ = false; bool usb_host_power_connected_ = false; + bool power_indicator_dirty_ = false; int battery_percent_ = 0; bool calendar_time_ready_ = false; int calendar_date_key_ = 0; @@ -158,8 +159,7 @@ class Ui { lv_obj_t* battery_nub_ = nullptr; lv_obj_t* battery_fill_ = nullptr; lv_obj_t* battery_mark_label_ = nullptr; - lv_obj_t* plug_parts_[5] = {}; - lv_obj_t* usb_power_parts_[4] = {}; + lv_obj_t* power_plug_parts_[4] = {}; lv_obj_t* footer_label_ = nullptr; lv_obj_t* home_time_label_ = nullptr; lv_obj_t* home_time_shadow_label_ = nullptr; diff --git a/scripts/check_firmware_regressions.py b/scripts/check_firmware_regressions.py index e187dc8..dc71c7c 100755 --- a/scripts/check_firmware_regressions.py +++ b/scripts/check_firmware_regressions.py @@ -695,19 +695,36 @@ def check_weather_ui_contract() -> None: fail("expired WeChat QR refresh path is missing") -def check_zectrix_power_contract() -> None: +def check_shared_power_contract() -> None: config = read("main/app_config.h") battery = read("main/battery_monitor.cc") ui = read("main/ui.cc") + ui_header = read("main/ui.h") + app = read("main/app_main.cc") defaults = read("sdkconfig.zectrix.defaults") for required in [ "#define WEC_BOARD_HAS_INSTALLED_BATTERY 1", "CONFIG_WEC_BATTERY_INSTALLED=y", "constexpr float kDividerRatio = 2.0f;", "!battery_status_known_ && WEC_BOARD_HAS_INSTALLED_BATTERY", - ]: - if required not in "\n".join([config, battery, ui, defaults]): - fail(f"Zectrix power contract is missing {required}") + "UpdatePowerIndicatorsLocked();", + "usb_host_power_connected_ || !battery_available", + "power_plug_parts_[4]", + "power_indicator_dirty_ = true;", + "if (power_indicator_dirty_)", + "ui.SetUsbHostPowerStatus(UsbHostPowerConnected());", + "const BatteryReading startup_battery = battery.Read();", + '\\"external_power_connected\\":%s', + '\\"battery_monitor_enabled\\":%s', + ]: + if required not in "\n".join([config, battery, ui, ui_header, app, defaults]): + fail(f"shared power contract is missing {required}") + if "plug_parts_[5]" in ui_header or "usb_power_parts_[4]" in ui_header: + fail("hardware targets must not use separate external-power glyphs") + if app.index("ui.SetUsbHostPowerStatus(UsbHostPowerConnected());") > app.index( + "bot.PrepareStartupUi();" + ): + fail("power state must be primed before the first shared page is rendered") def check_screen_title_contract() -> None: @@ -815,7 +832,7 @@ def main() -> None: check_epd_carousel_contract() check_epd_pet_timer_contract() check_weather_ui_contract() - check_zectrix_power_contract() + check_shared_power_contract() check_screen_title_contract() check_no_usb_mass_storage_contract() check_serial_bootloader_contract()