482 lines
18 KiB
Python
482 lines
18 KiB
Python
"""CircuitPython ILI9341 driver for Hosyond ESP32-S3 Touchscreen board.
|
|
|
|
This driver wraps adafruit_framebuf using a 1-bit MONO_HLSB canvas buffer,
|
|
then converts it row-by-row to 16-bit RGB565 via a lookup table (LUT) during show().
|
|
"""
|
|
|
|
import time
|
|
|
|
try:
|
|
import adafruit_framebuf
|
|
except ImportError:
|
|
adafruit_framebuf = None
|
|
|
|
|
|
class ILI9341:
|
|
WIDTH = 320
|
|
HEIGHT = 240
|
|
|
|
def __init__(self, spi, cs, dc, rst=None, bl=None, width=WIDTH, height=HEIGHT, invert_color=True):
|
|
if adafruit_framebuf is None:
|
|
raise RuntimeError("adafruit_framebuf is required in CIRCUITPY/lib")
|
|
self.spi = spi
|
|
self.cs = cs
|
|
self.dc = dc
|
|
self.rst = rst
|
|
self.width = width
|
|
self.height = height
|
|
self.invert_color = invert_color
|
|
|
|
# 1-bit canvas buffer (1 = White/On, 0 = Black/Off)
|
|
self.hw_len = (width * height) // 8
|
|
self.canvas_buffer = bytearray(self.hw_len)
|
|
self.canvas = adafruit_framebuf.FrameBuffer(
|
|
self.canvas_buffer,
|
|
width,
|
|
height,
|
|
adafruit_framebuf.MHMSB, # Matches MONO_HLSB (most significant bit first)
|
|
)
|
|
|
|
# Pre-allocate chunk buffer for conversion (16 rows: 320 * 16 * 2 = 10,240 bytes)
|
|
self.chunk_rows = 16
|
|
self.row_buffer = bytearray(width * self.chunk_rows * 2)
|
|
|
|
# Precompute lookup table for fast 1-bit to 16-bit conversion
|
|
# Each byte (8 pixels) maps to 16 bytes of RGB565 (8 pixels * 2 bytes)
|
|
self.lut = []
|
|
for i in range(256):
|
|
entry = bytearray(16)
|
|
for bit in range(8):
|
|
if i & (1 << (7 - bit)):
|
|
# White pixel: 0xFFFF (High byte: 0xFF, Low byte: 0xFF)
|
|
entry[bit * 2] = 0xFF
|
|
entry[bit * 2 + 1] = 0xFF
|
|
else:
|
|
# Black pixel: 0x0000
|
|
entry[bit * 2] = 0x00
|
|
entry[bit * 2 + 1] = 0x00
|
|
self.lut.append(bytes(entry))
|
|
|
|
# Setup CS and DC
|
|
self.cs.switch_to_output(value=True)
|
|
self.dc.switch_to_output(value=False)
|
|
|
|
# Setup Reset if present
|
|
if self.rst is not None:
|
|
self.rst.switch_to_output(value=True)
|
|
|
|
# Setup Backlight PWM if present
|
|
if bl is not None:
|
|
import pwmio
|
|
self.bl_pwm = pwmio.PWMOut(bl, frequency=1000, duty_cycle=65535)
|
|
else:
|
|
self.bl_pwm = None
|
|
|
|
self.reset()
|
|
self.init_display()
|
|
self.clear(0)
|
|
self.show()
|
|
|
|
def reset(self):
|
|
if self.rst is not None:
|
|
self.rst.value = True
|
|
time.sleep(0.005)
|
|
self.rst.value = False
|
|
time.sleep(0.015)
|
|
self.rst.value = True
|
|
time.sleep(0.015)
|
|
else:
|
|
# Software reset command if no reset pin
|
|
self.write_cmd(0x01)
|
|
time.sleep(0.150)
|
|
|
|
def _lock_spi(self):
|
|
while not self.spi.try_lock():
|
|
pass
|
|
self.spi.configure(baudrate=40000000, phase=0, polarity=0)
|
|
|
|
def _unlock_spi(self):
|
|
self.spi.unlock()
|
|
|
|
def write_cmd(self, cmd):
|
|
self._lock_spi()
|
|
try:
|
|
self.cs.value = False
|
|
self.dc.value = False
|
|
self.spi.write(bytes([cmd & 0xFF]))
|
|
self.cs.value = True
|
|
finally:
|
|
self._unlock_spi()
|
|
|
|
def write_data(self, data):
|
|
if isinstance(data, int):
|
|
payload = bytes([data & 0xFF])
|
|
elif isinstance(data, (bytes, bytearray, memoryview)):
|
|
payload = data
|
|
else:
|
|
payload = bytes(data)
|
|
self._lock_spi()
|
|
try:
|
|
self.cs.value = False
|
|
self.dc.value = True
|
|
self.spi.write(payload)
|
|
self.cs.value = True
|
|
finally:
|
|
self._unlock_spi()
|
|
|
|
def init_display(self):
|
|
# SWRESET
|
|
self.write_cmd(0x01)
|
|
time.sleep(0.150)
|
|
|
|
self.write_cmd(0xCF); self.write_data(b"\x00\xC1\x30")
|
|
self.write_cmd(0xED); self.write_data(b"\x64\x03\x12\x81")
|
|
self.write_cmd(0xE8); self.write_data(b"\x85\x00\x78")
|
|
self.write_cmd(0xCB); self.write_data(b"\x39\x2C\x00\x34\x02")
|
|
self.write_cmd(0xF7); self.write_data(b"\x20")
|
|
self.write_cmd(0xEA); self.write_data(b"\x00\x00")
|
|
|
|
self.write_cmd(0xC0); self.write_data(b"\x13") # Power Control 1
|
|
self.write_cmd(0xC1); self.write_data(b"\x13") # Power Control 2
|
|
self.write_cmd(0xC5); self.write_data(b"\x22\x35") # VCOM Control 1
|
|
self.write_cmd(0xC7); self.write_data(b"\xBD") # VCOM Control 2
|
|
|
|
# Memory Access Control (MADCTL) = 0x68 (Landscape: MV=1, MX=1, MY=0, BGR color filter)
|
|
self.write_cmd(0x36); self.write_data(b"\x68")
|
|
|
|
self.write_cmd(0xB6); self.write_data(b"\x0A\xA2") # Display Function Control
|
|
self.write_cmd(0x3A); self.write_data(b"\x55") # Pixel Format (COLMOD) = 16-bit RGB565
|
|
self.write_cmd(0xF6); self.write_data(b"\x01\x30")
|
|
self.write_cmd(0xB1); self.write_data(b"\x00\x1B") # Frame Rate Control
|
|
self.write_cmd(0xF2); self.write_data(b"\x00")
|
|
self.write_cmd(0x26); self.write_data(b"\x01") # Gamma Curve
|
|
|
|
self.write_cmd(0xE0); self.write_data(b"\x0F\x35\x31\x0B\x0E\x06\x49\xA7\x33\x07\x0F\x03\x0C\x0A\x00")
|
|
self.write_cmd(0xE1); self.write_data(b"\x00\x0A\x0F\x04\x11\x08\x36\x58\x4D\x07\x10\x0C\x32\x34\x0F")
|
|
|
|
if self.invert_color:
|
|
self.write_cmd(0x21) # INVON
|
|
else:
|
|
self.write_cmd(0x20) # INVOFF
|
|
|
|
self.write_cmd(0x11) # SLPOUT
|
|
time.sleep(0.120)
|
|
self.write_cmd(0x29) # DISPON
|
|
time.sleep(0.010)
|
|
|
|
def invert(self, enable):
|
|
self.write_cmd(0x21 if enable else 0x20)
|
|
|
|
def set_window(self, x0, y0, x1, y1):
|
|
self.write_cmd(0x2A)
|
|
self.write_data(bytes([x0 >> 8, x0 & 0xFF, x1 >> 8, x1 & 0xFF]))
|
|
self.write_cmd(0x2B)
|
|
self.write_data(bytes([y0 >> 8, y0 & 0xFF, y1 >> 8, y1 & 0xFF]))
|
|
self.write_cmd(0x2C)
|
|
|
|
def clear(self, color=0):
|
|
self.canvas.fill(1 if color else 0)
|
|
|
|
def pixel(self, x, y, color):
|
|
self.canvas.pixel(x, y, 1 if color else 0)
|
|
|
|
def line(self, x0, y0, x1, y1, color):
|
|
self.canvas.line(x0, y0, x1, y1, 1 if color else 0)
|
|
|
|
def rect(self, x, y, width, height, color):
|
|
self.canvas.rect(x, y, width, height, 1 if color else 0)
|
|
|
|
def fill_rect(self, x, y, width, height, color):
|
|
self.canvas.fill_rect(x, y, width, height, 1 if color else 0)
|
|
|
|
def text(self, text, x, y, color=1):
|
|
self.canvas.text(str(text), x, y, 1 if color else 0)
|
|
|
|
def text_large(self, text, x, y, scale=2, color=1):
|
|
tmp = bytearray(8)
|
|
fb = adafruit_framebuf.FrameBuffer(tmp, 8, 8, adafruit_framebuf.MHMSB)
|
|
color = 1 if color else 0
|
|
for ch in str(text):
|
|
fb.fill(0)
|
|
fb.text(ch, 0, 0, 1)
|
|
for py in range(8):
|
|
for px in range(8):
|
|
if fb.pixel(px, py):
|
|
self.canvas.fill_rect(x + px * scale, y + py * scale, scale, scale, color)
|
|
x += 8 * scale
|
|
|
|
def draw_bitmap_threshold(self, bitmap, x=0, y=0, threshold=1):
|
|
width = min(getattr(bitmap, "width", self.width), self.width - x)
|
|
height = min(getattr(bitmap, "height", self.height), self.height - y)
|
|
for yy in range(height):
|
|
for xx in range(width):
|
|
self.canvas.pixel(x + xx, y + yy, 1 if bitmap[xx, yy] >= threshold else 0)
|
|
|
|
def draw_bitmap_color(self, bitmap, palette, x=0, y=0):
|
|
width = min(getattr(bitmap, "width", self.width), self.width - x)
|
|
height = min(getattr(bitmap, "height", self.height), self.height - y)
|
|
row_buf = bytearray(width * 2)
|
|
for yy in range(height):
|
|
idx = 0
|
|
for xx in range(width):
|
|
val = bitmap[xx, yy]
|
|
if palette is None:
|
|
rgb = val
|
|
else:
|
|
color = palette[val]
|
|
if isinstance(color, tuple) or isinstance(color, list):
|
|
r, g, b = color[0], color[1], color[2]
|
|
elif isinstance(color, int):
|
|
r = (color >> 16) & 0xFF
|
|
g = (color >> 8) & 0xFF
|
|
b = color & 0xFF
|
|
else:
|
|
r, g, b = 0, 0, 0
|
|
r5 = r >> 3
|
|
g6 = g >> 2
|
|
b5 = b >> 3
|
|
rgb = (r5 << 11) | (g6 << 5) | b5
|
|
row_buf[idx] = (rgb >> 8) & 0xFF
|
|
row_buf[idx + 1] = rgb & 0xFF
|
|
idx += 2
|
|
self.draw_rgb565(x, yy + y, width, 1, row_buf, sync_canvas=False)
|
|
|
|
def show(self):
|
|
"""Optimized conversion of 1-bit frame buffer to 16-bit RGB565 over SPI."""
|
|
self.set_window(0, 0, self.width - 1, self.height - 1)
|
|
self.dc.value = True
|
|
self.cs.value = False
|
|
|
|
lut = self.lut
|
|
canvas_buf = self.canvas_buffer
|
|
row_buf = self.row_buffer
|
|
width_bytes = self.width // 8 # 40 bytes per row
|
|
|
|
num_chunks = self.height // self.chunk_rows # 240 // 16 = 15 chunks
|
|
for chunk in range(num_chunks):
|
|
start_row = chunk * self.chunk_rows
|
|
idx = 0
|
|
# Loop for 16 rows * 40 bytes/row = 640 bytes. Slice assignment maps directly to LUT.
|
|
for y in range(start_row, start_row + self.chunk_rows):
|
|
offset = y * width_bytes
|
|
for x_byte_idx in range(width_bytes):
|
|
val = canvas_buf[offset + x_byte_idx]
|
|
row_buf[idx : idx + 16] = lut[val]
|
|
idx += 16
|
|
|
|
self._lock_spi()
|
|
try:
|
|
self.spi.write(row_buf)
|
|
finally:
|
|
self._unlock_spi()
|
|
|
|
self.cs.value = True
|
|
|
|
def set_brightness(self, level):
|
|
if self.bl_pwm is not None:
|
|
level = max(0, min(100, level))
|
|
self.bl_pwm.duty_cycle = int(level * 65535 / 100)
|
|
|
|
def set_power(self, on):
|
|
if on:
|
|
self.write_cmd(0x11) # SLPOUT
|
|
time.sleep(0.120)
|
|
self.write_cmd(0x29) # DISPON
|
|
if self.bl_pwm is not None:
|
|
self.bl_pwm.duty_cycle = 65535
|
|
else:
|
|
self.write_cmd(0x28) # DISPOFF
|
|
self.write_cmd(0x10) # SLPIN
|
|
time.sleep(0.010)
|
|
if self.bl_pwm is not None:
|
|
self.bl_pwm.duty_cycle = 0
|
|
|
|
def _update_mono_canvas_rgb565(self, x, y, w, h, data):
|
|
for cy in range(h):
|
|
screen_y = y + cy
|
|
if screen_y < 0 or screen_y >= self.height:
|
|
continue
|
|
for cx in range(w):
|
|
screen_x = x + cx
|
|
if screen_x < 0 or screen_x >= self.width:
|
|
continue
|
|
idx = (cy * w + cx) * 2
|
|
h_byte = data[idx]
|
|
l_byte = data[idx + 1]
|
|
# Extract RGB from RGB565
|
|
r = (h_byte & 0xF8)
|
|
g = ((h_byte & 0x07) << 5) | ((l_byte & 0xE0) >> 3)
|
|
b = (l_byte & 0x1F) << 3
|
|
# Convert to luminance
|
|
lum = (r * 299 + g * 587 + b * 114) // 1000
|
|
mono = 1 if lum >= 128 else 0
|
|
self.canvas.pixel(screen_x, screen_y, mono)
|
|
|
|
def draw_rgb565(self, x, y, w, h, data, sync_canvas=True):
|
|
"""Draw raw RGB565 pixel data on the screen at specified (x,y) with width and height."""
|
|
# Clip coordinates
|
|
x_start = max(0, x)
|
|
x_end = min(self.width - 1, x + w - 1)
|
|
y_start = max(0, y)
|
|
y_end = min(self.height - 1, y + h - 1)
|
|
|
|
if x_start > x_end or y_start > y_end:
|
|
return True
|
|
|
|
# Fast path: if completely visible on screen, draw in one go
|
|
if x_start == x and x_end == x + w - 1 and y_start == y and y_end == y + h - 1:
|
|
self.set_window(x_start, y_start, x_end, y_end)
|
|
self.dc.value = True
|
|
self.cs.value = False
|
|
self._lock_spi()
|
|
try:
|
|
self.spi.write(data)
|
|
finally:
|
|
self._unlock_spi()
|
|
self.cs.value = True
|
|
else:
|
|
# Slow path: row-by-row clipping
|
|
for cy in range(y_start, y_end + 1):
|
|
src_y = cy - y
|
|
src_row_offset = (src_y * w + (x_start - x)) * 2
|
|
row_len_bytes = (x_end - x_start + 1) * 2
|
|
|
|
self.set_window(x_start, cy, x_end, cy)
|
|
self.dc.value = True
|
|
self.cs.value = False
|
|
self._lock_spi()
|
|
try:
|
|
self.spi.write(memoryview(data)[src_row_offset : src_row_offset + row_len_bytes])
|
|
finally:
|
|
self._unlock_spi()
|
|
self.cs.value = True
|
|
|
|
# Sync the internal 1-bit canvas buffer
|
|
if sync_canvas:
|
|
self._update_mono_canvas_rgb565(x, y, w, h, data)
|
|
return True
|
|
|
|
def _convert_bgr24_to_rgb565(self, bgr_buf, rgb565_buf, width, src_offset, num_pixels):
|
|
idx = 0
|
|
for i in range(src_offset, src_offset + num_pixels):
|
|
b = bgr_buf[i * 3]
|
|
g = bgr_buf[i * 3 + 1]
|
|
r = bgr_buf[i * 3 + 2]
|
|
r_5 = r >> 3
|
|
g_6 = g >> 2
|
|
b_5 = b >> 3
|
|
rgb565_buf[idx] = (r_5 << 3) | (g_6 >> 3)
|
|
rgb565_buf[idx + 1] = ((g_6 & 0x07) << 5) | b_5
|
|
idx += 2
|
|
|
|
def _convert_bgra32_to_rgb565(self, bgra_buf, rgb565_buf, width, src_offset, num_pixels):
|
|
idx = 0
|
|
for i in range(src_offset, src_offset + num_pixels):
|
|
b = bgra_buf[i * 4]
|
|
g = bgra_buf[i * 4 + 1]
|
|
r = bgra_buf[i * 4 + 2]
|
|
r_5 = r >> 3
|
|
g_6 = g >> 2
|
|
b_5 = b >> 3
|
|
rgb565_buf[idx] = (r_5 << 3) | (g_6 >> 3)
|
|
rgb565_buf[idx + 1] = ((g_6 & 0x07) << 5) | b_5
|
|
idx += 2
|
|
|
|
def draw_bmp(self, filename, x=0, y=0):
|
|
import struct
|
|
try:
|
|
with open(filename, 'rb') as f:
|
|
header = f.read(54)
|
|
if len(header) < 54 or header[0:2] != b'BM':
|
|
print("Err: Not a valid BMP file")
|
|
return False
|
|
|
|
pixel_offset = struct.unpack('<I', header[10:14])[0]
|
|
width, height = struct.unpack('<ii', header[18:26])
|
|
planes, bpp = struct.unpack('<HH', header[26:30])
|
|
compression = struct.unpack('<I', header[30:34])[0]
|
|
|
|
if bpp not in (24, 32):
|
|
print("Err: Only 24-bit and 32-bit BMP formats supported")
|
|
return False
|
|
|
|
if compression != 0:
|
|
print("Err: Only uncompressed BMP supported")
|
|
return False
|
|
|
|
f.seek(pixel_offset)
|
|
bottom_up = True
|
|
if height < 0:
|
|
height = -height
|
|
bottom_up = False
|
|
|
|
row_bytes = (width * bpp) // 8
|
|
row_padded = ((width * bpp + 31) // 32) * 4
|
|
|
|
read_buf = bytearray(row_padded)
|
|
rgb565_buf = bytearray(width * 2)
|
|
|
|
for row_idx in range(height):
|
|
n = f.readinto(read_buf)
|
|
if n < row_padded:
|
|
break
|
|
|
|
screen_y = y + (height - 1 - row_idx) if bottom_up else y + row_idx
|
|
if screen_y < 0 or screen_y >= self.height:
|
|
continue
|
|
|
|
x_start = x
|
|
x_end = x + width - 1
|
|
|
|
if x_start >= self.width or x_end < 0:
|
|
continue
|
|
|
|
win_x0 = max(0, x_start)
|
|
win_x1 = min(self.width - 1, x_end)
|
|
|
|
if win_x1 < win_x0:
|
|
continue
|
|
|
|
src_offset_pixels = win_x0 - x_start
|
|
win_w = win_x1 - win_x0 + 1
|
|
|
|
# Convert pixel data to RGB565 row buffer
|
|
if bpp == 24:
|
|
self._convert_bgr24_to_rgb565(read_buf, rgb565_buf, width, src_offset_pixels, win_w)
|
|
elif bpp == 32:
|
|
self._convert_bgra32_to_rgb565(read_buf, rgb565_buf, width, src_offset_pixels, win_w)
|
|
|
|
# Draw directly to the screen via SPI window
|
|
self.set_window(win_x0, screen_y, win_x1, screen_y)
|
|
self.dc.value = True
|
|
self.cs.value = False
|
|
self._lock_spi()
|
|
try:
|
|
self.spi.write(memoryview(rgb565_buf)[:win_w * 2])
|
|
finally:
|
|
self._unlock_spi()
|
|
self.cs.value = True
|
|
|
|
# Also update internal 1-bit canvas buffer for screenshots/refresh consistency
|
|
for px in range(win_w):
|
|
screen_x = win_x0 + px
|
|
src_px = src_offset_pixels + px
|
|
if bpp == 24:
|
|
b = read_buf[src_px * 3]
|
|
g = read_buf[src_px * 3 + 1]
|
|
r = read_buf[src_px * 3 + 2]
|
|
else:
|
|
b = read_buf[src_px * 4]
|
|
g = read_buf[src_px * 4 + 1]
|
|
r = read_buf[src_px * 4 + 2]
|
|
# 0 = Black, 1 = White in conversion for MONO_HLSB canvas
|
|
lum = (r * 299 + g * 587 + b * 114) // 1000
|
|
mono_c = 1 if lum >= 128 else 0
|
|
self.canvas.pixel(screen_x, screen_y, mono_c)
|
|
|
|
return True
|
|
except Exception as e:
|
|
print("Error drawing BMP:", e)
|
|
return False
|
|
|