Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion docs/current-progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down Expand Up @@ -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`。最终图标的物理像素外观仍需现场目视确认。
12 changes: 11 additions & 1 deletion docs/hardware.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion main/app_config.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
#include <driver/gpio.h>
#include <driver/spi_common.h>

#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"

Expand Down
25 changes: 23 additions & 2 deletions main/app_main.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
72 changes: 37 additions & 35 deletions main/ui.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand All @@ -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();
}
Expand Down Expand Up @@ -1022,6 +1023,9 @@ void Ui::Tick() {
calendar_date_key_ = date_key;
UpdateStatusLabelsLocked();
}
if (power_indicator_dirty_) {
UpdatePowerIndicatorsLocked();
}
display_.Unlock();
}

Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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;
}

Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 5 additions & 5 deletions main/ui.h
Original file line number Diff line number Diff line change
Expand Up @@ -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_;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
27 changes: 22 additions & 5 deletions scripts/check_firmware_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
Loading