# ============================================================================= # st7789_driver.py — ST7789V(240x320) SPI 드라이버 (GMT020-02 등) # 외부 디스플레이 라이브러리 의존 없음(폐쇄망 최소 의존): # spidev + RPi.GPIO + numpy(RGB565 변환) + Pillow(Image 입력)만 사용. # 하드웨어/모듈 부재 시 SimulationDisplay 로 폴백(PNG 저장) — sht30 드라이버와 동일 철학. # ============================================================================= import time try: import numpy as np except ImportError: # numpy 없으면 순수 파이썬 변환(느리지만 동작) np = None class SimulationDisplay: """SPI/GPIO 불가 환경 폴백 — 프레임을 PNG 로 저장해 렌더 결과를 검증한다.""" def __init__(self, width=240, height=320, preview_path="/tmp/display_preview.png"): self.width, self.height = width, height self.preview_path = preview_path def display(self, image): image.save(self.preview_path) def set_backlight(self, on): pass def close(self): pass class ST7789: """ST7789V 240x320 — 초기화·전체 프레임 블릿. 부분 갱신은 쓰지 않는다(30초 주기).""" def __init__(self, spi_bus=0, spi_device=0, speed_hz=40_000_000, dc=25, rst=24, bl=None, width=240, height=320, rotation=0, invert=True): import spidev import RPi.GPIO as GPIO self.GPIO = GPIO self.width, self.height = width, height self.rotation = rotation self.dc, self.rst, self.bl = dc, rst, bl GPIO.setwarnings(False) GPIO.setmode(GPIO.BCM) GPIO.setup(dc, GPIO.OUT) GPIO.setup(rst, GPIO.OUT) if bl is not None: # 7핀 모듈(BLK 없음)은 백라이트 제어 생략 GPIO.setup(bl, GPIO.OUT) self.spi = spidev.SpiDev() self.spi.open(spi_bus, spi_device) self.spi.max_speed_hz = speed_hz self.spi.mode = 0 self._reset() self._init_panel(invert) self.set_backlight(True) # ── 저수준 ────────────────────────────────────────────── def _cmd(self, cmd, data=None): self.GPIO.output(self.dc, 0) self.spi.writebytes([cmd]) if data: self.GPIO.output(self.dc, 1) self.spi.writebytes(list(data)) def _reset(self): self.GPIO.output(self.rst, 1); time.sleep(0.05) self.GPIO.output(self.rst, 0); time.sleep(0.05) self.GPIO.output(self.rst, 1); time.sleep(0.15) def _init_panel(self, invert): self._cmd(0x01); time.sleep(0.15) # SWRESET self._cmd(0x11); time.sleep(0.12) # SLPOUT self._cmd(0x3A, [0x55]) # COLMOD: 16bit RGB565 self._cmd(0x36, [self._madctl()]) # MADCTL: 회전 self._cmd(0xB2, [0x0C, 0x0C, 0x00, 0x33, 0x33]) # PORCTRL self._cmd(0xB7, [0x35]) # GCTRL self._cmd(0xBB, [0x19]) # VCOMS self._cmd(0xC0, [0x2C]) # LCMCTRL self._cmd(0xC2, [0x01]) # VDVVRHEN self._cmd(0xC3, [0x12]) # VRHS self._cmd(0xC4, [0x20]) # VDVS self._cmd(0xC6, [0x0F]) # FRCTRL2 60Hz self._cmd(0xD0, [0xA4, 0xA1]) # PWCTRL1 self._cmd(0xE0, [0xD0, 0x04, 0x0D, 0x11, 0x13, 0x2B, # PVGAMCTRL 0x3F, 0x54, 0x4C, 0x18, 0x0D, 0x0B, 0x1F, 0x23]) self._cmd(0xE1, [0xD0, 0x04, 0x0C, 0x11, 0x13, 0x2C, # NVGAMCTRL 0x3F, 0x44, 0x51, 0x2F, 0x1F, 0x1F, 0x20, 0x23]) self._cmd(0x21 if invert else 0x20) # INVON/INVOFF self._cmd(0x13) # NORON self._cmd(0x29); time.sleep(0.05) # DISPON def _madctl(self): return {0: 0x00, 90: 0x60, 180: 0xC0, 270: 0xA0}.get(self.rotation, 0x00) def _set_window(self): w, h = (self.width, self.height) if self.rotation in (0, 180) else (self.height, self.width) self._cmd(0x2A, [0, 0, (w - 1) >> 8, (w - 1) & 0xFF]) # CASET self._cmd(0x2B, [0, 0, (h - 1) >> 8, (h - 1) & 0xFF]) # RASET self._cmd(0x2C) # RAMWR # ── 공개 API ──────────────────────────────────────────── def display(self, image): """PIL RGB 이미지를 전체 프레임으로 전송.""" w, h = (self.width, self.height) if self.rotation in (0, 180) else (self.height, self.width) if image.size != (w, h): image = image.resize((w, h)) if np is not None: arr = np.asarray(image.convert("RGB"), dtype=np.uint16) rgb565 = ((arr[..., 0] & 0xF8) << 8) | ((arr[..., 1] & 0xFC) << 3) | (arr[..., 2] >> 3) buf = rgb565.byteswap().tobytes() # 빅엔디안 else: px = image.convert("RGB").load() buf = bytearray(w * h * 2) i = 0 for y in range(h): for x in range(w): r, g, b = px[x, y] v = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3) buf[i] = v >> 8; buf[i + 1] = v & 0xFF i += 2 self._set_window() self.GPIO.output(self.dc, 1) if hasattr(self.spi, "writebytes2"): # 버퍼 직접 전송(자동 분할) self.spi.writebytes2(buf) else: # 구버전 spidev 폴백: list 로 분할 전송 for ofs in range(0, len(buf), 4096): self.spi.writebytes(list(buf[ofs:ofs + 4096])) def set_backlight(self, on): if self.bl is not None: self.GPIO.output(self.bl, 1 if on else 0) def close(self): try: self.set_backlight(False) self.spi.close() except Exception: pass def open_display(cfg): """설정 기반 디스플레이 오픈 — 하드웨어 불가 시 시뮬레이션 폴백.""" if getattr(cfg, "FORCE_SIMULATION", False): return SimulationDisplay(cfg.WIDTH, cfg.HEIGHT), True try: disp = ST7789( spi_bus=cfg.SPI_BUS, spi_device=cfg.SPI_DEVICE, speed_hz=cfg.SPI_SPEED_HZ, dc=cfg.PIN_DC, rst=cfg.PIN_RST, bl=cfg.PIN_BL, width=cfg.WIDTH, height=cfg.HEIGHT, rotation=cfg.ROTATION, invert=cfg.INVERT, ) return disp, False except Exception as e: print(f"[WARN] ST7789 init failed ({e}) — simulation fallback") return SimulationDisplay(cfg.WIDTH, cfg.HEIGHT), True