"""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('= 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