From f57b5ecee91cee28663b5d77c62bb07355fd2457 Mon Sep 17 00:00:00 2001 From: Anastasios Papalyras Date: Tue, 28 Jan 2025 13:17:12 +0200 Subject: [PATCH] Add sharpmem (#724) sharpmem: add implementation of sharpmem display driver * Implement Configure, Clear and ClearBuffer, add some tests, add documentation/comments * Reverse white * Inverted bits, fix and improve ClearBuffer, cleanup * Refine doc comment * Driver refactor, optimizations toggle, additional tests & support for all SKUs * Fix address overflow padding, add wire-level tests for assumed address encoding * Minor rename * Cleanup and doc fixes * Add device configs * Bounds check * Add example and smoketest entry * Use uf2 output file format * Refine example --- examples/sharpmem/main.go | 90 +++++++++ sharpmem/sharpmem.go | 374 ++++++++++++++++++++++++++++++++++++++ sharpmem/sharpmem_test.go | 225 +++++++++++++++++++++++ sharpmem/util.go | 30 +++ smoketest.sh | 1 + 5 files changed, 720 insertions(+) create mode 100644 examples/sharpmem/main.go create mode 100644 sharpmem/sharpmem.go create mode 100644 sharpmem/sharpmem_test.go create mode 100644 sharpmem/util.go diff --git a/examples/sharpmem/main.go b/examples/sharpmem/main.go new file mode 100644 index 000000000..2a3f2d728 --- /dev/null +++ b/examples/sharpmem/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "image/color" + "machine" + "math/rand/v2" + "time" + + "tinygo.org/x/drivers/sharpmem" +) + +var ( + // example wiring using a nice!view and nice!nano: + // (view) (nano) + // MOSI --> P0.24 + // SCK ---> P0.22 + // GND ---> GND + // VCC ---> 3.3V + // CS ----> P0.06 + + spi = machine.SPI0 + + sckPin = machine.SPI0_SCK_PIN // SCK + sdoPin = machine.SPI0_SDO_PIN // MOSI + sdiPin = machine.SPI0_SDI_PIN // (any pin) + + csPin = machine.P0_06 // CS +) + +func main() { + time.Sleep(time.Second) + + err := spi.Configure(machine.SPIConfig{ + Frequency: 2000000, + SCK: sckPin, + SDO: sdoPin, + SDI: sdiPin, + Mode: 0, + LSBFirst: true, + }) + if err != nil { + println("spi.Configure() failed, error:", err.Error()) + return + } + + csPin.Configure(machine.PinConfig{Mode: machine.PinOutput}) + + display := sharpmem.New(spi, csPin) + + cfg := sharpmem.ConfigLS011B7DH03 + display.Configure(cfg) + + // clear the display before first use + err = display.Clear() + if err != nil { + println("display.Clear() failed, error:", err.Error()) + return + } + + // random boxes pop into and out of existence + for { + x0 := int16(rand.IntN(int(cfg.Width - 7))) + y0 := int16(rand.IntN(int(cfg.Height - 7))) + + for x2 := int16(0); x2 < 16; x2++ { + x2 := x2 + c := color.RGBA{R: 255, G: 255, B: 255, A: 255} + + if x2 >= 8 { + // effectively erases the box after it showed up + x2 = x2 - 8 + c = color.RGBA{R: 0, G: 0, B: 0, A: 255} + } + + for x := int16(0); x < x2; x++ { + for y := int16(0); y < 8; y++ { + display.SetPixel(x0+x, y0+y, c) + } + } + + err = display.Display() + if err != nil { + println("display.Display() failed, error:", err.Error()) + continue + } + + time.Sleep(33 * time.Millisecond) + } + } +} diff --git a/sharpmem/sharpmem.go b/sharpmem/sharpmem.go new file mode 100644 index 000000000..45f965bcb --- /dev/null +++ b/sharpmem/sharpmem.go @@ -0,0 +1,374 @@ +package sharpmem + +import ( + "errors" + "image/color" + + "tinygo.org/x/drivers" +) + +const ( + bitWriteCmd uint8 = 0b00000001 + bitVcom uint8 = 0b00000010 + bitClear uint8 = 0b00000100 +) + +var ( + ConfigLS010B7DH04 = Config{Width: 128, Height: 128} + ConfigLS011B7DH03 = Config{Width: 160, Height: 68} + ConfigLS012B7DD01 = Config{Width: 184, Height: 38} + ConfigLS013B7DH03 = ConfigLS010B7DH04 + ConfigLS013B7DH05 = Config{Width: 144, Height: 168} + ConfigLS018B7DH02 = Config{Width: 230, Height: 303} + ConfigLS027B7DH01 = Config{Width: 400, Height: 240} + ConfigLS027B7DH01A = ConfigLS027B7DH01 + ConfigLS032B7DD02 = Config{Width: 336, Height: 536} + ConfigLS044Q7DH01 = Config{Width: 320, Height: 240} +) + +type Pin interface { + High() + Low() +} + +// Device represents a Sharp Memory Display device. This driver implementation +// concerns the 1-bit color versions only (black and white memory displays). +// +// Supported SKUs include: +// LS010B7DH04, LS011B7DH03, LS012B7DD01, LS013B7DH03, LS013B7DH05, +// LS018B7DH02, LS027B7DH01, LS027B7DH01A, LS032B7DD02, LS044Q7DH01 +// +// Note: Only SKU LS011B7DH03 (160x68) has been tested as of writing. +// +// The driver includes optimizations (frame and per-line invalidation) that +// only transmit the changed lines to the display. These optimizations are on +// by default, and they can be disabled with the respective config option. +type Device struct { + bus drivers.SPI + csPin Pin + buffer []byte + txBuf []byte + lineDiff []byte + width int16 + height int16 + bufferSize int16 + bytesPerLine int16 + vcom uint8 + diffing bool +} + +type Config struct { + Width int16 + Height int16 + + // DisableOptimizations disables frame and line invalidation optimizations. + // Useful if constant frame times are desired. + DisableOptimizations bool +} + +// New creates a new device connection. +// The SPI bus must have already been configured. +func New(bus drivers.SPI, csPin Pin) Device { + d := Device{ + bus: bus, + csPin: csPin, + } + return d +} + +// Configure initializes the display with specified configuration. It can be +// called multiple times on the same display, resetting its internal state. +func (d *Device) Configure(cfg Config) { + if cfg.Width == 0 { + cfg.Width = 160 + } + if cfg.Height == 0 { + cfg.Height = 68 + } + + d.width = cfg.Width + d.height = cfg.Height + d.diffing = !cfg.DisableOptimizations + + d.initialize() +} + +// initialize properly initializes the display and the in-memory image buffers. +func (d *Device) initialize() { + d.csPin.Low() + + // initialize VCOM as high + d.vcom = bitVcom + + // bytesPerLine has to be 16-bit aligned, as some resolutions require + // padding to the nearest 2nd byte. + d.bytesPerLine = ceilDiv(d.width, 16) * 2 + + // preallocate a contiguous byte buffer for all lines, including + // protocol-required padding for each line apriori (easier to transfer). + d.bufferSize = d.bytesPerLine * d.height + d.buffer = make([]byte, d.bufferSize) + // A bit being 1 is white (reflective), 0 is black (less reflective). + for i := range d.buffer { + d.buffer[i] = 0xff + } + + // auxiliary buffer for SPI transfers to avoid dynamic allocations + d.txBuf = make([]byte, 2) + + if d.diffing { + // buffer to store the changed lines. First bit is whether any line has + // changed at all (i.e. the frame is invalid), followed by N bits, + // one for each line. + d.lineDiff = make([]byte, bitfieldBufLen(1+d.height)) + } +} + +// SetPixel enables or disables a pixel in the buffer. +// color.RGBA{0, 0, 0, 255} is considered transparent (reflective, white), +// anything else will enable a pixel on the screen (make it appear less +// reflective, black). +func (d *Device) SetPixel(x, y int16, c color.RGBA) { + if d.width == 0 { + return + } + + // bounds check + if x < 0 || x >= d.width || y < 0 || y >= d.height { + return + } + + offset := y * d.bytesPerLine + + div := offset + x/8 + mod := uint8(x % 8) + + prev := hasBit(d.buffer[div], mod) + curr := c.R == 0 && c.G == 0 && c.B == 0 && c.A == 255 + + if prev == curr { + return + } + + if curr { + d.buffer[div] = setBit(d.buffer[div], mod) + } else { + d.buffer[div] = unsetBit(d.buffer[div], mod) + } + + if d.diffing { + d.invalidateLine(y) + } +} + +// Size returns the current size of the display. +func (d *Device) Size() (x, y int16) { + return d.width, d.height +} + +// Display renders the buffer to the screen. It only transmits changed lines if +// optimizations are enabled. It should be called at >=1hz, even if the +// buffer hasn't been modified. +func (d *Device) Display() error { + if d.width == 0 { + return errors.New("display not configured") + } + + if d.diffing { + if !hasBit(d.lineDiff[0], 0) { + // no pixels have been modified, simply toggle VCOM + return d.holdDisplay() + } + + defer func() { + for i := 0; i < len(d.lineDiff); i++ { + d.lineDiff[i] = 0x00 + } + }() + } + + cmd := bitWriteCmd | d.vcom + + d.toggleVcom() + + // Padding to use for high bits of line numbers that overflow 8 bits. + var hiPad = uint8(0) + if d.height >= 512 { + hiPad = 3 + 3 // 3 mode bits + 3 low bits + } else if d.height >= 256 { + hiPad = 3 + 4 // 3 mode bits + 4 low bits + } + + // start transfer + d.csPin.High() + + for i := int16(0); i < d.height; i++ { + if d.diffing { + // Skip rendering lines that haven't changed. + linediv := (i + 1) / 8 + linemod := uint8((i + 1) % 8) + if !hasBit(d.lineDiff[linediv], linemod) { + continue + } + } + + // The first 5 bits are either dummy or part of the current line + // (1-indexed) if it overflows 8-bits. + // The last 3 bits are the command for the first line and dummy bits + // for subsequent lines (set as command for simplicity) + hi := uint8((i + 1) >> 8) + hi = hi << hiPad + d.txBuf[0] = cmd | hi + + // The second byte is the low bits of the current line (1-indexed). + // for <8 bits cases, the high bits are dummy, so we leave them as 0. + d.txBuf[1] = uint8(i + 1) + + // send the first two bytes + err := d.bus.Tx(d.txBuf, nil) + if err != nil { + return err + } + + // send the line data + err = d.bus.Tx(d.buffer[i*d.bytesPerLine:(i+1)*d.bytesPerLine], nil) + if err != nil { + return err + } + } + + // Trailer 16 bits (low) + d.txBuf[0] = 0x00 + d.txBuf[1] = 0x00 + err := d.bus.Tx(d.txBuf, nil) + if err != nil { + return err + } + + // end transfer + d.csPin.Low() + + return nil +} + +// holdDisplay simply toggles VCOM without updating any lines. +func (d *Device) holdDisplay() error { + d.txBuf[0] = d.vcom + d.txBuf[1] = 0x00 + + d.toggleVcom() + + // begin transaction + d.csPin.High() + + err := d.bus.Tx(d.txBuf, nil) + if err != nil { + return err + } + + // end transaction + d.csPin.Low() + + return nil +} + +// Clear clears both the in-memory buffer and the display. +func (d *Device) Clear() error { + if d.width == 0 { + return errors.New("display not configured") + } + + d.ClearBuffer() + return d.ClearDisplay() +} + +// ClearBuffer clears the in-memory buffer. The display is not updated. +func (d *Device) ClearBuffer() { + if d.width == 0 { + return + } + + if d.diffing { + // detect what rows need to be reset on the next render + d.invalidateModifiedLines() + } + + // reset the in-memory buffer + for i := 0; i < len(d.buffer); i++ { + d.buffer[i] = 0xff + } +} + +// invalidateModifiedLines marks any line that has at least a single black pixel +// as invalidated. Padding bits, if any, are always 1. +func (d *Device) invalidateModifiedLines() { + for y := int16(0); y < d.height; y++ { + offset := y * d.bytesPerLine + + updateLine := false + for x := int16(0); x < d.width; x++ { + div := offset + x/8 + mod := uint8(x % 8) + + if !hasBit(d.buffer[div], mod) { + updateLine = true + break + } + } + + if updateLine { + d.invalidateLine(y) + } + } +} + +// ClearDisplay clears the display. The in-memory buffer is not updated. A +// subsequent call to Display() will re-render the content as it was before +// clearing. +func (d *Device) ClearDisplay() error { + if d.width == 0 { + return errors.New("display not configured") + } + + d.txBuf[0] = d.vcom | bitClear + d.txBuf[1] = 0x00 + + d.toggleVcom() + + // begin transaction + d.csPin.High() + + err := d.bus.Tx(d.txBuf, nil) + if err != nil { + return err + } + + // end transaction + d.csPin.Low() + + return nil +} + +// invalidateLine marks a line and the frame itself as invalidated. +func (d *Device) invalidateLine(line int16) { + // mark the frame as invalidated + d.lineDiff[0] = setBit(d.lineDiff[0], 0) + + // mark the line as invalidated + linediv := (line + 1) / 8 + linemod := uint8((line + 1) % 8) + d.lineDiff[linediv] = setBit(d.lineDiff[linediv], linemod) +} + +// toggleVcom toggles the VCOM, as is instructed by the datasheet. +// Toggling VCOM can help maintain the display's longevity. It should ideally +// be called at least once per second, preferably at 4-100 Hz. +// Toggling VCOM causes a tiny bit of flicker, but without it the pixels can +// be permanently damaged by the DC bias accumulating over time. +func (d *Device) toggleVcom() { + if d.vcom != 0 { + d.vcom = 0x00 + } else { + d.vcom = bitVcom + } +} diff --git a/sharpmem/sharpmem_test.go b/sharpmem/sharpmem_test.go new file mode 100644 index 000000000..43a964521 --- /dev/null +++ b/sharpmem/sharpmem_test.go @@ -0,0 +1,225 @@ +package sharpmem + +import ( + "image/color" + "math/rand/v2" + "testing" + + qt "github.com/frankban/quicktest" +) + +func Test_setBit(t *testing.T) { + c := qt.New(t) + + for i := uint8(0); i < 8; i++ { + v := uint8(1) << i + + c.Assert(setBit(0x00, i), qt.Equals, v) + c.Assert(setBit(0x00, (i+1)%8), qt.Not(qt.Equals), v) + } +} + +func Test_unsetBit(t *testing.T) { + c := qt.New(t) + + for i := uint8(0); i < 8; i++ { + v := uint8(1) << i + + c.Assert(unsetBit(v, i), qt.Equals, uint8(0x00)) + c.Assert(unsetBit(v, (i+1)%8), qt.Not(qt.Equals), uint8(0x00)) + } +} + +func Test_hasBit(t *testing.T) { + c := qt.New(t) + + for i := uint8(0); i < 8; i++ { + v := uint8(1) << i + + c.Assert(hasBit(v, i), qt.Equals, true) + c.Assert(hasBit(v, (i+1)%8), qt.Equals, false) + } +} + +func Test_bitfieldBufLen(t *testing.T) { + c := qt.New(t) + + for i := int16(1); i < 536; i++ { + requiredBufferSize := i / 8 + wouldOverflow := i % 8 + + if wouldOverflow > 0 { + requiredBufferSize += 1 + } + + c.Assert(bitfieldBufLen(i), qt.Equals, requiredBufferSize) + } +} + +type mockBus struct { + b []byte +} + +func (m *mockBus) Tx(w, _ []byte) error { + m.b = append(m.b, w...) + return nil +} + +func (m *mockBus) Transfer(b byte) (byte, error) { + m.b = append(m.b, b) + return 0x00, nil +} + +type mockPin struct{} + +func (m mockPin) High() { +} + +func (m mockPin) Low() { +} + +func Test_Device(t *testing.T) { + c := qt.New(t) + + cfgs := []Config{ + ConfigLS010B7DH04, + ConfigLS011B7DH03, + ConfigLS012B7DD01, + ConfigLS013B7DH03, + ConfigLS013B7DH05, + ConfigLS018B7DH02, + ConfigLS027B7DH01, + ConfigLS027B7DH01A, + ConfigLS032B7DD02, + ConfigLS044Q7DH01, + } + + cfgLen := len(cfgs) + for i := 0; i < cfgLen; i++ { + cfgs = append(cfgs, Config{ + Width: cfgs[i].Width, + Height: cfgs[i].Height, + DisableOptimizations: true, + }) + } + + spi := &mockBus{} + pin := mockPin{} + display := New(spi, pin) + + for _, cfg := range cfgs { + display.Configure(cfg) + + x, y := display.Size() + c.Assert(x, qt.Equals, cfg.Width) + c.Assert(y, qt.Equals, cfg.Height) + + for i := 0; i < 10; i++ { + x := int16(rand.IntN(int(cfg.Width))) + y := int16(rand.IntN(int(cfg.Height))) + display.SetPixel(x, y, color.RGBA{R: 255, G: 255, B: 255, A: 255}) + } + + for i := 0; i < 10; i++ { + x := int16(rand.IntN(int(cfg.Width))) + y := int16(rand.IntN(int(cfg.Height))) + display.SetPixel(x, y, color.RGBA{R: 0, G: 0, B: 0, A: 255}) + } + + err := display.Display() + c.Assert(err, qt.Equals, nil) + + err = display.ClearDisplay() + c.Assert(err, qt.Equals, nil) + + display.ClearBuffer() + } +} + +func Test_HiPad(t *testing.T) { + c := qt.New(t) + + spi := &mockBus{} + pin := mockPin{} + display := New(spi, pin) + + t.Run("LS011B7DH03, 8-bit address", func(t *testing.T) { + t.Cleanup(func() { + spi.b = nil + }) + + display.Configure(ConfigLS011B7DH03) + + display.SetPixel(0, display.height-1, color.RGBA{R: 255, G: 255, B: 255, A: 255}) + err := display.Display() + c.Assert(err, qt.Equals, nil) + + // 160 perfectly divisible by 16, so 20 bytes of pixel data + c.Assert(spi.b, qt.HasLen, 2+20+2) + + // line is 1-indexed on the wire (67+1) + // 68 in binary + // 0b01000100 + + // DDDDDMMM + c.Assert(spi.b[0], qt.Equals, uint8(0b00000011)) // mode 1, vcom is high on first run + + c.Assert(spi.b[1], qt.Equals, uint8(0b01000100)) // the actual address + }) + + t.Run("LS018B7DH02, 9-bit address", func(t *testing.T) { + t.Cleanup(func() { + spi.b = nil + }) + + display.Configure(ConfigLS018B7DH02) + + display.SetPixel(0, display.height-1, color.RGBA{R: 255, G: 255, B: 255, A: 255}) + err := display.Display() + c.Assert(err, qt.Equals, nil) + + // 2 first bytes command+address + // 230 bits are not divisible by 16, 240 is (15*16), so 30 bytes for line data + // 2 trailing bytes + c.Assert(spi.b, qt.HasLen, 2+30+2) + + // line is 1-indexed on the wire (302+1) + // 303 in binary (split in 2 bytes) + // R + // 0b00000001 0b00101111 + // ^ + + // RDDDDMMM + c.Assert(spi.b[0], qt.Equals, uint8(0b10000011)) // mode 1, vcom is high on first run + // ^ + + c.Assert(spi.b[1], qt.Equals, uint8(0b00101111)) // rest of the address (low 8 bits) + }) + + t.Run("LS032B7DD02, 10-bit address", func(t *testing.T) { + t.Cleanup(func() { + spi.b = nil + }) + + display.Configure(ConfigLS032B7DD02) + + display.SetPixel(0, display.height-1, color.RGBA{R: 255, G: 255, B: 255, A: 255}) + err := display.Display() + c.Assert(err, qt.Equals, nil) + + c.Assert(spi.b, qt.HasLen, 2+336/8+2) // 2 command+address, width / 2, 2 trailing bytes + + // line is 1-indexed on the wire (535+1) + // 536 in binary (split in 2 bytes) + // RR + // 0b00000010 0b00011000 + // ^^ + + // RRDDDMMM + c.Assert(spi.b[0], qt.Equals, uint8(0b10000011)) // mode 1, vcom is high on first run + // ^^ + + c.Assert(spi.b[1], qt.Equals, uint8(0b00011000)) // rest of the address (low 8 bits) + }) + +} diff --git a/sharpmem/util.go b/sharpmem/util.go new file mode 100644 index 000000000..8c637da6a --- /dev/null +++ b/sharpmem/util.go @@ -0,0 +1,30 @@ +package sharpmem + +// setBit sets the bit at pos in n to 1 and returns the updated number. +func setBit(n uint8, pos uint8) uint8 { + n |= 1 << pos + return n +} + +// unsetBit sets the bit at pos in n to 0 and returns the updated number. +func unsetBit(n uint8, pos uint8) uint8 { + n &^= 1 << pos + return n +} + +// hasBit returns whether the bit at pos in n is 1. +func hasBit(n uint8, pos uint8) bool { + n = n & (1 << pos) + return n > 0 +} + +// bitfieldBufLen returns the required buffer size for keeping track of +// changed lines. +func bitfieldBufLen(bits int16) int16 { + return 1 + (bits-1)/8 +} + +// ceilDiv divides a with b, but it uses the ceiling if modulo is not 0. +func ceilDiv(a, b int16) int16 { + return 1 + (a-1)/b +} diff --git a/smoketest.sh b/smoketest.sh index 92b031d19..ea74e86cd 100755 --- a/smoketest.sh +++ b/smoketest.sh @@ -137,6 +137,7 @@ tinygo build -size short -o ./build/test.hex -target=macropad-rp2040 ./examples/ tinygo build -size short -o ./build/test.hex -target=macropad-rp2040 ./examples/encoders/quadrature-interrupt tinygo build -size short -o ./build/test.uf2 -target=pico ./examples/mcp9808/main.go tinygo build -size short -o ./build/test.hex -target=pico ./examples/tmc5160/main.go +tinygo build -size short -o ./build/test.uf2 -target=nicenano ./examples/sharpmem/main.go # network examples (espat) tinygo build -size short -o ./build/test.hex -target=challenger-rp2040 ./examples/net/ntpclient/ # network examples (wifinina)