一张条件表,保留三类不同的证据
本实验比较同一个黑白测试图中40×40方块与VALUE0/1变化:f走固定驱动全窗口快刷,p只更新局部窗口。把软件调用耗时、固定机位照片中的残影、整板输入电量放入同一条件表;它们由不同仪器/运行组取得,不能互相代替。
原D09要求同内容比较残影/耗时/耗电;本站没有实板与PPK2,所以提供完整可运行例程、实体操作、空原始表与计算工具,不填毫秒、mJ或“几次局刷无残影”的亲测结论。面板供应商规格和库内等待参数只作各自来源的参考。
参考:[1] GxEPD2 1.6.9具体面板声明:partial能力与只读快刷常量[2] GxEPD2具体驱动:全刷、局刷、powerOff与hibernate[4] Good Display该具体面板规格:温度条件、三种厂商标称刷新方式
选定这一块面板与驱动,再定义全刷
面板为Good Display GDEY0213B74/SSD1680Z,黑白显示,控制器RAM128×250、可见122×250;rotation1得到250×122。配DESPI-C02有P3标记2.2/0.47Ω的已核修订,选择2.2Ω,供电与逻辑只用3V3。不同后缀、彩色屏、另一适配板或不明升压模块不能按该结果外推。
厂商页面在25°C条件列全刷3s、快刷1.5s、局刷0.3s,工作温度0–50°C;这是厂商条件值,不是本例库调用的保证或本站测量。这里只做B/W,不因器件支持灰阶就声称本代码实现四灰。
固定GxEPD2 1.6.9中的useFastFullUpdate是只读常量true;最终f明确是该驱动的全窗口快刷流程,而不是可切换普通全刷。首次赋值尝试真实编译失败已留历史,最终没有修改上游常量或SDK来假装三种模式都已可用。
参考:[1] GxEPD2 1.6.9具体面板声明:partial能力与只读快刷常量[2] GxEPD2具体驱动:全刷、局刷、powerOff与hibernate[3] GxEPD2_BW:旋转窗口、字节对齐与旧图像RAM同步[4] Good Display该具体面板规格:温度条件、三种厂商标称刷新方式[5] DESPI-C02厂家产品:与本例已核P3=2.2Ω修订分别确认
固定库与完整N8R8目标构建
CLI1.5.1、core3.3.12,GxEPD2 1.6.9、GFX1.12.4、BusIO1.17.4;IDE对应S3 Dev Module、Flash8MB、OPI PSRAM、QIO、8M with spiffs、CDC Disabled、Hardware CDC/JTAG。用板载USB-to-UART接口上传和115200串口。
在解压工程根目录先安装精确依赖再构建;sketch.yaml记录可选profile而不设置default_profile,避免无意下载另一套工具。版本、库锁及许可见工程文件,传感器或网络不属于此例。
arduino-cli core install esp32:esp32@3.3.12
arduino-cli lib install --no-deps 'Adafruit BusIO@1.17.4' 'Adafruit GFX Library@1.12.4' 'GxEPD2@1.6.9'
arduino-cli compile --fqbn 'esp32:esp32:esp32s3:FlashSize=8M,PSRAM=opi,FlashMode=qio,PartitionScheme=default_8MB,CDCOnBoot=default,USBMode=hwcdc' .
# 上传端口先按F06插拔确认,使用同一完整FQBN;串口115200
python3 tests/energy_test.py参考:[1] GxEPD2 1.6.9具体面板声明:partial能力与只读快刷常量[2] GxEPD2具体驱动:全刷、局刷、powerOff与hibernate[6] DevKitC1 v1.1固定官方提交:供电与J1排针
先建立基线,最多五次局刷后重做全窗口
首次启动只接受f;成功后p才开放。两次刷新开始至少间隔180秒,这是本教程保守实验节奏,不是厂商承诺的最小间隔。满五次局刷后再要求f;五次上限是实验控制,不表示前五次必无残影,任何一次不可接受都提前停止并重建基线。
局部窗口在rotation1坐标为x8/y32/w112/h64,包含所有VALUE与方块变化;其他图案不变。旋转后控制器原生x方向按8像素对齐;该窗口y/h已为8的倍数。不要把两个相近型号的窗口、LUT和缓冲规则混用。
本例使用4000B一页RAM缓冲,GxEPD2_BW nextPage在快局刷后调用writeImageAgain同步旧图像RAM。刷新后powerOff,不重置控制器,软件只在该次连续供电会话保留基线。h会hibernate并作废基线,下一次只允许f;RESET、掉电后同样重建,旧画面仍在不代表控制器有可用差分基线。
// ---- RefreshPlan.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
struct RefreshPlan {
enum Decision{ALLOW,STOPPED,WAIT_INTERVAL,NEED_FULL};
static constexpr uint64_t intervalMs=180000;
bool baseline=false,attempted=false,stopped=false;unsigned partials=0;uint64_t last_attempt=0;
Decision check(uint64_t now,bool partial)const{
if(stopped)return STOPPED;
if(partial&&(!baseline||partials>=5))return NEED_FULL;
if(attempted&&(now<last_attempt||now-last_attempt<intervalMs))return WAIT_INTERVAL;
return ALLOW;
}
void begin(uint64_t now){last_attempt=now;attempted=true;}
void success(bool partial){if(partial)++partials;else{baseline=true;partials=0;}}
void hibernated(){baseline=false;partials=0;}
void fault(){stopped=true;baseline=false;}
};
struct CallBudget {
uint64_t started=0;bool faulted=false;
void begin(uint64_t now){started=now;faulted=false;}
void observe(uint64_t now,bool busy){if(busy&&now>=started&&now-started>=8000000)faulted=true;}
bool finish(uint64_t now,bool busy){observe(now,busy);if(now<started||now-started>=8000000||busy)faulted=true;return !faulted;}
};
// ---- ButtonCommand.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
struct ButtonCommand {
uint8_t candidate=0,stable=0,armed=0;uint64_t changed=0;
char step(uint64_t now,uint8_t mask){
mask&=3;
if(mask!=candidate){candidate=mask;changed=now;}
if(now<changed||now-changed<50||candidate==stable)return 0;
uint8_t previous=stable;stable=candidate;
if(stable==0){uint8_t action=armed;armed=0;return action==1?'f':action==2?'p':0;}
if(previous==0&&(stable==1||stable==2))armed=stable;
else armed=0; // Both buttons or an overlapping change cancels; never guess.
return 0;
}
};
参考:[1] GxEPD2 1.6.9具体面板声明:partial能力与只读快刷常量[2] GxEPD2具体驱动:全刷、局刷、powerOff与hibernate[3] GxEPD2_BW:旋转窗口、字节对齐与旧图像RAM同步[9] ESP-IDF5.5.5完整启动微秒接口
完整入口与调用预算分别保留
RefreshPlan和ButtonCommand直接进入真实固件;完整入口下方给出。所有长期时间从esp_timer_get_time完整微秒值转换,不把已经截断的millis扩成64位。软件序号与手工局刷计数分别保存;任何错误停止后断电查线,不继续轰炸面板。
开始前BUSY高最多等2秒,仍高就停止且不发显示命令。库自己的busyCallback仅观察,不能取消正在执行的库调用。8秒预算是调用返回后的故障判定,观察到超限/BUSY仍高就停止;它不是硬件超时中断或“最多8秒返回”的保证。
日志refresh_call_us包住初始化(如有)、绘图/页面循环和库等待,through_poweroff_us再包含powerOff返回;两者都不是光学稳定时间。每行保留mode/value/序号,第一轮有初始化,应单列初次和已初始化组,不能偷偷删除较慢数据。
// ---- esp32-epaper-refresh-budget.ino ----
// SPDX-License-Identifier: MIT
#include <Arduino.h>
#include <SPI.h>
#include <GxEPD2_BW.h>
#include <gdey/GxEPD2_213_GDEY0213B74.h>
#include "esp_timer.h"
#include "RefreshPlan.h"
#include "ButtonCommand.h"
constexpr int PIN_SCK=12,PIN_MOSI=11,PIN_CS=5,PIN_DC=4,PIN_RST=6,PIN_BUSY=10;
SPIClass epdSPI(FSPI);
GxEPD2_BW<GxEPD2_213_GDEY0213B74,GxEPD2_213_GDEY0213B74::HEIGHT>
display(GxEPD2_213_GDEY0213B74(PIN_CS,PIN_DC,PIN_RST,PIN_BUSY));
RefreshPlan plan;CallBudget budget;ButtonCommand buttons;
constexpr int FULL_BUTTON=17,PARTIAL_BUTTON=18;
bool initialized=false,value=false;uint32_t sequence=0;
static uint64_t now_us(){return static_cast<uint64_t>(esp_timer_get_time());}
static bool is_busy(){return digitalRead(PIN_BUSY)==HIGH;}
void observe_busy(const void*){budget.observe(now_us(),is_busy());delay(1);}
bool preflight(){
uint64_t start=now_us();while(is_busy()&&now_us()-start<2000000)delay(1);
if(is_busy()){plan.fault();Serial.println("STOP BUSY HIGH before command; no display call started");return false;}
return true;
}
void draw_test(){
display.fillScreen(GxEPD_WHITE);display.setTextColor(GxEPD_BLACK);display.setTextSize(1);
display.drawRect(0,0,display.width(),display.height(),GxEPD_BLACK);
display.setCursor(8,16);display.print("GDEY0213B74 B/W");
display.drawRect(8,32,112,64,GxEPD_BLACK);
if(value)display.fillRect(16,40,40,40,GxEPD_BLACK);
else display.drawRect(16,40,40,40,GxEPD_BLACK);
display.setCursor(65,56);display.print(value?"VALUE 1":"VALUE 0");
display.setCursor(132,48);display.print("STATIC");
display.setCursor(132,65);display.print("BORDER");
display.setCursor(8,113);display.print("Optics/current: NOT VERIFIED");
}
void refresh_test(char command){
bool partial=command=='p';uint64_t requested=now_us();
auto decision=plan.check(requested/1000,partial);
if(decision!=RefreshPlan::ALLOW){Serial.printf("REJECT decision=%u: 1=stopped 2=wait180s 3=full baseline required\n",unsigned(decision));return;}
if(!preflight())return;
plan.begin(now_us()/1000);++sequence;value=!value;
uint64_t started=now_us();budget.begin(started);
/* Re-init only once after boot. Do not reset between full baseline and partials. */
if(!initialized){display.init(115200,true,10,false);display.setRotation(1);initialized=true;}
// This fixed driver uses compile-time fast-full waveform; never mutate it.
if(partial)display.setPartialWindow(8,32,112,64);else display.setFullWindow();
display.firstPage();do{draw_test();}while(display.nextPage());
uint64_t refresh_return=now_us();
if(!budget.finish(refresh_return,is_busy())){
plan.fault();Serial.println("STOP call returned with BUSY/budget fault; cannot cancel inside library; power off and inspect");return;
}
/* powerOff retains our host baseline assumption only in this uninterrupted
powered session. hibernate/reset/power loss always invalidates it below. */
budget.begin(now_us());display.powerOff();uint64_t end=now_us();
if(!budget.finish(end,is_busy())){plan.fault();Serial.println("STOP powerOff returned with BUSY/budget fault");return;}
plan.success(partial);
Serial.printf("D09,%lu,%c,%u,%llu,%llu,%llu,%u,%u\n",(unsigned long)sequence,command,unsigned(value),
(unsigned long long)started,(unsigned long long)(refresh_return-started),
(unsigned long long)(end-started),unsigned(is_busy()),plan.partials);
Serial.println("CALL_RETURNED_BUSY_LOW only; record external photo/temperature/current. MCU remains awake.");
}
void hibernate_test(){
if(plan.stopped||!initialized||!preflight())return;
budget.begin(now_us());display.hibernate();
if(!budget.finish(now_us(),is_busy())){plan.fault();Serial.println("STOP hibernate returned fault");return;}
plan.hibernated();initialized=false;
Serial.println("HIBERNATED: baseline invalid; next image must use f, not p. Board remains powered.");
}
void setup(){
Serial.begin(115200);delay(300);pinMode(PIN_BUSY,INPUT);
pinMode(FULL_BUTTON,INPUT);pinMode(PARTIAL_BUTTON,INPUT); // each external10k to3V3, SPST to GND
digitalWrite(PIN_CS,HIGH);pinMode(PIN_CS,OUTPUT);
if(!epdSPI.begin(PIN_SCK,-1,PIN_MOSI,PIN_CS)){plan.fault();Serial.println("STOP SPI begin failed");return;}
display.epd2.selectSPI(epdSPI,SPISettings(4000000,MSBFIRST,SPI_MODE0));display.epd2.setBusyCallback(observe_busy);
Serial.println("D09 confirmed GDEY0213B74 + DESPI-C02 P3=2.2ohm only; no automatic refresh");
Serial.println("f=full-window fast waveform, p=partial(max5), h=hibernate+invalidate baseline; wait180s each refresh");
Serial.println("Physical17=full / 18=partial, release after >=100ms; external10k pullups, both USB unplugged for metering");
Serial.println("kind,seq,mode,value,start_us,refresh_call_us,through_poweroff_us,busy_after,partials_since_full");
}
void loop(){
if(Serial.available()){char c=Serial.read();if(c=='f'||c=='p')refresh_test(c);else if(c=='h')hibernate_test();}
uint8_t mask=(digitalRead(FULL_BUTTON)==LOW?1:0)|(digitalRead(PARTIAL_BUTTON)==LOW?2:0);
char command=buttons.step(now_us()/1000,mask);if(command)refresh_test(command);
delay(1);
}
参考:[2] GxEPD2具体驱动:全刷、局刷、powerOff与hibernate[3] GxEPD2_BW:旋转窗口、字节对齐与旧图像RAM同步[9] ESP-IDF5.5.5完整启动微秒接口
USB功能组先记录调用,再固定照片条件
仅USB-to-UART供电,PPK2输出与所有外部电源断开。先f确认黑白边框与静态文字,拍一张全窗口基线;等待180秒后按p,在同一机位/曝光/光线拍改变区和未改变区。连续做到最多五次p,再f,至少重复三轮。温度用独立温度计记录,建议先在接近25°C且稳定的环境比较;不是替器件扩大工作范围。
每次填写面板批次、室温、固件SHA、上一次模式/间隔、软件调用时间与照片路径。可把残影主观分为0不可辨、1放大可辨、2正常观看可辨、3影响阅读;这是自定义有序等级,不能相加当物理灰度量。用同一参考全刷照片和盲顺序复看,保留原图,不用后期提对比的照片伪装原结果。
若需要光学刷新耗时,用包含按钮动作与屏幕的定帧录像,从已定义的触发动作到最后一次可辨画面变化逐帧记录,并报告帧率与量化误差。软件调用返回和人眼稳定可能不同,两列分开。
| 运行组 | 操作 | 结果 |
|---|---|---|
| USB功能 | f→等待→p×最多5→f,保留原日志/照片 | pending |
| PPK2独立供电 | 同一固件与图案,改用实体按钮,保留电流轨迹/照片 | pending |
| 休眠恢复 | 串口h后p被拒绝,等待后f重建 | 逻辑检查通过;实体pending |
参考:[2] GxEPD2具体驱动:全刷、局刷、powerOff与hibernate[4] Good Display该具体面板规格:温度条件、三种厂商标称刷新方式
测整板电量时,拔掉两条开发板USB
本组是另一条明确记录条件的测量运行,不能把USB功能组的软件耗时悄悄写成同一时刻的PPK2数据。全部断电,拔开发板USB-to-UART和原生USB,不接供电串口、调试器、另一屏幕电源或电池。PPK2自身DATA/POWER接电脑允许;它不是目标板的USB旁路。
按L02先验极性/量程:PPK2 Source Meter、5.0V,VOUT到开发板J1-21(5V),GND到J1-22,Source Meter不用VIN。面板仍接板端3V3,不能把5V移到屏幕VCC。Source模式DUT上限600mA;另一1A是Ampere连续模式,不能混用。DUT可能超过400mA时按厂家给PPK2 POWER ONLY额外至少1A输入供电,仍不能越过600mA输出限制。
先核板端5V/3V3及仪器削顶/掉压/复位;超限样本作无效,不用它估峰值。用GPIO17/18按钮做同样f/p图案,手工记录刷新历史与照片;本组无开发板USB实时串口,不为补日志重新插线。软件时间若不可取得就留空,光学时间用该组录像记录。电流轨迹选含操作前后各至少1秒静态平台的完整活动窗口,保证把刷新与powerOff尾部都纳入;另报告静态平台功耗,不把不同长度窗口的总电量直接当单次更新比较。
记录PPK2固件/应用实际版本、模式、采样率、量程、电压、线材与窗口判定方式,保持两种刷新相同窗口规则、图案、温度和重复次数。本例得到的是整板输入,包含LDO/常亮灯/USB桥接/MCU/屏幕;不等同裸面板标称功率,也不直接推电池续航。
- 做到这里,你应该看到
- 串口功能组与脱USB测流组标记清楚,无并行供电路径。
参考:[4] Good Display该具体面板规格:温度条件、三种厂商标称刷新方式[6] DevKitC1 v1.1固定官方提交:供电与J1排针[7] PPK2:Source600mA与Ampere1A两个模式不同[8] PPK2:0.8–5.0V与可能大于400mA时的额外输入电源
显式转换列名与单位,再对完整窗口积分
current-trace-template.csv只有t_s/voltage_V/current_mA表头,不声称它是PPK2的原生导出格式。先保存仪器原工程与原始CSV,查看实际列名/单位,再用normalize_trace.py明确选择时间与电流单位;电压可取逐样实测列,或明确使用板端实测常量近似,不能把设定5.0V冒充每个采样点电压。
integrate_energy.py流式读取,要求严格递增时间、有限非负电流、正电压和完整覆盖窗口,不外推缺失开始/结束。窗口内相邻样本间隔超过声明max-gap就拒绝;不能放大上限掩盖丢掉的峰。小负值先查零偏,不偷偷截0。边界在两个样本之间时,对采样电流及采样功率分别线性插值再梯形积分。
Q[mC]=∫I[mA]dt[s],E[mJ]=∫V·I[mA]dt[s],mWh=E[mJ]/3600。不要把mJ误当J,也不要从输入5V的电量直接除3.7V电池容量。九组合成算术/无效输入检查通过,只证明计算工具,不提供本站电量。
# 下面列名、单位、电压/窗口都换成自己真实原始记录
python3 tools/normalize_trace.py raw.csv trace.csv --time-column REPLACE_TIME_COLUMN --time-unit us --current-column REPLACE_CURRENT_COLUMN --current-unit uA --voltage-column REPLACE_MEASURED_VOLTAGE_COLUMN
python3 tools/integrate_energy.py trace.csv --start REPLACE_START_S --end REPLACE_END_S --max-gap REPLACE_MAX_GAP_S
python3 tests/energy_test.py参考:[7] PPK2:Source600mA与Ampere1A两个模式不同[8] PPK2:0.8–5.0V与可能大于400mA时的额外输入电源
积分工具保留单位和缺样判定
完整转换器与积分器都在下载包,原始CSV输入不被覆盖;读取失败返回非零,不将部分输出当有效测量。下面给出实际积分源,合成fixture与真实观测模板分别命名。
# ---- tools/integrate_energy.py ----
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Integrate nonnegative sampled current and power over one covered time window.
Input CSV: t_s,voltage_V,current_mA. Linear interpolation of sampled P and I.
Synthetic tests prove arithmetic only, never an ESP32 measurement.
"""
import argparse,csv,json,math
def integrate(path,start,end,max_gap):
if not all(math.isfinite(x) for x in (start,end,max_gap)) or start>=end or max_gap<=0:
raise ValueError('finite start<end and positive max_gap required')
previous=None;charge=energy=covered=0.0;rows=0
with open(path,newline='') as stream:
reader=csv.DictReader(stream)
if not reader.fieldnames or not {'t_s','voltage_V','current_mA'}<=set(reader.fieldnames):raise ValueError('missing explicit units/columns')
for row in reader:
t,v,i=(float(row[k]) for k in ('t_s','voltage_V','current_mA'))
if not all(math.isfinite(x) for x in (t,v,i)) or v<=0 or i<0:raise ValueError('nonfinite/negative current or nonpositive voltage; inspect zero offset')
rows+=1
if previous is not None:
a,av,ai=previous;dt=t-a
if dt<=0:raise ValueError('timestamps must strictly increase')
left=max(a,start);right=min(t,end)
if right>left:
if dt>max_gap+1e-12:raise ValueError('sample gap exceeds declared limit in window')
f0=(left-a)/dt;f1=(right-a)/dt
i0=ai+(i-ai)*f0;i1=ai+(i-ai)*f1
p0=av*ai+(v*i-av*ai)*f0;p1=av*ai+(v*i-av*ai)*f1
charge+=(i0+i1)*0.5*(right-left);energy+=(p0+p1)*0.5*(right-left);covered+=right-left
previous=(t,v,i)
if rows<2 or not math.isclose(covered,end-start,rel_tol=1e-8,abs_tol=1e-9):raise ValueError('window not fully bracketed by samples; no extrapolation')
return {'start_s':start,'end_s':end,'duration_s':end-start,'input_rows':rows,'max_gap_s':max_gap,'charge_mC':charge,'energy_mJ':energy,'energy_mWh':energy/3600,'mean_current_mA':charge/(end-start),'method':'trapezoid of sampled nonnegative I and V*I; interpolate cut endpoints; includes whole measured input'}
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('csv');parser.add_argument('--start',required=True,type=float);parser.add_argument('--end',required=True,type=float);parser.add_argument('--max-gap',required=True,type=float);args=parser.parse_args()
print(json.dumps(integrate(args.csv,args.start,args.end,args.max_gap),indent=2,allow_nan=False))
参考:[7] PPK2:Source600mA与Ampere1A两个模式不同[8] PPK2:0.8–5.0V与可能大于400mA时的额外输入电源
用自己的结果决定更新预算
同时比较重复实验的残影等级、调用/光学时间、完整活动窗口电量和静态平台;先满足可读性再看电量。若局刷不可接受、局部内容越界或供电不稳,即使电量较低也不采用。本文没有一组结果,所以没有“每N分钟最佳”的本站结论。
第六次p无变化先看是否五次上限;刚启动/休眠后p被拒绝先做f;180秒内无动作先看间隔门,别反复连按。局刷错位先回查旋转和8像素寻址;BUSY故障先断电查FPC/P3/电源/短线。库超时并不证明面板损坏,保留原错误与原图。
恢复只用已保存的固件与正常BOOT/RESET刷写;不修改面板波形、不提高VCC、不关掉欠压保护。GxEPD2为GPL-3.0,源码应用/文档的MIT不覆盖库或链接固件分发义务,第三方许可单列。全部实板、残影、PPK2与调用时间结果仍pending。
参考:[1] GxEPD2 1.6.9具体面板声明:partial能力与只读快刷常量[2] GxEPD2具体驱动:全刷、局刷、powerOff与hibernate[3] GxEPD2_BW:旋转窗口、字节对齐与旧图像RAM同步[4] Good Display该具体面板规格:温度条件、三种厂商标称刷新方式[5] DESPI-C02厂家产品:与本例已核P3=2.2Ω修订分别确认[6] DevKitC1 v1.1固定官方提交:供电与J1排针
资料来源与版本说明
保留D09 ID/slug,原刷新预算目标落实为可执行实验方法;首次把只读驱动常量当运行参数的失败源码/日志保留,最终准确比较该驱动固定全窗口快刷与局刷,不声称改变厂商波形或获得本站测量。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。