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