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 최악 케이스
48
raspberry_pi_zero2w_display/README.md
Normal 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`
|
||||
85
raspberry_pi_zero2w_display/WIRING.html
Normal 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>
|
||||
50
raspberry_pi_zero2w_display/config.py
Normal 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
|
||||
409
raspberry_pi_zero2w_display/display_dashboard.py
Normal 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)
|
||||
114
raspberry_pi_zero2w_display/install.sh
Normal 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 절 참고"
|
||||
209
raspberry_pi_zero2w_display/render_preview.py
Normal 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)
|
||||
BIN
raspberry_pi_zero2w_display/render_preview/01_summary_leak.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
raspberry_pi_zero2w_display/render_preview/02_summary_pm.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
raspberry_pi_zero2w_display/render_preview/03_combined_leak.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
raspberry_pi_zero2w_display/render_preview/04_combined_pm25.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
raspberry_pi_zero2w_display/render_preview/05_combined_pm10.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 15 KiB |
BIN
raspberry_pi_zero2w_display/render_preview/09_metric_normal.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
raspberry_pi_zero2w_display/render_preview/10_offline.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
raspberry_pi_zero2w_display/render_preview/11_summary_stale.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
4
raspberry_pi_zero2w_display/requirements.txt
Normal 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
|
||||
159
raspberry_pi_zero2w_display/st7789_driver.py
Normal 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
|
||||
|
|
@ -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
|
||||
36
raspberry_pi_zero2w_display/test_connection.py
Normal 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())
|
||||
40
raspberry_pi_zero2w_display/vendor.sh
Normal 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 가 자동 오프라인 설치."
|
||||