From 10ca0458dbee7e6923668606aa13da5e6e30b958 Mon Sep 17 00:00:00 2001 From: Carl Pottle Date: Tue, 1 Apr 2025 16:27:20 -0700 Subject: [PATCH 1/2] New driver for ILI9488 over SPI bus ILI9488 does not support 16 bits per pixel over SPI. It does support it over 8 or 16 bit parallel bus. As a result, the existing ili9486 driver will work with some ili9488 boards. Some vendors sell boards with ILI9488 and serial to parallel converters. I beleive those boards will work with either the ili9486 or this new driver. I tested this code with the following demos: - gui/demos/scale.py - gui/demos/aclock.py - gui/demos/aclock_large.py - gui/demos/scale.py I tested landscape and portrait in color and greyscale. --- drivers/ili94xx/ili9488.py | 323 ++++++++++++++++++++++++++++++++ gui/core/writer.py | 21 ++- setup_examples/ili9488_esp32.py | 40 ++++ setup_examples/ili9488_pico.py | 39 ++++ 4 files changed, 419 insertions(+), 4 deletions(-) create mode 100644 drivers/ili94xx/ili9488.py create mode 100644 setup_examples/ili9488_esp32.py create mode 100644 setup_examples/ili9488_pico.py diff --git a/drivers/ili94xx/ili9488.py b/drivers/ili94xx/ili9488.py new file mode 100644 index 0000000..0b3dc3d --- /dev/null +++ b/drivers/ili94xx/ili9488.py @@ -0,0 +1,323 @@ +# ILI9488 nano-gui driver for ili9488 displays + +### Based on ili9486.py by Peter Hinch. +### Retaining his copyright +# +# Copyright (c) Peter Hinch 2022-2025 +# Released under the MIT license see LICENSE + +# +# Note: If your hardware uses the ILI9488 parallel interface +# you will likely be better off using the ili9486 driver. +# It will send 2 bytes per pixel which will run faster. +# +# You must use this driver only when using the ILI9488 SPI +# interface. It will send 3 bytes per pixel. +# + +from time import sleep_ms +import gc +import framebuf +import asyncio +from drivers.boolpalette import BoolPalette + + +# Portrait mode greyscale +@micropython.viper +def _lcopy_gs(dest: ptr8, source: ptr8, length: int) : + # rgb666 - 18bit/pixel + n: int = 0 + x: int = 0 + while x < length: + c : uint = source[x] + # Store the index in the 4 high order bits + p : uint = c & 0xF0 # current pixel + q : uint = c << 4 # next pixel + + dest[n] = p + n += 1 + dest[n] = p + n += 1 + dest[n] = p + n += 1 + + dest[n] = q + n += 1 + dest[n] = q + n += 1 + dest[n] = q + n += 1 + + x += 1 + + +# Portrait mode color +@micropython.viper +def _lcopy(dest: ptr8, source: ptr8, lut: ptr8, length: int) : + # rgb666 - 18bit/pixel + n: int = 0 + x: int = 0 + while x < length: + c : uint = source[x] + p : uint = 3 * (c >> 4) # current pixel + q : uint = 3 * (c & 0x0F) # next pixel + + dest[n] = lut[p] + n += 1 + dest[n] = lut[p+1] + n += 1 + dest[n] = lut[p+2] + n += 1 + + dest[n] = lut[q] + n += 1 + dest[n] = lut[q+1] + n += 1 + dest[n] = lut[q+2] + n += 1 + + x += 1 + +# FB is in landscape mode greyscale +@micropython.viper +def _lscopy_gs(dest: ptr8, source: ptr8, ch: int) : + col = ch & 0x1FF # Unpack (viper old 4 parameter limit) + height = (ch >> 9) & 0x1FF + wbytes = ch >> 19 # Width in bytes is width // 2 + # rgb666 - 18bit/pixel + n = 0 + clsb = col & 1 + idx = col >> 1 # 2 pixels per byte + while height: + if clsb : + c = source[idx] << 4 + else : + c = source[idx] & 0xf0 + dest[n] = c + n += 1 + dest[n] = c + n += 1 + dest[n] = c + n += 1 + idx += wbytes + height -= 1 + +# FB is in landscape mode color, hence issue a column at a time to portrait mode hardware. +@micropython.viper +def _lscopy(dest: ptr8, source: ptr8, lut: ptr8, ch: int) : + col = ch & 0x1FF # Unpack (viper old 4 parameter limit) + height = (ch >> 9) & 0x1FF + wbytes = ch >> 19 # Width in bytes is width // 2 + # rgb666 - 18bit/pixel + n = 0 + clsb = col & 1 + idx = col >> 1 # 2 pixels per byte + while height: + if clsb: + c = 3 * (source[idx] & 0x0F) + else: + c = 3 * (source[idx] >> 4) + dest[n] = lut[c] + n += 1 + dest[n] = lut[c+1] + n += 1 + dest[n] = lut[c+2] + n += 1 + idx += wbytes + height -= 1 + + +class ILI9488(framebuf.FrameBuffer): + + # A lookup table with 3 bytes per entry formatted for direct send to ILI9488 + lut = bytearray(48) + + COLOR_INVERT = 0 + + # Convert r, g, b in range 0-255 to a 16 bit colour value + # LS byte goes into LUT offset 0, MS byte into offset 1 + # Same mapping in linebuf so LS byte is shifted out 1st + # ILI9488 expects RGB order. 8 bit register writes require padding + @classmethod + def rgb(cls, r, g, b): + r_device = cls.COLOR_INVERT ^ (r & 0xfc) + g_device = cls.COLOR_INVERT ^ (g & 0xfc) + b_device = cls.COLOR_INVERT ^ (b & 0xfc) + return (r_device << 16) | (g_device << 8) | b_device + + # Transpose width & height for landscape mode + def __init__( + self, spi, cs, dc, rst, height=320, width=480, usd=False, mirror=False, init_spi=False + ): + self._spi = spi + self._cs = cs + self._dc = dc + self._rst = rst + self.lock_mode = False # If set, user lock is passed to .do_refresh + self.height = height # Logical dimensions for GUIs + self.width = width + self._long = max(height, width) # Physical dimensions of screen and aspect ratio + self._short = min(height, width) + self._spi_init = init_spi + self._gscale = False # Interpret buffer as index into color LUT + self.mode = framebuf.GS4_HMSB + self.palette = BoolPalette(self.mode) + gc.collect() + buf = bytearray(height * width // 2) + self.mvb = memoryview(buf) + super().__init__(buf, width, height, self.mode) # Logical aspect ratio + self._linebuf = bytearray(self._short * 3) + + # Hardware reset + self._rst(0) + sleep_ms(50) + self._rst(1) + sleep_ms(50) + if self._spi_init: # A callback was passed + self._spi_init(spi) # Bus may be shared + self._lock = asyncio.Lock() + # Send initialization commands + + self._wcmd(b"\x01") # SWRESET Software reset + sleep_ms(100) + self._wcmd(b"\x11") # sleep out + sleep_ms(20) + self._wcd(b"\x3a", b"\x66") # interface pixel format 18 bits per pixel + # Normally use defaults. This allows it to work on the Waveshare board with a + # shift register. If size is not 320x480 assume no shift register. + # Default column address start == 0, end == 0x13F (319) + if self._short != 320: # Not the Waveshare board: no shift register + self._wcd(b"\x2a", int.to_bytes(self._short - 1, 4, "big")) + # Default page address start == 0 end == 0x1DF (479) + if self._long != 480: + self._wcd(b"\x2b", int.to_bytes(self._long - 1, 4, "big")) # SET_PAGE ht + # self._wcd(b"\x36", b"\x48" if usd else b"\x88") # MADCTL: RGB portrait mode + madctl = 0x48 if usd else 0x88 + if mirror: + madctl ^= 0x80 + self._wcd(b"\x36", madctl.to_bytes(1, "big")) # MADCTL: RGB portrait mode + self._wcmd(b"\x11") # sleep out + self._wcmd(b"\x29") # display on + + # Write a command. + def _wcmd(self, command): + self._dc(0) + self._cs(0) + self._spi.write(command) + self._cs(1) + + # Write a command followed by a data arg. + def _wcd(self, command, data): + self._dc(0) + self._cs(0) + self._spi.write(command) + self._cs(1) + self._dc(1) + self._cs(0) + self._spi.write(data) + self._cs(1) + + def greyscale(self, gs=None): + if gs is not None: + self._gscale = gs + return self._gscale + + # @micropython.native # Made almost no difference to timing + def show(self): # Physical display is in portrait mode + clut = ILI9488.lut + lb = self._linebuf + buf = self.mvb + cm = self._gscale # color False, greyscale True + if self._spi_init: # A callback was passed + self._spi_init(self._spi) # Bus may be shared + self._wcmd(b"\x2c") # WRITE_RAM + self._dc(1) + self._cs(0) + if self.width < self.height: # Portrait 300 ms on ESP32 240MHz, 30MHz SPI clock + wd = self.width // 2 + ht = self.height + if cm : + for start in range(0, wd * ht, wd): # For each line + _lcopy_gs(lb, buf[start:], wd) # Copy greyscale + self._spi.write(lb) + else : + for start in range(0, wd * ht, wd): # For each line + _lcopy(lb, buf[start:], clut, wd) # Copy and map colors + self._spi.write(lb) + else: # Landscape 330 ms on ESP32 240MHz, 30MHz SPI clock + width = self.width + wd = width - 1 + cargs = (self.height << 9) + (width << 18) # Viper 4-arg limit + if cm : + for col in range(width): # For each column of landscape display + _lscopy_gs(lb, buf, wd - col + cargs) # Copy greyscale + self._spi.write(lb) + else : + for col in range(width): # For each column of landscape display + _lscopy(lb, buf, clut, wd - col + cargs) # Copy and map colors + self._spi.write(lb) + + self._cs(1) + + def short_lock(self, v=None): + if v is not None: + self.lock_mode = v # If set, user lock is passed to .do_refresh + return self.lock_mode + + # nanogui apps typically call with no args. ugui and tgui pass split and + # may pass a Lock depending on lock_mode + async def do_refresh(self, split=4, elock=None): + if elock is None: + elock = asyncio.Lock() + async with self._lock: + lines, mod = divmod(self._long, split) # Lines per segment + if mod: + raise ValueError("Invalid do_refresh arg.") + clut = ILI9488.lut + lb = self._linebuf + buf = self.mvb + cm = self._gscale # color False, greyscale True + self._wcmd(b"\x2c") # WRITE_RAM + self._dc(1) + if self.width < self.height: # Portrait: write sets of rows + wd = self.width // 2 + line = 0 + for _ in range(split): # For each segment + async with elock: + if self._spi_init: # A callback was passed + self._spi_init(self._spi) # Bus may be shared + self._cs(0) + if cm: + for start in range(wd * line, wd * (line + lines), wd): # For each line + _lcopy_gs(lb, buf[start:], wd) # Copy and greyscale + self._spi.write(lb) + else : + for start in range(wd * line, wd * (line + lines), wd): # For each line + _lcopy(lb, buf[start:], clut, wd) # Copy and map colors + self._spi.write(lb) + + line += lines + self._cs(1) # Allow other tasks to use bus + await asyncio.sleep_ms(0) + else: # Landscape: write sets of cols. lines is no. of cols per segment. + cargs = (self.height << 9) + (self.width << 18) # Viper 4-arg limit + sc = self.width - 1 # Start and end columns + ec = sc - lines # End column + for _ in range(split): # For each segment + async with elock: + if self._spi_init: # A callback was passed + self._spi_init(self._spi) # Bus may be shared + self._cs(0) + if cm : + for col in range(sc, ec, -1): # For each column of landscape display + _lscopy_gs(lb, buf, col + cargs) # Copy and map colors + self._spi.write(lb) + else : + for col in range(sc, ec, -1): # For each column of landscape display + _lscopy(lb, buf, clut, col + cargs) # Copy and map colors + self._spi.write(lb) + + sc -= lines + ec -= lines + self._cs(1) # Allow other tasks to use bus + await asyncio.sleep_ms(0) diff --git a/gui/core/writer.py b/gui/core/writer.py index 48f5e14..90096f0 100644 --- a/gui/core/writer.py +++ b/gui/core/writer.py @@ -263,10 +263,23 @@ class CWriter(Writer): return c if not 0 <= idx <= 15: raise ValueError('Color nos must be 0..15') - x = idx << 1 - ssd.lut[x] = c & 0xff - ssd.lut[x + 1] = c >> 8 - return idx + + if len(ssd.lut) == 32 : + # ssd supports 2 byte color + x = idx << 1 + ssd.lut[x] = c & 0xff + ssd.lut[x + 1] = c >> 8 + return idx + elif len(ssd.lut) == 48 : + # ssd supports 3 byte color + x = idx * 3 + ssd.lut[x] = c >> 16 + ssd.lut[x + 1] = (c >> 8) & 0xff + ssd.lut[x + 2] = c & 0xff + return idx + else : + raise TypeError("lut of %d bytes not supported" % (len(ssd.lut))) + def __init__(self, device, font, fgcolor=None, bgcolor=None, verbose=True): if not hasattr(device, 'palette'): diff --git a/setup_examples/ili9488_esp32.py b/setup_examples/ili9488_esp32.py new file mode 100644 index 0000000..91a44f6 --- /dev/null +++ b/setup_examples/ili9488_esp32.py @@ -0,0 +1,40 @@ +# +# Setup for ThingPulse Grande Kit +# +# Has ILI9488 display running on an ESP32-WROVER-E +# +# Released under the MIT License (MIT). See LICENSE. + +from micropython import const + +# +# Pin assignments here are for the Grande Kit values from schematic: +# https://thingpulse.com/wp-content/uploads/2022/10/Schematic_Color-Kit-Grande_2023-01-14-pdf.jpg +# You will need to customize for your board. + +LCD_DC = const(2) +LCD_CS = const(15) +LCD_CLK = const(5) +LCD_MOSI = const(18) +LCD_MISO = const(19) +LCD_BackLight = const(32) +LCD_RST = const(4) + +from machine import Pin, SPI, freq +from drivers.ili94xx.ili9488 import ILI9488 as SSD + +# Screen configuration +# (Create and export an SSD instance) + +prst = Pin(LCD_RST, Pin.OUT, value=1) +pdc = Pin(LCD_DC, Pin.OUT, value=1) +pcs = Pin(LCD_CS, Pin.OUT, value=1) + +# turn on back light +backlight=Pin(LCD_BackLight, Pin.OUT, value=1) + +# Use SPI bus 1, 30 Mhz is maximum speed. +spi = SPI(1, 30_000_000, sck=Pin(LCD_CLK), mosi=Pin(LCD_MOSI, Pin.OUT), miso=Pin(LCD_MISO, Pin.OUT)) + +# Precaution before instantiating framebuf +ssd = SSD(spi, height=480, width=320, dc=pdc, cs=pcs, rst=prst, usd=False) diff --git a/setup_examples/ili9488_pico.py b/setup_examples/ili9488_pico.py new file mode 100644 index 0000000..8121585 --- /dev/null +++ b/setup_examples/ili9488_pico.py @@ -0,0 +1,39 @@ +# +# Setup for ILI9488 interfaced to Raspberry PI Pico 2 and Pico +# +# Released under the MIT License (MIT). See LICENSE. + +from micropython import const + +# Modify these Pin assignments to match your hardware. + +# Simple GPIO's +LCD_DC = 'GPIO21' # Pin 27 +LCD_RST = 'GPIO22' # Pin 29 +LCD_CS = 'GPIO27' # Pin 32 +LCD_BackLight = 'GPIO28' # Pin 34 + +# SPI pins + +LCD_CLK = 'GPIO18' # Pin 24 +LCD_MOSI = 'GPIO19' # Pin 25 +LCD_MISO = 'GPIO16' # Pin 21 + +from machine import Pin, SPI +from drivers.ili94xx.ili9488 import ILI9488 as SSD + +# Screen configuration +# (Create and export an SSD instance) + +prst = Pin(LCD_RST, Pin.OUT, value=1) +pdc = Pin(LCD_DC, Pin.OUT, value=1) +pcs = Pin(LCD_CS, Pin.OUT, value=1) + +# turn on back light +backlight=Pin(LCD_BackLight, Pin.OUT, value=1) + +# Use SPI bus 0, 24 Mhz is maximum speed on PICO +spi = SPI(0, 24_000_000, sck=Pin(LCD_CLK), mosi=Pin(LCD_MOSI, Pin.OUT), miso=Pin(LCD_MISO, Pin.OUT)) + +# Precaution before instantiating framebuf +ssd = SSD(spi, height=480, width=320, dc=pdc, cs=pcs, rst=prst, usd=False) From 82b263a4224fb9a54db660806743913e540ec8d6 Mon Sep 17 00:00:00 2001 From: Carl Pottle Date: Fri, 4 Apr 2025 11:28:03 -0700 Subject: [PATCH 2/2] Address code review feedback Three relatively small changes: 1) Undo changes to gui/core/writer.py 2) Modify setup_examples/ili9488_pico.py to specify pins by number rather than string. 3) As recommended, use a 2 byte per entry color lookup table rather than 3 bytes (needed to undo changes in writer.py). I ran the same tests for these changes as last time. --- drivers/ili94xx/ili9488.py | 63 +++++++++++++++++----------------- gui/core/writer.py | 21 +++--------- setup_examples/ili9488_pico.py | 14 ++++---- 3 files changed, 43 insertions(+), 55 deletions(-) diff --git a/drivers/ili94xx/ili9488.py b/drivers/ili94xx/ili9488.py index 0b3dc3d..193f95e 100644 --- a/drivers/ili94xx/ili9488.py +++ b/drivers/ili94xx/ili9488.py @@ -53,27 +53,29 @@ def _lcopy_gs(dest: ptr8, source: ptr8, length: int) : # Portrait mode color @micropython.viper -def _lcopy(dest: ptr8, source: ptr8, lut: ptr8, length: int) : - # rgb666 - 18bit/pixel +def _lcopy(dest: ptr8, source: ptr8, lut: ptr16, length: int) : + # Convert lut rgb 565 to rgb666 n: int = 0 x: int = 0 while x < length: c : uint = source[x] - p : uint = 3 * (c >> 4) # current pixel - q : uint = 3 * (c & 0x0F) # next pixel + p : uint = c >> 4 # current pixel + q = c & 0x0F # next pixel - dest[n] = lut[p] + v : uint16 = lut[p] + dest[n] = (v & 0xF800) >> 8 # R n += 1 - dest[n] = lut[p+1] + dest[n] = (v & 0x07E0) >> 3 # G n += 1 - dest[n] = lut[p+2] + dest[n] = (v & 0x001F) << 3 # B n += 1 - dest[n] = lut[q] + v = lut[q] + dest[n] = (v & 0xF800) >> 8 # R n += 1 - dest[n] = lut[q+1] + dest[n] = (v & 0x07E0) >> 3 # G n += 1 - dest[n] = lut[q+2] + dest[n] = (v & 0x001F) << 3 # B n += 1 x += 1 @@ -104,46 +106,45 @@ def _lscopy_gs(dest: ptr8, source: ptr8, ch: int) : # FB is in landscape mode color, hence issue a column at a time to portrait mode hardware. @micropython.viper -def _lscopy(dest: ptr8, source: ptr8, lut: ptr8, ch: int) : +def _lscopy(dest: ptr8, source: ptr8, lut: ptr16, ch: int) : + # Convert lut rgb 565 to rgb666 col = ch & 0x1FF # Unpack (viper old 4 parameter limit) height = (ch >> 9) & 0x1FF wbytes = ch >> 19 # Width in bytes is width // 2 - # rgb666 - 18bit/pixel n = 0 clsb = col & 1 idx = col >> 1 # 2 pixels per byte while height: if clsb: - c = 3 * (source[idx] & 0x0F) + c = source[idx] & 0x0F else: - c = 3 * (source[idx] >> 4) - dest[n] = lut[c] - n += 1 - dest[n] = lut[c+1] - n += 1 - dest[n] = lut[c+2] - n += 1 + c = source[idx] >> 4 + v : uint16 = lut[c] + dest[n] = (v & 0xF800) >> 8 # R + n += 1 + dest[n] = (v & 0x07E0) >> 3 # G + n += 1 + dest[n] = (v & 0x001F) << 3 # B + n += 1 + idx += wbytes height -= 1 class ILI9488(framebuf.FrameBuffer): - # A lookup table with 3 bytes per entry formatted for direct send to ILI9488 - lut = bytearray(48) + lut = bytearray(32) COLOR_INVERT = 0 # Convert r, g, b in range 0-255 to a 16 bit colour value - # LS byte goes into LUT offset 0, MS byte into offset 1 - # Same mapping in linebuf so LS byte is shifted out 1st - # ILI9488 expects RGB order. 8 bit register writes require padding + # 5-6-5 format + # byte order not swapped (compared to ili9486 driver). @classmethod def rgb(cls, r, g, b): - r_device = cls.COLOR_INVERT ^ (r & 0xfc) - g_device = cls.COLOR_INVERT ^ (g & 0xfc) - b_device = cls.COLOR_INVERT ^ (b & 0xfc) - return (r_device << 16) | (g_device << 8) | b_device + return cls.COLOR_INVERT ^ ( + (r & 0xF8) << 8 | (g & 0xFC) << 3 | (b >> 3) + ) # Transpose width & height for landscape mode def __init__( @@ -233,7 +234,7 @@ class ILI9488(framebuf.FrameBuffer): self._wcmd(b"\x2c") # WRITE_RAM self._dc(1) self._cs(0) - if self.width < self.height: # Portrait 300 ms on ESP32 240MHz, 30MHz SPI clock + if self.width < self.height: # Portrait 350 ms on ESP32 160 MHz, 26.6 MHz SPI clock wd = self.width // 2 ht = self.height if cm : @@ -244,7 +245,7 @@ class ILI9488(framebuf.FrameBuffer): for start in range(0, wd * ht, wd): # For each line _lcopy(lb, buf[start:], clut, wd) # Copy and map colors self._spi.write(lb) - else: # Landscape 330 ms on ESP32 240MHz, 30MHz SPI clock + else: # Landscape 370 ms on ESP32 160 MHz, 26.6 MHz SPI clock width = self.width wd = width - 1 cargs = (self.height << 9) + (width << 18) # Viper 4-arg limit diff --git a/gui/core/writer.py b/gui/core/writer.py index 90096f0..48f5e14 100644 --- a/gui/core/writer.py +++ b/gui/core/writer.py @@ -263,23 +263,10 @@ class CWriter(Writer): return c if not 0 <= idx <= 15: raise ValueError('Color nos must be 0..15') - - if len(ssd.lut) == 32 : - # ssd supports 2 byte color - x = idx << 1 - ssd.lut[x] = c & 0xff - ssd.lut[x + 1] = c >> 8 - return idx - elif len(ssd.lut) == 48 : - # ssd supports 3 byte color - x = idx * 3 - ssd.lut[x] = c >> 16 - ssd.lut[x + 1] = (c >> 8) & 0xff - ssd.lut[x + 2] = c & 0xff - return idx - else : - raise TypeError("lut of %d bytes not supported" % (len(ssd.lut))) - + x = idx << 1 + ssd.lut[x] = c & 0xff + ssd.lut[x + 1] = c >> 8 + return idx def __init__(self, device, font, fgcolor=None, bgcolor=None, verbose=True): if not hasattr(device, 'palette'): diff --git a/setup_examples/ili9488_pico.py b/setup_examples/ili9488_pico.py index 8121585..5e9d200 100644 --- a/setup_examples/ili9488_pico.py +++ b/setup_examples/ili9488_pico.py @@ -8,16 +8,16 @@ from micropython import const # Modify these Pin assignments to match your hardware. # Simple GPIO's -LCD_DC = 'GPIO21' # Pin 27 -LCD_RST = 'GPIO22' # Pin 29 -LCD_CS = 'GPIO27' # Pin 32 -LCD_BackLight = 'GPIO28' # Pin 34 +LCD_DC = const(21) # PICO Pin 27 +LCD_RST = const(22) # PICO Pin 29 +LCD_CS = const(27) # PICO Pin 32 +LCD_BackLight = const(28) # PICO Pin 34 # SPI pins -LCD_CLK = 'GPIO18' # Pin 24 -LCD_MOSI = 'GPIO19' # Pin 25 -LCD_MISO = 'GPIO16' # Pin 21 +LCD_CLK = const(18) # PICO Pin 24 +LCD_MOSI = const(19) # PICO Pin 25 +LCD_MISO = const(16) # PICO Pin 21 from machine import Pin, SPI from drivers.ili94xx.ili9488 import ILI9488 as SSD