POSA_LEAKSMS/raspberry_pi_zero2w_display/st7789_driver.py
cw2007 42a6ee4bb0 feat(display): Zero 2 W ST7789 상태 디스플레이 노드
Pi Zero 2 W + ST7789 패널에 노드 상태(누수/온습도/미세먼지·오프라인·정체)를 상시 표시하는
디스플레이 노드 신설. 배포 전 렌더 검증 원칙에 따라 합성 최악 프레임 11종을 패널 실해상도로
render_preview/ 에 PNG 로 선렌더.

- st7789_driver.py · display_dashboard.py · render_preview.py(합성 프레임 생성)
- install.sh · vendor.sh · systemd/display-dashboard.service · 배선(WIRING)·연결테스트
- render_preview/01~11: 요약/통합/메트릭/오프라인/stale/legacy-무PM 최악 케이스
2026-07-19 10:47:00 +09:00

159 lines
6.8 KiB
Python

# =============================================================================
# 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