ENG | Waveshare Pico-CapTouch-ePaper-2.9
Personal notes, experiments, failures, frustration. Notes on portrait mode.
I bought Waveshare Pico-CapTouch-ePaper-2.9 for experiments, because it was surprisingly cheap (~360CZK/15EUR at 35% discount) and I was curious about this display technology - I haven’t seen epaper except of some Amazon Kindle or similar ebook readers, but it’s long time ago.
This particular screen promises four shades or gray (but dark gray is nearly black, so it is more like three), partial refresh and capacitive touch screen and it is a shield for Raspberry Pi Pico.
In default, portrait mode, USB cable is on top.
Tip: Buy some combined female-male headers for your Pico as shield does not expose any pins from Raspberry Pi Pico to use I2C sensors for example.
Most useful parts of this article are likely notes on portrait mode and why it is problematic. I’ve spent hours realizing why it does not always work, so you don’t have to.
Documentation and drivers
State
Documentation is a bit problematic. Waveshare 1,2, 3 does not expose underlaying hardware in their documentation or examples. However large parts of their specification are verbatim copy of SSD1680 driver, specifications of panel match GDEY029T94-T01 from Gooddisplay perfectly and here, touch screen driver is FocalTech FT6336U.
Having this knowledge helps to find alternative drivers either from WaveShare or GoodDisplay.
Code examples are all of very poor quality, with hardcoded constants, repeated code, no abstraction, toggling SPI chip select pin for every single byte etc. As with documentation, code from WaveShare and GoodDisplay looks similar, it has renamed prefixes and it’s a mess with functions copy-pasted between displays, STM32, ESP32, and Arduino examples with wrappers around SPI communication. Some parts of code are unused, commented out and likely published as soon as they work. If they do.
In retrospect, MicroPython example from WaveShare was probably the most complete piece of code, which included grayscale mode.
Do not download MicroPython from WaveShare. It does not add anything, it’s obsolete and there are not even versions with WiFi support. Official source is here.
Goal
My goal was to create concise driver for this display and to understand its working principles, because of poor quality of original drivers.
Which I did. it’s half of the length of official one and even adds support for landscape mode (it turned out, it works only partially). In the end I failed a bit. It’s hard enough to make code that works as display itself has some quirks and driver datasheet is not helpful as implementing driver assumes some vendor specific knowledge of display panel and SSD1680 internal logic. So it’s basically a blackbox.
Actually it was almost complete failure. SSD1680 has datasheet describing commands and their sequence for normal operation. But it does not explain anything, its hidden states, logic, quirks and it is not helpful at all if you want to write your own driver. So only way how to create working driver is to copy existing code - I assume only few people in China knows how to do that and whom to ask.
On top of SSD1680 has some look up tables programmed in OTP (one time programmable) memory, which are not disclosed.
In the end, at least I decoded how orientation (likely) works.
I strongly discourage anyone from trying to understand how SSD1680 and display modes work internally. Unless you have way too much free time.
About displays
All eink displays have probably similar working principle.
They have capsules of black and white charged particles suspended in oil. When voltage is applied to one side it attracts black particles and repels white ones or vice versa.
They have two monochrome image buffers, which can be called black&white and red or primary and secondary. Then they have waveforms/lookup tables, which define how to generate electric signal for each pixel so it changes color based on content of image buffers. If there are red particles, they likely need longer time to get to the surface.
All refresh modes but full refresh are optional and not supported by every display
- Full refresh - slow, depends only on the primary buffer, black and white.
- Partial refresh - fast and it relies on both buffers to toggle pixels between black and white fast
- Graycale refresh - slow, it uses primary buffer for black and white and then almost inverts pixel according to secondary buffer. Combination of driver and display supports 4 levels of gray, but it is far from optimal as dark gray is close to black.
- Red mode likely works in similar fashion as grayscale mode, but secondary buffer is for transparent/red, waveform lookup table is different. Except there are red particles which move through the oil at different speed.
Some lookup tables are in a display’s OTP (one time programmable) memory and are either selected automatically or via some magic commands.
Getting started with a simple driver
MicroPython
Official example almost works. All what is needed is to uncomment three lines. Then maybe create the copy which clears display to white and exits, because it’s recommended to store display without any image or refresh it daily. To try grayscale mode, it’s needed to uncomment it. I wanted to try display in landscape mode.
First working Python code
It took me long time to find out why my code does not work, while it’s sending the very same data as official example. When I tried SoftSPI which needs to define MISO pin (master in, slave out), it turned out that this pin is the same as reset pin. The following line solved all the problems (note
miso=None):
1 self.spi = SPI(1, baudrate=1_000_000, polarity=1, phase=1, sck=10, mosi=11, miso=None)Despite this, official example works.
This code differs from official example in several ways:
- Touch pad code is removed
- Single function
writecan replace several lines of code of repeatedsend_command,send_data - We are not setting CS/DC pins with every single byte
- Code for partial and grayscale updates was removed (and added later)
- Functions for landscape orientation were added (later i found they don’t work for more lines)
- Debugging functions are left (updates are rare)
It is kind of good as a starting point
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# https://www.waveshare.com/pico-captouch-epaper-2.9.htm
# https://github.com/waveshareteam/Pico_CapTouch_ePaper/blob/main/python/Pico_CapTouch_ePaper_Test_2in9.py
# Driver is SSD1680
from machine import Pin, SPI
import utime
# e-Paper
RST_PIN = 12
DC_PIN = 8
CS_PIN = 9
BUSY_PIN = 13
EPD_WIDTH = 128
EPD_HEIGHT = 296
EPD_STRIPS = EPD_WIDTH//8
UPDATE_FULL = 0xf7
UPDATE_PART = 0xfc
class DisplayDriver:
def __init__(self, cs = CS_PIN, dc = DC_PIN, rst = RST_PIN, busy = BUSY_PIN):
# SPI pins
self.cs = Pin(cs, Pin.OUT, value=1)
self.dc = Pin(dc, Pin.OUT, value=0)
self.rst = Pin(rst, Pin.OUT, value=1)
self.busy = Pin(busy, Pin.IN)
self.spi = SPI(1, baudrate=1_000_000, polarity=1, phase=1, sck=10, mosi=11, miso=None)
self.window_bytes = 0
def init(self, landscape:bool=False):
# Hard reset
self.reset()
self.wait_busy()
# Soft reset
self.write(0x12)
utime.sleep_ms(10)
self.wait_busy()
# Driver output control
self.write(0x01, [0x27, 0x01, 0x00])
self.write(0x3c, [0x05]) # https://github.com/ZinggJM/GxEPD2/blob/9c6775b9c5a51743ab0d8aa36760a51d38ddf32b/src/epd/GxEPD2_290_T94.cpp#L354
if not landscape:
self.set_orientation_port()
else:
self.set_orientation_land()
self.write(0x18, [0x80]) # Likely optional
self.set_window_full()
self.write(0x21, [0x00, 0x80])
self.wait_busy()
def reset(self):
# https://github.com/ZinggJM/GxEPD2/blob/master/src/GxEPD2_EPD.cpp#L111
# https://github.com/waveshareteam/Pico_CapTouch_ePaper/blob/da6878258c0504d8ae1cb8c896a11dacbda29642/python/Pico_CapTouch_ePaper_Test_2in9.py#L214
self.rst.value(1)
utime.sleep_ms(50)
self.rst.value(0)
utime.sleep_ms(10)
self.rst.value(1)
utime.sleep_ms(50)
def write(self, cmd, data = None):
cmd_bytes = bytes([cmd])
self.cs.value(0)
self.dc.value(0)
utime.sleep_us(10)
self.spi.write(cmd_bytes)
if data is not None:
self.dc.value(1)
utime.sleep_us(10)
data_bytes = bytes(data)
self.spi.write(data_bytes)
if len(data) < 16:
print(f"cmd: {cmd:02x}, bytes: {data_bytes.hex(' ')}")
else:
print(f"cmd: {cmd:02x}, bytes: {data_bytes[0:15].hex(' ')} ... ({len(data_bytes)} bytes)")
else:
print(f"cmd: {cmd:02x}")
self.cs.value(1)
def wait_busy(self):
print("wait_busy")
while self.busy.value() == 1:
utime.sleep_ms(10)
print("wait_busy exiting")
def set_orientation_port(self):
self.write(0x11, [0b011]) # 0:x_first, 1:x_incr, 1:y_incr
def set_orientation_land(self):
self.write(0x11, [0b101]) # 1:y_first, 0:x_decr, 1:y_incr
def set_cursor_port(self, x, y):
"""
x needs to be divisible by 8 (raw values are 0..15)
y must be in interval 0..295
"""
print(f"set_cursor_port {x},{y}")
self.write(0x4e, [x // 8])
self.write(0x4f, [y & 0xff, y >> 8])
def set_cursor_land(self, x, y):
self.set_cursor_port(EPD_WIDTH-1-y, x)
def set_window_port(self, left:int, top:int, right:int, bottom:int):
assert left % 8 == 0, "window left must be divisible by 8"
assert (right+1) % 8 == 0, "window right+1 must be divisible by 8"
print(f"set_window_port: {left},{top},{right},{bottom}")
self.write(0x44, [left // 8, right // 8])
self.write(0x45, [top & 0xff, top >> 8, bottom & 0xff, bottom >> 8])
self.window_bytes = (right - left + 1) * (bottom - top + 1) // 8
def set_window_full(self):
self.set_window_port(0, 0, EPD_WIDTH-1, EPD_HEIGHT-1)
def set_window_land(self, left:int, top:int, right:int, bottom:int):
print(f"set_window_land: {left},{top},{right},{bottom}")
x_start = EPD_WIDTH - 1 - bottom
x_end = EPD_WIDTH - 1 - top
self.set_window_port(x_start, left, x_end, right)
def turn_on_display(self, data=0xf7):
print(f"turn_on_display {data:02x}")
self.write(0x22, [data])
self.write(0x20)
self.wait_busy()
def write_ram_bw(self, data):
assert len(data) == self.window_bytes, "write_ram_bw: data size does not match window"
self.write(0x24, data)
def write_ram_red(self, data):
assert len(data) == self.window_bytes, "write_ram_red: data size does not match window"
self.write(0x26, data)
drv = DisplayDriver()
drv.init(landscape=False)
drv.set_cursor_port(0,0)
drv.wait_busy()
drv.write_ram_bw( bytes([0xff]) * (EPD_WIDTH * EPD_HEIGHT // 8) )
drv.turn_on_display(UPDATE_FULL)
drv.set_window_land(0, 0, 10, 7)
drv.set_cursor_land(0, 0)
bitmap = bytearray([0x70, 0x18, 0x7d, 0xb6, 0xbc, 0x3c, 0xbc, 0xb6, 0x7d, 0x18, 0x70])
bitmap = bytearray(~b & 0xFF for b in bitmap)
drv.write_ram_bw(bitmap)
drv.turn_on_display(UPDATE_FULL)
NOTES
- full code will be provided as github link or under the article. Once I polish it.
- the code is not much better than examples - it does not separate driver, display, and example
Partial updates
Partial updates work correctly only if both buffers are written at time of full refresh and “red” buffer is not touched afterwards.
Here is and example what happens when you make one half of screen white and half black, write stripes to “red” buffer and then repeatedly write white-black-white-black pattern over using partial updates. What was black or white and does not change stays. When there is a change, data from red buffer appear as gray.
I had even more weird example where i wrote data only to part of frame buffer and rest of the image altered between old buffers.
Here I answered my two questions which I initially had:
- It is possible that refresh can be limited to a region and some regions may work in grayscale mode (rarely updated, update may take 15s) and some in partial refresh mode?
- Short answer: No.
- Long answer: Partial update is applied to whole display, not to a window. However its results depends on what is on display (hidden state), what is in new buffer and what is in red buffer. Overwriting red buffer in such a way that it does not correspond to display after full update or writing it after partial update is fun and can produce grayscale image. It likely points to RAM which is used internally. Partial update when grayscale image is stored in memory results in toggling between buffer A and buffer B.
- I noticed that partial refresh does not work properly when secondary buffer was never written. Secondary buffer should likely contain previous image, but it’s not shown in the demo or documented.
- See above, if secondary buffer is not written and does not contain copy of primary buffer after full update - partial update partially fails on areas which changed.
TL;DR: Before first partial refresh, secondary buffer must contain the same data as primary buffer which was used for full refresh.
Grayscale modes
For unknown reason there is something wrong with addressing. From WaveShare example and trial-errors it works only in portrait mode and with pages in range between 1 to 16 (corresponding to x=8 to x=127+8). Otherwise whole display will turn black. Only way to use landscape mode is to rotate framebuffer to portrait mode and hide this as an “implementation detail” and hide it in FrameBuffer code. It’s actually typical for various framebuffer drawing libraries, because not every display supports rotation.
This feature is not documented … surprisingly.
Also, contrary to documentation, grayscale mode interprets zero as white, which is not consistent with other modes that (sadly) interpret zero as black (I’m kind of used to that zero is background and one is text).
Nonetheless when I gave up landscape mode, it worked.
Believe me or not, generating and preparing test images, was the most enjoyable part of this research ;)
PS: I later found out that my landscape mode does not work as intended.
Scripts for image conversion to grayscale
To display this images, it needs more complex code with grayscale image support, not the one above.
It’s recommended to resize image to roughly 2.4:1 ratio. eInk display has 37/16=2.3125:1 which is very close to ultrawide displays (3840/1600=2.4, 3440/1440=43/18=2.3888)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#!/bin/sh
# 16 color grayscale image with 4 color palette
magick -size 4x1 xc:black \
-depth 8 \
-colorspace gray \
+dither \
-fill "gray(0)" -draw "point 0,0" \
-fill "gray(34)" -draw "point 1,0" \
-fill "gray(204)" -draw "point 2,0" \
-fill "gray(255)" -draw "point 3,0" \
-type palette -depth 4 palette-gray4.png
# resize, crop, convert to grayscale, dither to 4 colors in palette
magick ~/Downloads/1763276438.png \
-gamma 1.0 \
-filter MagicKernelSharp2021 -resize 296x \
-crop 296x128+0+0 \
-colorspace gray \
-dither FloydSteinberg -remap palette-gray4.png \
-depth 4 \
image_gray4.png
# rotate
magick image_gray4.png -rotate 90 image_gray4_r.png
# convert to framebuffers plane_a, plane_b
./convert_image_to_array.py > background_image.py
# upload
#mpremote fs cp background_image.py :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#!/usr/bin/python
from PIL import Image
# Map pixel values to classes
PIX_BLACK = 0
PIX_DGRAY = 34
PIX_LGRAY = 204
PIX_WHITE = 255
def load_2bit_image(path):
img = Image.open(path).convert("L")
return img
def make_planes(img):
width, height = img.size
plane_a = bytearray(width // 8 * height)
plane_b = bytearray(width // 8 * height)
print( "# Generated file")
print(f"# w={width}, h={height}, len={len(plane_a)}")
pages = width // 8
px = img.load()
for y in range(height):
for x in range(width):
page = x >> 3
bit = 7 - x & 0x07
v = px[x, y]
# This is opposite of what documentation states
# For grayscale mode 0b00 is white
if v == PIX_LGRAY or v == PIX_BLACK:
plane_a[page + y*pages] |= (1 << bit)
if v == PIX_DGRAY or v == PIX_BLACK:
plane_b[y * pages + page] |= (1 << bit)
return plane_a, plane_b
def print_plane(name:str, width:int, data):
print(f"{name} = bytes( \\")
for i, b in enumerate(data):
bytes_per_line = width // 8
# end of line
if (i % bytes_per_line == 0 and i != 0):
print("' \\")
# start of line
if (i % bytes_per_line == 0):
print(" b'", end='')
print(f"\\x{b:02x}", end ='')
print("')")
if __name__ == "__main__":
img = load_2bit_image("image_gray4_r.png")
plane_a, plane_b = make_planes(img)
print_plane("plane_a", img.width, plane_a)
print_plane("plane_b", img.width, plane_b)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
~/devel/eink$ nvim create_images.sh
~/devel/eink$./create_images.sh
~/devel/eink$ imv image_gray4.png
~/devel/eink$ mpremote fs cp background_image.py :
cp background_image.py :
~/devel/eink$ mpremote run eink.py
wait_busy
cmd: 12
wait_busy
cmd: 01, bytes: 27 01 00
cmd: 11, bytes: 03
cmd: 3c, bytes: 04
set_window_port: 8,0,135,295
cmd: 44, bytes: 01 10
cmd: 45, bytes: 00 00 27 01
wait_busy
cmd: 32, bytes: 00 60 00 00 00 00 00 00 00 00 00 00 20 60 10 ... (153 bytes)
cmd: 3f, bytes: 22
cmd: 03, bytes: 17
cmd: 04, bytes: 41 ae 32
cmd: 2c, bytes: 28
set_cursor_port 8,0
cmd: 4e, bytes: 01
cmd: 4f, bytes: 00 00
wait_busy
cmd: 24, bytes: 29 29 55 2a 52 aa d6 ad 25 02 20 00 00 00 00 ... (4736 bytes)
cmd: 26, bytes: d6 d6 aa d5 ad 55 29 52 da fd df ff ff ff ff ... (4736 bytes)
turn_on_display c7
cmd: 22, bytes: c7
cmd: 20
wait_busy
Landscape and portrait mode
TL;DR: Landscape mode doesn’t work properly in MicroPython without ugly workarounds
The display is internally organized as 16×296 bytes (128 pixels ÷ 8 bits per byte = 16). The x coordinate must always be divisible by 8.
Portrait mode is tricky, because logic is crazy. Or let’s call it completely retarded.
Orientation
The SSD1680 has an orientation register (command 0x11) with three bits:
- Bit 2: Which axis is primary? (0 = x-first, portrait, 1 = y-first, landscape)
- Bit 1: X direction (0 = decrement, 1 = increment)
- Bit 0: Y direction (0 = decrement, 1 = increment)
Cursor position 0,0 is top-left, position 15,295 is bottom-right (note: x is in “pages” of 8 pixels). Cursor can start and end up in four corners depending on orientation. Cursor position is typically the same as a window start and windows end is the opposite corner.
Portrait mode
Default (USB up): orientation = 0x03
- Increment x, then increment y
- Window 0,0 -> 15,295
- MicroPython buffer format:
MONO_HLSB(horizontal, least significant byte first)
Upside down: orientation = 0x00
- Decrement x, then decrement y
- Window 15,295 -> 0,0
- No framebuffer changes needed! The hardware decodes bytes differently based on x direction. Convenient. This time.
Landscape mode
Set bit 2 to make y primary axis. Modes 0x05, 0x06 should work for 90°/270° rotation. But here is the problem. MicroPython only supports MONO_VLSB (vertical, LSB first) for rotated displays, actually this is default mode for many monochrome displays. One byte represents a column of 8 pixels. But the SSD1680’s bit interpretation changes with x direction, so:
- Modes 0x05/0x06: Cursor moves correctly, but all text is upside down
- Modes 0x04/0x07: Rows are going upwards, requiring you to send the framebuffer as 16 separate chunks and setting cursor for each row.
Workarounds
- Use mode 0x04,0x07 and set cursor for each row
- Some Arduino graphic libraries or LVGL allow to rotate framebuffer. Also C++ is significantly faster.
Other notes on internal logic
- when cursor position is updated beyond end of window, it wraps to start of the window according to orientation bit and move up or down.
- cursor can possibly go outside of window (if it moves up, but window boundary is down)
- when cursor is outside of window, it does not wrap (unless it reaches end coordinate)
- memory has enough space for 176x296 pixels.
Fact, that framebuffer format used by many monochrome displays does not work here and decoding how this works internally takes hours is pretty annoying.
Summary of problems
- Documentation of SSD1680 is basically useless per se - it lists commands, not internal tables, some bits are unclear without knowing more details
- Default MISO pin for SPI1 on Raspberry Pi Pico is repurposed for RESET output
- Partial refresh likely depends on three states, technically can be exploited for grayscale.
- Partial refresh seem to update buffer B and swap between A,B.
- Landscape mode … works … kind of.
- Grayscale addressing window offset is weird
- Grayscale image has inverted colors
- Border data byte is weird (values 0x04, 0x05)
- Command 0x37 in partial update is weird/magic
- Command 0x22 is also mysterious
- When something fails (wrong window/cursor/addressing mode), buffer is often ignored
Links
- https://www.youtube.com/watch?v=dhRgw0HfrYU
- https://www.youtube.com/watch?v=MsbiO8EAsGw
- https://github.com/waveshareteam/Pico_CapTouch_ePaper/tree/main
- https://github.com/waveshareteam/Pico_ePaper_Code/blob/main/python/Pico_ePaper-2.9.py
- https://github.com/waveshareteam/Pico_ePaper_Code/blob/main/python/Pico_ePaper-2.9-B_V4.py
- https://github.com/waveshareteam/Pico_ePaper_Code/blob/main/python/Pico_ePaper-2.13-B_V4.py
https://www.crystalfontz.com/controllers/uploaded/SSD1680.pdf
Conclusion
Display itself is not bad and it’s perfectly useable. Well … maybe it’s too small. Which is interesting, because 20 years ago, there were PDAs with 160x160 resolution, even early smart phones had 320x480 resolution and 3.5” displays. But today when it’s nearly impossible to buy smartphone with less than 6 inch and 2400×1080 pixels it feels wierd.
For the price, I’m happy to have one.
TODO: pixel density comparison table 1920x1200 24”, 2560x1440 27”, 4K 32”, 1920x1080 14”, 15.6”, Pimoroni, this one
Full update time in about two or three seconds is fine, partial updates are fast.
Code example from waveshare works (after uncommenting few lines). It can be shortened by combining writeCommand, writeData to a single write and wrap repetitive code to function. Same in C, there could be write(uint8_t command, size_t dataSize, uint8_t *data); or maybe variable arguments. Other than that, due to blackbox nature of a device, lack of comments, explanation, only way how to make this work is to follow the exact same commands.
Reminds me a joke
1
2
3
4
5
6
7
8
9
10
//
// Dear maintainer:
//
// Once you are done trying to 'optimize' this routine,
// and have realized what a terrible mistake that was,
// please increment the following counter as a warning
// to the next guy:
//
// total_hours_wasted_here = 42
//