feat(display): Zero 2 W ST7789 상태 디스플레이 노드

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

- st7789_driver.py · display_dashboard.py · render_preview.py(합성 프레임 생성)
- install.sh · vendor.sh · systemd/display-dashboard.service · 배선(WIRING)·연결테스트
- render_preview/01~11: 요약/통합/메트릭/오프라인/stale/legacy-무PM 최악 케이스
This commit is contained in:
cw2007 2026-07-19 10:47:00 +09:00
parent ec78be0bda
commit 42a6ee4bb0
22 changed files with 1205 additions and 0 deletions

View file

@ -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`

View file

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>배선도 — Pi Zero 2 W + ST7789V (GMT020-02)</title>
<style>
body { font-family: system-ui, "Malgun Gothic", sans-serif; max-width: 720px; margin: 30px auto; padding: 0 16px; line-height: 1.6; }
h1 { font-size: 1.75rem; } h2 { font-size: 1.25rem; margin-top: 28px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #bbb; padding: 8px 12px; text-align: left; font-size: .9rem; }
th { background: #f2f2f5; }
.pin { font-family: Consolas, monospace; font-weight: 700; }
.warn { background: #fff6e0; border: 1px solid #e5c56a; border-radius: 8px; padding: 10px 14px; font-size: .9rem; }
/* 핀맵(보드 위에서 본 40핀 헤더) */
.pinmap { border-collapse: collapse; margin: 12px auto; }
.pinmap td { border: 1px solid #999; padding: 4px 8px; font-size: .78rem; text-align: center; min-width: 108px; }
.pinmap .no { min-width: 34px; font-family: Consolas, monospace; font-weight: 700; background: #f2f2f5; }
.pinmap .use { background: #dff3e4; font-weight: 700; }
.pinmap .use small { display: block; color: #1a7a3c; font-weight: 700; font-size: .7rem; }
.pinmap .pwr3 { background: #ffe9e0; } .pinmap .pwr5 { background: #ffd9d9; } .pinmap .gnd { background: #e8e8ec; }
.pinmap .pwr3.use, .pinmap .gnd.use { background: #dff3e4; }
.legend { display: flex; gap: 14px; justify-content: center; font-size: .78rem; margin-bottom: 4px; flex-wrap: wrap; }
.legend i { display: inline-block; width: 12px; height: 12px; border: 1px solid #999; vertical-align: -2px; margin-right: 4px; }
</style>
</head>
<body>
<h1>배선도 — Pi Zero 2 W + ST7789V GMT020-02 (2.0" 240×320 IPS)</h1>
<p class="warn">⚠️ 반드시 <b>3.3V</b> 사용. 5V 연결 시 패널 손상. 배선은 전원 차단 상태에서.</p>
<h2>연결표 (7핀)</h2>
<table>
<tr><th>LCD 핀</th><th>기능</th><th>Pi GPIO (BCM)</th><th>물리 핀 번호</th><th>선색 예시</th></tr>
<tr><td class="pin">VCC</td><td>전원 3.3V</td><td class="pin">3V3</td><td>17</td><td>빨강</td></tr>
<tr><td class="pin">GND</td><td>접지</td><td class="pin">GND</td><td>20</td><td>검정</td></tr>
<tr><td class="pin">SCL</td><td>SPI 클럭(SCLK)</td><td class="pin">GPIO11</td><td>23</td><td>주황</td></tr>
<tr><td class="pin">SDA</td><td>SPI 데이터(MOSI)</td><td class="pin">GPIO10</td><td>19</td><td>노랑</td></tr>
<tr><td class="pin">RES</td><td>리셋 (RST/RESET 과 동일 표기)</td><td class="pin">GPIO24</td><td>18</td><td>초록</td></tr>
<tr><td class="pin">DC</td><td>데이터/명령 선택</td><td class="pin">GPIO25</td><td>22</td><td>파랑</td></tr>
<tr><td class="pin">CS</td><td>칩 선택(CE0)</td><td class="pin">GPIO8</td><td>24</td><td>보라</td></tr>
</table>
<h2>Zero 2 W 40핀 핀맵 (위에서 본 그림)</h2>
<p><b>배선 7가닥 전부 물리 17~24번 블록</b>에 모여 있다 — 헤더 중간 8핀 구간만 쓰면 된다.</p>
<p>보드 부품면(위)에서 본 헤더. <b>SD 카드 슬롯이 위</b>, GPIO 헤더가 오른쪽 세로로 오는 방향 기준
— <b>핀 1 = 헤더 맨 위 왼쪽(사각 납땜 패드)</b>. 왼쪽 열=홀수 핀(보드 안쪽), 오른쪽 열=짝수 핀(보드 가장자리).</p>
<div class="legend">
<span><i style="background:#dff3e4"></i>이번 배선 사용</span>
<span><i style="background:#ffe9e0"></i>3.3V</span>
<span><i style="background:#ffd9d9"></i>5V</span>
<span><i style="background:#e8e8ec"></i>GND</span>
</div>
<table class="pinmap">
<tr><td class="pwr3">3V3</td><td class="no">1</td><td class="no">2</td><td class="pwr5">5V</td></tr>
<tr><td>GPIO2 (SDA1)</td><td class="no">3</td><td class="no">4</td><td class="pwr5">5V</td></tr>
<tr><td>GPIO3 (SCL1)</td><td class="no">5</td><td class="no">6</td><td class="gnd">GND</td></tr>
<tr><td>GPIO4</td><td class="no">7</td><td class="no">8</td><td>GPIO14 (TXD)</td></tr>
<tr><td class="gnd">GND</td><td class="no">9</td><td class="no">10</td><td>GPIO15 (RXD)</td></tr>
<tr><td>GPIO17</td><td class="no">11</td><td class="no">12</td><td>GPIO18</td></tr>
<tr><td>GPIO27</td><td class="no">13</td><td class="no">14</td><td class="gnd">GND</td></tr>
<tr><td>GPIO22</td><td class="no">15</td><td class="no">16</td><td>GPIO23</td></tr>
<tr><td class="pwr3 use">3V3<small>→ VCC</small></td><td class="no use">17</td><td class="no use">18</td><td class="use">GPIO24<small>→ RES</small></td></tr>
<tr><td class="use">GPIO10 (MOSI)<small>→ SDA</small></td><td class="no use">19</td><td class="no use">20</td><td class="gnd use">GND<small>→ GND</small></td></tr>
<tr><td>GPIO9 (MISO)</td><td class="no">21</td><td class="no use">22</td><td class="use">GPIO25<small>→ DC</small></td></tr>
<tr><td class="use">GPIO11 (SCLK)<small>→ SCL</small></td><td class="no use">23</td><td class="no use">24</td><td class="use">GPIO8 (CE0)<small>→ CS</small></td></tr>
<tr><td class="gnd">GND</td><td class="no">25</td><td class="no">26</td><td>GPIO7 (CE1)</td></tr>
<tr><td>ID_SD</td><td class="no">27</td><td class="no">28</td><td>ID_SC</td></tr>
<tr><td>GPIO5</td><td class="no">29</td><td class="no">30</td><td class="gnd">GND</td></tr>
<tr><td>GPIO6</td><td class="no">31</td><td class="no">32</td><td>GPIO12</td></tr>
<tr><td>GPIO13</td><td class="no">33</td><td class="no">34</td><td class="gnd">GND</td></tr>
<tr><td>GPIO19</td><td class="no">35</td><td class="no">36</td><td>GPIO16</td></tr>
<tr><td>GPIO26</td><td class="no">37</td><td class="no">38</td><td>GPIO20</td></tr>
<tr><td class="gnd">GND</td><td class="no">39</td><td class="no">40</td><td>GPIO21</td></tr>
</table>
<p class="warn">방향 확인법: 헤더 납땜면에서 <b>핀 1만 사각 패드</b>(나머지는 원형). 핀 1 은 SD 카드 슬롯과 같은 쪽 모서리다.</p>
<h2>점검</h2>
<ol>
<li>SPI 활성 확인: <code>ls /dev/spidev0.0</code> (install.sh 가 dtparam=spi=on 설정, 재부팅 필요할 수 있음)</li>
<li>화면 백지면 DC/RES 스왑 여부부터 확인(가장 흔한 실수)</li>
<li>색이 반전돼 보이면 <code>config.py</code> 의 <code>INVERT</code> 토글</li>
<li>렌더만 검증: <code>FORCE_SIMULATION = True</code> → <code>/tmp/display_preview.png</code></li>
</ol>
</body>
</html>

View file

@ -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

View file

@ -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)

View file

@ -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 절 참고"

View file

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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())

View file

@ -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 가 자동 오프라인 설치."