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

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

209 lines
9.2 KiB
Python

#!/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)