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 최악 케이스
159 lines
6.8 KiB
Python
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
|