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 최악 케이스
209 lines
9.2 KiB
Python
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)
|