From 04257c78852fbb52e4b9e2ba443f4720f19949c1 Mon Sep 17 00:00:00 2001 From: Andre Courchesne Date: Tue, 4 Aug 2026 13:51:18 -0700 Subject: [PATCH] gdew0154m09: add e-paper display driver --- examples/gdew0154m09/main.go | 69 +++++++ gdew0154m09/gdew0154m09.go | 297 ++++++++++++++++++++++++++++++ gdew0154m09/gdew0154m09_test.go | 313 ++++++++++++++++++++++++++++++++ gdew0154m09/registers.go | 23 +++ smoketest.sh | 1 + 5 files changed, 703 insertions(+) create mode 100644 examples/gdew0154m09/main.go create mode 100644 gdew0154m09/gdew0154m09.go create mode 100644 gdew0154m09/gdew0154m09_test.go create mode 100644 gdew0154m09/registers.go diff --git a/examples/gdew0154m09/main.go b/examples/gdew0154m09/main.go new file mode 100644 index 000000000..c4496d482 --- /dev/null +++ b/examples/gdew0154m09/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "device/esp" + "image/color" + "machine" + "time" + + "tinygo.org/x/drivers/gdew0154m09" + "tinygo.org/x/tinyfont" + "tinygo.org/x/tinyfont/freemono" +) + +func main() { + // Hold the M5Stack CoreInk power rail on. + machine.GPIO12.Configure(machine.PinConfig{Mode: machine.PinOutput}) + machine.GPIO12.High() + + cs := machine.IO9 + dc := machine.GPIO15 + rst := machine.IO0 + busy := machine.IO4 + cs.Configure(machine.PinConfig{Mode: machine.PinOutput}) + dc.Configure(machine.PinConfig{Mode: machine.PinOutput}) + rst.Configure(machine.PinConfig{Mode: machine.PinOutput}) + busy.Configure(machine.PinConfig{Mode: machine.PinInput}) + cs.High() + dc.High() + rst.High() + + // Enable and reset the ESP32 VSPI peripheral before configuring SPI3. + esp.DPORT.SetPERIP_RST_EN_SPI3_RST(1) + esp.DPORT.SetPERIP_CLK_EN_SPI3_CLK_EN(1) + esp.DPORT.SetPERIP_RST_EN_SPI3_RST(0) + if err := machine.SPI3.Configure(machine.SPIConfig{ + Frequency: gdew0154m09.Baudrate, + SCK: machine.IO18, + SDO: machine.IO23, + SDI: machine.NoPin, + Mode: gdew0154m09.SPIMode, + }); err != nil { + println("could not configure SPI:", err.Error()) + return + } + + display := gdew0154m09.New(machine.SPI3, cs, dc, rst, busy) + if err := display.Configure(gdew0154m09.DefaultConfig); err != nil { + println("could not configure display:", err.Error()) + return + } + + black := color.RGBA{A: 0xff} + tinyfont.WriteLine(display, &freemono.Bold18pt7b, 15, 90, "TinyGo", black) + for x := int16(0); x < gdew0154m09.Width; x++ { + display.SetPixel(x, 0, black) + display.SetPixel(x, gdew0154m09.Height-1, black) + } + for y := int16(0); y < gdew0154m09.Height; y++ { + display.SetPixel(0, y, black) + display.SetPixel(gdew0154m09.Width-1, y, black) + } + + if err := display.Display(); err != nil { + println("could not refresh display:", err.Error()) + } + for { + time.Sleep(time.Hour) + } +} diff --git a/gdew0154m09/gdew0154m09.go b/gdew0154m09/gdew0154m09.go new file mode 100644 index 000000000..66b51d7bc --- /dev/null +++ b/gdew0154m09/gdew0154m09.go @@ -0,0 +1,297 @@ +// Package gdew0154m09 provides a driver for the Good Display GDEW0154M09 +// 1.54-inch 200x200 monochrome e-paper panel used by the M5Stack CoreInk. +// +// The initialization sequence is based on the MIT-licensed M5Stack +// M5Core-Ink library: +// https://github.com/m5stack/M5Core-Ink +package gdew0154m09 // import "tinygo.org/x/drivers/gdew0154m09" + +import ( + "errors" + "image/color" + "time" + + "tinygo.org/x/drivers" +) + +const ( + // Width and Height are the physical panel dimensions in pixels. + Width = 200 + Height = 200 + + // Baudrate and SPIMode are the recommended SPI bus configuration. + Baudrate = 10_000_000 + SPIMode = 3 + + frameBufferSize = Width * Height / 8 +) + +var ( + ErrBusyTimeout = errors.New("gdew0154m09: busy timeout") + ErrInvalidBusyTimeout = errors.New("gdew0154m09: busy timeout must not be negative") +) + +// OutputPin is the interface required from an output pin. Pins must be +// configured by the caller before they are passed to New. +type OutputPin interface { + Set(level bool) +} + +// InputPin is the interface required from an input pin. The pin must be +// configured by the caller before it is passed to New. +type InputPin interface { + Get() bool +} + +// Config contains the display configuration. +type Config struct { + // BusyTimeout is the maximum time to wait for a panel operation. A zero + // duration selects the default timeout. + BusyTimeout time.Duration +} + +// DefaultConfig contains the recommended display configuration. +var DefaultConfig = Config{ + BusyTimeout: 5 * time.Second, +} + +// Device is a GDEW0154M09 display connected over SPI. +type Device struct { + bus drivers.SPI + cs OutputPin + dc OutputPin + rst OutputPin + busy InputPin + + buffer [frameBufferSize]byte + previous [frameBufferSize]byte + tx [3]byte + busyTimeout time.Duration +} + +var _ drivers.Displayer = (*Device)(nil) + +// New returns a new GDEW0154M09 driver. The SPI bus and pins must already be +// configured. New performs no I/O. +func New(bus drivers.SPI, cs, dc, rst OutputPin, busy InputPin) *Device { + d := &Device{ + bus: bus, + cs: cs, + dc: dc, + rst: rst, + busy: busy, + busyTimeout: DefaultConfig.BusyTimeout, + } + d.ClearBuffer() + fill(d.previous[:], 0xff) + return d +} + +// Configure resets and initializes the display controller. It also clears the +// current and previous framebuffers to white without refreshing the panel. +func (d *Device) Configure(config Config) error { + if config.BusyTimeout < 0 { + return ErrInvalidBusyTimeout + } + if config.BusyTimeout == 0 { + config.BusyTimeout = DefaultConfig.BusyTimeout + } + d.busyTimeout = config.BusyTimeout + + d.hardwareReset() + if err := d.waitUntilIdle(); err != nil { + return err + } + + if err := d.sendCommand2(commandPanelSetting, 0xdf, 0x0e); err != nil { + return err + } + if err := d.sendCommand1(commandFITIInternalCode, 0x55); err != nil { + return err + } + if err := d.sendCommand1(commandUnknownAA, 0x0f); err != nil { + return err + } + if err := d.sendCommand1(commandUnknownE9, 0x02); err != nil { + return err + } + if err := d.sendCommand1(commandBoosterSoftStart, 0x11); err != nil { + return err + } + if err := d.sendCommand1(commandPowerSequence, 0x0a); err != nil { + return err + } + if err := d.sendCommand3(commandResolutionSetting, 0xc8, 0x00, 0xc8); err != nil { + return err + } + if err := d.sendCommand1(commandTCONSetting, 0x00); err != nil { + return err + } + if err := d.sendCommand1(commandVCOMDataInterval, defaultVCOMDataInterval); err != nil { + return err + } + if err := d.sendCommand1(commandPowerSaving, 0x00); err != nil { + return err + } + if err := d.sendCommand(commandPowerOn); err != nil { + return err + } + + time.Sleep(100 * time.Millisecond) + if err := d.waitUntilIdle(); err != nil { + return err + } + d.ClearBuffer() + fill(d.previous[:], 0xff) + return nil +} + +// Size returns the dimensions of the display in pixels. +func (d *Device) Size() (width, height int16) { + return Width, Height +} + +// SetPixel updates one pixel in the framebuffer. Transparent and light colors +// are rendered white; opaque dark colors are rendered black. +func (d *Device) SetPixel(x, y int16, c color.RGBA) { + if x < 0 || x >= Width || y < 0 || y >= Height { + return + } + index := int(y)*(Width/8) + int(x)/8 + mask := byte(0x80 >> uint(x%8)) + brightness := uint16(c.R) + uint16(c.G) + uint16(c.B) + if c.A == 0 || brightness >= 3*128 { + d.buffer[index] |= mask + } else { + d.buffer[index] &^= mask + } +} + +// ClearBuffer sets every pixel in the framebuffer to white without refreshing +// the panel. +func (d *Device) ClearBuffer() { + fill(d.buffer[:], 0xff) +} + +// Display sends the previous and current framebuffers to the panel and starts +// a full refresh. It blocks until the refresh completes or the busy timeout is +// reached. +func (d *Device) Display() error { + if err := d.sendCommand(commandDataStartOld); err != nil { + return err + } + if err := d.sendData(d.previous[:]); err != nil { + return err + } + time.Sleep(2 * time.Millisecond) + + if err := d.sendCommand(commandDataStartNew); err != nil { + return err + } + if err := d.sendData(d.buffer[:]); err != nil { + return err + } + time.Sleep(2 * time.Millisecond) + + if err := d.sendCommand(commandDisplayRefresh); err != nil { + return err + } + if err := d.waitUntilIdle(); err != nil { + return err + } + copy(d.previous[:], d.buffer[:]) + return nil +} + +// ClearDisplay clears the framebuffer and refreshes the panel. +func (d *Device) ClearDisplay() error { + d.ClearBuffer() + return d.Display() +} + +// DeepSleep powers off the panel and enters deep sleep. Configure must be +// called to wake and reinitialize the controller. +func (d *Device) DeepSleep() error { + if err := d.sendCommand1(commandVCOMDataInterval, deepSleepVCOMDataInterval); err != nil { + return err + } + if err := d.waitUntilIdle(); err != nil { + return err + } + if err := d.sendCommand(commandPowerOff); err != nil { + return err + } + return d.sendCommand1(commandDeepSleep, deepSleepCheckCode) +} + +// IsBusy reports whether the active-low busy signal is asserted. +func (d *Device) IsBusy() bool { + return !d.busy.Get() +} + +func (d *Device) hardwareReset() { + d.rst.Set(true) + time.Sleep(10 * time.Millisecond) + d.rst.Set(false) + time.Sleep(100 * time.Millisecond) + d.rst.Set(true) + time.Sleep(100 * time.Millisecond) +} + +func (d *Device) waitUntilIdle() error { + deadline := time.Now().Add(d.busyTimeout) + for d.IsBusy() { + if time.Now().After(deadline) { + return ErrBusyTimeout + } + time.Sleep(time.Millisecond) + } + return nil +} + +func (d *Device) sendCommandWithData(command byte, data []byte) error { + if err := d.sendCommand(command); err != nil { + return err + } + return d.sendData(data) +} + +func (d *Device) sendCommand1(command, data byte) error { + d.tx[0] = data + return d.sendCommandWithData(command, d.tx[:1]) +} + +func (d *Device) sendCommand2(command, data0, data1 byte) error { + d.tx[0] = data0 + d.tx[1] = data1 + return d.sendCommandWithData(command, d.tx[:2]) +} + +func (d *Device) sendCommand3(command, data0, data1, data2 byte) error { + d.tx[0] = data0 + d.tx[1] = data1 + d.tx[2] = data2 + return d.sendCommandWithData(command, d.tx[:3]) +} + +func (d *Device) sendCommand(command byte) error { + d.dc.Set(false) + d.cs.Set(false) + _, err := d.bus.Transfer(command) + d.cs.Set(true) + return err +} + +func (d *Device) sendData(data []byte) error { + d.dc.Set(true) + d.cs.Set(false) + err := d.bus.Tx(data, nil) + d.cs.Set(true) + return err +} + +func fill(buffer []byte, value byte) { + for index := range buffer { + buffer[index] = value + } +} diff --git a/gdew0154m09/gdew0154m09_test.go b/gdew0154m09/gdew0154m09_test.go new file mode 100644 index 000000000..9b5c7c658 --- /dev/null +++ b/gdew0154m09/gdew0154m09_test.go @@ -0,0 +1,313 @@ +package gdew0154m09 + +import ( + "bytes" + "errors" + "image/color" + "testing" + "time" +) + +type fakeOutputPin struct { + high bool + changes []bool +} + +func (p *fakeOutputPin) Set(level bool) { + p.high = level + p.changes = append(p.changes, level) +} + +type fakeInputPin struct { + high bool +} + +func (p *fakeInputPin) Get() bool { + return p.high +} + +type spiOperation struct { + command bool + data []byte +} + +type fakeSPI struct { + t *testing.T + cs *fakeOutputPin + dc *fakeOutputPin + operations []spiOperation + failAt int + failErr error +} + +func (s *fakeSPI) Transfer(value byte) (byte, error) { + s.t.Helper() + if s.cs.high { + s.t.Error("chip select was high during command transfer") + } + if s.dc.high { + s.t.Error("data/command pin was high during command transfer") + } + s.operations = append(s.operations, spiOperation{command: true, data: []byte{value}}) + if len(s.operations) == s.failAt { + return 0, s.failErr + } + return 0, nil +} + +func (s *fakeSPI) Tx(write, read []byte) error { + s.t.Helper() + if s.cs.high { + s.t.Error("chip select was high during data transfer") + } + if !s.dc.high { + s.t.Error("data/command pin was low during data transfer") + } + data := append([]byte(nil), write...) + s.operations = append(s.operations, spiOperation{data: data}) + if len(s.operations) == s.failAt { + return s.failErr + } + return nil +} + +func newTestDevice(t *testing.T) (*Device, *fakeSPI, *fakeOutputPin, *fakeInputPin) { + t.Helper() + cs := &fakeOutputPin{high: true} + dc := &fakeOutputPin{high: true} + rst := &fakeOutputPin{high: true} + busy := &fakeInputPin{high: true} + bus := &fakeSPI{t: t, cs: cs, dc: dc} + return New(bus, cs, dc, rst, busy), bus, rst, busy +} + +func TestNewStartsWithWhiteBuffers(t *testing.T) { + device, _, _, _ := newTestDevice(t) + for index := range device.buffer { + if device.buffer[index] != 0xff || device.previous[index] != 0xff { + t.Fatalf("pixel byte %d was not initialized white", index) + } + } +} + +func TestSize(t *testing.T) { + device, _, _, _ := newTestDevice(t) + width, height := device.Size() + if width != Width || height != Height { + t.Fatalf("size = %dx%d, want %dx%d", width, height, Width, Height) + } +} + +func TestSetPixel(t *testing.T) { + device, _, _, _ := newTestDevice(t) + black := color.RGBA{A: 0xff} + white := color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff} + + device.SetPixel(0, 0, black) + device.SetPixel(199, 199, black) + if device.buffer[0] != 0x7f { + t.Fatalf("first byte = %#x, want 0x7f", device.buffer[0]) + } + if device.buffer[len(device.buffer)-1] != 0xfe { + t.Fatalf("last byte = %#x, want 0xfe", device.buffer[len(device.buffer)-1]) + } + + device.SetPixel(0, 0, white) + if device.buffer[0] != 0xff { + t.Fatalf("first byte after white pixel = %#x, want 0xff", device.buffer[0]) + } +} + +func TestSetPixelIgnoresOutOfBoundsCoordinates(t *testing.T) { + device, _, _, _ := newTestDevice(t) + device.SetPixel(-1, 0, color.RGBA{A: 0xff}) + device.SetPixel(0, -1, color.RGBA{A: 0xff}) + device.SetPixel(Width, 0, color.RGBA{A: 0xff}) + device.SetPixel(0, Height, color.RGBA{A: 0xff}) + for index, value := range device.buffer { + if value != 0xff { + t.Fatalf("byte %d changed to %#x", index, value) + } + } +} + +func TestConfigure(t *testing.T) { + device, bus, rst, _ := newTestDevice(t) + if err := device.Configure(Config{}); err != nil { + t.Fatal(err) + } + if device.busyTimeout != DefaultConfig.BusyTimeout { + t.Fatalf("busy timeout = %v, want %v", device.busyTimeout, DefaultConfig.BusyTimeout) + } + + wantReset := []bool{true, false, true} + if !equalBools(rst.changes, wantReset) { + t.Fatalf("reset changes = %v, want %v", rst.changes, wantReset) + } + wantCommands := []byte{0x00, 0x4d, 0xaa, 0xe9, 0xb6, 0xf3, 0x61, 0x60, 0x50, 0xe3, 0x04} + if got := commandBytes(bus.operations); !bytes.Equal(got, wantCommands) { + t.Fatalf("commands = %#v, want %#v", got, wantCommands) + } + + wantData := [][]byte{ + {0xdf, 0x0e}, {0x55}, {0x0f}, {0x02}, {0x11}, {0x0a}, + {0xc8, 0x00, 0xc8}, {0x00}, {0xd7}, {0x00}, + } + if got := dataTransfers(bus.operations); !equalByteSlices(got, wantData) { + t.Fatalf("data transfers = %#v, want %#v", got, wantData) + } +} + +func TestConfigurePropagatesSPIErrors(t *testing.T) { + wantErr := errors.New("SPI failure") + for _, operation := range []int{1, 2} { + t.Run(operationName(operation), func(t *testing.T) { + device, bus, _, _ := newTestDevice(t) + bus.failAt = operation + bus.failErr = wantErr + if err := device.Configure(DefaultConfig); !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want %v", err, wantErr) + } + if !bus.cs.high { + t.Error("chip select was not released after SPI error") + } + }) + } +} + +func TestConfigureRejectsNegativeTimeout(t *testing.T) { + device, bus, rst, _ := newTestDevice(t) + err := device.Configure(Config{BusyTimeout: -time.Millisecond}) + if !errors.Is(err, ErrInvalidBusyTimeout) { + t.Fatalf("error = %v, want %v", err, ErrInvalidBusyTimeout) + } + if len(bus.operations) != 0 || len(rst.changes) != 0 { + t.Fatal("invalid configuration performed hardware I/O") + } +} + +func TestDisplaySendsOldAndNewFrames(t *testing.T) { + device, bus, _, _ := newTestDevice(t) + device.SetPixel(0, 0, color.RGBA{A: 0xff}) + if err := device.Display(); err != nil { + t.Fatal(err) + } + + if len(bus.operations) != 5 { + t.Fatalf("operation count = %d, want 5", len(bus.operations)) + } + assertCommand(t, bus.operations[0], commandDataStartOld) + if len(bus.operations[1].data) != frameBufferSize || bus.operations[1].data[0] != 0xff { + t.Fatal("old frame was not initially white") + } + assertCommand(t, bus.operations[2], commandDataStartNew) + if len(bus.operations[3].data) != frameBufferSize || bus.operations[3].data[0] != 0x7f { + t.Fatal("new frame did not contain the black pixel") + } + assertCommand(t, bus.operations[4], commandDisplayRefresh) + + bus.operations = nil + device.SetPixel(0, 0, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}) + if err := device.Display(); err != nil { + t.Fatal(err) + } + if bus.operations[1].data[0] != 0x7f { + t.Fatal("second refresh did not send the first frame as old data") + } +} + +func TestDisplayPropagatesSPIErrors(t *testing.T) { + wantErr := errors.New("SPI failure") + for operation := 1; operation <= 5; operation++ { + t.Run(operationName(operation), func(t *testing.T) { + device, bus, _, _ := newTestDevice(t) + bus.failAt = operation + bus.failErr = wantErr + if err := device.Display(); !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want %v", err, wantErr) + } + if !bus.cs.high { + t.Error("chip select was not released after SPI error") + } + }) + } +} + +func TestBusyTimeout(t *testing.T) { + device, _, _, busy := newTestDevice(t) + busy.high = false + device.busyTimeout = time.Millisecond + if err := device.waitUntilIdle(); !errors.Is(err, ErrBusyTimeout) { + t.Fatalf("error = %v, want %v", err, ErrBusyTimeout) + } +} + +func TestDeepSleep(t *testing.T) { + device, bus, _, _ := newTestDevice(t) + if err := device.DeepSleep(); err != nil { + t.Fatal(err) + } + wantCommands := []byte{commandVCOMDataInterval, commandPowerOff, commandDeepSleep} + if got := commandBytes(bus.operations); !bytes.Equal(got, wantCommands) { + t.Fatalf("commands = %#v, want %#v", got, wantCommands) + } + wantData := [][]byte{{deepSleepVCOMDataInterval}, {deepSleepCheckCode}} + if got := dataTransfers(bus.operations); !equalByteSlices(got, wantData) { + t.Fatalf("data transfers = %#v, want %#v", got, wantData) + } +} + +func assertCommand(t *testing.T, operation spiOperation, command byte) { + t.Helper() + if !operation.command || len(operation.data) != 1 || operation.data[0] != command { + t.Fatalf("operation = %#v, want command %#x", operation, command) + } +} + +func commandBytes(operations []spiOperation) []byte { + var commands []byte + for _, operation := range operations { + if operation.command { + commands = append(commands, operation.data[0]) + } + } + return commands +} + +func dataTransfers(operations []spiOperation) [][]byte { + var transfers [][]byte + for _, operation := range operations { + if !operation.command { + transfers = append(transfers, operation.data) + } + } + return transfers +} + +func equalBools(left, right []bool) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func equalByteSlices(left, right [][]byte) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if !bytes.Equal(left[index], right[index]) { + return false + } + } + return true +} + +func operationName(operation int) string { + return "operation-" + string(rune('0'+operation)) +} diff --git a/gdew0154m09/registers.go b/gdew0154m09/registers.go new file mode 100644 index 000000000..19bf835cc --- /dev/null +++ b/gdew0154m09/registers.go @@ -0,0 +1,23 @@ +package gdew0154m09 + +const ( + commandPanelSetting = 0x00 + commandPowerOff = 0x02 + commandPowerOn = 0x04 + commandDeepSleep = 0x07 + commandDisplayRefresh = 0x12 + commandDataStartOld = 0x10 + commandDataStartNew = 0x13 + commandVCOMDataInterval = 0x50 + commandResolutionSetting = 0x61 + commandTCONSetting = 0x60 + commandPowerSaving = 0xe3 + commandPowerSequence = 0xf3 + commandBoosterSoftStart = 0xb6 + commandUnknownAA = 0xaa + commandUnknownE9 = 0xe9 + commandFITIInternalCode = 0x4d + deepSleepCheckCode = 0xa5 + defaultVCOMDataInterval = 0xd7 + deepSleepVCOMDataInterval = 0xf7 +) diff --git a/smoketest.sh b/smoketest.sh index 2990485cf..65cd51c97 100755 --- a/smoketest.sh +++ b/smoketest.sh @@ -29,6 +29,7 @@ tinygo build -size short -o ./build/test.hex -target=microbit ./examples/easyste tinygo build -size short -o ./build/test.hex -target=itsybitsy-m0 ./examples/flash/console/spi tinygo build -size short -o ./build/test.hex -target=pyportal ./examples/flash/console/qspi tinygo build -size short -o ./build/test.hex -target=microbit ./examples/gc9a01/main.go +tinygo build -size short -o ./build/test.bin -target=esp32-coreboard-v2 ./examples/gdew0154m09/main.go tinygo build -size short -o ./build/test.hex -target=feather-m0 ./examples/gps/i2c/main.go tinygo build -size short -o ./build/test.hex -target=feather-m0 ./examples/gps/uart/main.go tinygo build -size short -o ./build/test.hex -target=itsybitsy-m0 ./examples/hcsr04/main.go