diff --git a/raspberry_pi_zero2w_display/README.md b/raspberry_pi_zero2w_display/README.md
new file mode 100644
index 0000000..b61ac14
--- /dev/null
+++ b/raspberry_pi_zero2w_display/README.md
@@ -0,0 +1,48 @@
+# 미니 상태 표시기 — Pi Zero 2 W + ST7789V (GMT020-02 2.0" 240×320)
+
+관제 서버의 요약 상태를 2인치 IPS 화면에 상시 표시하는 글랜스 장치.
+서버 `api/status_summary.php` 를 30초 폴링(읽기 전용, X-Status-Key HMAC 인증),
+페이지 자동 순환: 시스템 요약 → 센서별(누수/온습도) 상세.
+배경색 = 시스템 상태(정상 녹색 / 온습도 경보 주황 / 누수 청색 / 오프라인 회색 /
+서버 무응답 적색 STALE).
+
+## 배선 (GMT020-02 7핀 ↔ Zero 2 W 40핀, BCM)
+
+| LCD 핀 | 기능 | Pi 핀(BCM) | 물리 핀 |
+|---|---|---|---|
+| VCC | 3.3V | 3V3 | 17 |
+| GND | GND | GND | 20 |
+| SCL | SPI SCLK | GPIO11 | 23 |
+| SDA | SPI MOSI | GPIO10 | 19 |
+| RES | Reset | GPIO24 | 18 |
+| DC | Data/Cmd | GPIO25 | 22 |
+| CS | Chip Select | GPIO8 (CE0) | 24 |
+
+핀 변경 시 `config.py` 의 PIN_* 수정. 상세 그림: `WIRING.html`.
+
+## 설치
+
+```bash
+cp config_local.example.py config_local.py # STATUS_URL / STATUS_KEY 입력(파생키 — API_KEY 아님)
+./install.sh # SPI 활성 + apt + venv (번들 있으면 오프라인)
+python3 test_connection.py # 서버 연결 검증
+sudo cp systemd/display-dashboard.service /etc/systemd/system/
+sudo systemctl daemon-reload && sudo systemctl enable --now display-dashboard
+```
+
+폐쇄망: 빌드 PC에서 `bash vendor.sh` → `debs/`+`wheelhouse/` 동봉 후 USB 이송.
+하드닝: 센서 노드와 동일 `harden_pi.sh` 사용 가능(아웃바운드 전용 장치).
+
+## 화면 텍스트
+
+기본 폰트 DejaVu → 라벨은 영문 축약(ALL OK / LEAK! / TEMP C …).
+한글 표시를 원하면 한글 TTF 를 복사하고 `config.py` FONT_PATH 를 바꾼다
+(폐쇄망이므로 폰트 파일도 USB 로 반입).
+
+## 문제 해결
+
+- 화면 백지: SPI 활성 여부(`ls /dev/spidev0.0`), 배선(DC/RES 스왑 흔함)
+- 색 이상: `config.py` INVERT 토글
+- 403: STATUS_KEY 불일치 — `php -r 'echo hash_hmac("sha256","status","<서버 API_KEY>");'` 로 재계산
+- 서비스 로그: `journalctl -u display-dashboard -e`
+- 하드웨어 없이 렌더 확인: `FORCE_SIMULATION = True` → `/tmp/display_preview.png`
diff --git a/raspberry_pi_zero2w_display/WIRING.html b/raspberry_pi_zero2w_display/WIRING.html
new file mode 100644
index 0000000..9988508
--- /dev/null
+++ b/raspberry_pi_zero2w_display/WIRING.html
@@ -0,0 +1,85 @@
+
+
+
+
+배선도 — Pi Zero 2 W + ST7789V (GMT020-02)
+
+
+
+배선도 — Pi Zero 2 W + ST7789V GMT020-02 (2.0" 240×320 IPS)
+
+⚠️ 반드시 3.3V 사용. 5V 연결 시 패널 손상. 배선은 전원 차단 상태에서.
+
+연결표 (7핀)
+
+| LCD 핀 | 기능 | Pi GPIO (BCM) | 물리 핀 번호 | 선색 예시 |
+| VCC | 전원 3.3V | 3V3 | 17 | 빨강 |
+| GND | 접지 | GND | 20 | 검정 |
+| SCL | SPI 클럭(SCLK) | GPIO11 | 23 | 주황 |
+| SDA | SPI 데이터(MOSI) | GPIO10 | 19 | 노랑 |
+| RES | 리셋 (RST/RESET 과 동일 표기) | GPIO24 | 18 | 초록 |
+| DC | 데이터/명령 선택 | GPIO25 | 22 | 파랑 |
+| CS | 칩 선택(CE0) | GPIO8 | 24 | 보라 |
+
+
+Zero 2 W 40핀 핀맵 (위에서 본 그림)
+배선 7가닥 전부 물리 17~24번 블록에 모여 있다 — 헤더 중간 8핀 구간만 쓰면 된다.
+보드 부품면(위)에서 본 헤더. SD 카드 슬롯이 위, GPIO 헤더가 오른쪽 세로로 오는 방향 기준
+— 핀 1 = 헤더 맨 위 왼쪽(사각 납땜 패드). 왼쪽 열=홀수 핀(보드 안쪽), 오른쪽 열=짝수 핀(보드 가장자리).
+
+ 이번 배선 사용
+ 3.3V
+ 5V
+ GND
+
+
+| 3V3 | 1 | 2 | 5V |
+| GPIO2 (SDA1) | 3 | 4 | 5V |
+| GPIO3 (SCL1) | 5 | 6 | GND |
+| GPIO4 | 7 | 8 | GPIO14 (TXD) |
+| GND | 9 | 10 | GPIO15 (RXD) |
+| GPIO17 | 11 | 12 | GPIO18 |
+| GPIO27 | 13 | 14 | GND |
+| GPIO22 | 15 | 16 | GPIO23 |
+| 3V3→ VCC | 17 | 18 | GPIO24→ RES |
+| GPIO10 (MOSI)→ SDA | 19 | 20 | GND→ GND |
+| GPIO9 (MISO) | 21 | 22 | GPIO25→ DC |
+| GPIO11 (SCLK)→ SCL | 23 | 24 | GPIO8 (CE0)→ CS |
+| GND | 25 | 26 | GPIO7 (CE1) |
+| ID_SD | 27 | 28 | ID_SC |
+| GPIO5 | 29 | 30 | GND |
+| GPIO6 | 31 | 32 | GPIO12 |
+| GPIO13 | 33 | 34 | GND |
+| GPIO19 | 35 | 36 | GPIO16 |
+| GPIO26 | 37 | 38 | GPIO20 |
+| GND | 39 | 40 | GPIO21 |
+
+방향 확인법: 헤더 납땜면에서 핀 1만 사각 패드(나머지는 원형). 핀 1 은 SD 카드 슬롯과 같은 쪽 모서리다.
+
+점검
+
+- SPI 활성 확인:
ls /dev/spidev0.0 (install.sh 가 dtparam=spi=on 설정, 재부팅 필요할 수 있음)
+- 화면 백지면 DC/RES 스왑 여부부터 확인(가장 흔한 실수)
+- 색이 반전돼 보이면
config.py 의 INVERT 토글
+- 렌더만 검증:
FORCE_SIMULATION = True → /tmp/display_preview.png
+
+
+
diff --git a/raspberry_pi_zero2w_display/config.py b/raspberry_pi_zero2w_display/config.py
new file mode 100644
index 0000000..d8fe183
--- /dev/null
+++ b/raspberry_pi_zero2w_display/config.py
@@ -0,0 +1,50 @@
+# =============================================================================
+# config.py — 미니 상태 표시기 (Pi Zero 2 W + ST7789V GMT020-02 2.0" 240x320)
+# 비밀값(STATUS_URL/STATUS_KEY)은 config_local.py 에서 로드한다(커밋 금지).
+# cp config_local.example.py config_local.py 후 실값 입력.
+# =============================================================================
+
+# ─────────────────────────────────────────────
+# 폴링/화면 주기
+# ─────────────────────────────────────────────
+POLL_INTERVAL = 30 # 서버 상태 폴링 주기(초) — 대시보드 자동 새로고침과 동일
+PAGE_INTERVAL = 8 # 페이지 자동 순환 주기(초): 요약 ↔ 센서별
+FETCH_TIMEOUT = 10 # HTTP 타임아웃(초)
+STALE_AFTER = 120 # 마지막 성공 수신 후 이 시간(초) 지나면 STALE 표시
+
+# ─────────────────────────────────────────────
+# ST7789V SPI 배선 (BCM 번호) — README.md 배선표와 일치
+# GMT020-02(7핀): GND / VCC / SCL=SCLK / SDA=MOSI / RES / DC / CS (BLK 없음)
+# ─────────────────────────────────────────────
+SPI_BUS = 0 # SPI0
+SPI_DEVICE = 0 # CE0 (GPIO8)
+SPI_SPEED_HZ = 40_000_000
+PIN_DC = 25 # Data/Command
+PIN_RST = 24 # Reset — 물리 18번(SDA 옆). 7핀을 물리 17~24 블록에 집약
+PIN_BL = None # 7핀 모듈은 백라이트 핀 없음(내부 상시 점등). 8핀이면 BCM 번호(예: 18)
+
+WIDTH = 240
+HEIGHT = 320
+ROTATION = 0 # 0/90/180/270 — 설치 방향에 맞춰 조정
+INVERT = True # GMT020-02(IPS) 는 색반전 ON 이 정색
+
+# 하드웨어 없이 개발/검증: True 면 SPI 대신 /tmp/display_preview.png 로 렌더 저장.
+# spidev/RPi.GPIO 미설치 환경에서는 자동으로 시뮬레이션 폴백.
+FORCE_SIMULATION = False
+
+# 한글 라벨이 필요하면 한글 TTF 경로 지정(기본은 DejaVu — 영문/숫자만).
+FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
+FONT_PATH_LIGHT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
+
+LOG_FILE = "/var/log/display_dashboard.log"
+
+# ─────────────────────────────────────────────
+# 비밀값 로드 (fail closed — config_local.py 없으면 즉시 중단)
+# ─────────────────────────────────────────────
+try:
+ from config_local import * # noqa: F401,F403 — STATUS_URL/STATUS_KEY/DEVICE_NAME
+except ImportError as e:
+ raise RuntimeError(
+ "config_local.py not found. Run `cp config_local.example.py config_local.py`, "
+ "then fill in STATUS_URL / STATUS_KEY."
+ ) from e
diff --git a/raspberry_pi_zero2w_display/display_dashboard.py b/raspberry_pi_zero2w_display/display_dashboard.py
new file mode 100644
index 0000000..a0a50dd
--- /dev/null
+++ b/raspberry_pi_zero2w_display/display_dashboard.py
@@ -0,0 +1,409 @@
+#!/usr/bin/env python3
+# =============================================================================
+# display_dashboard.py — 미니 상태 표시기 메인 루프 (Pi Zero 2 W + ST7789V 2.0")
+#
+# 서버 api/status_summary.php 를 30초 폴링 → 240x320 화면 렌더(Pillow) → SPI 블릿.
+# 페이지 자동 순환: [0] 시스템 요약 → [1..n] 센서별 상세.
+# 배경색 = 시스템 상태(정상 녹색 / 온습도 주황 / 미세먼지 마젠타 / 누수 청색 / 오프라인 회색).
+# 서버 미응답 시 마지막 데이터 + STALE 배너(경과 시간) 표시.
+#
+# 글꼴: 기본 DejaVu(영문/숫자) — 2인치 글랜스 화면이라 라벨은 영문 축약.
+# 한글 라벨 원하면 config FONT_PATH 에 한글 TTF 지정.
+# =============================================================================
+import sys
+import time
+
+import requests
+from PIL import Image, ImageDraw, ImageFont
+
+import config
+from st7789_driver import open_display
+
+W, H = config.WIDTH, config.HEIGHT
+
+# 상태색 (배경, 강조) — 대시보드 팔레트와 동일 계열
+STATE_COLORS = {
+ "ok": ((10, 40, 28), (53, 214, 160)),
+ "alert": ((52, 30, 6), (255, 178, 62)),
+ "pm": ((42, 12, 48), (208, 120, 236)), # 미세먼지 — 마젠타/바이올렛(온습도 주황과 명확히 구분)
+ "leak": ((8, 30, 52), (63, 199, 230)),
+ "offline": ((30, 32, 44), (120, 133, 184)),
+ "stale": ((40, 12, 20), (255, 84, 112)),
+}
+INK = (234, 238, 255) # 1차: 값/제목
+INK2 = (150, 160, 200) # 2차: 보조 텍스트
+INK3 = (96, 104, 140) # 3차: eyebrow 라벨·눈금
+HAIR = (42, 48, 72) # 헤어라인 구분선
+TEMP_C = (255, 149, 100)
+HUM_C = (74, 194, 224)
+PM_C = (208, 120, 236) # 미세먼지 수치 — 마젠타(온습도경보로 오독 방지)
+
+PAD = 16 # 좌우 여백(8px 그리드)
+
+
+def font(size, bold=True):
+ path = config.FONT_PATH if bold else config.FONT_PATH_LIGHT
+ try:
+ return ImageFont.truetype(path, size)
+ except OSError:
+ return ImageFont.load_default()
+
+
+# 타이포 위계: huge(위젯 히어로) > hero(상태) > value(수치) > label > eyebrow
+F_HUGE = font(66)
+F_HERO = font(44)
+F_BIG = font(44) # draw_error 호환 별칭
+F_VALUE = font(30)
+F_MED = font(30) # 호환 별칭
+F_LABEL = font(16)
+F_SM = font(16) # 호환 별칭
+F_XS = font(13, bold=False)
+F_EYE = font(12, bold=False)
+
+
+def _dim(color, f=0.30):
+ """색을 배경쪽으로 눌러 채움용 저채도 버전 생성."""
+ return tuple(int(c * f) for c in color)
+
+
+def _eyebrow(d, xy, text, fill=INK3, ls=2):
+ """트래킹된 대문자 소형 라벨(Apple eyebrow). 그린 뒤 끝 x 반환."""
+ x, y = xy
+ for ch in text.upper():
+ d.text((x, y), ch, font=F_EYE, fill=fill)
+ x += d.textlength(ch, font=F_EYE) + ls
+ return x
+
+
+def fetch_status():
+ # 최소 권한: config_local 에는 파생 STATUS_KEY 만 있다(API_KEY 비보유).
+ r = requests.get(
+ config.STATUS_URL,
+ headers={
+ "X-Status-Key": config.STATUS_KEY,
+ # 커스텀 UA — 공유호스팅 WAF가 기본 python-requests UA를 403 차단하므로
+ "User-Agent": "LeakDisplay/2.0",
+ },
+ timeout=config.FETCH_TIMEOUT,
+ )
+ r.raise_for_status()
+ return r.json()
+
+
+def state_label(state):
+ return {"ok": "ALL OK", "alert": "ALERT", "pm": "PM ALERT",
+ "leak": "LEAK!", "offline": "OFFLINE"}.get(state, state.upper())
+
+
+def sensor_state(s):
+ """센서 1개의 표시 상태 — 우선순위 누수 > 미세먼지 > 온습도 > offline > ok.
+
+ (기존 버그: offline 을 leak/미세먼지보다 먼저 판정 → 오프라인이 누수를 가림.
+ 또한 미세먼지 티어 부재. 이 순서로 재정렬해 실제 경보가 오프라인/온습도에 묻히지 않게.)
+ 누수·미세먼지는 값이 있으면 오프라인이라도 우선 노출한다.
+ """
+ if s.get("leak") == 1:
+ return "leak", "LEAK!"
+ pm_status = s.get("pm_status", "normal") or "normal"
+ if pm_status != "normal":
+ return "pm", {"high_pm25": "PM2.5", "high_pm10": "PM10"}.get(pm_status, "DUST")
+ if s.get("pending"):
+ return "alert", "CHECK.."
+ # th_status 신규 키 우선, 없으면 metric_status 별칭(구 API 호환)
+ th_status = s.get("th_status", s.get("metric_status", "normal")) or "normal"
+ if th_status != "normal":
+ return "alert", th_status.replace("_", " ").upper()
+ if s.get("offline"):
+ return "offline", "OFFLINE"
+ return "ok", "OK"
+
+
+def new_frame(state):
+ bg, accent = STATE_COLORS.get(state, STATE_COLORS["offline"])
+ img = Image.new("RGB", (W, H), bg)
+ d = ImageDraw.Draw(img)
+ d.rectangle([0, 0, W, 4], fill=accent) # 상단 상태 스트립(얇게)
+ return img, d, accent
+
+
+def _hairline(d, y):
+ d.line([(PAD, y), (W - PAD, y)], fill=HAIR, width=1)
+
+
+def draw_summary(data, stale_sec):
+ sys_ = data["system"]
+ state = "stale" if stale_sec is not None else sys_["state"]
+ img, d, accent = new_frame(state)
+
+ # ── 헤더: eyebrow → hero 상태 → 시각 ──────────────────────────────
+ _eyebrow(d, (PAD, 22), "SYSTEM STATUS")
+ d.text((PAD, 38), state_label(sys_["state"]), font=F_HERO, fill=accent)
+ if stale_sec is not None:
+ d.text((PAD, 90), f"STALE {int(stale_sec // 60)}m · no server",
+ font=F_XS, fill=(255, 150, 170))
+ else:
+ d.text((PAD, 90), data["ts"][5:16].replace("-", "."), font=F_XS, fill=INK2)
+
+ _hairline(d, 116)
+
+ # ── KPI 목록: 라벨(eyebrow) 좌 / 값 우 ───────────────────────────
+ rows = [
+ ("ONLINE", f"{sys_['online']}/{sys_['total']}"),
+ ("LEAK OPEN", str(sys_["unrecovered"])),
+ ("TODAY ALERTS", str(sys_["today_alerts"])),
+ ("SMS LEFT", str(sys_["sms_balance"]) if sys_["sms_balance"] is not None else "-"),
+ ]
+ y = 130
+ for label, val in rows:
+ _eyebrow(d, (PAD, y + 4), label)
+ d.text((W - PAD, y), val, font=F_LABEL, fill=INK, anchor="ra")
+ y += 34
+
+ # ── 푸터: 센서 상태 점 ───────────────────────────────────────────
+ _hairline(d, H - 44)
+ x = PAD
+ for s in data["sensors"]:
+ st, _ = sensor_state(s)
+ _, dot = STATE_COLORS.get(st, STATE_COLORS["offline"])
+ cy = H - 24
+ d.ellipse([x, cy - 5, x + 10, cy + 5], fill=dot)
+ d.text((x + 16, cy - 9), f"S{s['id']}", font=F_LABEL, fill=INK)
+ x += 68
+ return img
+
+
+def _sparkline(d, x, y, w, h, values, color):
+ """면적 채움 + 추세선 + 현재값 도트. 반환: (lo, hi) 또는 None(데이터부족)."""
+ pts = [(i, v) for i, v in enumerate(values) if v is not None]
+ if len(pts) < 2:
+ d.text((x, y + h // 2 - 7), "no data", font=F_XS, fill=INK3)
+ return None
+ vals = [v for _, v in pts]
+ lo, hi = min(vals), max(vals)
+ span = (hi - lo) or 1.0
+ n = len(values)
+ coords = [
+ (x + w * (i / (n - 1) if n > 1 else 0), y + h - h * ((v - lo) / span))
+ for i, v in pts
+ ]
+ # 저채도 면적 채움(라인 아래) → 라인 → 현재값 도트
+ poly = coords + [(coords[-1][0], y + h), (coords[0][0], y + h)]
+ d.polygon(poly, fill=_dim(color))
+ d.line(coords, fill=color, width=2)
+ cx, cyy = coords[-1]
+ d.ellipse([cx - 3, cyy - 3, cx + 3, cyy + 3], fill=color, outline=INK)
+ return lo, hi
+
+
+def _leak_timeline(d, x, y, w, events, accent):
+ """24h(0~1440분) 축에 누수 이벤트 시각(HH:MM)을 틱+도트로 표시."""
+ base = y + 12
+ d.line([(x, base), (x + w, base)], fill=HAIR, width=1)
+ d.text((x, base + 6), "0h", font=F_XS, fill=INK3)
+ d.text((x + w, base + 6), "24h", font=F_XS, fill=INK3, anchor="ra")
+ if not events:
+ d.text((x + w / 2, base - 18), "no events", font=F_XS, fill=INK3, anchor="ma")
+ return
+ for t in events:
+ try:
+ hh, mm = t.split(":")
+ frac = (int(hh) * 60 + int(mm)) / 1440.0
+ except (ValueError, AttributeError):
+ continue
+ px = x + w * frac
+ d.line([(px, base - 9), (px, base + 9)], fill=accent, width=2)
+ d.ellipse([px - 3, base - 13, px + 3, base - 7], fill=accent)
+
+
+def _wide_spark(d, x, y, w, h, values, color):
+ """넓은 히어로 추세: 면적+라인+도트, min/max 를 우측 상·하단 코너에."""
+ rng = _sparkline(d, x, y, w, h, values, color)
+ if rng is not None:
+ lo, hi = rng
+ d.text((x + w, y - 14), f"{hi:.0f}", font=F_XS, fill=INK3, anchor="ra")
+ d.text((x + w, y + h + 2), f"{lo:.0f}", font=F_XS, fill=INK3, anchor="ra")
+
+
+def _leak_sub(s):
+ """누수 서브상태 텍스트/색 — 결합 노드 ZONE A용(전체 상태와 독립 표기)."""
+ if s.get("leak") == 1:
+ return "LEAK!", STATE_COLORS["leak"][1]
+ return "DRY", STATE_COLORS["ok"][1]
+
+
+def _pm_fmt(v):
+ """PM 정수 표기 — 값 없으면 em dash."""
+ return "—" if v is None else str(int(round(v)))
+
+
+def _zone_leak(d, s, y0):
+ """ZONE A(y46~120) — 누수: 큰 DRY/LEAK! + 24h 이벤트 수."""
+ _eyebrow(d, (PAD, y0), "LEAK STATUS")
+ txt, col = _leak_sub(s)
+ d.text((PAD, y0 + 14), txt, font=F_HERO, fill=col)
+ d.text((W - PAD, y0 + 2), "24H EVENTS", font=F_EYE, fill=INK3, anchor="ra")
+ d.text((W - PAD, y0 + 18), str(len(s.get("leak_events") or [])),
+ font=F_VALUE, fill=INK, anchor="ra")
+
+
+def _zone_pm(d, s, y0):
+ """ZONE B(y128~198) — 미세먼지: PM2.5 큰 값(마젠타) + PM10 보조. 값 없으면 '—'."""
+ _eyebrow(d, (PAD, y0), "FINE DUST · PM2.5")
+ pm25 = _pm_fmt(s.get("pm25"))
+ d.text((PAD, y0 + 14), pm25, font=F_HERO, fill=PM_C)
+ w = d.textlength(pm25, font=F_HERO)
+ d.text((PAD + w + 8, y0 + 40), "µg/m³", font=F_XS, fill=INK3)
+ d.text((W - PAD, y0 + 2), "PM10", font=F_EYE, fill=INK3, anchor="ra")
+ d.text((W - PAD, y0 + 18), _pm_fmt(s.get("pm10")), font=F_VALUE, fill=PM_C, anchor="ra")
+
+
+def _zone_th(d, s, y0):
+ """ZONE C(y208~300) — 온습도: TEMP / HUM 2단(오직 sht30 행 값)."""
+ temp_txt = f"{s['temp']:.1f}°" if s.get("temp") is not None else "—"
+ hum_txt = f"{s['hum']:.0f}%" if s.get("hum") is not None else "—"
+ _eyebrow(d, (PAD, y0), "TEMP °C")
+ d.text((PAD, y0 + 16), temp_txt, font=F_HERO, fill=TEMP_C)
+ xr = W // 2 + 8
+ _eyebrow(d, (xr, y0), "HUM %")
+ d.text((xr, y0 + 16), hum_txt, font=F_HERO, fill=HUM_C)
+
+
+def _draw_combined(d, s):
+ """결합 노드(sensor 1·3): 240x320 3분할 — LEAK / 미세먼지 / 온습도."""
+ _zone_leak(d, s, 48)
+ _hairline(d, 124)
+ _zone_pm(d, s, 130)
+ _hairline(d, 202)
+ _zone_th(d, s, 210)
+
+
+def _draw_metric(d, s):
+ """온습도 전용 노드(sensor 2): 온도 히어로 + 습도 보조 (기존 레이아웃)."""
+ hist = s.get("history") or []
+ temps = [p.get("temp") for p in hist]
+ hums = [p.get("hum") for p in hist]
+ _eyebrow(d, (PAD, 46), "TEMPERATURE °C")
+ temp_txt = f"{s['temp']:.1f}°" if s.get("temp") is not None else "—"
+ # 이상치(예: -999.0)로 240px 를 넘으면 폭에 맞는 작은 폰트로 폴백(무단 클립 방지)
+ hero_font = F_HUGE if d.textlength(temp_txt, font=F_HUGE) <= W - 2 * PAD else F_HERO
+ d.text((PAD, 56), temp_txt, font=hero_font, fill=TEMP_C)
+ _wide_spark(d, PAD, 146, W - 2 * PAD, 58, temps, TEMP_C)
+ _hairline(d, 226)
+ _eyebrow(d, (PAD, 238), "HUMIDITY %")
+ hum_txt = f"{s['hum']:.0f}%" if s.get("hum") is not None else "—"
+ d.text((PAD, 252), hum_txt, font=F_VALUE, fill=HUM_C)
+ _sparkline(d, W - PAD - 96, 254, 96, 30, hums, HUM_C)
+
+
+def _draw_leak_only(d, s, st_text, accent):
+ """누수 전용 노드: 누수 히어로 + 24h 타임라인 (기존 레이아웃)."""
+ _eyebrow(d, (PAD, 46), "LEAK STATUS")
+ d.text((PAD, 64), st_text, font=F_HUGE, fill=accent)
+ _hairline(d, 190)
+ _eyebrow(d, (PAD, 202), "LEAK · 24H")
+ _leak_timeline(d, PAD, 224, W - 2 * PAD, s.get("leak_events") or [], accent)
+
+
+def draw_sensor(data, idx, stale_sec):
+ s = data["sensors"][idx]
+ st, st_text = sensor_state(s)
+ frame_state = "stale" if stale_sec is not None else st
+ img, d, accent = new_frame(frame_state)
+
+ leak_node = bool(s.get("leak_node"))
+ metric_node = bool(s.get("metric_node"))
+ pm_node = bool(s.get("pm_node")) # 구 API 는 키 부재 → False
+
+ # ── 헤더 2행: (1) SENSOR id 좌 · 상태 우 (2) 능력 칩 ─────────
+ # 칩+상태를 한 줄에 몰면 결합 노드(칩 3개)에서 우측 상태와 겹친다 →
+ # 식별/상태는 위 행, 능력 칩은 아래 행으로 분리(충돌 없음).
+ chips = []
+ if leak_node:
+ chips.append("LEAK")
+ if metric_node:
+ chips.append("T/H")
+ if pm_node:
+ chips.append("PM")
+ _eyebrow(d, (PAD, 12), f"SENSOR {s['id']}")
+ d.text((W - PAD, 10), st_text, font=F_LABEL, fill=accent, anchor="ra")
+ _eyebrow(d, (PAD, 30), " · ".join(chips), fill=INK3)
+
+ # ── 본문: 능력 조합으로 분기(우발적 순서 아님) ────────────────
+ if leak_node and (metric_node or pm_node):
+ _draw_combined(d, s) # 결합 노드 3분할
+ elif metric_node:
+ _draw_metric(d, s) # 온습도 전용
+ elif leak_node:
+ _draw_leak_only(d, s, st_text, accent) # 누수 전용
+ else:
+ d.text((PAD, 120), st_text, font=F_HUGE, fill=accent)
+
+ # ── 푸터: 갱신 시각 ──────────────────────────────────────────
+ if s.get("last_seen"):
+ d.text((PAD, H - 22), f"upd {s['last_seen'][5:16]}", font=F_XS, fill=INK3)
+
+ return img
+
+
+def draw_error(err_text):
+ img, d, accent = new_frame("stale")
+ _eyebrow(d, (PAD, 22), "CONNECTION")
+ d.text((PAD, 38), "NO DATA", font=F_HERO, fill=accent)
+ _hairline(d, 116)
+ d.text((PAD, 130), "Server unreachable", font=F_LABEL, fill=INK)
+ d.text((PAD, 158), str(err_text)[:64], font=F_XS, fill=INK2)
+ d.text((PAD, H - 30), time.strftime("%m-%d %H:%M"), font=F_XS, fill=INK3)
+ return img
+
+
+def main():
+ print(f"[INFO] display_dashboard start — {getattr(config, 'DEVICE_NAME', 'display')}")
+ disp, simulated = open_display(config)
+ if simulated:
+ print("[INFO] simulation mode — frames saved to /tmp/display_preview.png")
+
+ data = None
+ last_ok = 0.0
+ last_fetch = 0.0
+ last_err = "not fetched yet"
+ page = 0
+ last_page_flip = 0.0
+
+ while True:
+ now = time.time()
+
+ if now - last_fetch >= config.POLL_INTERVAL or (data is None and now - last_fetch >= 5):
+ last_fetch = now
+ try:
+ data = fetch_status()
+ last_ok = now
+ except Exception as e:
+ last_err = e
+ print(f"[WARN] fetch failed: {e}")
+
+ stale_sec = None
+ if data is not None and (now - last_ok) > config.STALE_AFTER:
+ stale_sec = now - last_ok
+
+ try:
+ if data is None:
+ img = draw_error(last_err)
+ else:
+ pages = 1 + len(data["sensors"])
+ page = min(page, pages - 1) # 센서 수 감소 시 즉시 클램프(IndexError 방지)
+ if now - last_page_flip >= config.PAGE_INTERVAL:
+ page = (page + 1) % pages
+ last_page_flip = now
+ img = draw_summary(data, stale_sec) if page == 0 else draw_sensor(data, page - 1, stale_sec)
+ disp.display(img)
+ except Exception as e:
+ print(f"[ERROR] render/display failed: {e}")
+
+ time.sleep(1)
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except KeyboardInterrupt:
+ sys.exit(0)
diff --git a/raspberry_pi_zero2w_display/install.sh b/raspberry_pi_zero2w_display/install.sh
new file mode 100644
index 0000000..c581f0c
--- /dev/null
+++ b/raspberry_pi_zero2w_display/install.sh
@@ -0,0 +1,114 @@
+#!/usr/bin/env bash
+# =============================================================================
+# 미니 상태 표시기 설치 (Pi Zero 2 W + ST7789V) — SPI 활성 + 패키지 + venv
+# 대상 OS: Raspberry Pi OS Bookworm (64-bit)
+#
+# 폐쇄망: debs/ 또는 wheelhouse/ 가 있으면 자동 오프라인 모드(네트워크 0).
+# 번들이 불완전하면 온라인 폴백 없이 명확히 중단(폐쇄망 규칙). OFFLINE=1/0 강제 가능.
+# =============================================================================
+set -euo pipefail
+cd "$(dirname "$0")"
+
+SERVICE_USER="${SERVICE_USER:-pi}"
+
+echo "=========================================="
+echo " 미니 상태 표시기 (ST7789V) 설치"
+echo "=========================================="
+
+if [ -r /proc/device-tree/model ]; then
+ MODEL="$(tr -d '\0' < /proc/device-tree/model)"
+ echo "[INFO] Board: ${MODEL}"
+ case "${MODEL}" in
+ *"Zero 2"*) : ;;
+ *) echo "[WARN] 이 폴더는 Pi Zero 2 W 용입니다 (다른 보드도 진행은 가능)." ;;
+ esac
+fi
+
+OFFLINE="${OFFLINE:-}"
+if [ -z "${OFFLINE}" ]; then
+ if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then
+ OFFLINE=1
+ echo "[INFO] 오프라인 번들 감지 → 폐쇄망 설치 모드(네트워크 없음)"
+ else
+ OFFLINE=0
+ echo "[INFO] 오프라인 번들 없음 → 온라인 설치 모드(인터넷 필요)"
+ fi
+fi
+
+# 1) 시스템 패키지: requests/Pillow/numpy/GPIO/spidev + 폰트
+echo "[1/4] 시스템 패키지 설치..."
+if [ "${OFFLINE}" = "1" ]; then
+ if ! compgen -G "debs/*.deb" >/dev/null; then
+ echo "[ERROR] 오프라인 모드인데 debs/ 가 비어 있습니다. vendor.sh 산출물을 넣고 재실행." >&2
+ exit 1
+ fi
+ sudo apt-get install -y --no-install-recommends --no-download ./debs/*.deb
+else
+ sudo apt-get update
+ sudo apt-get install -y python3-requests python3-venv python3-pil python3-numpy \
+ python3-rpi.gpio python3-spidev fonts-dejavu-core
+fi
+
+# 2) SPI 활성화 (dtparam + 그룹)
+echo "[2/4] SPI 활성화..."
+BOOT_CFG=""
+for c in /boot/firmware/config.txt /boot/config.txt; do
+ if [ -f "$c" ]; then BOOT_CFG="$c"; break; fi
+done
+if [ -n "${BOOT_CFG}" ]; then
+ if ! grep -q "^dtparam=spi=on" "${BOOT_CFG}"; then
+ echo " dtparam=spi=on 추가 (${BOOT_CFG}) — 재부팅 후 적용"
+ echo "dtparam=spi=on" | sudo tee -a "${BOOT_CFG}" >/dev/null
+ NEED_REBOOT=1
+ else
+ echo " dtparam=spi=on 이미 설정됨"
+ fi
+else
+ echo "[WARN] config.txt 를 찾지 못함 — raspi-config 로 SPI 수동 활성화 필요"
+fi
+for grp in spi gpio; do
+ if getent group "$grp" >/dev/null 2>&1; then
+ sudo usermod -aG "$grp" "${SERVICE_USER}" || echo "[WARN] usermod $grp 실패"
+ fi
+done
+
+# 3) venv (+시스템 패키지 상속) + requests
+echo "[3/4] Python venv..."
+if [ ! -d venv ]; then
+ python3 -m venv --system-site-packages venv
+fi
+# shellcheck disable=SC1091
+source venv/bin/activate
+if [ "${OFFLINE}" = "1" ]; then
+ if [ ! -d wheelhouse ]; then
+ echo "[ERROR] 오프라인 모드인데 wheelhouse/ 가 없습니다. vendor.sh 산출물을 넣고 재실행." >&2
+ exit 1
+ fi
+ pip install --no-index --find-links=wheelhouse -r requirements.txt
+else
+ pip install -r requirements.txt
+fi
+
+# 4) 로그 파일 + 설정 잠금
+echo "[4/4] 로그·권한..."
+sudo touch /var/log/display_dashboard.log
+sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/display_dashboard.log || true
+sudo chmod 640 /var/log/display_dashboard.log
+if [ -f config_local.py ]; then
+ chmod 600 config_local.py
+ sudo chown "${SERVICE_USER}:${SERVICE_USER}" config_local.py 2>/dev/null || true
+fi
+if [ ! -f config_local.py ]; then
+ echo ""
+ echo "[!] config_local.py 가 없습니다: cp config_local.example.py config_local.py 후 실값 입력"
+fi
+
+echo ""
+echo "=========================================="
+echo " 설치 완료 (OFFLINE=${OFFLINE})"
+echo "=========================================="
+if [ "${NEED_REBOOT:-0}" = "1" ]; then
+ echo " ⚠️ SPI 방금 활성화 → 'sudo reboot' 후 화면이 동작합니다."
+fi
+echo " 단독 실행: source venv/bin/activate && python3 display_dashboard.py"
+echo " 서비스 등록: README.md systemd 절 참고"
diff --git a/raspberry_pi_zero2w_display/render_preview.py b/raspberry_pi_zero2w_display/render_preview.py
new file mode 100644
index 0000000..d5759fb
--- /dev/null
+++ b/raspberry_pi_zero2w_display/render_preview.py
@@ -0,0 +1,209 @@
+#!/usr/bin/env python3
+# =============================================================================
+# render_preview.py — 디스플레이 렌더 검증 하네스 (하드웨어 없이 PNG 로 프레임 생성)
+#
+# iot-verification 규칙: 디스플레이 변경은 코드 리딩만으로 배포 금지 —
+# 합성(worst-case) status JSON 을 실제 draw 코드에 먹여 240x320 PNG 로 렌더하고 눈으로 확인.
+#
+# ST7789 하드웨어는 건드리지 않는다(draw_summary/draw_sensor 는 순수 Pillow 반환).
+# 폰트는 리눅스 DejaVu 경로 대신 로컬(윈도우 Arial 등)로 오버라이드 후 모듈을 임포트.
+#
+# 실행: python render_preview.py
+# 출력: ./render_preview/*.png (정확히 패널 해상도 240x320)
+# =============================================================================
+import os
+import sys
+import types
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+OUT = os.path.join(HERE, "render_preview")
+os.makedirs(OUT, exist_ok=True)
+sys.path.insert(0, HERE)
+
+# ── requests 는 이 하네스에서 불필요 — 미설치 환경에서도 임포트되도록 스텁 ──
+if "requests" not in sys.modules:
+ sys.modules["requests"] = types.ModuleType("requests")
+
+
+def _pick_font(candidates):
+ for p in candidates:
+ if os.path.exists(p):
+ return p
+ return None
+
+
+# ── 폰트 오버라이드: display_dashboard 임포트 전에 config 에 심어야
+# 모듈 로드 시점의 F_HUGE/F_HERO/... 가 실제 TTF 로 초기화된다 ──
+import config # noqa: E402
+
+_bold = _pick_font([
+ "C:/Windows/Fonts/arialbd.ttf", "C:/Windows/Fonts/segoeuib.ttf",
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
+])
+_reg = _pick_font([
+ "C:/Windows/Fonts/arial.ttf", "C:/Windows/Fonts/segoeui.ttf",
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
+])
+if _bold:
+ config.FONT_PATH = _bold
+if _reg:
+ config.FONT_PATH_LIGHT = _reg
+config.FORCE_SIMULATION = True
+print(f"[harness] fonts: bold={config.FONT_PATH}\n reg ={config.FONT_PATH_LIGHT}")
+
+import display_dashboard as dd # noqa: E402
+
+
+# ── 합성 센서 팩토리 — 모든 계약 필드 기본값(누락 필드 graceful 테스트도 가능) ──
+def sensor(sid, *, leak_node=False, metric_node=False, pm_node=False,
+ offline=False, leak=0, pending=False,
+ metric_status="normal", th_status=None, pm_status="normal",
+ temp=None, hum=None, pm25=None, pm10=None, pm1_0=None,
+ leak_events=None, history=None, last_seen="2026-07-19 14:32:07",
+ include_pm_keys=True):
+ s = {
+ "id": sid, "name": f"node{sid}", "location": f"zone-{sid}",
+ "leak_node": leak_node, "metric_node": metric_node,
+ "offline": offline, "leak": leak, "pending": pending,
+ "metric_status": metric_status,
+ "temp": temp, "hum": hum,
+ "last_seen": last_seen,
+ "history": history or [],
+ "leak_events": leak_events or [],
+ }
+ if th_status is not None:
+ s["th_status"] = th_status
+ else:
+ s["th_status"] = metric_status
+ if include_pm_keys:
+ s.update({"pm_node": pm_node, "pm_status": pm_status,
+ "pm25": pm25, "pm10": pm10, "pm1_0": pm1_0})
+ return s
+
+
+def hist(n=18, t0=6.0, h0=48.0):
+ out = []
+ for i in range(n):
+ out.append({"t": f"{(i + 6) % 24:02d}:00",
+ "temp": round(t0 + i * 0.4, 1),
+ "hum": round(h0 + (i % 5) * 2.0)})
+ return out
+
+
+def wrap(sensors, state, *, unrecovered=0, sms=182, today=3):
+ online = sum(0 if x["offline"] else 1 for x in sensors)
+ return {
+ "ts": "2026-07-19 14:32:07",
+ "system": {"state": state, "online": online, "total": len(sensors),
+ "unrecovered": unrecovered, "sms_balance": sms,
+ "today_alerts": today},
+ "sensors": sensors,
+ }
+
+
+def save(img, name):
+ p = os.path.join(OUT, name)
+ assert img.size == (config.WIDTH, config.HEIGHT), f"bad size {img.size}"
+ img.save(p)
+ print(f" saved {name} {img.size}")
+ return p
+
+
+# ── 시나리오 정의 ──────────────────────────────────────────────────────────
+# 결합 노드(1·3) = 누수 + 온습도 + 미세먼지 한 sensor_id 아래
+COMBINED_LEAK = sensor(1, leak_node=True, metric_node=True, pm_node=True,
+ leak=1, temp=22.4, hum=61, pm25=14, pm10=22,
+ leak_events=["03:12", "03:15", "07:48"], history=hist())
+COMBINED_PM = sensor(3, leak_node=True, metric_node=True, pm_node=True,
+ leak=0, pm_status="high_pm25", pm25=88, pm10=57,
+ temp=25.7, hum=54, leak_events=["01:20"], history=hist())
+COMBINED_OK = sensor(1, leak_node=True, metric_node=True, pm_node=True,
+ leak=0, pm25=11, pm10=19, temp=23.1, hum=49,
+ leak_events=[], history=hist())
+COMBINED_OFFLINE_LEAK = sensor(3, leak_node=True, metric_node=True, pm_node=True,
+ offline=True, leak=1, pm25=None, pm10=None,
+ temp=None, hum=None, leak_events=["22:05"])
+COMBINED_PM10 = sensor(1, leak_node=True, metric_node=True, pm_node=True,
+ leak=0, pm_status="high_pm10", pm25=41, pm10=173,
+ temp=24.0, hum=58, history=hist())
+# 온습도 전용 노드(2)
+METRIC_OK = sensor(2, metric_node=True, temp=24.8, hum=52, history=hist())
+# 오프라인(누수·미세먼지 없음 → OFFLINE 로 표기)
+OFFLINE_NODE = sensor(2, metric_node=True, offline=True, temp=None, hum=None,
+ last_seen="2026-07-19 11:02:41")
+# 구 API(=미세먼지 키 자체 없음) — pm_node 부재 → graceful, zone B '—'
+LEGACY_COMBINED = sensor(1, leak_node=True, metric_node=True, leak=0,
+ temp=23.3, hum=50, history=hist(), include_pm_keys=False)
+
+print("[harness] rendering frames ->", OUT)
+paths = []
+
+# 시스템 요약 — 누수 승격(푸터 점: leak/ok/pm/offline 색 자동)
+summary_data = wrap([COMBINED_LEAK, METRIC_OK, COMBINED_PM, OFFLINE_NODE],
+ "leak", unrecovered=1, today=5)
+paths.append(save(dd.draw_summary(summary_data, None), "01_summary_leak.png"))
+
+# 시스템 요약 — 미세먼지 티어가 시스템 상태로 승격된 경우
+summary_pm = wrap([COMBINED_OK, METRIC_OK, COMBINED_PM], "pm", today=4)
+paths.append(save(dd.draw_summary(summary_pm, None), "02_summary_pm.png"))
+
+# 결합 노드 상세
+paths.append(save(dd.draw_sensor(wrap([COMBINED_LEAK], "leak"), 0, None),
+ "03_combined_leak.png"))
+paths.append(save(dd.draw_sensor(wrap([COMBINED_PM], "pm"), 0, None),
+ "04_combined_pm25.png"))
+paths.append(save(dd.draw_sensor(wrap([COMBINED_PM10], "pm"), 0, None),
+ "05_combined_pm10.png"))
+paths.append(save(dd.draw_sensor(wrap([COMBINED_OK], "ok"), 0, None),
+ "06_combined_normal.png"))
+paths.append(save(dd.draw_sensor(wrap([COMBINED_OFFLINE_LEAK], "leak"), 0, None),
+ "07_combined_offline_leak.png"))
+paths.append(save(dd.draw_sensor(wrap([LEGACY_COMBINED], "ok"), 0, None),
+ "08_combined_legacy_no_pm.png"))
+
+# 온습도 전용 노드(2)
+paths.append(save(dd.draw_sensor(wrap([METRIC_OK], "ok"), 0, None),
+ "09_metric_normal.png"))
+
+# 오프라인 노드
+paths.append(save(dd.draw_sensor(wrap([OFFLINE_NODE], "offline"), 0, None),
+ "10_offline.png"))
+
+# STALE(서버 미응답) 오버레이 — 요약 페이지
+paths.append(save(dd.draw_summary(summary_data, 372), "11_summary_stale.png"))
+
+# ── 정밀 검증: 상태/색 precedence 를 코드가 아니라 결과로 단언 ──────────────
+def _assert_state(s, expect_st, expect_txt):
+ st, txt = dd.sensor_state(s)
+ ok = (st == expect_st and txt == expect_txt)
+ print(f" [{'OK ' if ok else 'ERR'}] sensor_state -> ({st!r},{txt!r}) "
+ f"expect ({expect_st!r},{expect_txt!r})")
+ return ok
+
+
+print("[harness] precedence assertions:")
+allok = True
+allok &= _assert_state(COMBINED_OFFLINE_LEAK, "leak", "LEAK!") # 누수 > offline
+allok &= _assert_state(COMBINED_PM, "pm", "PM2.5") # 미세먼지 > 온습도
+allok &= _assert_state(COMBINED_PM10, "pm", "PM10")
+allok &= _assert_state(
+ sensor(9, leak_node=True, metric_node=True, pm_node=True, leak=1,
+ pm_status="high_pm25"), "leak", "LEAK!") # 누수 > 미세먼지
+allok &= _assert_state(
+ sensor(9, metric_node=True, offline=True, th_status="condensation_risk"),
+ "alert", "CONDENSATION RISK") # 온습도 > offline
+allok &= _assert_state(sensor(9, metric_node=True, offline=True),
+ "offline", "OFFLINE")
+allok &= _assert_state(METRIC_OK, "ok", "OK")
+# 구 API(th_status 없음, metric_status 별칭 사용)
+allok &= _assert_state(
+ {"id": 9, "leak": 0, "metric_status": "high_humidity",
+ "leak_node": True, "metric_node": True, "offline": False, "pending": False},
+ "alert", "HIGH HUMIDITY")
+
+print("\n[harness] done.", len(paths), "PNGs")
+print("[harness] precedence:", "ALL PASS" if allok else "FAILURES ABOVE")
+for p in paths:
+ print(" ", p)
+if not allok:
+ sys.exit(1)
diff --git a/raspberry_pi_zero2w_display/render_preview/01_summary_leak.png b/raspberry_pi_zero2w_display/render_preview/01_summary_leak.png
new file mode 100644
index 0000000..569c73f
Binary files /dev/null and b/raspberry_pi_zero2w_display/render_preview/01_summary_leak.png differ
diff --git a/raspberry_pi_zero2w_display/render_preview/02_summary_pm.png b/raspberry_pi_zero2w_display/render_preview/02_summary_pm.png
new file mode 100644
index 0000000..60fce4e
Binary files /dev/null and b/raspberry_pi_zero2w_display/render_preview/02_summary_pm.png differ
diff --git a/raspberry_pi_zero2w_display/render_preview/03_combined_leak.png b/raspberry_pi_zero2w_display/render_preview/03_combined_leak.png
new file mode 100644
index 0000000..430325e
Binary files /dev/null and b/raspberry_pi_zero2w_display/render_preview/03_combined_leak.png differ
diff --git a/raspberry_pi_zero2w_display/render_preview/04_combined_pm25.png b/raspberry_pi_zero2w_display/render_preview/04_combined_pm25.png
new file mode 100644
index 0000000..cb8596d
Binary files /dev/null and b/raspberry_pi_zero2w_display/render_preview/04_combined_pm25.png differ
diff --git a/raspberry_pi_zero2w_display/render_preview/05_combined_pm10.png b/raspberry_pi_zero2w_display/render_preview/05_combined_pm10.png
new file mode 100644
index 0000000..adcb431
Binary files /dev/null and b/raspberry_pi_zero2w_display/render_preview/05_combined_pm10.png differ
diff --git a/raspberry_pi_zero2w_display/render_preview/06_combined_normal.png b/raspberry_pi_zero2w_display/render_preview/06_combined_normal.png
new file mode 100644
index 0000000..ae2d111
Binary files /dev/null and b/raspberry_pi_zero2w_display/render_preview/06_combined_normal.png differ
diff --git a/raspberry_pi_zero2w_display/render_preview/07_combined_offline_leak.png b/raspberry_pi_zero2w_display/render_preview/07_combined_offline_leak.png
new file mode 100644
index 0000000..281e046
Binary files /dev/null and b/raspberry_pi_zero2w_display/render_preview/07_combined_offline_leak.png differ
diff --git a/raspberry_pi_zero2w_display/render_preview/08_combined_legacy_no_pm.png b/raspberry_pi_zero2w_display/render_preview/08_combined_legacy_no_pm.png
new file mode 100644
index 0000000..0508f25
Binary files /dev/null and b/raspberry_pi_zero2w_display/render_preview/08_combined_legacy_no_pm.png differ
diff --git a/raspberry_pi_zero2w_display/render_preview/09_metric_normal.png b/raspberry_pi_zero2w_display/render_preview/09_metric_normal.png
new file mode 100644
index 0000000..bdd280b
Binary files /dev/null and b/raspberry_pi_zero2w_display/render_preview/09_metric_normal.png differ
diff --git a/raspberry_pi_zero2w_display/render_preview/10_offline.png b/raspberry_pi_zero2w_display/render_preview/10_offline.png
new file mode 100644
index 0000000..2aaf591
Binary files /dev/null and b/raspberry_pi_zero2w_display/render_preview/10_offline.png differ
diff --git a/raspberry_pi_zero2w_display/render_preview/11_summary_stale.png b/raspberry_pi_zero2w_display/render_preview/11_summary_stale.png
new file mode 100644
index 0000000..6d8d3b3
Binary files /dev/null and b/raspberry_pi_zero2w_display/render_preview/11_summary_stale.png differ
diff --git a/raspberry_pi_zero2w_display/requirements.txt b/raspberry_pi_zero2w_display/requirements.txt
new file mode 100644
index 0000000..e3dcea3
--- /dev/null
+++ b/raspberry_pi_zero2w_display/requirements.txt
@@ -0,0 +1,4 @@
+# 미니 표시기 파이썬 의존성 — 무거운 패키지(Pillow/numpy/RPi.GPIO/spidev)는
+# apt(debs/)로 설치하고 venv 는 --system-site-packages 로 물려받는다(sht30 과 동일 전략).
+# pip 로는 순수 파이썬 requests 계열만(이미 apt python3-requests 가 있으면 no-op).
+requests>=2.28
diff --git a/raspberry_pi_zero2w_display/st7789_driver.py b/raspberry_pi_zero2w_display/st7789_driver.py
new file mode 100644
index 0000000..a013958
--- /dev/null
+++ b/raspberry_pi_zero2w_display/st7789_driver.py
@@ -0,0 +1,159 @@
+# =============================================================================
+# 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
diff --git a/raspberry_pi_zero2w_display/systemd/display-dashboard.service b/raspberry_pi_zero2w_display/systemd/display-dashboard.service
new file mode 100644
index 0000000..492f5aa
--- /dev/null
+++ b/raspberry_pi_zero2w_display/systemd/display-dashboard.service
@@ -0,0 +1,51 @@
+# =============================================================================
+# /etc/systemd/system/display-dashboard.service
+# 미니 상태 표시기 (Pi Zero 2 W + ST7789V) 부팅 자동 실행
+#
+# 설치:
+# sudo cp systemd/display-dashboard.service /etc/systemd/system/
+# sudo systemctl daemon-reload && sudo systemctl enable --now display-dashboard
+# 경로가 /home/pi/display 가 아니면 WorkingDirectory/ExecStart 수정.
+# =============================================================================
+
+[Unit]
+Description=Mini Status Display (Pi Zero 2 W, ST7789V)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=pi
+Group=pi
+# /dev/spidev0.0 + GPIO 문자장치 접근
+SupplementaryGroups=spi gpio
+WorkingDirectory=/home/pi/display
+ExecStart=/home/pi/display/venv/bin/python display_dashboard.py
+Restart=always
+RestartSec=10
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=display-dashboard
+Environment=PYTHONUNBUFFERED=1
+PrivateTmp=true
+
+# ── 샌드박스 (sht30 유닛과 동일 정책) ──────────────────────────────────────
+NoNewPrivileges=true
+ProtectSystem=strict
+ReadWritePaths=/var/log
+ProtectHome=read-only
+ProtectKernelModules=true
+ProtectKernelTunables=true
+ProtectControlGroups=true
+RestrictSUIDSGID=true
+LockPersonality=true
+RestrictRealtime=true
+# HTTPS 폴링 + 로컬 소켓(DNS/journal). AF_NETLINK 은 getaddrinfo 용.
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
+# ⚠️ PrivateDevices 금지 — /dev/spidev0.0, /dev/gpiomem 이 가려져 화면 불가.
+
+StartLimitBurst=5
+StartLimitInterval=600s
+
+[Install]
+WantedBy=multi-user.target
diff --git a/raspberry_pi_zero2w_display/test_connection.py b/raspberry_pi_zero2w_display/test_connection.py
new file mode 100644
index 0000000..cbd93a8
--- /dev/null
+++ b/raspberry_pi_zero2w_display/test_connection.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+# =============================================================================
+# test_connection.py — 표시기 → 서버 status_summary 연결 검증 (하드닝 전 필수 통과)
+# 성공: 시스템 상태/센서 수 출력 후 exit 0. 실패: 원인 출력 후 exit 1.
+# =============================================================================
+import sys
+
+import requests
+
+import config
+
+
+def main() -> int:
+ print(f"[TEST] GET {config.STATUS_URL}")
+ try:
+ r = requests.get(config.STATUS_URL,
+ headers={"X-Status-Key": config.STATUS_KEY,
+ "User-Agent": "LeakDisplay/2.0"},
+ timeout=config.FETCH_TIMEOUT)
+ except Exception as e:
+ print(f"[FAIL] 네트워크 오류: {e}")
+ return 1
+ if r.status_code != 200:
+ print(f"[FAIL] HTTP {r.status_code}: {r.text[:200]}")
+ if r.status_code == 403:
+ print(" → STATUS_KEY 불일치: php -r 'echo hash_hmac(\"sha256\",\"status\",\"<서버 API_KEY>\");' 로 재계산.")
+ return 1
+ data = r.json()
+ sys_ = data.get("system", {})
+ print(f"[OK] state={sys_.get('state')} online={sys_.get('online')}/{sys_.get('total')} "
+ f"sensors={len(data.get('sensors', []))} ts={data.get('ts')}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/raspberry_pi_zero2w_display/vendor.sh b/raspberry_pi_zero2w_display/vendor.sh
new file mode 100644
index 0000000..663f4b7
--- /dev/null
+++ b/raspberry_pi_zero2w_display/vendor.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+# =============================================================================
+# vendor.sh — 폐쇄망 설치용 사전 다운로드 (미니 표시기, Pi Zero 2 W / aarch64)
+# 인터넷 되는 빌드 머신에서 실행 → wheelhouse/(pip), debs/(apt) 산출 → USB 로 이송.
+# 무거운 바이너리(Pillow/numpy/RPi.GPIO/spidev)는 .deb 로 해결하므로
+# wheel 은 순수 파이썬(requests 계열)만 필요 — 플랫폼 핀 부담이 없다.
+# .deb 는 대상과 동일 OS/아키텍처(Bookworm aarch64)에서 받는 것이 가장 안전.
+# =============================================================================
+set -euo pipefail
+cd "$(dirname "$0")"
+
+echo "[vendor] (1/2) Python wheel → wheelhouse/"
+mkdir -p wheelhouse
+python3 -m pip download -r requirements.txt -d wheelhouse \
+ || echo "[WARN] 일부 다운로드 실패(순수 파이썬 패키지만 필요 — 아래 개수 확인)"
+WHEEL_COUNT="$(ls -A wheelhouse 2>/dev/null | wc -l)"
+echo " wheelhouse 파일 수: ${WHEEL_COUNT}"
+[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse 비어 있음"; exit 1; }
+
+echo "[vendor] (2/2) apt .deb → debs/"
+mkdir -p debs
+# nftables/unattended-upgrades 포함 → harden_pi.sh 도 오프라인 실행 가능.
+BASE_PKGS="python3-requests python3-venv python3-pil python3-numpy python3-rpi.gpio python3-spidev fonts-dejavu-core nftables unattended-upgrades"
+DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
+ --no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
+ | awk '/^\w/{print $1}' | sort -u)"
+( cd debs && apt-get download ${BASE_PKGS} ${DEPS} ) 2>/dev/null \
+ || echo "[WARN] 일부 .deb 다운로드 실패(가상/전이 의존일 수 있음 — 필수 패키지는 아래에서 검증)"
+
+MISS=0
+for p in ${BASE_PKGS}; do
+ if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then
+ echo "[ERROR] 필수 패키지 .deb 누락: ${p}"
+ MISS=1
+ fi
+done
+[ "${MISS}" = "0" ] || exit 1
+
+echo ""
+echo "[vendor] 완료 — wheelhouse/ + debs/ 를 이 폴더째 USB 로 옮기면 install.sh 가 자동 오프라인 설치."