10.2 寸黑白红三色墨水屏价签拆解及驱动过程简记 - 10.2 inch 3-Color BWR ESL
书接很久以前的 这里. 最近机缘巧合又收了一个 10.2 寸的黑白红墨水屏价签. 很新, 屏幕保护膜都没撕, 又可见倒闭的公司库存.
可惜后盖上的贴纸不见了, 不知道是哪家公司的. 先拆拆看.
拆解
目前市面上大大小小价签很多种, 拆解价签的方法大同小异. 拆价签翻车的案例太多, 尤其大屏幕, 一旦翻车就是一顿外卖的损失. 所以拆解前一定要做好功课, 了解屏幕的结构, 以及拆解的方法. 卡扣一般会特别紧, 需要较薄的刀片, 但又不能太薄, 否则可能会受伤. 比如这个:
另外加上软质塑料片, 这玩意文具店很多, 用来拆解屏幕背面和 PCB 的双面胶.
撬开后电池盖, 注意卡扣位置. 然后取掉电池. 方便之后取出 PCB.
然后就是拆卡扣, 从屏幕背面的一个开口处开始, 用刀片, 慢慢撬一圈卡扣, 注意不要伤到自己和屏幕边缘.
卡扣分离后, 这时候可以用软质塑料片, 伸进去, 慢慢拆开 PCB 和后盖的双面胶.
PCB 也是用双面胶贴在屏幕背后的, 这一步是最容易翻车的, 一定要慢慢来. 塑料片伸进去横向推, 一点一点的推开 PCB 和背板的双面胶.
PCB 丝印: Endor Telink1020 2021-07-22 change U5, 可见这是 BLOZI(保资) 的价签. 但是在今天, 他家官网都是挂的. 难道价签厂家也倒闭了…
24pin 屏幕排线丝印: HINK-E102A01-A1, 搜索可以找到全网唯一参考资料1, 虽然是个 CSDN, 但提供了不少线索, 里面说明了驱动 IC 是 SSD1677, 微雪 3in7 也使用了相同驱动 IC, 但那是一个黑白屏幕.
如果没有找到对应的驱动 IC, 那可能就需要参考 wenting 的方法逆向了. 请自行学习.
驱动
拆解完成后就可以尝试驱动了. 一般来说, 24pin 就是 AIO(All-In-One) 串口屏了, 驱动版都是通用的. 这里随便找了一个驱动板(咸鱼), 使用 RPi Pico(RP2040 MCU) 来驱动. 开发框架使用 Rust embassy.
Rust embedded-graphics 提供了非常方便的 Framebuffer API(注意, 没有屏幕旋转支持).
传统情况下, 我们只需要实现一个 Display
trait, 就可以使用 embedded-graphics 的各种绘图 API 了.
但这里, 我们再抽象一级, 直接预留一个 update_frame(raw: &[u8])
接口, 直接接收 Framebuffer::data()
作为参数.
这是在经历了 epd 项目之后, 尝试七八种屏幕之后发现最合适最通用的方法.
先写个骨架, 一切 SPI 串口 EPD 都可以这样搞, 唯一需要注意的是部分驱动 IC 的 BUSY 使用反逻辑:
struct EPD10in2<'a> {
spi: Spi<'a, SPI0, Blocking>,
dc: Output<'a, AnyPin>,
busy: Input<'a, AnyPin>,
}
impl EPD10in2<'_> {
fn send_command(&mut self, cmd: u8) {
self.dc.set_low();
self.spi.blocking_write(&[cmd]);
}
fn send_data(&mut self, data: &[u8]) {
self.dc.set_high();
self.spi.blocking_write(data);
}
fn send_command_data(&mut self, cmd: u8, data: &[u8]) {
self.send_command(cmd);
self.send_data(data);
}
pub fn busy_wait(&mut self) {
loop {
if self.busy.is_low() {
info!("busy out");
break;
}
}
}
// ...
pub fn init(&mut self) {}
pub fn update_frame(&mut self, raw: &[u8]) {}
pub fn refresh(&mut self) {}
}
BWR 驱动 - 三色
微雪驱动使用了自定义黑白 LUT 和 4 阶灰度的 LUT 来驱动, 但个人经验是, 自定义 LUT 方法不适合三色 BWR 屏幕. 除非确定 LUT 来自厂家调教. 三色屏幕建议使用出厂的 OTP LUT(One-time-programming LUT). 这样可以保证屏幕的寿命, 也直接使用厂商调教过的颜色效果. 否则电子墨水屏在 LUT 表错误的情况下, 极容易永久性损坏, 例如我手头有若干永久性残影的屏幕.
当然, 的确是可以自己调教三色 LUT 逻辑, 相关论文有不少, 例如 Zeng, W.; Yi, Z.; Zhou, X.; Zhao, Y.; Feng, H.; Yang, J.; Liu, L.; Chi, F.; Zhang, C.; Zhou, G. Design of Driving Waveform for Shortening Red Particles Response Time in Three-Color Electrophoretic Displays. Micromachines 2021, 12, 578. https://doi.org/10.3390/mi12050578. 请沿着引文链自行探索.
一般来说, 三色墨水屏在墨囊黑白粒子之外额外加入了第三种颜色的粒子, 例如红色, 黄色. 彩色粒子的带电量和粘度(粒子物理运动特性)和黑色粒子可以通过较弱电压区分. 驱动过程大概是: 清屏, 激活(让黑色和彩色粒子尽可能分层而不是黏在一起), 然后利用较弱电压, 使得彩色粒子在屏幕上浮动, 形成彩色图像.
通读 SSD1677 的数据手册, 对照微雪的驱动代码, 找到核心修改点. 几乎所有 EPD 驱动 IC 的手册都是极其含糊, 这个也不例外. 其中一些关键词, 可能是需要你通读过其他同类型驱动 IC 才能理解.
SSD1677 有如上两种模式, 一种是 BWR 三色, 一种是黑白两色. 按照不同方式使用 LUT.
这两个含糊的 Command 描述文档, 隐藏了 BWR 驱动的细节.
- Display Mode 1 即 BWR 三色模式, Display Mode 2 为黑白模式
- Display Update Control 2 命令的 0x99 和 0x91 分别可以加载不同模式的 OTP LUT
- 0xC7, 0xCF 决定了最终刷新使用的 Display Mode
- Write Display Option 命令中设置了 WS(LUT) 不同时间片对应的 Display Mode
那么这里, 我们直接加载 Display Mode 1 的 OTP LUT, 然后使用 Display Update Control 2(with Display Mode 1) 命令刷新.
由此修改 init()
函数:
pub fn init(&mut self) {
self.send_command(0x12); // Soft reset
Delay.delay_ms(20_u32);
self.send_command_data(0x46, &[0xF7]);
self.busy_wait();
self.send_command_data(0x47, &[0xF7]);
self.busy_wait();
// Driver output control
// 0x27F = 639
self.send_command_data(0x01, &[0x7F, 0x02, 0x00]);
// set gate voltage
self.send_command_data(0x03, &[0x00]);
// set source voltage
self.send_command_data(0x04, &[0x41, 0xA8, 0x32]); // POR
// set data entry sequence
self.send_command_data(0x11, &[0x03]);
// set border
self.send_command_data(0x3C, &[0x03]);
// set booster strength
self.send_command_data(0x0C, &[0xAE, 0xC7, 0xC3, 0xC0, 0xC0]);
// set internal sensor on
self.send_command_data(0x18, &[0x80]);
// set vcom value
self.send_command_data(0x2C, &[0x44]);
// setting X direction start/end position of RAM
// 640 -> 639 => 0x27F
// 960 -> 959 => 0x3BF
self.send_command_data(0x44, &[0x00, 0x00, 0xBF, 0x03]);
self.send_command_data(0x45, &[0x00, 0x00, 0x7F, 0x02]);
self.send_command_data(0x4E, &[0x00, 0x00]);
self.send_command_data(0x4F, &[0x00, 0x00]);
self.send_command_data(0x37, &[0x00; 10]); // Use Mode 1 !!!
// Load Waveform !!!
// 0x91, Load LUT with Mode 1
self.send_command_data(0x22, &[0x91]);
self.send_command(0x20);
self.busy_wait();
// Display Update Control 2
self.send_command_data(0x22, &[0xCF]);
}
除了屏幕大小设定的修改, 最核心的用 !!!
标注.
补齐写 RAM 函数和屏幕刷新函数:
pub fn update_bw_frame(&mut self, buf: &[u8]) {
// self.send_command_data(0x4E, &[0x00, 0x00]);
// self.send_command_data(0x4F, &[0x00, 0x00]);
self.send_command(0x24);
self.send_data(buf);
}
pub fn update_red_frame(&mut self, buf: &[u8]) {
// self.send_command_data(0x4E, &[0x00, 0x00]);
// self.send_command_data(0x4F, &[0x00, 0x00]);
self.send_command(0x26);
self.send_data(buf);
}
pub fn refresh(&mut self) {
let mut delay = Delay;
self.send_command(0x20); // Master activation
delay.delay_ms(100_u32); //must
self.busy_wait();
}
在屏幕大小正确设定的前提下, 0x4E/0x4F(RAM 当前 X/Y) 可以只写一次, 之后就不需要了, 自动增长.
为了测试效果, 我们找一张 LLM 生成的图. 由于屏幕是 960x640, 而一般 LLM 生成的图是正方形, 需要进行缩放. 这里可以使用 Context Aware Image Resizing(CAIR) 算法, 或者传统直接缩放.
考虑到我们的屏幕只有三种颜色, 无法体现图片的丰富彩色和灰度细节, 需要使用抖动 (Diffusion Dithering) 来模拟灰度. 然后提取 BW frame 和 Red frame, 分别写 RAM. 相关任务可以通过 ImageMagick 完成:
convert ~/Downloads/_34f9c9ae-d1c2-47a9-ab55-089ffc7cb626.jpeg -size 960x640 -dither FloydSteinberg -remap 3color.gif out.gif
这里 3color.gif
是只包含红黑白三色的索引色图, 用来提供调色板. out.gif
是经过抖动处理的图片.
之前突然想到, Rust 的过程宏不就是在编译期执行的吗? 于是就写了个过程宏, 用来自动加载图片, 提取 BW/Red frame. 项目在 text-image.
全部刷新大概需要 20 秒左右, 最终效果如下:
BW 驱动 - 黑白双色
通过前面的描述, 其实大家都会发现, 首先 SSD1677 本身就支持三色和双色驱动两种模式, 且三色屏幕的彩色粒子和黑色粒子如果一同处理, 那完全是可以把三色屏幕当做双色屏幕来驱动的.
方法1: 依然使用三色模式, 只不过 RED RAM 永远置空. 这是最简单的, 不需要修改任何代码, 但需要忍受长大 20 秒的刷新时间.
方法2: 启用驱动 IC 的双色模式?
这里主要介绍双色模式, 它的好处是, 刷新速度可以调教到更快, 并且有可能支持灰度显示, 以及快速局部刷新. 黑白双色模式最主要的是驱动像素到新的状态, 需要拿到前一状态和目标状态, 然后执行对应的波形. 这需要驱动 IC 有对应的支持.
这里是驱动 IC 手册中含糊没有介绍清楚的部分:
可见 LUT 表和 上一篇文章 中的类似, 但似乎缺乏核心的 “AB” 概念? 其实不然. 我们大致按照 Display Mode 1 整理得到格式是:
- VS - L0(LUT0) - for BLACK
- VS - L1(LUT1) - for WHITE
- VS - L2(LUT2) - for RED (R=1, B/W=0)
- VS - L3(LUT3) - for RED (R=1, B/W=1), LUT3=LUT2
- VS - L4(LUT4) - reserved
- TP / RP - time period / repeat
- FR - Frame Rate
而按照 Display Mode 2, 经过测试, 发现 LUT 是:
- VS - L0(LUT0) - Black to Black
- VS - L1(LUT1) - Black to White
- VS - L2(LUT2) - White to Black
- VS - L3(LUT3) - White to White
- (其他部分一致)
可见, 这里其实是有新旧 AB 转换的概念的, 实测发现在 Display Mode 2 下, 0x24 B/W RAM 表示当前(目标/NEW)显示状态, 0x26 Red RAM 则是上一(OLD)状态. 那么看起来就可以实现快速刷新了. 只需要我们在写入新内容到 NEW RAM 同时, 把旧内容写入 OLD RAM. 驱动 IC 将自动使用 OLD/NEW 信息执行对应的波形, 完成像素位的状态转换.
那么是不是有种方法可以让驱动 IC 自动完成这个过程呢? 答案是肯定的. 这个功能在不同驱动 IC 的叫法不同, 比如在 UCxxxx 系列手册中, 叫做 N2OCP (New to Old Copy), 在 SSD1677 中, 叫做 “RAM ping-pong”.
翻看上面 0x37 Write Display Option 命令的 F[6]
位, 可以看到 “RAM Ping-Pong for Display Mode 2” 的描述.
且同时告知, 只有 Display Mode 2(黑白双色) 支持这个功能.
简单对 init()
函数做修改, 这里只贴出修改的部分:
pub fn init(&mut self) {
// ....
// Display Option
#[rustfmt::skip]
self.send_command_data(0x37, &[
0x00,
0xFF, //B
0xFF, //C
0xFF, //D
0xFF, //E
// 0x0F, RAM ping-pong disable
// 0x4F, RAM ping-pong enable
0x4F, //F, RAM ping-pong enable. only in Display Mode 2
0xFF, //G
0xFF, //H
0xFF, //I
0xFF, //J
]); // MODE 2
self.send_command_data(0x22, &[0x99]); // Load LUT with Mode 2
self.send_command(0x20);
self.busy_wait();
// Display Update Control 2
self.send_command_data(0x22, &[0xCF]);
}
废话不多说, 我们编写一张快速刷新的波形, 前置条件是需要屏幕是纯白的, 也就是刚刚清屏后的状态. 这可以通过 OTP LUT 实现. 这里只关注我们需要的黑色和白色状态.
- 对于 Black to Black 和 White to White, 什么都不做
- Black to White, 加 VSL 电压, 若干周期
- White to Black, 加 VSH 电压, 若干周期
经过测试, 我们得到了如下 LUT, 简洁到惊人. 0x0F
是个人测试的值, 即 15 个周期. RP=0 表示只重复一次.
相关内容和上一篇文章介绍的大同小异.
pub fn configure_partial_update(&mut self) {
#[rustfmt::skip]
const LUT: &[u8] = &[
0b00_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT0, B2B
0b10_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT1, B2W
0b01_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT2, W2B
0b00_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT3, W2W
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT5, reserved
// TP[xA, xB, xC, xD], RP
0x0F,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,//7
0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,//9
0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,
// FR
0x22,0x22,0x22,0x22,0x22
];
self.send_command_data(0x32, LUT);
}
而此时只需要写入 0x24 B/W RAM, 就可实现快速刷新, 实测大概 1s 左右即可完成刷屏. 且无闪动.
BW 驱动 - 灰度大法
看着 0x0F 这个时间周期值, 是不是手痒. 没错, 我们可以尝试调整这个值, 从而实现灰度显示. 灰度显示对于阅读器类应用意义重大, 矢量字体的抗锯齿渲染, 以及图片的灰度显示, 都可以大大提升用户体验.
这里假设我们需要实现 16 级别灰度, 正好对应 0x00 ~ 0x0F.
- 可以刷屏 16 次, 每次刷入 1/16 的灰度层次, 朴素但是可行
- !! 刷屏 4 次, 分别是 8, 4, 2, 1 个周期, 从而实现 16 级灰度, 相当于只用了 4 倍刷新时间
废话不多说, 直接上代码:
/// Level 0 to 15
fn configure_gray_update_level(&mut self, level: u8) {
#[rustfmt::skip]
let lut: &[u8] = &[
0b01_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT0, B2B
0b00_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT1, B2W
0b01_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT2, W2B
0b00_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT3, W2W
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//5
// TP[xA, xB, xC, xD], RP
level,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,//7
0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,//9
0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,
// FR
0x22,0x22,0x22,0x22,0x22
];
self.send_command_data(0x32, lut);
}
pub fn refresh_gray4_image(&mut self, buf: &[u8]) {
for level in (0..4).rev() {
// level: (8, 4, 2, 1)
self.configure_gray_update_level(1 << level);
self.send_command(0x24);
for chunk in buf.chunks(4) {
let mut n = 0;
for b in chunk {
if b & (0x10 << level) != 0 {
n = (n << 1) | 1;
} else {
n = n << 1;
}
if b & (1 << level) != 0 {
n = (n << 1) | 1;
} else {
n = n << 1;
}
}
// 0xFF is white, 0x00 is black
self.send_data(&[n]);
}
self.refresh();
}
}
pub fn refresh_gray2_image(&mut self, buf: &[u8]) {
for level in [1, 0] {
// level: 9, 5
self.configure_gray_update_level(1 << (level + 2) + 1);
self.send_command(0x24);
for chunk in buf.chunks(2) {
let mut n = 0;
for b in chunk {
if b & (0b01_00_00_00 << level) != 0 {
n = (n << 1) | 1;
} else {
n = n << 1;
}
if b & (0b00_01_00_00 << level) != 0 {
n = (n << 1) | 1;
} else {
n = n << 1;
}
if b & (0b00_00_01_00 << level) != 0 {
n = (n << 1) | 1;
} else {
n = n << 1;
}
if b & (0b00_00_00_01 << level) != 0 {
n = (n << 1) | 1;
} else {
n = n << 1;
}
}
// 0xFF is white, 0x00 is black
self.send_data(&[n]);
}
self.refresh();
}
}
以上代码中, refresh_gray4_image
和 refresh_gray2_image
分别是 4 级和 2 级灰度的刷新函数.
这里对灰度的每一位的权重映射做了微调.
格式兼容 embedded-graphics 中的 Framebuffer 和 Image 类型, 可以直接使用.
其中的 bit 操作, 其实基本上是 Github Copilot 写的, 我只是稍微修改了下边界情况和编译错误,
实现不是最优, 但好理解.
显示效果, 这里以互联网 UGC 时代的化石, 徐静蕾手写体为例:
你猜我抗锯齿字体怎么渲染的? 没错, 还是 text-image 过程宏, 使用方法:
let (w, h, raw) = text_image::text_image!(
text = "北京市发布持续低温黄色预警\n北京市发布道路结冰橙色预警\n\n-12.5℃ \n-16℃ -- -7℃\n相对湿度 36%\n东北风 1级",
font = "./徐静蕾手写体.ttf",
font_size = 48.0,
line_spacing = 0,
inverse,
Gray4,
);
info!("w: {}, h: {}", w, h);
epd.set_partial_refresh(Rectangle::new(Point::new(128, 160), Size::new(w, h)));
epd.refresh_gray4_image(raw);
当然这里用到了 “局部刷新” 函数, 我们这就介绍.
局部刷新
局部刷新是指, 只刷新屏幕的一个矩形部分, 而不是整屏刷新. 这在阅读器类应用中, 是非常重要的功能. 包括弹出式菜单, 局部 UI 元素等都可以用到. 在日历天气中, 局部刷新可以只刷新时间或天气的一小部分, 而不是整屏刷新, 从而大大提升观感.
实际上局部刷新只需要找驱动 IC 手册中的 RAM X/Y Start/End 关键词即可, UCxxxx 系列的驱动 IC, 也会直接提供 Partial Update 的相关命令. 这里一笔带过直接上代码:
fn clear_as_bw_mode(&mut self) {
// set X/Y ram counter
self.send_command_data(0x4E, &[0x00, 0x00]);
self.send_command_data(0x4F, &[0x00, 0x00]);
const NBUF: usize = 960 * 640 / 8;
self.send_command(0x24);
for i in 0..NBUF {
self.send_data(&[0xFF]); // W
}
// reset X/Y ram counter
self.send_command_data(0x4E, &[0x00, 0x00]);
self.send_command_data(0x4F, &[0x00, 0x00]);
self.send_command(0x26);
for i in 0..NBUF {
self.send_data(&[0xFF]); // Red off
}
}
pub fn set_partial_refresh(&mut self, rect: Rectangle) {
// clear old buf
self.clear_as_bw_mode();
let x0 = (rect.top_left.x as u16);
let x1 = rect.bottom_right().unwrap().x as u16;
let y0 = rect.top_left.y as u16;
let y1 = rect.bottom_right().unwrap().y as u16;
self.send_command_data(0x44, &[(x0 & 0xff) as u8, (x0 >> 8) as u8, (x1 & 0xff) as u8, (x1 >> 8) as u8]);
self.send_command_data(0x45, &[(y0 & 0xff) as u8, (y0 >> 8) as u8, (y1 & 0xff) as u8, (y1 >> 8) as u8]);
// set X/Y ram counter
self.send_command_data(0x4E, &[(x0 & 0xff) as u8, (x0 >> 8) as u8]);
self.send_command_data(0x4F, &[(y0 & 0xff) as u8, (y0 >> 8) as u8]);
}
需要注意的是:
- 在开启局部刷新前需要处理好 OLD/NEW RAM 的状态, 这里选择直接清空
- X/Y RAM counter 需要设置正确
- 部分驱动 IC 要求 gate 或 source 边界以 8 对齐, 需要参考手册, 并提前对图片进行裁剪补齐
结语
这里只是简单介绍了如何驱动这块价签, 以及如何实现局部刷新和灰度显示. 其中若干技术可以混用, 例如局部刷新 + 灰度显示, 局部刷新 + 三色显示等等. 屏幕也可以在不同状态下重新初始化, 最终实现的效果, 取决于你的想象力.
原理有了, 剩下的就是点子了. 之前做了一个 Bing Image Creator 加随机每日诗词的小工具. 效果还不错, 但一堆粗糙脚本未整理.
一些可能用到的技术再提一遍:
- Context Aware Image Resizing(CAIR) - PhotoShop 或一些图形学算法库
- Diffusion Dithering - ImageMagick / PhotoShop
- 各种 Text to Image 模型
- 我的 text-image 过程宏, 粗糙, 但支持各种图片加载, 字体抗锯齿渲染
- Rust Embedded 我推荐 embassy 框架, 至少它够统一, 而且是 async-first 的
- Rust embedded-graphics 提供了 Framebuffer API, 以及各种图形绘制 API
- 本项目粗糙代码, 未整理 commit