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.3V3V317빨강
GND접지GND20검정
SCLSPI 클럭(SCLK)GPIO1123주황
SDASPI 데이터(MOSI)GPIO1019노랑
RES리셋 (RST/RESET 과 동일 표기)GPIO2418초록
DC데이터/명령 선택GPIO2522파랑
CS칩 선택(CE0)GPIO824보라
+ +

Zero 2 W 40핀 핀맵 (위에서 본 그림)

+

배선 7가닥 전부 물리 17~24번 블록에 모여 있다 — 헤더 중간 8핀 구간만 쓰면 된다.

+

보드 부품면(위)에서 본 헤더. SD 카드 슬롯이 위, GPIO 헤더가 오른쪽 세로로 오는 방향 기준 +— 핀 1 = 헤더 맨 위 왼쪽(사각 납땜 패드). 왼쪽 열=홀수 핀(보드 안쪽), 오른쪽 열=짝수 핀(보드 가장자리).

+
+ 이번 배선 사용 + 3.3V + 5V + GND +
+ + + + + + + + + + + + + + + + + + + + + +
3V3125V
GPIO2 (SDA1)345V
GPIO3 (SCL1)56GND
GPIO478GPIO14 (TXD)
GND910GPIO15 (RXD)
GPIO171112GPIO18
GPIO271314GND
GPIO221516GPIO23
3V3→ VCC1718GPIO24→ RES
GPIO10 (MOSI)→ SDA1920GND→ GND
GPIO9 (MISO)2122GPIO25→ DC
GPIO11 (SCLK)→ SCL2324GPIO8 (CE0)→ CS
GND2526GPIO7 (CE1)
ID_SD2728ID_SC
GPIO52930GND
GPIO63132GPIO12
GPIO133334GND
GPIO193536GPIO16
GPIO263738GPIO20
GND3940GPIO21
+

방향 확인법: 헤더 납땜면에서 핀 1만 사각 패드(나머지는 원형). 핀 1 은 SD 카드 슬롯과 같은 쪽 모서리다.

+ +

점검

+
    +
  1. SPI 활성 확인: ls /dev/spidev0.0 (install.sh 가 dtparam=spi=on 설정, 재부팅 필요할 수 있음)
  2. +
  3. 화면 백지면 DC/RES 스왑 여부부터 확인(가장 흔한 실수)
  4. +
  5. 색이 반전돼 보이면 config.py 의 INVERT 토글
  6. +
  7. 렌더만 검증: FORCE_SIMULATION = True → /tmp/display_preview.png
  8. +
+ + 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 가 자동 오프라인 설치."