import time from machine import Pin, SPI import framebuf import micropython import struct class ST7796: def __init__(self, spi, cs, dc, rst, bl=None, width=480, height=320, invert_color=True): self.spi = spi self.cs = cs self.dc = dc self.rst = rst self.bl = bl self.width = width self.height = height self.invert_color = invert_color # 1. 1-bit Canvas Buffer (Standard MONO_HLSB for drawing) self.hw_len = (self.width * self.height) // 8 self.canvas_buffer = bytearray(self.hw_len) self.canvas = framebuf.FrameBuffer(self.canvas_buffer, self.width, self.height, framebuf.MONO_HLSB) # Pre-allocate chunk buffer for conversion (16 rows: 480 * 16 * 2 = 15360 bytes) self.chunk_rows = 16 self.row_buffer = bytearray(self.width * self.chunk_rows * 2) # Initialize pins self.cs.init(self.cs.OUT, value=1) self.dc.init(self.dc.OUT, value=0) self.rst.init(self.rst.OUT, value=1) if self.bl is not None: # PWM or Pin backlight control if isinstance(self.bl, Pin): self.bl.init(self.bl.OUT, value=1) self.reset() self.init_display() self.clear(0) self.show() # --- DRAWING WRAPPERS --- def pixel(self, x, y, c): self.canvas.pixel(x, y, c) def line(self, x1, y1, x2, y2, c): self.canvas.line(x1, y1, x2, y2, c) def rect(self, x, y, w, h, c): self.canvas.rect(x, y, w, h, c) def fill_rect(self, x, y, w, h, c): self.canvas.fill_rect(x, y, w, h, c) def text(self, msg, x, y, c=1): self.canvas.text(msg, x, y, c) def clear(self, c=0): self.canvas.fill(c) # --- SCALABLE TEXT --- def text_large(self, msg, x, y, scale=2, c=1): char_w = 8; char_h = 8 tmp_buf = bytearray(char_w * char_h // 8) tmp_fb = framebuf.FrameBuffer(tmp_buf, char_w, char_h, framebuf.MONO_HLSB) for char in msg: tmp_fb.fill(0); tmp_fb.text(char, 0, 0, 1) for py in range(8): for px in range(8): if tmp_fb.pixel(px, py): self.canvas.fill_rect(x + (px * scale), y + (py * scale), scale, scale, c) x += (8 * scale) # --- RAW BITMAPS (1:1 scale) --- def bitmap(self, x, y, w, h, pixel_data): img = framebuf.FrameBuffer(pixel_data, w, h, framebuf.MONO_HLSB) self.canvas.blit(img, x, y) # --- PBM FILE LOADER WITH SCALING --- def draw_pbm(self, filename, x, y, scale=1): try: with open(filename, 'rb') as f: line1 = f.readline() if not line1.startswith(b'P4'): print("Err: Not P4 PBM"); return while True: line = f.readline() if not line.startswith(b'#'): break dims = line.split(); w = int(dims[0]); h = int(dims[1]) data = bytearray(f.read()) src_fb = framebuf.FrameBuffer(data, w, h, framebuf.MONO_HLSB) if scale == 1: self.canvas.blit(src_fb, x, y) else: for sy in range(h): for sx in range(w): if src_fb.pixel(sx, sy): self.canvas.fill_rect(x + (sx * scale), y + (sy * scale), scale, scale, 1) print(f"Loaded {filename} (scale {scale})") except OSError: print(f"Error: Could not open {filename}") @micropython.native 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 @micropython.native 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): """Draw a 24-bit or 32-bit uncompressed color BMP image at (x, y) coordinates.""" 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(1) self.cs(0) self.spi.write(memoryview(rgb565_buf)[:win_w * 2]) self.cs(1) # 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 @micropython.native 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(1) self.cs(0) self.spi.write(data) self.cs(1) 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(1) self.cs(0) self.spi.write(memoryview(data)[src_row_offset : src_row_offset + row_len_bytes]) self.cs(1) # Sync the internal 1-bit canvas buffer if sync_canvas: self._update_mono_canvas_rgb565(x, y, w, h, data) return True # --- SCREENSHOT --- def save_screenshot(self, filename): print(f"Saving screenshot to {filename}...") try: with open(filename, 'wb') as f: f.write(b'P4\n') f.write(f"{self.width} {self.height}\n".encode()) f.write(self.canvas_buffer) print("Saved!") except Exception as e: print(f"Error saving screenshot: {e}") # --- HARDWARE LOGIC --- def reset(self): self.rst(1); time.sleep_ms(5); self.rst(0); time.sleep_ms(15); self.rst(1); time.sleep_ms(15) def write_cmd(self, cmd): self.dc(0); self.cs(0); self.spi.write(bytearray([cmd])); self.cs(1) def write_data(self, data): self.dc(1); self.cs(0) if isinstance(data, int): self.spi.write(bytearray([data])) elif isinstance(data, list): self.spi.write(bytearray(data)) else: self.spi.write(data) self.cs(1) def init_display(self): # ST7796 Minimal Initialization Sequence (ported from working C++ codebase) self.write_cmd(0x01) # SWRESET time.sleep_ms(150) self.write_cmd(0x11) # SLPOUT time.sleep_ms(120) # Pixel Format (COLMOD) = 0x55 (16-bit color / RGB565) self.write_cmd(0x3A); self.write_data(0x55) time.sleep_ms(10) # Memory Access Control (MADCTL) = 0xE0 (Landscape: MY=1, MX=1, MV=1, RGB order) self.write_cmd(0x36); self.write_data(0xE0) time.sleep_ms(10) # Display Inversion Control if self.invert_color: self.write_cmd(0x21) # INVON else: self.write_cmd(0x20) # INVOFF time.sleep_ms(10) self.write_cmd(0x13) # NORON time.sleep_ms(10) self.write_cmd(0x29) # DISPON time.sleep_ms(120) def invert(self, enable): if enable: self.write_cmd(0x21) # INVON else: self.write_cmd(0x20) # INVOFF def set_window(self, x0, y0, x1, y1): # Column Address Set (CASET) self.write_cmd(0x2A) self.write_data(bytearray([x0 >> 8, x0 & 0xFF, x1 >> 8, x1 & 0xFF])) # Row Address Set (RASET) self.write_cmd(0x2B) self.write_data(bytearray([y0 >> 8, y0 & 0xFF, y1 >> 8, y1 & 0xFF])) # Memory Write (RAMWR) self.write_cmd(0x2C) @micropython.native def _convert_rows(self, start_row, num_rows, row_buf): """Converts 1-bit monochrome row segment to 16-bit RGB565 format. Compiles block-wise bitwise operations at native speed. """ width = self.width canvas_buf = self.canvas_buffer idx = 0 for y in range(start_row, start_row + num_rows): byte_offset = y * (width // 8) for x_byte_idx in range(width // 8): val = canvas_buf[byte_offset + x_byte_idx] # Unroll 8 bits for speed # Bit 7 if val & 0x80: row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF else: row_buf[idx] = 0x00; row_buf[idx+1] = 0x00 idx += 2 # Bit 6 if val & 0x40: row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF else: row_buf[idx] = 0x00; row_buf[idx+1] = 0x00 idx += 2 # Bit 5 if val & 0x20: row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF else: row_buf[idx] = 0x00; row_buf[idx+1] = 0x00 idx += 2 # Bit 4 if val & 0x10: row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF else: row_buf[idx] = 0x00; row_buf[idx+1] = 0x00 idx += 2 # Bit 3 if val & 0x08: row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF else: row_buf[idx] = 0x00; row_buf[idx+1] = 0x00 idx += 2 # Bit 2 if val & 0x04: row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF else: row_buf[idx] = 0x00; row_buf[idx+1] = 0x00 idx += 2 # Bit 1 if val & 0x02: row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF else: row_buf[idx] = 0x00; row_buf[idx+1] = 0x00 idx += 2 # Bit 0 if val & 0x01: row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF else: row_buf[idx] = 0x00; row_buf[idx+1] = 0x00 idx += 2 def show(self): """Refreshes the screen by writing the frame buffer segment-by-segment.""" self.set_window(0, 0, self.width - 1, self.height - 1) self.dc(1) self.cs(0) num_chunks = self.height // self.chunk_rows for chunk in range(num_chunks): start_row = chunk * self.chunk_rows self._convert_rows(start_row, self.chunk_rows, self.row_buffer) self.spi.write(self.row_buffer) self.cs(1)