chore(deploy+docs): 노드 번들 생성기·하드닝 갱신 + 설계문서

- tools/make_deploy_bundles.py: PMS7003·Zero2W 디스플레이 노드 번들 생성 반영(폐쇄망 동봉)
- tools/harden_pi.sh: 하드닝 스크립트 갱신
- docs/superpowers: 노드 센서 통합·스모크 파일럿·대시보드 다크 설계/계획 문서
- docs/claude-config: IoT 납품·검증 스킬/룰 참조본
- review/: 프로젝트 리뷰·UI v2 목업
This commit is contained in:
cw2007 2026-07-19 10:48:09 +09:00
parent 6f8bf9413c
commit 90b93f818c
12 changed files with 2572 additions and 65 deletions

View file

@ -0,0 +1,43 @@
# Claude Code 전역 설정 이동용 사본
이 폴더 = 이 프로젝트 작업에서 추출한 Claude Code 전역 스킬/규칙의 사본.
다른 PC에서 아래 경로로 복사하면 동일하게 동작한다. 비밀값 없음(커밋 안전).
## 파일
| 파일 | 종류 | 설치 위치 (다른 PC) |
|---|---|---|
| `iot-airgap-delivery.SKILL.md` | 스킬 (필요 시 자동/수동 호출) | `~/.claude/skills/iot-airgap-delivery/SKILL.md` |
| `iot-verification.rule.md` | 규칙 (전 세션 자동 로드) | `~/.claude/rules/common/iot-verification.md` |
Windows에서 `~` = `C:\Users\<계정>`.
## 설치 (다른 PC)
```bash
# Git Bash / WSL
mkdir -p ~/.claude/skills/iot-airgap-delivery ~/.claude/rules/common
cp iot-airgap-delivery.SKILL.md ~/.claude/skills/iot-airgap-delivery/SKILL.md
cp iot-verification.rule.md ~/.claude/rules/common/iot-verification.md
```
```powershell
# PowerShell
New-Item -ItemType Directory -Force "$env:USERPROFILE\.claude\skills\iot-airgap-delivery", "$env:USERPROFILE\.claude\rules\common" | Out-Null
Copy-Item iot-airgap-delivery.SKILL.md "$env:USERPROFILE\.claude\skills\iot-airgap-delivery\SKILL.md"
Copy-Item iot-verification.rule.md "$env:USERPROFILE\.claude\rules\common\iot-verification.md"
```
설치 후 새 Claude Code 세션에서 스킬 목록에 `iot-airgap-delivery` 뜨면 성공.
## 내용 요약
- **스킬**: 파생키(HMAC) 구조·번들 재생성 워크플로·403/WAF 디버깅 사다리·
디스플레이 렌더 오프디바이스 검증·납품 전 병렬 감사 절차.
- **규칙**: 위 중 "항상 지켜야 하는" 행동 규칙만 요약(마스터키 취급, deploy/ 수정 금지,
requests 커스텀 UA 필수, 렌더 PNG 검증 후 배포, 폴링 엔드포인트 인덱스/LIMIT/try-catch).
## 갱신 방법
전역 파일(`~/.claude/...`)을 고친 뒤 이 폴더로 다시 복사해서 커밋.
(전역이 원본, 이 폴더는 배포용 사본.)

View file

@ -0,0 +1,90 @@
---
name: iot-airgap-delivery
description: Use when working on IoT sensor systems with air-gapped deploy bundles (Pi nodes + shared-hosting PHP server) — covers derived-key auth, bundle regeneration workflow, 403/WAF debugging, display render verification, and pre-delivery audit. Trigger on deploy bundle work, HMAC key mismatch/403 errors, Pi display rendering, or "납품" (delivery) prep.
---
# IoT Air-gap Delivery Workflow
Battle-tested workflow for IoT systems shaped like: sensor nodes (Raspberry Pi / STM32)
→ shared-hosting PHP server (cafe24-style) → optional mini display node, deployed as
self-contained offline bundles generated by a bundle-generator script.
## 1. Key architecture (get this right first — most repeated confusion)
- **API_KEY** = master secret. Lives ONLY in: server `config.local.php` + build machine
`deploy/.secrets/api_key.txt`. Never on leaf devices.
- **Derived keys** = `HMAC-SHA256(message=<label>, key=API_KEY)`:
- `STATUS_KEY = hash_hmac('sha256', 'status', API_KEY)` → display node only
- `CRON_SECRET = hash_hmac('sha256', 'cron', API_KEY)` → cron HTTP access
- The server stores NO derived keys — it recomputes them per request and compares with
`hash_equals`. So "server-side status key" does not exist as a stored value.
- To print the expected derived key on the server:
`php -r 'require "config.php"; echo hash_hmac("sha256","status",API_KEY),"\n";'`
- NEVER put the same value in both API_KEY and a derived-key slot. They are different values.
- Regenerating `api_key.txt` breaks EVERY deployed node. The generator must reuse an
existing secret file; verify the printed key prefix is unchanged after regeneration.
## 2. Bundle workflow (source is truth)
1. Edit source files only (never hand-edit files inside `deploy/`).
2. Re-run the generator (e.g. `python tools/make_deploy_bundles.py`).
3. Confirm output reuses the existing API_KEY (prefix printed unchanged).
4. Deploy: server bundle → hosting upload; node bundle → copy to Pi →
`sudo systemctl restart <service>`.
5. `deploy/` must be gitignored (contains live secrets); remind to back up
`deploy/.secrets/` offline — losing it orphans every deployed node.
## 3. 403 / auth-failure debugging ladder (in order)
1. Distinguish WHO rejected: `curl -sS -i -H "X-<Key-Header>: <key>" <url>`
- JSON error body (`{"error":"forbidden"}`) → app rejected → key mismatch.
- HTML 403 → Apache/WAF rejected → NOT a key problem.
2. Key mismatch: print server-side derived key (see §1), diff against device config byte-for-byte
(watch for pasted whitespace/newlines).
3. curl works but device fails with SAME key →
a. Service not restarted after config edit (config imported once at process start).
b. Service loads a DIFFERENT config file (check systemd `WorkingDirectory`/`ExecStart`,
grep the actual file the service imports).
c. **WAF blocks default `python-requests/x.y` User-Agent** (very common on shared hosting;
curl's UA passes). Fix: always send a custom UA (`"<AppName>/2.0"`) in every
`requests` call. Reproduce with `curl -A "python-requests/2.31.0"`.
4. 503 `server_not_configured` → server API_KEY still default → upload real config.local.php.
5. Placeholder junk keys (e.g. `wnegfjk...`) in a config = never-configured file; find which
file the process actually loads before editing anything.
## 4. Display node changes (small TFT, Pillow rendering)
- Any render-code change: verify OFF-DEVICE first. Write a scratchpad script that stubs
`config` + driver modules via `sys.modules`, feeds synthetic data (including worst cases:
all-sections node, missing history, single data point, extreme values), saves PNGs at the
panel's exact resolution.
- Read the PNGs back (vision) to check: no overlapping text, no clipping at panel edges,
no dead zones; then send previews to the user via SendUserFile before deploying.
- Layout: slot-based adaptive layout (only sections the node type needs, evenly divided)
beats fixed y-coordinates. Guard: clamp page index when sensor count shrinks; fall back
to a smaller font when a formatted value exceeds panel width.
- SPI display init fail `No such file or directory` → `/dev/spidev*` missing → enable SPI
(`sudo raspi-config nonint do_spi 0`) + reboot; then check user in `spi,gpio` groups.
## 5. Server API endpoints (shared hosting)
- Poll-heavy endpoints (device polls every ~30s): every time-window query MUST ride an
index. Composite `(sensor_id, measured_at)` needs a `sensor_id` predicate — loop
per-sensor with a prepared statement instead of one global range scan.
- Cap unbounded result sets (`LIMIT`) — a flapping sensor must not inflate the JSON payload.
- Wrap the whole handler in try/catch → generic `{"error":"internal"}` + `error_log`;
set `ini_set('display_errors','0')` centrally (shared hosting often ships On).
- Rate-limit per IP (file-based fixed window, LOCK_EX, same pattern as login throttle);
fail-open on storage errors so monitoring devices never die from the throttle itself.
- Guard optional tables with a `table_exists` helper before querying.
- Additive JSON only: add keys, never rename/remove — old deployed clients keep working.
## 6. Pre-delivery audit (납품 전)
1. Launch TWO parallel review agents: security-reviewer + code-reviewer, scoped to the
changed surfaces. Verify each finding against actual code before acting.
2. Always check FIRST: real secret files (`config_local.py`, `config.local.php`) covered by
`.gitignore` AND never committed (`git log --all --full-history -- <path>`); rotate if leaked.
3. Fix by severity: CRITICAL (secrets/injection) → HIGH (perf/full scans) → MEDIUM
(error disclosure, unbounded payloads) → LOW (render edge cases).
4. `php -l` every touched PHP file; re-run render verification; regenerate bundles; then commit.

View file

@ -0,0 +1,43 @@
# IoT Delivery Verification Rules
> Companion to [build-deployment.md](./build-deployment.md) (air-gapped bundles) and the
> `iot-airgap-delivery` skill (full workflow). These are always-on behavioral rules.
## Derived-key discipline (CRITICAL)
- Master secret (API_KEY) lives only on the server config + build machine secrets dir.
Leaf devices get HMAC-derived keys only (`hash_hmac('sha256', '<label>', API_KEY)`).
- Servers recompute derived keys per request — never ask "where is the derived key stored
on the server"; print it: `php -r 'require "config.php"; echo hash_hmac(...);'`
- Never regenerate the master secret casually — it orphans every deployed node. Bundle
generators must reuse the existing secret file; verify the key prefix after every run.
- Before any commit: real secret files (`config_local.py`, `config.local.php`,
`secrets.h`) must be gitignored AND absent from history
(`git log --all --full-history -- <path>`). Leak → rotate.
## Deploy bundle discipline
- Source tree is truth. Never hand-edit generated `deploy/` files — edit source, re-run
the generator, redeploy.
- After every source change that ships: regenerate bundles, then state which bundle goes
where (server upload vs Pi copy + `systemctl restart <service>`).
## HTTP client discipline (shared-hosting targets)
- Every Python `requests` call to a shared-hosting server MUST send a custom User-Agent
(WAFs commonly 403 the default `python-requests/x.y` while curl passes).
- Debugging 403: first decide WHO rejected (JSON body = app/key; HTML = WAF/Apache),
then follow the ladder in the `iot-airgap-delivery` skill. Don't guess — curl it.
## Render verification (device displays)
- Never ship display-render changes verified only by reading code. Render synthetic
worst-case frames to PNG at exact panel resolution, inspect them visually, show the
user previews (SendUserFile), THEN deploy.
## Poll-heavy endpoint rules
- Time-window SQL must ride an index (composite indexes need their leading column in
WHERE — loop per-entity if needed). Cap result sets with LIMIT. Whole handler in
try/catch with generic error JSON. File-based per-IP rate limit that fails OPEN
(monitoring must not die from its own throttle). JSON responses evolve additively only.

View file

@ -0,0 +1,97 @@
# 대시보드 다크 UI 전면 개편 구현 계획
날짜: 2026-07-16
목업: `review/ui_mockup_v2.html` (사용자 승인 완료)
브랜치: feat/pi-c-sensor3-leak-node (또는 신규 feat/dashboard-dark-ui)
## 배경·목표
목업(다크 관제 테마, 글래스 툴바, 결합 센서 카드, 카드형 테이블)을 실제
`php/dashboard.php` + `php/assets/app.css` + `php/tabs/*.php` 에 반영한다.
폐쇄망 제약 유지: 외부 CDN/폰트/JS 0, 차트는 서버 렌더 인라인 SVG.
## 센서 토폴로지 (표현의 핵심)
| id | 구성 | 보고 주기 | 특징 |
|----|------|-----------|------|
| 1 | 누수 + 온습도 결합 (Pi-A) | 10분 | 누수 사진 촬영·업로드 |
| 2 | 온습도 단독 (Pi-B) | 1시간 | 오프라인 판정 임계 김(≈125분) |
| 3 | 누수 + 온습도 결합 (Pi-C) | 10분 | 누수 사진 촬영·업로드 |
### 발견된 기존 UX 결함 (이번 개편에서 반드시 해결)
`tabs/live.php` 는 id 중복 시 metric 카드가 leak 카드를 가린다
(`if (!isset($sensor_cards[$sid]))`). → 1·3번 센서의 **누수 상태가
실시간 화면에 표시되지 않는다.** 결합 카드로 통합해 해결한다.
## 카드 설계 (센서 특징별)
### A. 결합 카드 (id 1·3) — 한 장에 두 계통
- 헤더: 센서명 + 칩 2개 `누수` `온습도` (또는 단일 칩 `누수+온습도`)
- 상태 라인 우선순위: 오프라인 > 누수(확정) > 확인중(디바운스) > 온습도 경보 > 정상
- 누수와 온습도 경보 동시면: 상태 라인은 누수, 온습도 칩에 경보색 유지
- 본문 2행:
1. 누수 행: 상태 점 + 텍스트. 누수 중이면 경과 시간·SMS 발송 수·**최근 사진 썸네일**(photo.php 링크)
2. 온습도 행: 온도/습도 큰 숫자 + 24h 스파크라인 + 예보 칩(여유/접근)
- 누수 중 시각 효과: 카드 glow 펄스 + 하단 물결 SVG (reduced-motion 시 정지)
- 푸터: 최종확인 시각 · `10분 주기` 표기
### B. 온습도 단독 카드 (id 2)
- 결합 카드에서 누수 행 제거한 형태. `1시간 주기` 표기.
- 오프라인 elapsed 표기는 기존 로직 유지.
### C. 데이터 없음/오프라인 placeholder — 기존 로직 유지, 새 스타일만.
## 구현 페이즈
### Phase 1 — 디자인 토큰·셸 (dash-dark.css 신설 + dashboard.php)
0. 확인 완료: app.css 는 login/monthly_report/setup_wizard 등 7개 페이지 공용
→ 직접 교체 금지. **`assets/dash-dark.css` 신설**, dashboard.php 만
app.css 뒤에 추가 로드해 토큰·컴포넌트를 오버라이드. 타 페이지 무변경.
1. `dash-dark.css` 토큰: 다크 팔레트(--bg/--surface/--ink/--ok/--warn/--err/--leak/--temp/--hum),
spring easing 변수, 라운드/간격 토큰. 기존 클래스명 유지(마크업 변경 최소화).
2. 글래스 툴바(sticky + backdrop-filter), 새로고침 카운트다운 링(기존 30초 리로드
JS 그대로, 표시만 교체).
3. 히어로/알림 스트립/KPI 스트립/세그먼트 탭바 스타일 교체.
4. `prefers-reduced-motion`, `prefers-reduced-transparency` 블록.
5. 인쇄(tab=all) 대비: `@media print` 에서 다크 배경 → 흰 배경·검정 잉크로 강제
(기존 인쇄·보안 증적 워크플로 보존). monthly_report*·login 등 타 페이지는 건드리지 않음.
### Phase 2 — live 탭: 결합 센서 카드
1. `tabs/live.php` 카드 병합 로직 재작성: id별로 metric+leak 상태 모두 보유하는
구조(`['metric' => s|null, 'leak' => s|null]`)로 변경.
2. 결합/단독/누수단독 3분기 렌더. 상태 우선순위 규칙 위 설계대로.
3. 누수 중 카드에 최근 사진 썸네일: `load_tab_live()` 에서 미복구 사고의
최신 photo id 1~2건 조회 추가 (`leak_photos` 테이블, 기존 history 쿼리 재사용).
4. 24h 스파크라인: `load_tab_live()` 에 센서별 최근 24h 측정 다운샘플(≤48pt) 쿼리
추가, 인라인 SVG polyline 서버 렌더(트렌드 차트 헬퍼 축소판).
5. 보고 주기 표기: 설정 상수에서 도출(하드코딩 금지).
### Phase 3 — trend 탭 스타일 이식
1. 기존 SVG 차트 구조 유지(임계선·피크 플래그 이미 있음), 클래스 → 새 토큰 매핑.
2. 통계 위젯(7일 추이/도넛/게이지/리더보드) 카드 스타일 교체. 마크업 거의 그대로.
### Phase 4 — history 탭 카드형 테이블
1. 테이블 4종을 `tablecard`(헤더+건수 필) 래핑, 행 hover·경보 행 강조.
2. 사진 썸네일 hover 확대. details 접힘(모바일) 동작 유지.
3. 30일 평균·SMS 통계 그리드 새 스타일.
### Phase 5 — system 탭 + 마무리
1. system 탭 진단 목록 새 스타일(구조 변경 없음).
2. `tests/` PHP 테스트 통과 확인(헬퍼 시그니처 불변이므로 영향 최소 예상).
3. 반응형(모바일 768px) + 인쇄(tab=all) 실기 확인.
4. `deploy/01_server_cafe24` 에 app.css·tabs 복사본 있으면 동기화 확인.
## 지키는 것 (불변)
- 서버 렌더 구조·URL 스킴(?tab=, ?range=)·30초 리로드·details 복원 JS
- 임계 판정/SMS 로직 (표시 전용 개편)
- WCAG: 새로고침 일시정지 버튼, aria-current, 명도 대비(다크에서 4.5:1 재검증)
- 폐쇄망: 외부 리소스 0
## 리스크
- 다크 배경 인쇄: tab=all 인쇄 증적이 검정 배경으로 나가면 안 됨 → Phase 1 의
@media print 강제 라이트가 방어. 실기 인쇄 미리보기 확인 필수.
- app.css 651줄 전면 교체 → 페이지별(login, setup_wizard 등) 공용 여부 확인 후
dashboard 전용 분리 또는 공용 토큰만 교체 결정 (Phase 1 착수 시 grep).

View file

@ -0,0 +1,606 @@
# 연기(화재) 감지 노드 시범 — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 저가 광전식 연기 감지기 개조품의 감지 이벤트를 기존 PHP 서버까지 도달시켜 `[화재감지]` SMS를 발송한다.
**Architecture:** 기존 Pi4 누수 노드(`leak_sensor.py`)에 연기 채널을 `sensor_id=4`로 추가. 옵토커플러로 절연된 GPIO27 입력을 기존 엣지검출+confirm-read 디바운스가 처리하고, 확정 전이에서 `smoke_alarm`/`smoke_recovery` event_type를 HMAC 서명 POST. 서버(`sensor_data.php`)는 additive로 새 event_type를 수용해 별도 SMS 태그·수신자 그룹으로 라우팅. `is_leak`·누수 로직은 불변.
**Tech Stack:** Python 3.11 (RPi.GPIO/rpi-lgpio), PHP 8 + MySQL 8, HMAC-SHA256 raw-body 서명, systemd, 에어갭 배포 번들.
## Global Constraints
- **에어갭(폐쇄망):** 런타임/설치 시 네트워크 없음. 이 시범은 신규 스택·ADC 없음 → 추가 벤더링 불필요(기존 rpi-lgpio/requests 그대로).
- **HMAC raw-body 바이트 동일성:** body는 손직렬화 고정 필드순서 문자열. `is_leak`는 0/1 정수, 필드순서 불변. 서버 검증(`verify_signature_raw`, config.php:663)은 event_type 무관.
- **additive-only:** JSON/DB/응답 스키마는 추가만. 기존 필드 의미 변경 금지(특히 `is_leak`·누수 상태).
- **event_type 이중 게이트:** `sensor_data.php:208`(PHP 화이트리스트) + `sql/schema_sht30.sql:30`(DB ENUM) 둘 다 넓혀야 함. 하나만 하면 조용히 `periodic` 강등(로그·SMS 없음) 또는 DB insert 실패.
- **소스가 진실:** `deploy/` 생성물 손편집 금지. `make_deploy_bundles.py` 소스 편집 → 재생성.
- **커밋 전 전체 테스트 통과 수 명시**(`./run_tests.sh`), 커밋은 사용자 지시 시.
- **인증 경계:** 개조 저가 감지기는 인증품 아님. 보조 통보용. README/대시보드 배너 유지.
---
## 하드웨어 — 구매 목록 + 배선도
### 구매 (AliExpress, ≈ ₩5,000 + 공구)
| 부품 | 링크 / 검색 | 수량 | 가격 |
|---|---|---|---|
| **광전식 단독형 연기알람** (광전식 명시) | AliExpress item **1005010719685508** ("스마트 광전식 화재 경보기 배터리 85dB") | 2 (1개 실습용) | ₩1,507/개 |
| 대안 감지기 | item 1005011575755691 (EN14604 CE) / 1005010724878980 (PGST) | — | ₩1,507 |
| **PC817 옵토커플러** | 검색 `PC817` 10-pack | 1 | ~₩1,000 |
| 저항 (1kΩ, 10kΩ) | 검색 `resistor kit 1/4W` | 1 | ~₩1,000 |
| 점퍼선 F-F + 미니 브레드보드 | 검색 `dupont female female` / `mini breadboard 400` | 1 | ~₩2,000 |
**필수 공구:** 멀티미터(DC 전압), 인두기+납. 없으면 개조 불가 — 먼저 확보.
### 배선도 (개조, PC817 절연, 감지=HIGH)
```
저가 광전식 알람 (배터리 내장, 개조) 라즈베리파이 4 (40핀 헤더)
┌───────────────────────────┐
│ [알람 IC] │ 3V3 ── pin 1 ─────┐
│ │ 알람 시 LED 점등 │ │
│ LED 애노드 ●──┐ │ ┌────────────────┘
│ (1kΩ) │ PC817 │4 (collector)
│ └──────────────► 1핀(A) ▷|─────► │
│ GND ●──────────────────────► 2핀(K) │ 3핀(E)
│ │ [PC817 내부 포토트랜지스터] │
└───────────────────────────┘ └───► pin 13 (GPIO27) ──┬── 감지 입력
│
10kΩ 풀다운 ────┤
│
GND ── pin 9 ───────┘
동작: 평상시 → 알람 LED off → 옵토 off → GPIO27 = LOW (풀다운, 정상)
연기 → 알람 LED on → 옵토 도통 → GPIO27 = HIGH (감지)
```
**핀 매핑 (BCM/물리):**
| 신호 | 연결 | Pi 물리핀 |
|---|---|---|
| 3.3V (PC817 collector 측) | PC817 4핀 → 3V3 | pin 1 |
| SMOKE 입력 | PC817 3핀(emitter) → GPIO27 | pin 13 |
| 풀다운 | GPIO27 → 10kΩ → GND | pin 13 ↔ pin 9 |
| GND 공통 | 감지기 GND ↔ Pi GND(pin 9) | — |
**주의:**
1. LED 애노드 노드 전압을 멀티미터 DC로 먼저 측정(평상시 vs 테스트버튼). V면 직렬저항 = (V−1.2)/2mA. ~2V면 470Ω, 애매하면 1kΩ.
2. PC817을 하이사이드(collector→3V3, emitter→GPIO+풀다운)로 배선해야 감지=HIGH → 기존 `ACTIVE_LOW=False`와 극성 일치(코드 무변경).
3. 감지기 GND와 Pi GND 공통 접지 필수(옵토 LED 리턴 경로).
4. **한계:** 배터리死·단선은 무음(시범 감수). 실배포 승격 시 릴레이+NC(스펙 §9).
---
## File Structure
**서버(PHP):**
- `php/config.php` — SMS 태그 2개 + 쿨다운 상수 + summary SQL 2줄 (수정)
- `php/api/sensor_data.php` — event_type 화이트리스트 + is_leak 가드 + smoke 디스패치 분기 (수정)
- `php/sms_send.php` — `send_smoke_alert_sms()` / `send_smoke_recovery_sms()` (추가)
- `sql/schema_sht30.sql` — ENUM 확장 (수정, 신규 설치용)
- `sql/migration_leak_smoke.sql` — 기존 운영 DB ALTER (신규)
- `tests/smoke_alert_test.php` — 순수 로직 테스트 (신규)
**Pi 노드(Python):**
- `raspberry_pi4/leak_sensor.py` — 순수 헬퍼 `event_type_for()` + `_confirm_state_change` kind 분기 (수정)
- `raspberry_pi4/tests/test_smoke_event.py` — 순수 헬퍼 테스트 (신규)
**배포:**
- `tools/make_deploy_bundles.py` — Pi-C PI_META sensors + 서버 `SENSOR_LABELS[4]`/`SMOKE_SENSOR_IDS` (수정)
---
## Task 1: 서버 — event_type 수용 + is_leak 가드
**Files:**
- Modify: `php/api/sensor_data.php:208-218`
**Interfaces:**
- Produces: 서버가 `smoke_alarm`/`smoke_recovery` event_type를 `periodic`으로 강등하지 않고 로깅. `$is_smoke_event` 불리언. smoke 이벤트는 `is_leak=0` 강제.
- [ ] **Step 1: 화이트리스트 확장**
`php/api/sensor_data.php:208-209` 를 아래로 교체:
```php
$event_type = in_array($data['event_type'], ['startup', 'periodic', 'leak_alert', 'leak_recovery', 'smoke_alarm', 'smoke_recovery'], true)
? $data['event_type'] : 'periodic';
```
- [ ] **Step 2: is_leak 가드 + is_smoke_event (라인 213-218 블록 교체)**
```php
if ($event_type === 'leak_alert') {
$is_leak = 1;
} elseif ($event_type === 'leak_recovery') {
$is_leak = 0;
} elseif ($event_type === 'smoke_alarm' || $event_type === 'smoke_recovery') {
$is_leak = 0; // 연기 이벤트는 누수 상태를 건드리지 않음
}
$is_leak_event = ($event_type === 'leak_alert' || $event_type === 'leak_recovery');
$is_smoke_event = ($event_type === 'smoke_alarm' || $event_type === 'smoke_recovery');
```
- [ ] **Step 3: 커밋**
```bash
git add php/api/sensor_data.php
git commit -m "feat(smoke): ingest에 smoke_alarm/recovery event_type 수용 (is_leak 불변)"
```
---
## Task 2: 서버 — DB ENUM 확장 + 마이그레이션
**Files:**
- Modify: `sql/schema_sht30.sql:30-31`
- Create: `sql/migration_leak_smoke.sql`
**Interfaces:**
- Produces: `leak_sensor_log.event_type` ENUM이 smoke 값 수용. 없으면 Task1의 INSERT가 strict 모드에서 실패/절단.
- [ ] **Step 1: 신규 설치 스키마 ENUM 확장**
`sql/schema_sht30.sql:30` 의 event_type 컬럼을:
```sql
event_type ENUM('startup','periodic','leak_alert','leak_recovery','smoke_alarm','smoke_recovery') NOT NULL DEFAULT 'periodic',
```
- [ ] **Step 2: 기존 운영 DB용 마이그레이션 작성**
`sql/migration_leak_smoke.sql` 생성:
```sql
-- 연기 감지 이벤트 타입 추가 (additive). MySQL 8은 ADD IF NOT EXISTS 미지원 —
-- 재실행 시 동일 정의라 에러 없이 통과(값이 이미 있으면 그대로).
ALTER TABLE leak_sensor_log
MODIFY COLUMN event_type
ENUM('startup','periodic','leak_alert','leak_recovery','smoke_alarm','smoke_recovery')
NOT NULL DEFAULT 'periodic';
```
- [ ] **Step 3: 로컬/스테이징 DB에 적용 후 insert 검증**
Run:
```bash
mysql <db> < sql/migration_leak_smoke.sql
mysql <db> -e "INSERT INTO leak_sensor_log (device_id,sensor_id,sensor_name,event_type,is_leak,sensor_time,created_at) VALUES ('t',4,'연기','smoke_alarm',0,NOW(),NOW()); SELECT event_type FROM leak_sensor_log WHERE sensor_id=4 ORDER BY id DESC LIMIT 1;"
```
Expected: `smoke_alarm` 반환(절단/'' 아님).
- [ ] **Step 4: 커밋**
```bash
git add sql/schema_sht30.sql sql/migration_leak_smoke.sql
git commit -m "feat(smoke): leak_sensor_log.event_type ENUM에 smoke 추가 + 마이그레이션"
```
---
## Task 3: 서버 — 화재 SMS 태그·발송 함수·디스패치
**Files:**
- Modify: `php/config.php:157` (태그), `php/config.php:186` (summary SQL), `php/config.php` (쿨다운 상수)
- Modify: `php/sms_send.php` (함수 2개 추가, `send_leak_recovery_sms` 뒤 ~740)
- Modify: `php/api/sensor_data.php:496` (디스패치 분기)
- Create: `tests/smoke_alert_test.php`
**Interfaces:**
- Consumes: `sms_dispatch_to_recipients()`, `sensor_label()`, `alert_cooldown_suppresses()`, `SMS_TAG_SMOKE_*`.
- Produces: `send_smoke_alert_sms(array $sensor_data): bool`, `send_smoke_recovery_sms(array $sensor_data): bool`. `sms_summary_select_sql()`가 `smoke_alarm`,`smoke_recovery` 컬럼 포함.
- [ ] **Step 1: 실패 테스트 작성 (순수 로직 — 태그·summary SQL 존재)**
`tests/smoke_alert_test.php` 생성 (harness는 `recipients_group_test.php` 패턴):
```php
<?php
declare(strict_types=1);
$root = dirname(__DIR__);
require_once $root . '/php/config.php';
$failures = [];
function check(bool $cond, string $msg): void {
global $failures;
if (!$cond) { $failures[] = $msg; }
}
// 태그 상수 정의 + 문구
check(defined('SMS_TAG_SMOKE_ALARM') && SMS_TAG_SMOKE_ALARM === '[화재감지]', 'SMS_TAG_SMOKE_ALARM = [화재감지]');
check(defined('SMS_TAG_SMOKE_RECOVERY') && SMS_TAG_SMOKE_RECOVERY === '[화재복구]', 'SMS_TAG_SMOKE_RECOVERY = [화재복구]');
// summary SQL이 smoke 집계 컬럼 포함(대시보드/월간보고서 SSOT)
$sql = sms_summary_select_sql();
check(str_contains($sql, 'AS smoke_alarm'), 'summary SQL has smoke_alarm');
check(str_contains($sql, 'AS smoke_recovery'), 'summary SQL has smoke_recovery');
// 발송 함수 존재(누수 함수와 시그니처 동일)
check(function_exists('send_smoke_alert_sms'), 'send_smoke_alert_sms() exists');
check(function_exists('send_smoke_recovery_sms'), 'send_smoke_recovery_sms() exists');
if ($failures) {
fwrite(STDERR, "Smoke alert test failures:\n");
foreach ($failures as $f) { fwrite(STDERR, "- {$f}\n"); }
exit(1);
}
echo "Smoke alert checks passed.\n";
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `php tests/smoke_alert_test.php`
Expected: FAIL (상수·함수 미정의).
- [ ] **Step 3: config.php — 태그 + 쿨다운 상수**
`php/config.php:157` (`SMS_TAG_LEAK_RECOVERY` 정의 줄) 바로 뒤에 추가:
```php
const SMS_TAG_SMOKE_ALARM = '[화재감지]'; // 4번 채널(연기·광전식) 감지
const SMS_TAG_SMOKE_RECOVERY = '[화재복구]'; // 연기 해소(복구)
```
그리고 누수 쿨다운 상수(`LEAK_ALERT_COOLDOWN_SEC`) 근처(config.php:205 부근)에 추가:
```php
const SMOKE_ALERT_COOLDOWN_SEC = 300; // 화재 경보 SMS 재발신 최소 간격(초)
```
- [ ] **Step 4: config.php — summary SQL에 smoke 집계 추가**
`sms_summary_select_sql()` 의 `sms_fail` 줄(config.php:186) **앞에** 두 줄 삽입:
```php
. " SUM(message LIKE '" . SMS_TAG_SMOKE_ALARM . "%') AS smoke_alarm,\n"
. " SUM(message LIKE '" . SMS_TAG_SMOKE_RECOVERY . "%') AS smoke_recovery,\n"
```
- [ ] **Step 5: sms_send.php — 발송 함수 2개 (send_leak_recovery_sms 뒤 ~740 라인)**
`send_leak_alert_sms`(sms_send.php:625) 구조를 그대로 미러(락·쿨다운·에피소드 인지). 태그/쿨다운상수/문구만 교체:
```php
/**
* 화재(연기) 경보 SMS. send_leak_alert_sms 구조 동일(락+쿨다운+에피소드 인지).
*/
function send_smoke_alert_sms(array $sensor_data): bool {
$db = get_db();
$sensor_id = (int)$sensor_data['sensor_id'];
$lockName = substr("sms_smoke_alert_{$sensor_id}", 0, 64);
$lockState = 'unsupported';
try {
$lk = $db->prepare('SELECT GET_LOCK(:n, 0)');
$lk->execute([':n' => $lockName]);
$res = $lk->fetchColumn();
$lockState = ($res === null || $res === false) ? 'unsupported' : (((int)$res === 1) ? 'acquired' : 'busy');
} catch (Throwable $e) {
$lockState = 'unsupported';
error_log("[SMS] GET_LOCK 사용 불가({$lockName}): " . $e->getMessage());
}
if ($lockState === 'busy') {
error_log("[SMS] " . SMS_TAG_SMOKE_ALARM . " 동시 처리 감지 - 센서{$sensor_id} 중복 발송 생략");
return false;
}
try {
$recent = $db->prepare("
SELECT message, sent_at FROM leak_sms_log
WHERE sensor_id = :sensor_id AND status = 'success'
AND (message LIKE :alert OR message LIKE :recovery)
ORDER BY sent_at DESC, id DESC LIMIT 1
");
$recent->execute([
':sensor_id' => $sensor_id,
':alert' => SMS_TAG_SMOKE_ALARM . '%',
':recovery' => SMS_TAG_SMOKE_RECOVERY . '%',
]);
$row = $recent->fetch();
$lastMsg = ($row === false) ? null : (string)$row['message'];
$lastAt = ($row === false) ? null : (string)$row['sent_at'];
if (alert_cooldown_suppresses($lastMsg, $lastAt, SMS_TAG_SMOKE_ALARM, SMOKE_ALERT_COOLDOWN_SEC, time())) {
return false;
}
$dt = date('Y-m-d H:i:s');
[$loc, $name] = sensor_label($sensor_id, $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$message = SMS_TAG_SMOKE_ALARM . "\n"
. "위치: {$loc}\n"
. "관리주체: {$name}\n"
. "연기가 감지되었습니다. 즉시 확인하세요!\n"
. "시각: {$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "화재감지");
} finally {
if ($lockState === 'acquired') {
try { $db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => $lockName]); }
catch (Throwable $e) { error_log("[SMS] RELEASE_LOCK 실패({$lockName}): " . $e->getMessage()); }
}
}
}
/**
* 화재(연기) 복구 SMS. send_leak_recovery_sms 구조 동일(직전 경보 있을 때만 1회).
*/
function send_smoke_recovery_sms(array $sensor_data): bool {
$db = get_db();
$sensor_id = (int)$sensor_data['sensor_id'];
$stmt = $db->prepare("
SELECT message FROM leak_sms_log
WHERE sensor_id = :sid AND status = 'success'
AND (message LIKE :alert OR message LIKE :recovery)
ORDER BY sent_at DESC, id DESC LIMIT 1
");
$stmt->execute([
':sid' => $sensor_id,
':alert' => SMS_TAG_SMOKE_ALARM . '%',
':recovery' => SMS_TAG_SMOKE_RECOVERY . '%',
]);
$last = $stmt->fetchColumn();
if ($last === false || !str_starts_with((string)$last, SMS_TAG_SMOKE_ALARM)) {
return false;
}
$dt = date('Y-m-d H:i:s');
[$loc, $name] = sensor_label($sensor_id, $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$message = SMS_TAG_SMOKE_RECOVERY . "\n"
. "위치: {$loc}\n"
. "관리주체: {$name}\n"
. "연기 감지가 해소(복구)되었습니다.\n"
. "시각: {$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "화재복구");
}
```
- [ ] **Step 6: sensor_data.php — 디스패치 분기 (누수 분기 뒤, 라인 496 다음)**
```php
// ── 화재(연기) 경보 / 복구 SMS (4번 채널) ──
// Pi가 confirm-read 디바운스로 확정 엣지에서만 보내므로 서버 디바운스 없이 즉시 발송.
// 재발신 폭주는 send_smoke_alert_sms 내부 쿨다운(SMOKE_ALERT_COOLDOWN_SEC)으로 억제.
if ($is_smoke_event) {
if ($event_type === 'smoke_alarm' && function_exists('send_smoke_alert_sms')) {
if (send_smoke_alert_sms($sensor_info)) { $sms_sent = true; }
} elseif ($event_type === 'smoke_recovery' && function_exists('send_smoke_recovery_sms')) {
if (send_smoke_recovery_sms($sensor_info)) { $sms_sent = true; }
}
}
```
- [ ] **Step 7: 테스트 통과 확인 + 전체 스위트**
Run: `php tests/smoke_alert_test.php` → Expected: PASS
Run: `./run_tests.sh` → Expected: 기존 전체 통과 + smoke 1개 추가 (통과 수 명시).
- [ ] **Step 8: 커밋**
```bash
git add php/config.php php/sms_send.php php/api/sensor_data.php tests/smoke_alert_test.php
git commit -m "feat(smoke): [화재감지]/[화재복구] SMS 태그·발송·디스패치 + 테스트"
```
---
## Task 4: 서버 — 라벨·연기 센서 목록 (config)
**Files:**
- Modify: `tools/make_deploy_bundles.py` (생성 소스 — 서버 config.local.php 산출)
- 운영: `php/config.local.php` (SENSOR_RECIPIENTS[4], git 미추적)
**Interfaces:**
- Produces: 서버가 `sensor_id=4`를 "연기" 라벨로 표시, 4번 SMS를 화재팀 그룹으로 라우팅.
- [ ] **Step 1: 생성기에 4번 라벨 + (선택) SMOKE_SENSOR_IDS 추가**
`tools/make_deploy_bundles.py` 의 `SENSOR_LABELS`(~344-348) 에 4번 추가:
```python
4: {"location": "<설치 위치>", "name": "연기(광전식)"},
```
(선택) 대시보드 카드용 `SMOKE_SENSOR_IDS = [4]` 상수를 서버 config 생성부에 추가.
- [ ] **Step 2: 운영 config.local.php에 수신자 그룹 (수동, git 미추적)**
`php/config.local.php` 의 `SENSOR_RECIPIENTS` 에:
```php
4 => ['010XXXXYYYY', '010XXXXZZZZ'], // 화재(연기) 수신자
```
- [ ] **Step 3: 수신자 해소 검증 (추측 금지 — 실제 출력)**
Run: `php -r 'require "php/config.php"; var_export(recipients_for_sensor(4));'`
Expected: 위 화재 그룹 반환(전역 폴백 아님).
- [ ] **Step 4: 커밋 (config.local.php는 커밋 대상 아님)**
```bash
git add tools/make_deploy_bundles.py
git commit -m "feat(smoke): 서버 4번 연기 라벨 + SMOKE_SENSOR_IDS"
```
---
## Task 5: Pi — 순수 헬퍼 `event_type_for()` (TDD)
**Files:**
- Modify: `raspberry_pi4/leak_sensor.py` (헬퍼 추가, `_confirm_state_change` 위 ~417)
- Create: `raspberry_pi4/tests/test_smoke_event.py`
**Interfaces:**
- Produces: `event_type_for(sensor: dict, candidate: bool) -> str` — kind=="smoke"면 smoke_alarm/smoke_recovery, 아니면 leak_alert/leak_recovery. GPIO 불필요한 순수 함수.
- [ ] **Step 1: 실패 테스트 작성**
`raspberry_pi4/tests/test_smoke_event.py` 생성:
```python
from leak_sensor import event_type_for
def test_smoke_kind_maps_to_smoke_events():
smoke = {"id": 4, "gpio": 27, "name": "연기", "kind": "smoke"}
assert event_type_for(smoke, True) == "smoke_alarm"
assert event_type_for(smoke, False) == "smoke_recovery"
def test_default_kind_maps_to_leak_events():
leak = {"id": 1, "gpio": 17, "name": "누수"}
assert event_type_for(leak, True) == "leak_alert"
assert event_type_for(leak, False) == "leak_recovery"
```
> 실행 전제: `event_type_for` 는 config/GPIO import 부작용 없는 순수 함수여야 테스트가 import 가능. leak_sensor.py 상단 import가 하드웨어에 의존하면, pytest 실행 시 `conftest.py` 에서 `RPi.GPIO`/`config` 를 스텁하거나(기존 테스트 하네스 확인), 헬퍼를 import-safe한 위치(부작용 전)로 둔다.
- [ ] **Step 2: 테스트 실패 확인**
Run: `cd raspberry_pi4 && python -m pytest tests/test_smoke_event.py -v`
Expected: FAIL (`event_type_for` 미정의 / ImportError).
- [ ] **Step 3: 헬퍼 구현 (leak_sensor.py, `_confirm_state_change` 정의 앞)**
```python
def event_type_for(sensor: dict, candidate: bool) -> str:
"""Map a confirmed transition to its event_type by sensor kind.
kind=='smoke' → smoke_alarm/smoke_recovery; default (leak) → leak_alert/leak_recovery."""
if sensor.get("kind") == "smoke":
return "smoke_alarm" if candidate else "smoke_recovery"
return "leak_alert" if candidate else "leak_recovery"
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `cd raspberry_pi4 && python -m pytest tests/test_smoke_event.py -v`
Expected: PASS (2 passed).
- [ ] **Step 5: 커밋**
```bash
git add raspberry_pi4/leak_sensor.py raspberry_pi4/tests/test_smoke_event.py
git commit -m "feat(smoke): event_type_for() 순수 헬퍼 + 테스트"
```
---
## Task 6: Pi — 확정 전이에서 kind 분기
**Files:**
- Modify: `raspberry_pi4/leak_sensor.py:434-448` (`_confirm_state_change` 의 candidate 분기)
**Interfaces:**
- Consumes: `event_type_for()` (Task 5), `post_to_server()`, `_handle_leak_with_photo()`.
- Produces: 연기 센서(kind=smoke)는 확정 감지 시 `smoke_alarm`(사진 없음), 복구 시 `smoke_recovery` POST. 누수 센서 경로 불변.
- [ ] **Step 1: candidate 분기 교체 (leak_sensor.py:434-448)**
```python
if candidate:
evt = event_type_for(sensor, True)
if sensor.get("kind") == "smoke":
log.warning(f"[SMOKE DETECTED] sensor{sensor['id']} ({sensor['name']}) !!!")
threading.Thread(target=post_to_server, args=(evt, sensor, False), daemon=True).start()
else:
log.warning(f"[LEAK DETECTED] sensor{sensor['id']} ({sensor['name']}) !!!")
threading.Thread(target=_handle_leak_with_photo, args=(sensor,), daemon=True).start()
else:
evt = event_type_for(sensor, False)
log.info(f"[{'SMOKE' if sensor.get('kind')=='smoke' else 'LEAK'} RECOVERED] "
f"sensor{sensor['id']} ({sensor['name']}) recovered to normal")
threading.Thread(target=post_to_server, args=(evt, sensor, False), daemon=True).start()
```
- [ ] **Step 2: 회귀 — 기존 Pi 테스트 통과 확인**
Run: `cd raspberry_pi4 && python -m pytest tests/ -v`
Expected: 기존 누수/디바운스 테스트 + smoke 헬퍼 테스트 전부 PASS (통과 수 명시).
- [ ] **Step 3: 커밋**
```bash
git add raspberry_pi4/leak_sensor.py
git commit -m "feat(smoke): 확정 전이 kind 분기 — 연기는 smoke_alarm/recovery(사진 없음)"
```
---
## Task 7: 배포 — 번들 재생성 + 마이그레이션 + 재시작
**Files:**
- Modify: `tools/make_deploy_bundles.py` (Pi-C PI_META sensors)
**Interfaces:**
- Consumes: Task 1-6 소스 변경, `deploy/.secrets/api_key.txt`.
- Produces: Pi-C 번들에 연기 채널(id4) config_local override. 서버 config.local.php에 라벨.
- [ ] **Step 1: PI_META에 연기 센서 추가**
`tools/make_deploy_bundles.py` 의 Pi-C(`04_pi_c_leak_id3`) `sensors` 에 추가:
```python
{"id": 4, "gpio": 27, "name": "연기(광전식)", "kind": "smoke"},
```
(config.py 검증은 id/gpio/name 필수만 확인 → 추가 키 `kind` 허용됨. 검증 로직 변경 불필요. 실행 전 config.py 검증부에서 추가 키를 거부하지 않는지 1회 확인.)
- [ ] **Step 2: 번들 재생성 + API_KEY prefix 불변 확인**
Run: `python tools/make_deploy_bundles.py`
Expected: 실행 로그에 기존 API_KEY prefix 재사용(신규 키 생성 안 됨).
- [ ] **Step 3: 서버 반영**
- `deploy/01_server_cafe24/<web_dir>/config.local.php` 서버 업로드 (SENSOR_LABELS[4], SENSOR_RECIPIENTS[4]).
- 서버에서 `mysql <db> < sql/migration_leak_smoke.sql` 실행.
- [ ] **Step 4: Pi 반영**
- Pi-C 번들 폴더 scp → `sudo systemctl restart leak-sensor` (연기 채널은 기존 leak-sensor 서비스에 편입).
- harden된 노드는 물리접근/USB 마이그레이션 번들.
- [ ] **Step 5: 커밋**
```bash
git add tools/make_deploy_bundles.py
git commit -m "feat(smoke): Pi-C 번들에 연기 채널(id4) + 배포 절차"
```
---
## Task 8: 하드웨어 조립 + 엔드투엔드 검증
**Files:** 없음 (물리 작업 + 검증)
- [ ] **Step 1: 개조**
1. 감지기 뚜껑 열고 멀티미터 DC로 알람 LED 애노드 확정(평상시 0V, 테스트버튼 ~2V).
2. LED노드 → 1kΩ → PC817 1핀, 2핀 → 감지기 GND.
3. PC817 4핀 → Pi 3V3(pin1), 3핀 → GPIO27(pin13) + 10kΩ 풀다운→GND(pin9).
- [ ] **Step 2: 유휴 레벨 확인**
Run (Pi): `python3 -c "import RPi.GPIO as g; g.setmode(g.BCM); g.setup(27, g.IN, pull_up_down=g.PUD_DOWN); import time; [print(g.input(27)) or time.sleep(1) for _ in range(3)]"`
Expected: 평상시 `0`(LOW).
- [ ] **Step 3: 엔드투엔드 (HTTP 200 아닌 SMS 실제 발송까지)**
1. 감지기 테스트버튼(또는 연기) → GPIO27 HIGH.
2. Pi 로그 `[SMOKE DETECTED] sensor4` 확인.
3. 서버 `leak_sensor_log` 에 `event_type='smoke_alarm'` 행 확인.
4. **화재팀 휴대폰에 `[화재감지]` SMS 도착 확인** (HTTP 200만으로 판정 금지).
5. 해제 → `[화재복구]` 1회 확인.
- [ ] **Step 4: 회귀 확인**
기존 누수(1/3)·온습도(2) 이벤트 정상 + `leak_sensor_status` 의 누수 `is_leak` 미오염 확인.
---
## Self-Review
**Spec 커버리지:**
- §2 하드웨어/배선 → 하드웨어 섹션 + Task 8. ✓
- §3 Pi 소프트(SENSORS+kind, 극성) → Task 5·6·7 (극성=감지HIGH, ACTIVE_LOW 불변; 풀다운은 외부 10kΩ=옵션 A). ✓
- §4 서버 additive(화이트리스트·ENUM·태그·발송·라우팅·라벨) → Task 1·2·3·4. ✓
- §5 상태모델(CLEAR/ALARM) → 기존 상태머신 재사용, Task 6 전이. ✓
- §6 배포 → Task 7. ✓
- §7 테스트 → Task 3(PHP)·5(Py)·8(E2E). ✓
**미결(스펙 §8) 반영:** 극성 풀다운은 외부 10kΩ(옵션 A, 코드변경 0)로 확정 — 내부 PUD_DOWN(옵션 B)은 setup_gpio 승격 필요하나 이번 시범은 A 채택. 카메라 OFF(연기 분기에서 `_handle_leak_with_photo` 미호출). LED노드 전압은 Task 8 Step1에서 실측.
**Placeholder 스캔:** `<설치 위치>`, `010XXXX…`(운영 비밀값), `<web_dir>`, `<db>`는 운영 환경별 실제값 — 코드 placeholder 아님. 나머지 코드 스텝은 실코드.
**타입 일관성:** `event_type_for(sensor, candidate)`(Task5) ↔ Task6 호출 시그니처 일치. `send_smoke_alert_sms/recovery(array): bool`(Task3) ↔ sensor_data.php 호출 일치. 태그 상수명 `SMS_TAG_SMOKE_ALARM/RECOVERY` 전 태스크 동일.
---
## 진행 순서 메모
- Task 1~4(서버) + Task 5(Pi 헬퍼)는 **하드웨어 없이 지금 가능** — 물건 오는 동안 선행.
- Task 6은 Task 5 의존. Task 7·8은 하드웨어 도착 후.

View file

@ -0,0 +1,173 @@
> **⚠️ SUPERSEDED 2026-07-19:** 화재/연기 감지는 PMS7003 미세먼지 metric 통합으로 대체됨.
> 개조/불꽃/CO 경로 폐기. 아래는 이력 보존용.
> 대체 설계: [`2026-07-19-node-sensor-integration-design.md`](./2026-07-19-node-sensor-integration-design.md)
# 연기(화재) 감지 노드 — 시범(Pilot) 설계
- **작성일:** 2026-07-18
- **브랜치:** feat/pi-c-sensor3-leak-node
- **범위:** 기존 Pi4 누수+온습도 노드에 저가 광전식 연기 감지를 곁다리로 추가 (시범/개념검증)
- **상태:** 설계 확정, 구현 계획 대기
---
## 1. 목표와 비목표
### 목표
- 저가 광전식 연기 감지기의 **감지 이벤트 1건**을 기존 PHP 서버까지 도달시켜 SMS 알림 발송.
- 기존 노드 구조(엣지검출 + confirm-read 디바운스 + HMAC 보고 + 센서별 SMS 그룹) **재사용**.
- **최대한 저가.** 시범(개념검증)이므로 인증·감시·로컬사이렌은 범위 밖.
### 비목표 (이번 시범에서 제외)
- 로컬 알람/사이렌 (필요 없음 — 감지 신호 도달만).
- 감시(FAULT)/단선검출 2선 루프, EOL 전원감시릴레이.
- KFI/UL 인증, 법정 자동화재탐지설비 대체.
- FAULT event_type, 정비 SMS 그룹.
> **인증 경계 (문서/대시보드 배너 필수):** 이 시범 노드는 **보조 통보용**이며 법정 소방 화재경보설비를 대체하지 않는다. 개조된 저가 감지기는 인증품이 아니다.
---
## 2. 하드웨어 (개조 시범 경로 — 어댑터 불필요)
> 확정 근거: 사용자가 12~24V 어댑터 없이 가는 개조를 명시 선택. 단독형 배터리 알람이라 **외부 전원 0.** 대가는 납땜 + 멀티미터 프로빙(§ 트레이드오프).
### BOM (1노드 ≈ ₩5,000, 외부 전원 없음)
| 부품 | 사양 | 가격 |
|---|---|---|
| 저가 광전식 단독형 연기알람 (AliExpress) | DC3V 배터리, 예비 포함 2개(1개 실습용). PGST 등 | ₩1,507~4,000/개 |
| PC817 옵토커플러 | 10팩 | ~₩1,000 |
| 저항 (1kΩ, 10kΩ) | 키트 | ~₩1,000 |
| 점퍼선 F-F + 미니 브레드보드 | 납땜 최소화용 | ~₩2,000 |
| (피에조 탭 시만) 1N4148 + 1~10µF | | ~₩100 |
**필요 공구(부품 아님, 개조 전제):** 멀티미터(DC전압) + 인두기+납. 없으면 개조가 릴레이 경로보다 오히려 번거로움.
### 탭 지점 (감지 시 전압이 스위칭하는 노드) — 멀티미터 DC로 확정
평상시 vs 테스트버튼/연기 상태 비교:
1. **알람 LED 애노드 (추천)** — 0V → ~2V 안정 DC.
2. 연동단자(연동형 모델만) — 0 → ~9VDC 안정.
3. 피에조 구동선 — 0 → 9~12Vpp 교류 (정류 필요, 채터링 → 비추천).
> Pi GPIO는 아날로그 저항을 못 읽는다(ADC 없음). 감지는 노드의 **전압 레벨 변화를 디지털 HIGH/LOW로** 읽는다.
### 배선 (PC817 절연, 감지=HIGH로 누수와 극성 통일)
```
[감지기측] LED노드(+) → 1kΩ → PC817 1핀(LED anode), PC817 2핀 → 감지기 GND
[Pi측] PC817 4핀(collector) → 3.3V, PC817 3핀(emitter) → GPIO27(BCM, 물리 pin13) + 10kΩ 풀다운→GND
동작: 연기 → LED 점등 → 옵토 도통 → GPIO27 = HIGH (감지, active-high)
```
- 옵토를 **하이사이드**(collector→3.3V, emitter→GPIO+풀다운)로 배선 → 감지=HIGH → 누수센서(ACTIVE_LOW=False)와 동일 극성 → **극성 코드 무변경.**
- LED 노드 전압 V일 때 직렬저항 = (V − 1.2) / 2mA. ~2V면 470Ω, 애매하면 1kΩ.
- PC817 이유: 노드가 3.3V 초과/교류여도 안전 + 프로빙 실수로부터 Pi 보호 + 접지 공유 불필요.
### GPIO 배정
| 신호 | Pi 핀 | 풀 | idle | 감지 |
|---|---|---|---|---|
| SMOKE | GPIO27 (물리 pin13) | 외부 10kΩ 풀다운→GND (또는 per-sensor PUD_DOWN) | LOW | HIGH(옵토 도통) |
- 기존: GPIO17=누수센서1, I2C(GPIO2/3)=SHT30, CSI=카메라. GPIO27은 config의 주석 처리된 여유 슬롯.
- **극성:** 감지=HIGH → `ACTIVE_LOW=False`(누수와 동일) 그대로. **풀다운만** 필요(옵토 off 시 LOW 확정) → §8 미결 A/B.
- **개조의 안전 한계:** 배터리 죽으면 Pi가 모름(무음 실패). 단선도 무음. 시범이라 감수 — 실배포 승격 시 릴레이+NC로 교체.
### fallback (어댑터 확보되면 권장 승격)
4선 광전 릴레이출력 감지기(₩4,250) + 12V 어댑터 → 무전압 접점 직결(납땜·옵토·멀티미터 불필요), NC=단선 페일세이프, 광전 확정, 부저 없음. 작업량은 개조보다 적으나 어댑터 필요.
---
## 3. 소프트웨어 — Pi 노드 (거의 신규 코드 없음)
### 접근: 기존 emitter 재사용
연기 채널을 `SENSORS` 목록의 한 항목으로 추가. 기존 엣지검출 + confirm-read 디바운스 + `_post_event` HMAC 파이프라인이 그대로 처리.
### 변경점
1. **`raspberry_pi4/config_local.py` (배포 생성물, 소스는 make_deploy_bundles.py):**
```python
SENSORS += [{"id": 4, "gpio": 27, "name": "연기(광전식)"}]
```
- `sensor_id = 4` (사용중 1/2/3와 겹치지 않음 → 누수 status 덮어쓰기 방지 + 독립 SMS 그룹).
2. **극성:** 옵토 하이사이드 배선이라 idle=LOW(옵토 off, 풀다운), 감지=HIGH(옵토 도통). `_read_is_leak` 관점에서 "감지=HIGH"이므로 전역 `ACTIVE_LOW=False`(누수와 동일)를 그대로 쓴다 → **극성 코드 변경 불필요.** 단 옵토 off 시 LOW를 확정하려면 GPIO27에 **풀다운**이 필요(전역은 PUD_OFF):
- 옵션 A (코드변경 0): 외부 10kΩ 풀다운 저항을 GPIO27→GND에 물림. config 무변경.
- 옵션 B (권장, 부품 0): `setup_gpio`를 per-sensor `pull` 키 지원으로 소폭 승격해 GPIO27만 내부 PUD_DOWN 적용. `ACTIVE_LOW`는 건드리지 않음.
> 구현 계획에서 A/B 확정. 부품 안 늘리려면 B, 코드 안 건드리려면 A.
3. **이벤트 방출:** 감지 확정 시 `smoke_alarm`, 해제 시 `smoke_recovery` event_type로 POST. 기존 `_post_event`/`post_to_server` 그대로(event_type 문자열만 추가).
4. **디바운스:** 기존 `CONFIRM_READS`/`CONFIRM_INTERVAL_SEC` 재사용. (시범 단계에선 별도 비대칭 디바운스 미도입.)
5. **카메라:** 기본 OFF(저가·단순). `smoke_alarm`에 캡처를 붙이려면 기존 `_handle_leak_with_photo` 재사용 가능(log_id 키만 맞으면 무관) — 옵션.
### 페이로드 (additive, 서명 바이트 순서 고정)
누수 body 재사용 + event_type만 smoke. 서버가 `is_leak`를 event_type로 강제하므로 `is_leak`는 그대로 두고 event_type로 구분.
```json
{"device_id":"…","device_location":"…","sensor_id":4,"sensor_name":"연기(광전식)","event_type":"smoke_alarm","is_leak":0,"timestamp":<int>}
```
---
## 4. 소프트웨어 — 서버 (PHP, additive만)
> **필수:** `sensor_data.php:208` 화이트리스트 + `schema_sht30.sql:30` ENUM이 모르는 event_type을 조용히 `periodic`으로 강등(로그·SMS 없음). 아래 additive 변경 없으면 감지가 HTTP 200만 뜨고 사라진다.
1. **`php/api/sensor_data.php:208`** — event_type 화이트리스트에 `'smoke_alarm'`, `'smoke_recovery'` 추가.
2. **`php/api/sensor_data.php:212-218`** — is_leak 강제 블록에 smoke 분기 추가(연기 상태 별도 플래그가 필요하면 additive 컬럼, 아니면 event_type만으로 로깅). **`is_leak`·누수 로직 불변.**
3. **`sql/schema_sht30.sql:30`** — `leak_sensor_log.event_type` ENUM에 `'smoke_alarm'`,`'smoke_recovery'` 추가 + **기존 운영 DB용 ALTER 마이그레이션**(`migration_leak_smoke.sql`, MySQL8 비멱등 주의).
4. **`php/config.php:144-157`** — `SMS_TAG_SMOKE_ALARM = '[화재감지]'`, `SMS_TAG_SMOKE_RECOVERY = '[화재복구]'` 추가 + `sms_summary_select_sql()`(175-187)에 SUM(message LIKE …) 라인 추가.
5. **`php/sms_send.php`** — `send_smoke_alert_sms()` / `send_smoke_recovery_sms()` (leak 함수 미러, 쿨다운 ~300s). `sms_dispatch_to_recipients()`가 sensor_id로 자동 라우팅 → id4 그룹. `sensor_data.php`의 leak 디스패치 분기(480-496) 근처에 smoke 분기 추가(즉시 발송; Pi가 디바운스하므로 서버 디바운스 미적용, SMS 재발신만 쿨다운).
6. **`php/config.php`** — `SENSOR_LABELS[4]`, (선택) `SMOKE_SENSOR_IDS = [4]`(대시보드 카드용). `config.local.php`에 `SENSOR_RECIPIENTS[4] => [화재 수신자…]`.
7. **대시보드(선택):** `status_summary.php`/`dashboard_data.php`에 연기 카드 additive. 미적용 시 기존 per-sensor 행에 편승.
### 최소 대안 (서버 코드 변경 0)
`sensor_id 4`를 누수 센서로 취급(`LEAK_SENSOR_IDS += 4`) → 감지가 `leak_alert` 파이프라인으로 흐름. **단 SMS가 "[누수감지]"로 떠 화재를 오인** → display-vs-policy 위반. 채택 안 함(시범이라도 문구 구분 유지).
---
## 5. 상태 모델 (시범 = 2상태)
```
CLEAR ──(연기 확정: confirm-read 통과)──▶ ALARM → smoke_alarm POST
ALARM ──(연속 clear 확정)──────────────▶ CLEAR → smoke_recovery POST
```
- FAULT/감시 없음(시범). 실배포 승격 시 릴레이+NC 또는 2선 감시로 CLEAR/ALARM/FAULT 확장.
- 보고 주기: CLEAR heartbeat = 기존 `REPORT_INTERVAL(600s)`, ALARM 재보고 = `LEAK_REPORT_INTERVAL(10s)` 재사용, SMS 재발신은 서버 쿨다운.
---
## 6. 배포 (기존 절차 재사용)
1. 소스 편집: `tools/make_deploy_bundles.py` PI_META의 Pi-C sensors에 `{"id":4,"gpio":27,"name":"연기(광전식)"}` 추가; 서버측 `SENSOR_LABELS[4]`, (선택) `SMOKE_SENSOR_IDS`.
2. 서버 PHP additive 변경(3~7항) + `config.local.php`에 `SENSOR_RECIPIENTS[4]`.
3. `python tools/make_deploy_bundles.py` → **API_KEY prefix 불변 확인**(.secrets 재사용).
4. 서버: `config.local.php` 업로드 + ENUM 마이그레이션(`migration_leak_smoke.sql`) 실행.
5. Pi: 번들 scp → `sudo systemctl restart leak-sensor` (연기 채널은 기존 leak-sensor 서비스에 편입 — 별도 서비스 불필요).
- harden_pi.sh로 SSH 잠긴 노드는 물리접근 또는 USB 마이그레이션 번들.
6. 에어갭: ADC·신규 스택 없음 → 추가 벤더링 불필요(기존 rpi-lgpio/requests 그대로).
---
## 7. 테스트 (TDD 대상)
- **순수함수:** smoke event_type body 빌더 바이트 동일성 + HMAC 서명 검증.
- **서버:** `smoke_alarm` 수용(화이트리스트), ENUM insert 성공, `[화재감지]` 태그 카운트, SMS 라우팅(id4 그룹).
- **통합:** 테스트버튼 눌러 실제 GPIO27 LOW → confirm-read 통과 → `smoke_alarm` POST → 서버 로그 + SMS 발송 **엔드투엔드 확인**(HTTP 200만으로 판정 금지 — SMS 실제 발송까지).
- **회귀:** 기존 누수/온습도 이벤트 불변 확인(`is_leak`·leak_sensor_status 미오염).
---
## 8. 미해결/구현계획에서 결정
- [ ] GPIO27 풀다운 방식 A(외부 10kΩ 저항) vs B(per-sensor 내부 PUD_DOWN 키) 확정.
- [ ] 카메라 캡처를 smoke_alarm에 붙일지(옵션, 기본 OFF).
- [ ] Ali 단독형 알람 실물 확인: 광전식 여부, 알람 LED 노드 전압(멀티미터 DC, 평상시 vs 테스트버튼), 탭 지점 확정.
- [ ] 저배터리 무음 실패 완화책(주기 교체 일정 or 저배터리 칩 패턴 감지) 필요 여부.
---
## 9. 승격 경로 (시범 → 실배포)
| 항목 | 시범(현재) | 실배포 승격 |
|---|---|---|
| 감지기 | Ali 저가 단독형 개조(₩1,507~) | Ali 4선 릴레이출력(₩4,250) → 누멘스 SNC-300-SR(KFI/UL 인증) |
| 전원 | 배터리(어댑터 불필요) | 12~24V 어댑터/DIN 전원 |
| 배선 | LED 탭 + PC817 옵토 | 무전압 접점 직결(옵토 불필요), NC=페일세이프 |
| 감시 | 없음(배터리死·단선 무음) | NC=단선검출 → MY2N EOL 릴레이(정식 FAULT) |
| 소프트웨어 | 동일 (SENSORS +1, 서버 additive) | 동일 + (선택)FAULT event_type |
소프트웨어가 하드웨어와 무관하게 동일하므로, 시범→실배포는 **하드웨어 교체(개조→릴레이→인증) + (선택)FAULT 추가**로 끝난다. Pi/서버 코드 구조는 그대로.

View file

@ -0,0 +1,110 @@
# 설계: 누수 노드 다중센서 통합 (누수 + 온습도 + 미세먼지) · 상시 카메라 조명
**날짜:** 2026-07-19
**브랜치:** feat/pi-c-sensor3-leak-node
**상태:** 설계 확정, 구현 진행
**대체:** [`2026-07-18-smoke-node-pilot-design.md`](./2026-07-18-smoke-node-pilot-design.md) (연기 개조/불꽃/CO 경로 폐기)
**참조 계획:** [`../plans/2026-07-18-smoke-node-pilot.md`](../plans/2026-07-18-smoke-node-pilot.md) (연기 시범 → PMS7003 metric 통합으로 전환)
## 목표
기존 누수 노드(Pi-A = sensor_id 1, Pi-C = sensor_id 3)를 **한 노드에 3종 센서**를 얹은
통합 노드로 확장한다:
1. **누수** — 기존 leak GPIO(GPIO17, active-high). 변경 없음.
2. **온습도(SHT30)** — I2C metric(`metric_type=sht30`). 이미 통합됨(2026-07-15 설계).
3. **미세먼지(PMS7003)** — UART metric(`metric_type=pms7003`). **이번 신규.**
연기(화재)의 **대리 지표(smoke proxy)** — 입자 유입 시 PM 농도가 급등한다.
세 센서 모두 **그 노드의 같은 sensor_id(1 또는 3)로 보고**하고, `metric_type` 문자열로만
구분한다. SHT30 동일-id 통합 방식을 그대로 미러(새 id·재번호 없음).
추가로, 누수 확인 카메라(NoIR)용 **상시점등 백색 LED**를 배선한다(스위칭·코드 없음).
## 핵심 결정 (확정)
1. **동일 sensor_id · metric_type 구분.** `leak_sensor_metric`(sensor_id + 측정시각 키)이
온습도·미세먼지를 한 테이블에서 `metric_type`으로 구분 → 한 sensor_id가 누수 상태 +
온습도 측정 + 미세먼지 측정을 동시에 가진다. PM 행은 `pm1_0/pm2_5/pm10`만 채우고
`temperature_c/humidity_percent`는 NULL(반대도 동일) — additive 컬럼.
2. **노드: 별도 서비스.** 불안정한 누수 코드는 건드리지 않는다. PMS7003 는 SHT30 처럼
**독립 systemd 서비스**(`pms7003-sensor`)로 얹는다. config 의 `SENSOR_ID`만 노드별(1/3).
3. **event_type:** 첫 성공 판독 = `startup`, 이후 `periodic`(SHT30 와 동일). PMS 는 누수/알람
이벤트를 절대 방출하지 않는다 — 임계 판정·SMS 는 전적으로 서버 몫.
4. **Pi metric_status = 물리 타당성만**(`normal` | `out_of_range`). 서버가 운영 임계를 재평가
하므로 Pi 의 status 를 경보 근거로 신뢰하지 않는다.
5. **경보/수신자:** 전역 `METRIC_PM25_HIGH`(75)·`METRIC_PM10_HIGH`(150) 초과 시 서버가
판정 → `[미세먼지경보]` SMS. 수신자는 기존 sensor_id 1·3 그룹 그대로(추가 매핑 불필요).
6. **카메라 조명: 상시점등.** 스위칭·GPIO 제어 없음. 백색 LED 모듈을 Pi 5V 에 직결해 항상 켠다.
## 데이터 흐름 / 페이로드
```
Pi-A (한 대) Pi-C: 동일, sensor_id=3
├ leak-sensor.service → POST(sensor_id=1) leak_alert/recovery/periodic (누수)
├ sht30-sensor.service → POST(sensor_id=1) startup/periodic (metric_type=sht30, 온·습도)
└ pms7003-sensor.service → POST(sensor_id=1) startup/periodic (metric_type=pms7003, PM)
서버 sensor_data.php: sensor_id=1 metric 저장(metric_type 구분) + 임계 재평가
→ high_pm25/high_pm10 breach → [미세먼지경보] SMS (SENSOR_RECIPIENTS[1])
→ is_leak 보존(비-누수 이벤트는 누수 상태를 덮지 않음)
대시보드: sensor_id=1 → 누수 칩 + 온습도 칩 + 미세먼지 칩
```
**PMS7003 보고 바디 (Pi 가 이 바이트 순서 그대로 만들어 HMAC 서명; KEY 이름 서버와 일치):**
```json
{"device_id":"..","device_location":"..","sensor_id":1,"sensor_name":"..","event_type":"periodic",
"timestamp":<int>,"metric_type":"pms7003","pm1_0":<int>,"pm2_5":<int>,"pm10":<int>,
"metric_status":"normal","app_version":".."}
```
- `pm1_0/pm2_5/pm10` = **맨 정수 µg/m³**(대기환산), 범위 0..1000.
- HTTP User-Agent: `PMS7003Sensor/2.0` (WAF 우회용 커스텀 UA).
## 서버 (이미 반영됨 — 참고)
- 스키마 `leak_sensor_metric`: `pm1_0/pm2_5/pm10 SMALLINT UNSIGNED NULL` additive.
- `config.php`: `METRIC_PM25_HIGH`(75) · `METRIC_PM10_HIGH`(150) · `METRIC_PM_HYSTERESIS`(10),
쿨다운은 `METRIC_ALERT_COOLDOWN_SEC` 재사용.
- 새 breach kind `high_pm25` · `high_pm10` → 둘 다 `SMS_TAG_HIGH_PM = '[미세먼지경보]'` 매핑.
## 하드웨어 배선
### PMS7003 (UART, 5V) — TXD 만 읽으면 됨
| 신호 | PMS7003 | Pi 4 | 비고 |
|---|---|---|---|
| 전원 | VCC | **5V (물리 pin2)** | 레이저 팬 구동, 5V 필수 |
| 접지 | GND | **GND (물리 pin6)** | |
| 데이터 | TXD | **RXD = GPIO15 (물리 pin10)** | PMS → Pi 단방향(읽기) |
- **UART 활성화 필수:** `enable_uart=1` + 직렬 로그인 콘솔 분리(포트 점유 해제). `install.sh`가
자동 처리, 첫 활성화면 재부팅 필요(migrate.sh 가 안내). 포트 = `/dev/serial0`.
- **버스 무충돌:** UART(GPIO14/15)는 누수 GPIO17·SHT30 I2C(GPIO2/3)와 겹치지 않는다 →
세 센서 병행 가능.
### 카메라 조명 (상시점등 백색 LED)
| 신호 | LED 모듈 | Pi 4 | 비고 |
|---|---|---|---|
| 전원 | VCC | **5V** | |
| 접지 | GND | **GND** | |
- **상시 켜짐 · 스위칭/코드 없음.** 저전력 모듈이라 발열 무해, 상시점등해도 문제 없음.
- **NoIR + 백색광 주의:** NoIR 카메라 + 백색 LED 는 화면 색이 **분홍빛**으로 나온다(누수 여부
식별에는 지장 없음). 색을 정상으로 원하면 **일반 카메라(IR-cut 필터 내장)**를 쓴다.
- 자세한 배선/주의는 `raspberry_pi4/WIRING.html` "Camera Lighting (Always-On)" 절 참조.
## 배포 (기존 절차 재사용)
- 소스 트리: PMS7003 Pi 코드는 `raspberry_pi4_pms7003/`(SHT30 소스 미러 —
`pms7003_sensor.py`/`pms7003_driver.py`/`install.sh`/`vendor.sh`/`systemd/pms7003-sensor.service`).
- 기존 하드닝 노드(SSH off) 대상 **USB 원샷 마이그레이션**: `tools/migration_leak_add_pms7003/`
→ 생성물 `deploy/07_migration_leak_add_pms7003/`. `migrate.sh`가 노드의 기존 `config_local.py`
에서 API_URL/API_KEY/위치/sensor_id 를 자동 추출해 같은 id 로 통합(시크릿 미포함 번들).
- 폐쇄망: 빌드 PC 에서 `cd pms7003 && bash vendor.sh` → `debs/`·`wheelhouse/` 동봉.
- 서버: `config.local.php`의 `METRIC_SENSOR_IDS` 에 1·3 포함 확인(온습도와 동일 목록).
## 비목표 (YAGNI)
- Pi-B(sensor_id 2, 온습도 전용) 변경 없음.
- 연기 개조/불꽃/CO/릴레이 감지 경로 — 전면 폐기(smoke proxy = PM 농도로 대체).
- PM 기반 자동 카메라 캡처 트리거 — 별개 이슈(이번 범위 밖).
- 카메라 조명 밝기 제어/스위칭 — 상시점등이라 불필요.

View file

@ -0,0 +1,252 @@
# leak_sms_v3 프로젝트 리뷰
## 1. 리뷰 개요
| 항목 | 내용 |
| --- | --- |
| 리뷰 일자 | 2026-07-11 |
| 기준 브랜치 | `feat/pi-c-sensor3-leak-node` |
| 기준 커밋 | `a72ea18` |
| 대상 | PHP/MySQL 서버, 누수·SHT30 Raspberry Pi 클라이언트, 배포 도구, SQL 스키마, 테스트와 운영 문서 |
| 제외 | 실제 Raspberry Pi/GPIO/I2C 장비 시험, 실 MySQL 통합 시험, 운영 SMS 게이트웨이, systemd 실기동 |
| 워킹 트리 | 수정 및 미추적 파일이 있는 상태. 리뷰 중 추가된 대시보드 분리 파일도 포함했으며 기존 변경은 수정하지 않음 |
이 보고서는 현재 워킹 트리의 정적 분석과 로컬에서 가능한 회귀 검증을 결합한 결과다. 단순한 코드 스타일보다 누수 감지의 정확성, 알림 전달 보장, 장애 복구, 보안 경계, 배포 재현성을 우선 평가했다.
## 2. 결론
**현장 무인 배포를 보류한다.**
정상 경로에는 HMAC 인증, nonce 재사용 방지, PDO 준비문, TOTP 로그인, CSRF 방어, 사진 형식·크기 검사 등 유효한 보호 장치가 있다. 그러나 누수 상태를 부팅 시 서버와 동기화하지 않는 문제, 독립 스레드 재시도로 인한 이벤트 역전, 하드웨어 오류를 정상 시뮬레이션 값으로 대체하는 동작 때문에 실제 누수·복구 상태와 서버 상태가 영구적으로 달라질 수 있다. 알림 실패를 재처리하는 내구성 있는 큐도 없다.
| 등급 | 건수 | 의미 |
| --- | ---: | --- |
| P0 | 0 | 즉시 악용 또는 전면 장애가 확인된 항목 |
| P1 | 7 | 현장 배포 전에 해결해야 할 정확성·안전·보안·준법 결함 |
| P2 | 7 | 다음 안정화 단계에서 해결해야 할 신뢰성·운영·테스트 결함 |
## 3. P1 발견 사항
### P1-01. 부팅 시 누수 상태가 동기화되지 않고 알림 재시도 경로도 없다
**근거**
- 누수 클라이언트는 시작 시 GPIO 값을 읽지만 서버에는 항상 `startup` 이벤트를 보낸다: [`raspberry_pi4/leak_sensor.py`](../raspberry_pi4/leak_sensor.py#L477-L480), [`raspberry_pi4/leak_sensor.py`](../raspberry_pi4/leak_sensor.py#L509-L511)
- 주기 보고도 항상 `periodic` 이벤트다: [`raspberry_pi4/leak_sensor.py`](../raspberry_pi4/leak_sensor.py#L456-L458)
- 서버는 `leak_alert`와 `recovery`만 상태 전환 이벤트로 인정하며, `startup`/`periodic`에서는 기존 `is_leak` 값을 보존한다: [`php/api/sensor_data.php`](../php/api/sensor_data.php#L207-L218), [`php/api/sensor_data.php`](../php/api/sensor_data.php#L329)
- 서버의 누수 SMS 처리는 상태 전환 이벤트 안에서만 실행된다: [`php/api/sensor_data.php`](../php/api/sensor_data.php#L469-L483)
- 사진 촬영·업로드는 GPIO callback에서 상태가 누수로 바뀐 경우에만 시작된다. 부팅 시 현재 상태 보고에는 이 경로가 없다: [`raspberry_pi4/leak_sensor.py`](../raspberry_pi4/leak_sensor.py#L356-L391), [`raspberry_pi4/leak_sensor.py`](../raspberry_pi4/leak_sensor.py#L416-L426)
- SMS 실패 여부와 무관하게 API는 HTTP 200 응답을 반환한다: [`php/api/sensor_data.php`](../php/api/sensor_data.php#L489-L495)
**영향**
- 서버가 정상으로 알고 있을 때 센서가 젖은 상태로 재부팅하면 누수 상태와 SMS가 누락된다.
- 서버가 누수로 알고 있을 때 센서가 마른 상태로 재부팅하면 열린 사고가 종료되지 않는다.
- 최초 등록 센서가 젖은 상태로 시작하면 상태 행은 누수로 생성될 수 있지만 SMS·사진·사고 이력은 생성되지 않는다.
- 최초 SMS 전송이 실패해도 이후 `periodic` 보고는 재전송을 유발하지 않는다.
**권장 조치**
1. `startup`을 현재 물리 상태의 권위 있는 동기화 이벤트로 정의하고 서버가 불일치를 전환으로 처리한다.
2. 상태 전환과 SMS 요청을 DB 트랜잭션으로 기록하고, 별도 durable outbox 작업자가 성공할 때까지 재시도한다.
3. 젖은 상태 부팅, 마른 상태 부팅, SMS 일시 실패 후 복구를 통합 테스트로 고정한다.
### P1-02. 누수와 복구 이벤트의 도착 순서가 역전될 수 있다
**근거**
- 누수와 복구 전송이 각각 독립 daemon thread에서 시작된다: [`raspberry_pi4/leak_sensor.py`](../raspberry_pi4/leak_sensor.py#L416-L433)
- 각 요청은 독립적으로 재시도한다: [`raspberry_pi4/leak_sensor.py`](../raspberry_pi4/leak_sensor.py#L219-L244)
- 서버는 센서별 시퀀스나 이벤트 발생 시각의 단조 증가를 검증하지 않고 도착 순서대로 상태와 사고 이력을 갱신한다: [`php/api/sensor_data.php`](../php/api/sensor_data.php#L329), [`php/api/sensor_data.php`](../php/api/sensor_data.php#L372-L412)
**영향**
`leak_alert` 전송이 지연되는 동안 `recovery`가 먼저 성공하면, 뒤늦은 누수 이벤트가 최종 상태를 다시 누수로 만든다. 실제 센서는 마른 상태인데 대시보드는 누수, 사고는 열린 상태, SMS는 늦게 발송되는 결과가 가능하다.
**권장 조치**
센서별 단일 FIFO 전송 큐를 디스크에 유지하고 각 이벤트에 영속적인 단조 증가 `sequence`를 부여한다. 서버는 마지막 처리 시퀀스보다 오래된 상태 전환을 중복 성공으로 응답하되 상태에는 적용하지 않아야 한다.
### P1-03. 운영 의존성 누락이 정상 시뮬레이션으로 위장되고 배포 시험은 실제 하드웨어를 확인하지 않는다
**근거**
- 누수 클라이언트는 GPIO import 실패 시 자동으로 `SimGPIO`를 사용한다: [`raspberry_pi4/leak_sensor.py`](../raspberry_pi4/leak_sensor.py#L40-L46), [`raspberry_pi4/leak_sensor.py`](../raspberry_pi4/leak_sensor.py#L95-L118)
- SHT30 드라이버는 SMBus를 사용할 수 없으면 자동 시뮬레이션으로 전환하고 현실적인 온·습도 값을 생성한다: [`raspberry_pi4_sht30/sht30_driver.py`](../raspberry_pi4_sht30/sht30_driver.py#L88), [`raspberry_pi4_sht30/sht30_driver.py`](../raspberry_pi4_sht30/sht30_driver.py#L116-L120)
- SHT30 연결 테스트는 실제 센서 측정값이 아니라 고정된 `24.0°C`, `48.0%` API 요청으로 서버 연결을 판정한다: [`raspberry_pi4_sht30/test_connection.py`](../raspberry_pi4_sht30/test_connection.py#L50-L75)
- 별도 센서 테스트도 실제 측정 예외를 출력만 하고 실패 상태에 반영하지 않는다: [`raspberry_pi4_sht30/test_sensor.py`](../raspberry_pi4_sht30/test_sensor.py#L71-L87)
- 생성된 설치 절차는 서비스 시작과 더미 연결 테스트 후 원격 접근을 강화한다: [`tools/make_deploy_bundles.py`](../tools/make_deploy_bundles.py#L197-L208)
**영향**
GPIO 또는 SMBus 라이브러리 누락은 정상처럼 보이는 시뮬레이션 값으로 대체된다. I2C bus open 실패와 실행 중 통신·CRC 실패는 시뮬레이션으로 전환되지 않지만, 배포 검증이 실제 센서와 실행 중인 서비스의 측정값을 확인하지 않으므로 이런 실패도 놓친 채 성공으로 판정할 수 있다. 더미 측정값이 최근 정상 데이터로 저장되고 현장 접근을 제한한 뒤에야 실제 센서 미동작을 발견할 가능성이 있다.
**권장 조치**
시뮬레이션은 명시적인 개발 전용 플래그가 있을 때만 허용한다. 운영 기본값은 fail-closed로 하고, 배포 검증은 실제 GPIO 상태 변화 또는 실제 I2C 측정, 서비스 안정 실행, 재부팅 후 재연결까지 확인해야 한다.
### P1-04. 불완전한 SHT30 값이 거짓 복구와 `0.0` 복구 SMS를 만든다
**근거**
- 서버는 범위를 벗어난 값을 `null`로 만들고 온도·습도 중 하나만 유효해도 요청을 수락한다: [`php/api/sensor_data.php`](../php/api/sensor_data.php#L234-L245)
- 임계값 판정은 누락된 차원을 복구된 것으로 취급한다: [`php/sms_send.php`](../php/sms_send.php#L358-L369)
- 복구 SMS 작성 시 누락된 값은 `0.0`으로 대체된다: [`php/api/sensor_data.php`](../php/api/sensor_data.php#L459-L465)
**영향**
고온 경보 뒤 `temperature=130`, 정상 습도가 들어오면 온도는 무효화되지만 복구로 판정되어 `0.0°C` 복구 SMS가 발송될 수 있다. 두 값이 모두 무효인 경우에도 heartbeat가 갱신되면 센서 장애가 온라인 상태로 가려질 수 있다.
**권장 조치**
SHT30 측정은 온도와 습도가 모두 존재하고 범위 안일 때만 정상 측정으로 처리한다. 무효 입력은 `sensor_error`/`out_of_range` 상태와 원인을 별도로 기록하고, 두 값이 모두 유효할 때만 복구 판정을 수행한다.
### P1-05. heartbeat 작업이 수동 cron에 의존하며 실행 중단을 탐지하지 못한다
**근거**
- offline 감지와 일일 상태 SMS는 [`php/cron_heartbeat.php`](../php/cron_heartbeat.php#L89-L143)에 있으며 API 요청과 일부 웹 요청에서 보조적으로 호출된다: [`php/api/sensor_data.php`](../php/api/sensor_data.php#L485-L486)
- 생성된 설치 문서는 cron 등록을 수동 단계로 안내한다: [`tools/make_deploy_bundles.py`](../tools/make_deploy_bundles.py#L345)
- 운영 점검은 cron의 마지막 성공 시각을 검사하지 않는다: [`php/ops_checks.php`](../php/ops_checks.php)
**영향**
cron이 누락되거나 멈추고 모든 Pi가 오프라인이면 API 유입도 없어 보조 실행이 일어나지 않는다. 이때 offline SMS와 일일 상태 보고가 모두 조용히 중단된다.
**권장 조치**
스케줄 설치를 자동화하고 마지막 시작·성공·실패 시각을 DB 또는 상태 파일에 기록한다. `ops_checks.php`가 지연을 실패로 판정하게 하고, 서버 자체 장애까지 감지할 외부 dead-man 점검을 둔다.
### P1-06. 단일 공용 API 키가 센서 ID와 결합되지 않는다
**근거**
- 모든 장치가 전역 `API_KEY`로 HMAC을 생성한다: [`php/config.php`](../php/config.php#L34), [`php/config.php`](../php/config.php#L551)
- 수집 API는 `sensor_id` 범위만 검사하며 키가 특정 `device_id`나 센서 집합에 속하는지 확인하지 않는다: [`php/api/sensor_data.php`](../php/api/sensor_data.php#L187-L218)
- 상태 테이블의 주 식별자는 `sensor_id`다: [`sql/schema_sht30.sql`](../sql/schema_sht30.sql#L45-L57)
**영향**
장치 한 대의 키가 유출되면 공격자는 다른 센서의 누수·복구·온습도 이벤트를 위조할 수 있다. 장치별 폐기와 회전도 불가능하여 침해 범위가 전체 설비로 확대된다.
**권장 조치**
장치 레지스트리에 장치별 키 또는 공개키, 허용 센서 ID, 활성 상태, 회전 버전을 둔다. 인증 후 payload의 `device_id`와 `sensor_id` 권한을 함께 검증하고 장치 단위 폐기·회전을 지원한다.
### P1-07. 누수 사진 보존 정책과 보안 문서의 데이터 범위가 실제 구현과 다르다
**근거**
- 사진 업로드 API는 누수 사진 파일과 메타데이터를 저장한다: [`php/api/photo_upload.php`](../php/api/photo_upload.php#L299-L337)
- 보존 정리 작업은 측정·로그 테이블만 삭제하고 사진 파일·사진 메타데이터·사고 이력을 처리하지 않는다: [`php/retention_cleanup.php`](../php/retention_cleanup.php#L64-L74)
- 보안 문서는 시스템이 SHT30 온습도만 처리하며 누수·카메라·사진을 수집하지 않는다고 기술한다: [`docs/SECURITY_PLAN_PASS_READINESS.md`](../docs/SECURITY_PLAN_PASS_READINESS.md#L7), [`docs/SECURITY_PLAN_PASS_READINESS.md`](../docs/SECURITY_PLAN_PASS_READINESS.md#L263), [`docs/SECURITY_PLAN_HWP_REVIEW.md`](../docs/SECURITY_PLAN_HWP_REVIEW.md#L199)
**영향**
사진과 사고 데이터가 무기한 남고, 데이터 목록·접근 통제·백업·파기 설명이 실제 처리와 불일치한다. 현장 사진에 사람, 설비 정보, 위치 단서가 포함될 경우 개인정보·보안 심사 근거가 부정확해진다.
**권장 조치**
사진과 사고 데이터의 목적, 보존 기간, 접근 주체, 백업 범위를 확정한다. 정리 작업이 DB 메타데이터와 파일을 일관되게 삭제하도록 만들고, 보안 문서의 데이터 흐름과 위협 모델을 현재 구현에 맞춘다.
## 4. P2 발견 사항
### P2-01. 수집 처리와 외부 알림에 원자성이 없다
nonce, 수집 로그, 상태, 측정값, 사고 이력이 각각 autocommit으로 기록된다: [`php/api/sensor_data.php`](../php/api/sensor_data.php#L266-L412). 중간 실패 시 일부 변경만 남고 catch 경로가 nonce를 해제하면 재시도가 중복 또는 상충 상태를 만들 수 있다: [`php/api/sensor_data.php`](../php/api/sensor_data.php#L426-L432). 센서 요청에 대한 DB 변경을 하나의 트랜잭션으로 묶고, SMS·사진 요청은 같은 트랜잭션에서 outbox에 기록해야 한다.
### P2-02. 사진 업로드가 재시작과 중복 요청에 안전하지 않다
중복 센서 요청 응답은 원래 `log_id`를 돌려주지 않으며, 클라이언트는 `log_id`가 없으면 사진을 건너뛴다: [`php/api/sensor_data.php`](../php/api/sensor_data.php#L274-L279), [`raspberry_pi4/leak_sensor.py`](../raspberry_pi4/leak_sensor.py#L371-L374). 사진 업로드는 한 번만 시도하고 임시 파일을 삭제한다: [`raspberry_pi4/leak_sensor.py`](../raspberry_pi4/leak_sensor.py#L390-L398). 서버는 파일 이동 뒤 DB insert가 실패하면 이동된 파일을 제거하지 않는다: [`php/api/photo_upload.php`](../php/api/photo_upload.php#L314-L346). 요청 ID별 결과 매핑, 로컬 spool, 재시도 가능한 업로드 ID, orphan 정리가 필요하다.
### P2-03. 잘못된 `event_type`이 오류가 아니라 `periodic`으로 조용히 바뀐다
알 수 없는 이벤트 문자열을 `periodic`으로 강제한다: [`php/api/sensor_data.php`](../php/api/sensor_data.php#L207-L209). 오타나 버전 불일치가 HTTP 200으로 숨겨져 누수 전환이 누락될 수 있다. 허용 enum 외 값에는 400을 반환하고 구조화된 거부 로그를 남겨야 한다.
### P2-04. `php/var`의 독립 접근 차단 파일이 저장소와 배포 번들에 포함되지 않는다
`.gitignore`가 `php/var/*`를 제외하고 `.gitkeep`만 다시 포함한다: [`.gitignore`](../.gitignore#L20-L21). 현재 저장소에도 `.gitkeep`만 추적되며 로컬의 `var/.htaccess`와 `var/photos/.htaccess`는 배포 자산이 아니다. 번들 생성기는 `var`를 다시 만들면서 `index.html`만 둔다: [`tools/make_deploy_bundles.py`](../tools/make_deploy_bundles.py#L262-L266). 상위 [`php/.htaccess`](../php/.htaccess#L24)의 rewrite가 동작하면 차단되지만, 감사·로그인·거부 로그와 사진을 위한 독립 방어선이 재현되지 않는다. 차단 파일을 명시적으로 추적·번들링하고 clean archive 검사를 추가해야 한다.
### P2-05. 테스트 실행기가 필수 도구와 테스트 부재를 성공으로 처리하며 현재 회귀 테스트도 2건 실패한다
[`run_tests.sh`](../run_tests.sh#L21)는 런타임이 없으면 테스트를 건너뛰고, 실행된 테스트가 0건이어도 성공 종료할 수 있다: [`run_tests.sh`](../run_tests.sh#L37-L54). CI 설정도 발견되지 않았다. 현재 PHP 회귀 테스트에서는 다음 두 실패가 확인됐다.
- `php/config.local.example.php`가 삭제된 워킹 트리라 보안 강화 테스트가 fatal error로 중단된다. 배포 문서 여러 곳은 여전히 이 파일 복사를 지시한다.
- 이슬점 기능이 로컬 설정으로 비활성화된 경우에도 threshold 회귀 테스트는 활성 동작을 기대해 실패한다. 테스트가 로컬 비밀·운영 설정에 종속되어 있다.
고정 fixture 설정을 사용하고 필수 런타임·테스트 누락을 실패로 처리해야 한다. PHP/Python/C/MySQL 통합과 이벤트 순서, 재시도, 장애 주입을 CI 필수 게이트로 구성해야 한다.
### P2-06. 월간 보고서가 이름·위치 변경 시 행을 덮어쓰며 현재 통합 범위를 반영하지 않는다
보고서는 `sensor_id`와 이름·위치로 그룹화한 여러 행을 `sensor_id` 하나로 다시 keying한다: [`php/monthly_report.php`](../php/monthly_report.php#L138-L143), [`php/monthly_report.php`](../php/monthly_report.php#L159-L164). 월중 이름이나 위치가 바뀌면 일부 집계가 덮어써진다. 또한 보고 범위가 SHT30 중심이라 누수·응축 등 현재 기능을 완전히 반영하지 않는다: [`php/monthly_report.php`](../php/monthly_report.php#L77). ID 기준으로 집계하고 표시명은 별도 최신 레지스트리에서 가져오며 통합 이벤트 범위를 명시해야 한다.
### P2-07. 일일 상태 SMS 실패가 그날 전체 재시도를 차단한다
일일 중복 조회는 성공 상태를 조건으로 하지 않고, 전송 결과를 확인하지 않은 채 전송 로그를 남긴다: [`php/sms_send.php`](../php/sms_send.php#L773-L811). 첫 시도가 일시 실패해도 실패 행이 당일 중복으로 취급되어 다음 시도를 막을 수 있다. 성공 기록만 dedup 대상으로 삼고 실패에는 제한된 backoff 재시도를 적용해야 한다.
## 5. 추가 관찰
- 새로 분리된 `php/dashboard_data.php`와 `php/tabs/*.php`는 현재 [`php/.htaccess`](../php/.htaccess#L24)의 직접 접근 차단 목록에 포함되지 않는다. tab 파일에는 `APP_VERSION` guard가 있고 helper는 직접 요청 시 데이터 출력을 하지 않아 즉시 정보 노출로 보지는 않았지만, 내부 include 전용 PHP 파일은 웹 서버에서 일괄 차단하는 편이 안전하다.
- 대시보드는 helper를 session 인증 전에 include하지만, 리뷰 시점 helper는 함수와 상수만 정의하고 쿼리나 출력을 실행하지 않았다. 향후 module 초기화 코드가 추가되면 인증 경계가 쉽게 무너질 수 있으므로 인증 이후 include하거나 bootstrap과 데이터 조회를 분리해야 한다.
- systemd service 파일은 `StartLimitInterval`과 `StartLimitBurst`를 `[Service]`에 둔 legacy 문법을 사용한다. [Debian systemd parser 소스](https://sources.debian.org/src/systemd/232-25%2Bdeb9u12/src/core/load-fragment-gperf.gperf.m4)는 이 조합을 호환 매핑하므로, 대상 Raspberry Pi에서 확인하지 않은 채 기능 결함으로 분류하지 않았다. 다만 `[Unit]`의 `StartLimitIntervalSec=`/`StartLimitBurst=`로 정규화하고 `systemd-analyze verify`와 실제 crash-loop 시험을 배포 게이트에 추가해야 한다.
- STM32 경로와 Raspberry Pi 경로가 함께 존재하므로 현재 생산 대상과 폐기·실험 코드를 문서와 빌드에서 명확히 구분할 필요가 있다.
## 6. 확인된 강점
- 원문 body 기반 HMAC, timestamp freshness, body size 제한, nonce 재사용 방지가 구현되어 있다.
- 주요 DB 접근은 PDO 준비문을 사용하고 센서 ID와 수치 범위를 검증한다.
- 관리자 인증에 bcrypt, TOTP, session cookie 강화, CSRF 검사가 적용되어 있다.
- 사진 업로드에 MIME·크기 검사, 무작위 파일명, 경로 검증이 있다.
- 중복 알림을 줄이기 위한 named lock과 cooldown 설계가 있다.
- 운영 비밀 파일은 저장소 제외 대상으로 두고 배포 도구도 별도 비밀 입력을 사용한다.
이 장치들은 유효하지만, P1의 상태 정확성·전달 보장 문제를 상쇄하지는 않는다.
## 7. 로컬 검증 결과
| 검증 | 결과 | 비고 |
| --- | --- | --- |
| PHP syntax lint | 통과 | 64개 파일, 실패 0건 |
| Python bytecode compile | 통과 | 37개 파일, 실패 0건 |
| PHP 회귀 테스트 | **5 통과 / 2 실패** | example config 삭제, 로컬 설정 종속 threshold 테스트 |
| Python↔PHP parity | PHP/Python 통과 | C compiler 부재로 C parity는 skip |
| SHT30 sensor test | 시뮬레이션으로 통과 | `PYTHONUTF8=1` 필요; 실제 I2C 검증 아님 |
| MySQL 통합 | 미실행 | 로컬 DB fixture 없음 |
| 실제 GPIO/I2C | 미실행 | 대상 하드웨어 없음 |
| systemd 검증 | 미실행 | Windows 리뷰 환경 |
기본 Windows CP949 콘솔에서 SHT30 테스트의 Unicode 출력이 `UnicodeEncodeError`를 일으켰다. UTF-8 모드에서는 통과했지만 자동 시뮬레이션을 사용했으므로 하드웨어 건전성의 증거로 사용할 수 없다.
## 8. 권장 수정 순서와 완료 기준
### 단계 A. 상태 정확성과 전달 보장
1. 센서별 persistent FIFO, `sequence`, 서버 stale-event 거부를 구현한다.
2. 부팅 상태 동기화를 정의하고 누수·복구 불일치를 정상 전환으로 처리한다.
3. DB 트랜잭션과 SMS/photo outbox를 도입한다.
4. 젖은 부팅, 마른 부팅, 순서 역전, 네트워크 단절, SMS 실패·복구 테스트를 통과한다.
### 단계 B. 하드웨어와 입력 신뢰성
1. 운영 시뮬레이션을 기본 금지하고 센서 오류 상태를 노출한다.
2. SHT30 완전성 검증과 오류/복구 상태기를 수정한다.
3. 실제 Pi에서 GPIO/I2C, 재부팅, 장시간 서비스 실행을 검증한다.
### 단계 C. 보안·운영·준법
1. 장치별 자격 증명과 센서 권한 매핑을 도입한다.
2. cron dead-man, 일일 SMS 재시도, systemd 검증을 배포 게이트에 넣는다.
3. 사진 보존·파기 구현과 보안 문서를 일치시킨다.
4. clean checkout에서 접근 차단 파일과 배포 번들을 검증한다.
### 단계 D. 자동 회귀 게이트
1. 테스트를 로컬 설정과 분리하고 필수 skip을 실패로 바꾼다.
2. PHP/Python/C/MySQL 조합을 CI에서 실행한다.
3. clean checkout 기준 배포 번들 생성, PHP lint, `systemd-analyze verify`, 문서 참조 검사를 필수화한다.
**배포 재개 기준:** P1 7건의 수정과 해당 장애 시나리오 자동화, 실제 Raspberry Pi 재부팅 시험, SMS 실패 후 재전송 증거, 사진 보존 정책 승인이 모두 완료되어야 한다.
## 9. 리뷰 한계
이 리뷰는 코드와 로컬 실행 결과에 기반한다. 운영 네트워크, 실제 SMS 사업자 응답, DB 부하·락 경합, Raspberry Pi 전원 장애, GPIO 노이즈, I2C 버스 장애는 재현하지 못했다. 따라서 P1 해결 뒤에도 현장 전 staging에서 장애 주입과 장시간 soak test가 필요하다.

12
review/README.md Normal file
View file

@ -0,0 +1,12 @@
# 프로젝트 리뷰
이 폴더는 `leak_sms_v3` 프로젝트의 코드, 운영, 보안, 테스트 리뷰 결과를 날짜별로 보관합니다.
## 현재 리뷰
- [2026-07-11 프로젝트 리뷰](./PROJECT_REVIEW_2026-07-11.md)
- 판정: **현장 무인 배포 보류**
- 발견 사항: P0 0건, P1 7건, P2 7건
- 기준: `feat/pi-c-sensor3-leak-node` 브랜치의 `a72ea18` 및 리뷰 시점 워킹 트리
가장 먼저 해결할 항목은 누수 상태 부팅 동기화, 이벤트 순서 보장, 운영 환경의 하드웨어 시뮬레이션 차단입니다. 상세 근거와 권장 검증은 위 보고서에 정리했습니다.

912
review/ui_mockup_v2.html Normal file
View file

@ -0,0 +1,912 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>누수·온습도 관제: UI 개선 목업 v2</title>
<style>
/* ============================================================
누수·온습도 통합 관제 — UI 개선 목업
원칙: Apple fluid interface — 즉각 반응(:active), 반투명 재질,
광학 타이포(큰 글자 음수 트래킹), spring 유사 easing,
prefers-reduced-motion 대응. 폐쇄망 전제: 외부 리소스 0.
============================================================ */
:root {
--bg: #0d1024;
--bg-2: #12162e;
--surface: rgba(255,255,255,.045);
--surface-2: rgba(255,255,255,.08);
--line: rgba(255,255,255,.09);
--ink: #eef0fa;
--ink-2: #9aa1c2;
--ink-3: #5d648a;
--ok: #34d399;
--warn: #fbbf24;
--err: #fb7185;
--leak: #60a5fa;
--temp: #f97362;
--hum: #38bdf8;
--accent: #818cf8;
--r-lg: 18px;
--r-md: 12px;
--r-sm: 8px;
--spring: cubic-bezier(.32,.72,.28,1); /* 감쇠 스프링 근사 */
}
* { margin:0; padding:0; box-sizing:border-box; }
html { -webkit-text-size-adjust:100%; }
body {
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", "Malgun Gothic", sans-serif;
background:
radial-gradient(1200px 600px at 80% -10%, rgba(129,140,248,.14), transparent 60%),
radial-gradient(900px 500px at -10% 30%, rgba(56,189,248,.08), transparent 55%),
var(--bg);
color: var(--ink);
min-height: 100vh;
padding-bottom: 60px;
}
a { color: inherit; text-decoration: none; }
/* ── 상단 글래스 툴바: 콘텐츠가 아래로 스크롤되는 반투명 재질 ── */
.topbar {
position: sticky; top: 0; z-index: 50;
display: flex; align-items: center; gap: 16px;
padding: 14px 28px;
background: rgba(13,16,36,.72);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border-bottom: 1px solid var(--line);
}
.brand { display:flex; align-items:center; gap:10px; font-weight:700; letter-spacing:-.01em; }
.brand .dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--ok); box-shadow: 0 0 12px var(--ok);
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity:.45; } }
.brand small { color: var(--ink-3); font-weight: 400; margin-left: 4px; }
.topbar .spacer { flex:1; }
.top-link {
font-size: .8125rem; color: var(--ink-2);
padding: 7px 12px; border-radius: var(--r-sm);
transition: background .18s var(--spring), color .18s;
}
.top-link:hover { background: var(--surface-2); color: var(--ink); }
.top-link:active { transform: scale(.96); }
.refresh-pill {
display:flex; align-items:center; gap:8px;
font-size:.75rem; color:var(--ink-2);
border:1px solid var(--line); border-radius:999px;
padding:5px 6px 5px 12px; background:var(--surface);
}
.refresh-pill button {
all:unset; cursor:pointer; font-size:.75rem; color:var(--ink);
background:var(--surface-2); border-radius:999px; padding:3px 10px;
transition: transform .15s var(--spring), background .18s;
}
.refresh-pill button:active { transform: scale(.93); }
.refresh-ring { width:16px; height:16px; }
.refresh-ring circle { fill:none; stroke:var(--accent); stroke-width:2.5;
stroke-dasharray: 38; stroke-linecap: round;
transform: rotate(-90deg); transform-origin: center;
animation: countdown 30s linear infinite; }
@keyframes countdown { from{stroke-dashoffset:0} to{stroke-dashoffset:38} }
.container { max-width: 1240px; margin: 0 auto; padding: 24px 28px; }
/* ── 상태 히어로: 시스템 전체 판정 1문장 + 큰 광학 타이포 ── */
.hero {
display:flex; align-items:center; justify-content:space-between; gap:24px;
padding: 26px 28px;
border-radius: var(--r-lg);
border: 1px solid rgba(52,211,153,.25);
background: linear-gradient(120deg, rgba(52,211,153,.12), rgba(52,211,153,.03) 55%, transparent);
margin-bottom: 18px;
}
.hero.err {
border-color: rgba(251,113,133,.35);
background: linear-gradient(120deg, rgba(251,113,133,.16), rgba(251,113,133,.04) 55%, transparent);
}
.hero h1 {
font-size: clamp(1.4rem, 2.6vw, 1.9rem);
letter-spacing: -.02em; line-height: 1.15; font-weight: 700;
}
.hero .sub { color: var(--ink-2); margin-top: 6px; font-size:.875rem; }
.hero .clock { text-align:right; color:var(--ink-3); font-size:.8125rem; font-variant-numeric: tabular-nums; }
.hero .clock b { display:block; font-size:1.05rem; color:var(--ink-2); font-weight:600; }
/* ── 알림 스트립 ── */
.strip {
display:flex; align-items:center; gap:12px;
padding: 12px 18px; border-radius: var(--r-md);
margin-bottom: 10px; font-size:.875rem;
border:1px solid rgba(251,113,133,.3);
background: rgba(251,113,133,.08);
transition: transform .18s var(--spring), background .18s;
}
.strip:hover { transform: translateX(3px); background: rgba(251,113,133,.13); }
.strip:active { transform: scale(.99); }
.strip .ico { font-size:1rem; }
.strip strong { color:#fecdd3; }
.strip span { color:var(--ink-2); flex:1; }
.strip em { font-style:normal; color:var(--ink-2); font-size:.8125rem; white-space:nowrap; }
.strip.warn { border-color: rgba(251,191,36,.3); background: rgba(251,191,36,.07); }
.strip.warn:hover { background: rgba(251,191,36,.12); }
.strip.warn strong { color:#fde68a; }
/* ── KPI 스트립: 큰 숫자 + 미니 스파크, 딥링크 카드 ── */
.kpis {
display:grid; grid-template-columns: repeat(6, 1fr); gap:12px;
margin: 18px 0;
}
.kpi {
display:block; padding: 16px 18px 14px;
background: var(--surface); border:1px solid var(--line);
border-radius: var(--r-md);
transition: transform .2s var(--spring), border-color .2s, background .2s;
}
.kpi:hover { transform: translateY(-3px); background: var(--surface-2); border-color: rgba(255,255,255,.18); }
.kpi:active { transform: translateY(-1px) scale(.98); }
.kpi .lbl { font-size:.75rem; color:var(--ink-2); letter-spacing:.01em; }
.kpi .val {
font-size: 1.9rem; font-weight: 700; letter-spacing: -.03em;
line-height: 1.1; margin-top: 4px; font-variant-numeric: tabular-nums;
}
.kpi .val small { font-size:.9rem; font-weight:500; color:var(--ink-2); letter-spacing:0; margin-left:2px; }
.kpi .sub { font-size:.71rem; color:var(--ink-3); margin-top:3px; }
.kpi.ok .val { color: var(--ok); }
.kpi.warn .val { color: var(--warn); }
.kpi.err .val { color: var(--err); }
.kpi.neutral .val { color: var(--ink); }
/* ── 세그먼트 탭바 (iOS segmented control 형태) ── */
.tabs {
display:flex; gap:4px; padding:4px;
background: var(--surface); border:1px solid var(--line);
border-radius: 999px; width: fit-content; margin-bottom: 26px;
}
.tab {
padding: 8px 20px; border-radius:999px; font-size:.875rem; color:var(--ink-2);
position:relative; transition: color .18s, background .22s var(--spring), transform .15s var(--spring);
}
.tab:hover { color: var(--ink); }
.tab:active { transform: scale(.96); }
.tab.active {
color:#fff; background: linear-gradient(135deg, #6366f1, #8b5cf6);
box-shadow: 0 4px 14px rgba(99,102,241,.35);
}
.tab .badge {
display:inline-flex; min-width:17px; height:17px; align-items:center; justify-content:center;
border-radius:999px; background:var(--err); color:#fff;
font-size:.66rem; font-weight:700; padding:0 5px; margin-left:6px; vertical-align:1px;
}
/* ── 섹션 헤더 ── */
h2.sec {
font-size: 1.05rem; font-weight: 700; letter-spacing: -.015em;
margin: 26px 0 14px; display:flex; align-items:baseline; gap:8px;
}
h2.sec small { font-weight:400; color:var(--ink-3); font-size:.8125rem; }
/* ── 센서 카드 ── */
.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(272px,1fr)); gap:14px; }
.card {
background: var(--surface); border:1px solid var(--line);
border-radius: var(--r-lg); padding: 18px;
position:relative; overflow:hidden;
transition: transform .22s var(--spring), border-color .22s, box-shadow .22s;
}
.card:hover { transform: translateY(-3px); box-shadow: 0 12px 32px rgba(0,0,0,.35); border-color: rgba(255,255,255,.16); }
.card::before {
content:""; position:absolute; inset:0 0 auto 0; height:3px;
background: var(--ok); opacity:.85;
}
.card.alert::before { background: var(--err); }
.card.leak::before { background: var(--leak); }
.card.offline::before { background: var(--ink-3); }
.card.alert { border-color: rgba(251,113,133,.35); background: rgba(251,113,133,.05); }
.card.leak { border-color: rgba(96,165,250,.35); background: rgba(96,165,250,.05); }
.card.offline { opacity:.72; }
.card.leak.pulse { animation: leakGlow 1.8s ease-in-out infinite; }
@keyframes leakGlow { 50% { box-shadow: 0 0 26px rgba(96,165,250,.28); } }
.c-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:2px; }
.c-head strong { font-size:1rem; letter-spacing:-.01em; }
.chip-type {
font-size:.66rem; padding:3px 9px; border-radius:999px; font-weight:600;
background: rgba(56,189,248,.14); color: var(--hum);
}
.chip-type.leak { background: rgba(96,165,250,.14); color: var(--leak); }
.c-loc { font-size:.75rem; color:var(--ink-3); margin-bottom:12px; }
.status-line { display:flex; align-items:center; gap:7px; font-size:.8125rem; font-weight:600; margin-bottom:12px; }
.status-dot { width:8px; height:8px; border-radius:50%; background:var(--ok); box-shadow:0 0 8px var(--ok); }
.status-line.ok { color: var(--ok); }
.status-line.err { color: var(--err); } .status-line.err .status-dot { background:var(--err); box-shadow:0 0 8px var(--err); animation:pulse 1.2s infinite; }
.status-line.leak { color: var(--leak); } .status-line.leak .status-dot { background:var(--leak); box-shadow:0 0 8px var(--leak); animation:pulse 1.2s infinite; }
.status-line.off { color: var(--ink-3); } .status-line.off .status-dot { background:var(--ink-3); box-shadow:none; }
.vals { display:flex; gap:18px; margin-bottom:8px; }
.v { flex:1; }
.v .n {
font-size:1.65rem; font-weight:700; letter-spacing:-.03em;
font-variant-numeric: tabular-nums; line-height:1.1;
}
.v .n small { font-size:.85rem; color:var(--ink-2); font-weight:500; letter-spacing:0; }
.v .l { font-size:.7rem; color:var(--ink-3); margin-top:2px; }
.v.temp .n { color: var(--temp); } .v.hum .n { color: var(--hum); }
.v .n.hot { text-shadow: 0 0 14px rgba(249,115,98,.5); }
.spark { width:100%; height:34px; margin: 2px 0 8px; }
.spark polyline { fill:none; stroke-width:1.6; stroke-linecap:round; stroke-linejoin:round; }
.spark .t { stroke: var(--temp); opacity:.85; }
.spark .h { stroke: var(--hum); opacity:.85; }
.fchips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.fchip {
font-size:.69rem; padding:3px 9px; border-radius:999px;
background: var(--surface-2); color: var(--ink-2); border:1px solid var(--line);
}
.fchip.near { background: rgba(251,191,36,.12); color:#fde68a; border-color:rgba(251,191,36,.25); }
.fchip.over { background: rgba(251,113,133,.12); color:#fecdd3; border-color:rgba(251,113,133,.3); }
.c-foot { font-size:.71rem; color:var(--ink-3); margin-top:10px; display:flex; justify-content:space-between; }
.c-foot .stale { color: var(--err); font-weight:600; }
/* 누수카드 물결 배경 */
.wave {
position:absolute; left:0; right:0; bottom:0; height:46px; opacity:.16; pointer-events:none;
}
/* ── 미니 패널 ── */
.panels { display:grid; grid-template-columns: repeat(auto-fit,minmax(300px,1fr)); gap:14px; }
.panel {
background: var(--surface); border:1px solid var(--line);
border-radius: var(--r-lg); padding: 18px 20px;
}
.panel h3 { font-size:.9375rem; letter-spacing:-.01em; margin-bottom:12px; }
.rows div { display:flex; justify-content:space-between; padding:7px 0; border-bottom:1px dashed var(--line); font-size:.8438rem; }
.rows div:last-child { border-bottom:none; }
.rows dt { color:var(--ink-2); }
.rows dd { font-weight:600; font-variant-numeric: tabular-nums; }
.rows .err-c { color: var(--err); } .rows .ok-c { color: var(--ok); }
.panel .note { font-size:.71rem; color:var(--ink-3); margin-top:10px; }
.gauge { display:flex; align-items:center; gap:14px; margin-bottom:8px; }
.gauge svg { width:64px; height:64px; }
.gauge .ring-bg { fill:none; stroke:var(--surface-2); stroke-width:6; }
.gauge .ring-v { fill:none; stroke-width:6; stroke-linecap:round;
transform:rotate(-90deg); transform-origin:center; }
.gauge b { font-size:1.3rem; letter-spacing:-.02em; }
.gauge span { display:block; font-size:.71rem; color:var(--ink-3); }
/* ── 대응 필요(playbook) ── */
.playbook {
border:1px solid rgba(251,191,36,.25); background: rgba(251,191,36,.05);
border-radius: var(--r-lg); padding: 16px 20px; margin-bottom: 18px;
}
.playbook summary { cursor:pointer; font-weight:700; font-size:.9375rem; list-style:none; display:flex; align-items:center; gap:10px; }
.playbook summary::-webkit-details-marker{display:none}
.playbook summary .chev { margin-left:auto; transition: transform .25s var(--spring); color:var(--ink-3); }
.playbook[open] summary .chev { transform: rotate(90deg); }
.playbook ol { margin: 12px 0 4px 20px; font-size:.8438rem; color:var(--ink-2); }
.playbook ol li { padding:3px 0; }
.pb-pill { font-size:.66rem; background:rgba(251,191,36,.15); color:#fde68a; padding:3px 9px; border-radius:999px; font-weight:600; }
/* ── 탭 페인 전환 ── */
.tabpane { display:none; }
.tabpane.active { display:block; animation: fadeUp .3s var(--spring); }
@keyframes fadeUp { from { opacity:0; transform: translateY(8px); } }
/* ── 차트 패널(추이 탭) ── */
.chart-panel {
background: var(--surface); border:1px solid var(--line);
border-radius: var(--r-lg); padding: 18px 20px; margin-bottom: 20px;
}
.chart-head { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px; margin-bottom:12px; }
.legend { display:flex; gap:14px; font-size:.75rem; color:var(--ink-2); }
.legend i { display:inline-block; width:14px; height:3px; border-radius:2px; vertical-align:middle; margin-right:5px; }
.legend .sw-t { background:var(--temp); } .legend .sw-h { background:var(--hum); }
.legend .sw-w { background:var(--warn); } .legend .sw-l { background:var(--leak); }
.range-toggle { display:flex; gap:3px; padding:3px; background:var(--surface-2); border-radius:999px; }
.range-toggle a {
font-size:.75rem; padding:4px 13px; border-radius:999px; color:var(--ink-2);
transition: background .2s var(--spring), color .18s, transform .15s var(--spring);
}
.range-toggle a:active { transform: scale(.95); }
.range-toggle a.cur { background:var(--accent); color:#fff; }
.bigchart { width:100%; height:auto; display:block; }
.bigchart .gl, svg .gl { stroke: rgba(255,255,255,.06); }
.bigchart .ax { fill: var(--ink-3); font-size:10px; }
.bigchart .ax-t { fill: var(--temp); font-size:10px; }
.bigchart .ax-h { fill: var(--hum); font-size:10px; }
.bigchart .ln-t { stroke: var(--temp); fill:none; stroke-width:2; stroke-linejoin:round; stroke-linecap:round; }
.bigchart .ln-h { stroke: var(--hum); fill:none; stroke-width:2; stroke-dasharray:6 3; stroke-linejoin:round; stroke-linecap:round; }
.bigchart .thr { stroke-dasharray:2 4; stroke-width:1; opacity:.8; }
.bigchart .area-t { fill:url(#gt2); stroke:none; }
.peak-flag { fill: rgba(249,115,98,.18); }
.peak-label { fill:#fda49a; font-size:10px; font-weight:600; }
/* 통계 위젯 그리드 */
.widgets { display:grid; grid-template-columns: 1.4fr 1fr 1fr; gap:14px; }
.widget {
background: var(--surface); border:1px solid var(--line);
border-radius: var(--r-lg); padding: 16px 18px;
}
.widget h3 { font-size:.875rem; letter-spacing:-.01em; margin-bottom:10px; }
.widget h3 small { font-weight:400; color:var(--ink-3); font-size:.7rem; margin-left:6px; }
.donut-wrap { display:flex; align-items:center; gap:16px; }
.donut-wrap svg { width:110px; height:110px; }
.donut-wrap .d-track { fill:none; stroke:var(--surface-2); stroke-width:12; }
.donut-wrap .seg { fill:none; stroke-width:12; }
.donut-num { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; }
.donut-num b { font-size:1.4rem; letter-spacing:-.02em; }
.donut-num small { font-size:.66rem; color:var(--ink-3); }
.donut-ring { position:relative; }
.dl { display:flex; align-items:center; gap:7px; font-size:.75rem; color:var(--ink-2); padding:3px 0; }
.dl i { width:9px; height:9px; border-radius:3px; }
.dl .dl-c { margin-left:auto; font-weight:700; color:var(--ink); font-variant-numeric:tabular-nums; }
.board-row { display:flex; align-items:center; gap:10px; padding:6px 0; }
.board-rank { width:20px; height:20px; border-radius:6px; background:var(--surface-2); font-size:.7rem; font-weight:700;
display:flex; align-items:center; justify-content:center; color:var(--ink-2); }
.board-main { flex:1; }
.board-name { font-size:.78rem; margin-bottom:3px; }
.board-bar { height:5px; background:var(--surface-2); border-radius:999px; overflow:hidden; }
.board-bar span { display:block; height:100%; border-radius:999px;
background:linear-gradient(90deg,var(--accent),#a78bfa); }
.board-cnt { font-size:.8rem; font-weight:700; font-variant-numeric:tabular-nums; }
.board-cnt small { color:var(--ink-3); font-weight:400; }
/* ── 이력 테이블 ── */
.tablecard {
background: var(--surface); border:1px solid var(--line);
border-radius: var(--r-lg); overflow:hidden; margin-bottom:18px;
}
.tablecard header {
display:flex; align-items:center; gap:10px;
padding:14px 18px; border-bottom:1px solid var(--line);
font-weight:700; font-size:.9375rem; letter-spacing:-.01em;
background: rgba(255,255,255,.02); position: static;
}
.tablecard header small { font-weight:400; color:var(--ink-3); font-size:.75rem; }
.tablecard header .count-pill {
margin-left:auto; font-size:.69rem; font-weight:600; color:var(--ink-2);
background:var(--surface-2); padding:3px 10px; border-radius:999px;
}
.twrap { overflow-x:auto; }
table.t { width:100%; border-collapse:collapse; font-size:.8125rem; }
table.t th {
text-align:left; font-weight:600; font-size:.69rem; color:var(--ink-3);
letter-spacing:.04em; padding:10px 16px; border-bottom:1px solid var(--line);
white-space:nowrap;
}
table.t td { padding:10px 16px; border-bottom:1px solid rgba(255,255,255,.05);
font-variant-numeric:tabular-nums; white-space:nowrap; }
table.t tr:last-child td { border-bottom:none; }
table.t tbody tr { transition: background .15s; }
table.t tbody tr:hover { background: rgba(255,255,255,.03); }
table.t tr.hot { background: rgba(251,113,133,.06); }
table.t tr.hot:hover { background: rgba(251,113,133,.1); }
.pill {
display:inline-block; font-size:.69rem; font-weight:600; padding:2px 10px; border-radius:999px;
}
.pill.ok { background:rgba(52,211,153,.13); color:var(--ok); }
.pill.warn { background:rgba(251,191,36,.13); color:var(--warn); }
.pill.err { background:rgba(251,113,133,.13); color:var(--err); }
.pill.leak { background:rgba(96,165,250,.13); color:var(--leak); }
.pill.mut { background:var(--surface-2); color:var(--ink-2); }
.thumbs { display:flex; gap:6px; }
.thumb {
width:44px; height:34px; border-radius:6px; border:1px solid var(--line);
background:linear-gradient(135deg,#1c2444,#25305c);
display:flex; align-items:center; justify-content:center; font-size:.85rem;
transition: transform .18s var(--spring);
}
.thumb:hover { transform: scale(1.35); }
/* 통계 스탯 그리드 */
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:10px; }
.stat {
background:var(--surface-2); border-radius:var(--r-md); padding:12px 14px;
}
.stat span { display:block; font-size:.69rem; color:var(--ink-3); }
.stat strong { font-size:1.25rem; letter-spacing:-.02em; font-variant-numeric:tabular-nums; }
.stat strong.w { color:var(--warn); } .stat strong.e { color:var(--err); }
/* 반응형 */
@media (max-width: 980px) { .kpis { grid-template-columns: repeat(3,1fr); } }
@media (max-width: 640px) {
.container { padding: 16px; }
.kpis { grid-template-columns: repeat(2,1fr); }
.hero { flex-direction:column; align-items:flex-start; }
.hero .clock { text-align:left; }
.tabs { width:100%; overflow-x:auto; }
}
/* 접근성: 모션 최소화 — 슬라이드/펄스 제거, 페이드만 유지 */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: opacity .2s !important; }
}
@media (prefers-reduced-transparency: reduce) {
.topbar { background: var(--bg-2); backdrop-filter:none; -webkit-backdrop-filter:none; }
}
</style>
</head>
<body>
<!-- 상단 글래스 툴바 -->
<header class="topbar">
<div class="brand"><span class="dot"></span>누수·온습도 관제 <small>v3</small></div>
<div class="spacer"></div>
<a class="top-link" href="#">설치 점검</a>
<a class="top-link" href="#">월간 보고</a>
<a class="top-link" href="#">보안 증적</a>
<div class="refresh-pill">
<svg class="refresh-ring" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>
30초 갱신
<button type="button" onclick="this.textContent=this.textContent==='⏸'?'▶':'⏸'">⏸</button>
</div>
<a class="top-link" href="#">로그아웃</a>
</header>
<main class="container">
<!-- 시스템 상태 히어로: 판정 1문장, 경보 시 .err 로 전환 -->
<section class="hero err">
<div>
<h1>⚠ 누수 감지, 즉시 확인 필요</h1>
<p class="sub">3번 센서(기계실 B) 09:42 누수 확정 · SMS 4명 발송 완료 · 나머지 3개소 정상</p>
</div>
<div class="clock">
<b>2026-07-16 10:05:12</b>
마지막 수신 32초 전
</div>
</section>
<!-- 알림 스트립 -->
<a class="strip" href="#">
<span class="ico">🚨</span>
<strong>미복구 누수 사고 1건</strong>
<span>기계실 B: 현장 조치·복구 확인 필요</span>
<em>실시간 현황 →</em>
</a>
<a class="strip warn" href="#">
<span class="ico">✉️</span>
<strong>SMS 잔여 42건</strong>
<span>경보 임계(50건) 이하, 충전 권장</span>
<em>시스템 점검 →</em>
</a>
<!-- KPI 스트립 -->
<nav class="kpis" aria-label="핵심 현황">
<a class="kpi err" href="#"><span class="lbl">경보 센서</span><div class="val">1<small>개</small></div><div class="sub">누수 1 · 온습도 0</div></a>
<a class="kpi ok" href="#"><span class="lbl">온라인</span><div class="val">4<small>/4</small></div><div class="sub">가동률 100%</div></a>
<a class="kpi err" href="#"><span class="lbl">미복구 누수</span><div class="val">1<small>건</small></div><div class="sub">09:42 발생</div></a>
<a class="kpi warn" href="#"><span class="lbl">오늘 경보</span><div class="val">3<small>건</small></div><div class="sub">최근 09:42</div></a>
<a class="kpi warn" href="#"><span class="lbl">SMS 잔여</span><div class="val">42<small>건</small></div><div class="sub">오늘 4건 발송 · 실패 0</div></a>
<a class="kpi ok" href="#"><span class="lbl">중요 진단</span><div class="val">0<small>건</small></div><div class="sub">자가진단 정상</div></a>
</nav>
<!-- 대응 필요: 접이식 플레이북 -->
<details class="playbook" open>
<summary><span class="pb-pill">경보</span> 대응 필요: 3번 센서(기계실 B) 누수 확정 <span class="chev">›</span></summary>
<ol>
<li>현장으로 이동해 누수 지점·규모를 확인합니다.</li>
<li>밸브 차단 등 1차 조치 후 사진을 촬영해 업로드합니다.</li>
<li>센서 건조 확인 후 복구 SMS 자동 발송을 확인합니다.</li>
<li>담당자: 시설 김OO <b>010-****-1234</b></li>
</ol>
</details>
<!-- 세그먼트 탭바 -->
<nav class="tabs" aria-label="대시보드 섹션">
<a class="tab active" href="#" data-tab="live">실시간 현황<span class="badge">2</span></a>
<a class="tab" href="#" data-tab="trend">추이·통계</a>
<a class="tab" href="#" data-tab="history">이력 조회</a>
<a class="tab" href="#" data-tab="system">시스템 점검</a>
<a class="tab" href="#" data-tab="print">🖨 인쇄</a>
</nav>
<!-- ═══════════ 탭: 실시간 현황 ═══════════ -->
<section class="tabpane active" id="pane-live">
<h2 class="sec">센서 현황 <small>· 4개소</small></h2>
<div class="grid">
<!-- 누수 경보 카드 -->
<div class="card leak pulse">
<div class="c-head"><strong>3번 센서</strong><span class="chip-type leak">누수</span></div>
<div class="c-loc">기계실 B / pi-c-01</div>
<div class="status-line leak"><span class="status-dot"></span>누수 감지 (09:42 확정)</div>
<div class="vals">
<div class="v"><div class="n" style="color:var(--leak)">23<small>분</small></div><div class="l">경과 시간</div></div>
<div class="v"><div class="n">4<small>명</small></div><div class="l">SMS 발송</div></div>
</div>
<div class="fchips">
<span class="fchip over">미복구, 현장 조치 필요</span>
</div>
<div class="c-foot"><span>최종확인 10:04:58</span><span>온라인</span></div>
<svg class="wave" viewBox="0 0 300 46" preserveAspectRatio="none">
<path d="M0 28 Q 25 18 50 28 T 100 28 T 150 28 T 200 28 T 250 28 T 300 28 V46 H0 Z" fill="#60a5fa"/>
</svg>
</div>
<!-- 온습도 정상(예보 여유) -->
<div class="card">
<div class="c-head"><strong>1번 센서</strong><span class="chip-type">온습도</span></div>
<div class="c-loc">전산실 A / pi-a-01</div>
<div class="status-line ok"><span class="status-dot"></span>정상</div>
<div class="vals">
<div class="v temp"><div class="n">24.3<small>°C</small></div><div class="l">온도</div></div>
<div class="v hum"><div class="n">51<small>%</small></div><div class="l">습도</div></div>
</div>
<svg class="spark" viewBox="0 0 300 34" preserveAspectRatio="none">
<polyline class="t" points="0,20 30,19 60,21 90,18 120,17 150,19 180,16 210,17 240,15 270,16 300,14"/>
<polyline class="h" points="0,26 30,25 60,27 90,26 120,24 150,25 180,23 210,24 240,22 270,23 300,22"/>
</svg>
<div class="fchips">
<span class="fchip">고온 여유 5.7°C</span><span class="fchip">고습 여유 19%</span>
</div>
<div class="c-foot"><span>측정 10:00:04</span><span>10분 주기</span></div>
</div>
<!-- 온습도 임계 접근(예보 경고) -->
<div class="card">
<div class="c-head"><strong>2번 센서</strong><span class="chip-type">온습도</span></div>
<div class="c-loc">서버실 / pi-b-01</div>
<div class="status-line ok"><span class="status-dot"></span>정상 (상승 추세)</div>
<div class="vals">
<div class="v temp"><div class="n hot">28.9<small>°C</small></div><div class="l">온도</div></div>
<div class="v hum"><div class="n">44<small>%</small></div><div class="l">습도</div></div>
</div>
<svg class="spark" viewBox="0 0 300 34" preserveAspectRatio="none">
<polyline class="t" points="0,26 30,25 60,24 90,22 120,21 150,18 180,16 210,13 240,11 270,9 300,7"/>
<polyline class="h" points="0,24 30,25 60,24 90,25 120,26 150,25 180,26 210,25 240,26 270,25 300,26"/>
</svg>
<div class="fchips">
<span class="fchip near">↗ ~35분 뒤 고온 도달</span><span class="fchip near">급상승 +1.2°C/10분</span>
</div>
<div class="c-foot"><span>측정 10:00:11</span><span>1시간 주기</span></div>
</div>
<!-- 누수 정상 카드 -->
<div class="card">
<div class="c-head"><strong>4번 센서</strong><span class="chip-type leak">누수</span></div>
<div class="c-loc">배관실 / pi-c-02</div>
<div class="status-line ok"><span class="status-dot"></span>정상 (건조)</div>
<div class="vals">
<div class="v"><div class="n" style="color:var(--ok)">37<small>일</small></div><div class="l">무사고 연속</div></div>
<div class="v"><div class="n">100<small>%</small></div><div class="l">24h 가동률</div></div>
</div>
<div class="fchips"><span class="fchip">마지막 점검 06-09</span></div>
<div class="c-foot"><span>최종확인 10:05:02</span><span>온라인</span></div>
</div>
</div>
<h2 class="sec">채널·오늘 요약</h2>
<div class="panels">
<section class="panel">
<h3>SMS 채널 상태</h3>
<div class="gauge">
<svg viewBox="0 0 64 64">
<circle class="ring-bg" cx="32" cy="32" r="26"/>
<circle class="ring-v" cx="32" cy="32" r="26" stroke="var(--warn)"
stroke-dasharray="163" stroke-dashoffset="95"/>
</svg>
<div><b>42건</b><span>잔여 / 경보 임계 50건</span></div>
</div>
<dl class="rows">
<div><dt>마지막 성공 발송</dt><dd>09:42:18 (누수 경보)</dd></div>
<div><dt>오늘 발송 / 실패</dt><dd>4건 / <span class="ok-c">0건</span></dd></div>
</dl>
<p class="note">SMS는 유일한 경보 채널입니다. 임계 이하로 내려가면 즉시 충전하세요.</p>
</section>
<section class="panel">
<h3>오늘 경보 요약</h3>
<dl class="rows">
<div><dt>오늘 경보 횟수</dt><dd class="err-c">3건</dd></div>
<div><dt>오늘 최근 경보</dt><dd>09:42 (누수 · 3번)</dd></div>
<div><dt>가동률</dt><dd class="ok-c">100% (4/4 온라인)</dd></div>
</dl>
<p class="note">7일·30일 추이는 추이·통계 탭에서 확인하세요.</p>
</section>
<section class="panel">
<h3>미복구 누수 사고 · 1건</h3>
<dl class="rows">
<div><dt>#41 · 3번 센서</dt><dd class="err-c">미복구</dd></div>
<div><dt>위치</dt><dd>기계실 B</dd></div>
<div><dt>감지 시각</dt><dd>2026-07-16 09:42</dd></div>
</dl>
<p class="note">사진·복구 시각 등 전체 이력은 이력 조회 탭에서 확인하세요.</p>
</section>
</div>
</section>
<!-- ═══════════ 탭: 추이·통계 ═══════════ -->
<section class="tabpane" id="pane-trend">
<h2 class="sec">온습도 추이 <small>· 1번 센서(전산실 A) 최근 48개</small></h2>
<div class="chart-panel">
<div class="chart-head">
<div class="legend">
<span><i class="sw-t"></i>온도(°C)</span>
<span><i class="sw-h"></i>습도(%)</span>
</div>
<div class="range-toggle" role="group" aria-label="추이 기간 선택">
<a href="#" class="cur">오늘</a><a href="#">3일</a><a href="#">7일</a>
</div>
</div>
<svg class="bigchart" viewBox="0 0 920 280" role="img" aria-label="온습도 추이 라인 차트">
<defs>
<linearGradient id="gt2" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f97362" stop-opacity=".14"/>
<stop offset="1" stop-color="#f97362" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- 그리드 + 좌(온도)/우(습도) 눈금 -->
<g>
<line class="gl" x1="44" y1="16" x2="876" y2="16"/>
<line class="gl" x1="44" y1="73" x2="876" y2="73"/>
<line class="gl" x1="44" y1="130" x2="876" y2="130"/>
<line class="gl" x1="44" y1="187" x2="876" y2="187"/>
<line class="gl" x1="44" y1="244" x2="876" y2="244"/>
<text class="ax-t" x="38" y="19" text-anchor="end">32</text>
<text class="ax-t" x="38" y="76" text-anchor="end">28</text>
<text class="ax-t" x="38" y="133" text-anchor="end">24</text>
<text class="ax-t" x="38" y="190" text-anchor="end">20</text>
<text class="ax-t" x="38" y="247" text-anchor="end">16</text>
<text class="ax-h" x="882" y="19" >80</text>
<text class="ax-h" x="882" y="76" >65</text>
<text class="ax-h" x="882" y="133" >50</text>
<text class="ax-h" x="882" y="190" >35</text>
<text class="ax-h" x="882" y="247" >20</text>
<text class="ax" x="44" y="270">07-16 00:00</text>
<text class="ax" x="460" y="270" text-anchor="middle">07-16 05:00</text>
<text class="ax" x="876" y="270" text-anchor="end">07-16 10:00</text>
</g>
<!-- 임계선: 고온 30°C / 고습 70% -->
<line class="thr" x1="44" y1="45" x2="876" y2="45" stroke="var(--temp)"/>
<text class="ax-t" x="48" y="41">고온 30°C</text>
<line class="thr" x1="44" y1="54" x2="876" y2="54" stroke="var(--hum)"/>
<text class="ax-h" x="872" y="50" text-anchor="end">고습 70%</text>
<!-- 온도 면적 + 라인 -->
<path class="area-t" d="M44,160 C120,158 180,150 260,146 C340,142 400,148 480,138 C560,128 620,120 700,106 C780,94 830,88 876,80 L876,244 L44,244 Z"/>
<path class="ln-t" d="M44,160 C120,158 180,150 260,146 C340,142 400,148 480,138 C560,128 620,120 700,106 C780,94 830,88 876,80" fill="none"/>
<!-- 습도 라인(파선) -->
<path class="ln-h" d="M44,128 C120,132 180,126 260,130 C340,134 400,128 480,132 C560,136 620,130 700,134 C780,130 830,133 876,131" fill="none"/>
<!-- 피크 마커 -->
<line class="gl" x1="876" y1="80" x2="876" y2="16"/>
<rect class="peak-flag" x="796" y="2" width="76" height="15" rx="4"/>
<text class="peak-label" x="834" y="13" text-anchor="middle">피크 27.4°C</text>
<circle cx="876" cy="80" r="4" fill="var(--temp)"/>
<text class="ax-t" x="866" y="72" text-anchor="end">27.4°C</text>
<circle cx="876" cy="131" r="3" fill="var(--hum)"/>
<text class="ax-h" x="866" y="150" text-anchor="end">50.2%</text>
</svg>
</div>
<h2 class="sec">경보·가동 통계</h2>
<div class="widgets">
<!-- 경보 발생 추이 7일 -->
<div class="widget">
<h3>경보 발생 추이 <small>최근 7일 · 유형별</small></h3>
<div class="legend" style="margin-bottom:8px">
<span><i class="sw-w"></i>온습도 경보</span>
<span><i class="sw-l"></i>누수 감지</span>
</div>
<svg viewBox="0 0 640 190" style="width:100%;height:auto" role="img" aria-label="온습도 경보 vs 누수 감지 7일 추이">
<line class="gl" x1="40" y1="20" x2="624" y2="20" stroke="rgba(255,255,255,.06)"/>
<line class="gl" x1="40" y1="88" x2="624" y2="88" stroke="rgba(255,255,255,.06)"/>
<line class="gl" x1="40" y1="156" x2="624" y2="156" stroke="rgba(255,255,255,.06)"/>
<text x="34" y="23" text-anchor="end" font-size="10" fill="var(--ink-3)">6</text>
<text x="34" y="91" text-anchor="end" font-size="10" fill="var(--ink-3)">3</text>
<text x="34" y="159" text-anchor="end" font-size="10" fill="var(--ink-3)">0</text>
<polyline points="40,156 137,133 234,156 331,110 428,133 525,156 624,88"
fill="none" stroke="var(--leak)" stroke-width="2" stroke-dasharray="6 3"
stroke-linejoin="round" stroke-linecap="round"/>
<polyline points="40,110 137,88 234,133 331,42 428,110 525,133 624,110"
fill="none" stroke="var(--warn)" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="331" cy="42" r="3.4" fill="var(--warn)"/>
<text x="40" y="182" font-size="9" fill="var(--ink-3)">07-10</text>
<text x="331" y="182" font-size="9" fill="var(--ink-3)" text-anchor="middle">07-13</text>
<text x="624" y="182" font-size="9" fill="var(--ink-3)" text-anchor="end">07-16</text>
</svg>
</div>
<!-- 경보 종류 도넛 30일 -->
<div class="widget">
<h3>경보 종류 <small>최근 30일</small></h3>
<div class="donut-wrap">
<div class="donut-ring">
<svg viewBox="0 0 120 120" role="img" aria-label="경보 종류 구성 총 14건">
<circle class="d-track" cx="60" cy="60" r="47" pathLength="100"/>
<g transform="rotate(-90 60 60)">
<circle class="seg" cx="60" cy="60" r="47" pathLength="100" stroke="var(--warn)"
stroke-dasharray="43 100" stroke-dashoffset="0"/>
<circle class="seg" cx="60" cy="60" r="47" pathLength="100" stroke="var(--leak)"
stroke-dasharray="29 100" stroke-dashoffset="-43"/>
<circle class="seg" cx="60" cy="60" r="47" pathLength="100" stroke="var(--err)"
stroke-dasharray="21 100" stroke-dashoffset="-72"/>
<circle class="seg" cx="60" cy="60" r="47" pathLength="100" stroke="var(--ink-3)"
stroke-dasharray="7 100" stroke-dashoffset="-93"/>
</g>
</svg>
<div class="donut-num"><b>14</b><small>건</small></div>
</div>
<div style="flex:1">
<span class="dl"><i style="background:var(--warn)"></i>고온<span class="dl-c">6</span></span>
<span class="dl"><i style="background:var(--leak)"></i>누수 감지<span class="dl-c">4</span></span>
<span class="dl"><i style="background:var(--err)"></i>고습<span class="dl-c">3</span></span>
<span class="dl"><i style="background:var(--ink-3)"></i>저온<span class="dl-c">1</span></span>
</div>
</div>
<p class="note" style="font-size:.66rem;color:var(--ink-3);margin-top:10px">단위: 온습도는 비정상 측정 건수, 누수는 감지 이벤트 건수</p>
</div>
<!-- 가동률 게이지 + 리더보드 -->
<div class="widget">
<h3>가동률 <small>온라인 센서</small></h3>
<div class="donut-wrap" style="margin-bottom:12px">
<div class="donut-ring">
<svg viewBox="0 0 120 120" role="img" aria-label="가동률 100%">
<g transform="rotate(-90 60 60)">
<circle class="d-track" cx="60" cy="60" r="47" pathLength="100"/>
<circle class="seg" cx="60" cy="60" r="47" pathLength="100" stroke="var(--ok)"
stroke-dasharray="100 100" stroke-linecap="round"/>
</g>
</svg>
<div class="donut-num"><b>100%</b><small>4 / 4 온라인</small></div>
</div>
<div style="flex:1">
<div style="font-size:.7rem;color:var(--ink-3);margin-bottom:6px">경보 최다 센서 · 30일</div>
<div class="board-row">
<span class="board-rank">1</span>
<div class="board-main"><div class="board-name">2번 센서</div>
<div class="board-bar"><span style="width:100%"></span></div></div>
<span class="board-cnt">7<small>건</small></span>
</div>
<div class="board-row">
<span class="board-rank">2</span>
<div class="board-main"><div class="board-name">3번 센서</div>
<div class="board-bar"><span style="width:57%"></span></div></div>
<span class="board-cnt">4<small>건</small></span>
</div>
<div class="board-row">
<span class="board-rank">3</span>
<div class="board-main"><div class="board-name">1번 센서</div>
<div class="board-bar"><span style="width:43%"></span></div></div>
<span class="board-cnt">3<small>건</small></span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══════════ 탭: 이력 조회 ═══════════ -->
<section class="tabpane" id="pane-history">
<div class="tablecard">
<header>누수 사고 이력 <small>사진·복구 대조</small><span class="count-pill">최근 20건</span></header>
<div class="twrap">
<table class="t">
<thead><tr><th>#</th><th>센서</th><th>위치</th><th>감지 시각</th><th>복구 시각</th><th>사진</th></tr></thead>
<tbody>
<tr class="hot">
<td>41</td><td>3번 센서</td><td>기계실 B</td><td>2026-07-16 09:42</td>
<td><span class="pill leak">미복구</span></td>
<td><div class="thumbs"><a class="thumb" href="#">📷</a><a class="thumb" href="#">📷</a></div></td>
</tr>
<tr>
<td>40</td><td>4번 센서</td><td>배관실</td><td>2026-06-09 14:03</td>
<td>2026-06-09 15:21</td>
<td><div class="thumbs"><a class="thumb" href="#">📷</a></div></td>
</tr>
<tr>
<td>39</td><td>3번 센서</td><td>기계실 B</td><td>2026-05-27 03:18</td>
<td>2026-05-27 08:40</td>
<td><div class="thumbs"><a class="thumb" href="#">📷</a><a class="thumb" href="#">📷</a><a class="thumb" href="#">📷</a></div></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="tablecard">
<header>최근 온습도 측정 <span class="count-pill">30건 기준</span></header>
<div class="twrap">
<table class="t">
<thead><tr><th>측정시각</th><th>센서</th><th>위치</th><th>온도</th><th>습도</th><th>상태</th></tr></thead>
<tbody>
<tr><td>10:00:11</td><td>2번 센서</td><td>서버실</td><td>28.9°C</td><td>44%</td><td><span class="pill ok">정상</span></td></tr>
<tr><td>10:00:04</td><td>1번 센서</td><td>전산실 A</td><td>24.3°C</td><td>51%</td><td><span class="pill ok">정상</span></td></tr>
<tr class="hot"><td>09:00:09</td><td>2번 센서</td><td>서버실</td><td>30.6°C</td><td>43%</td><td><span class="pill err">고온</span></td></tr>
<tr><td>09:00:03</td><td>1번 센서</td><td>전산실 A</td><td>24.1°C</td><td>52%</td><td><span class="pill ok">정상</span></td></tr>
<tr><td>08:00:07</td><td>2번 센서</td><td>서버실</td><td>29.2°C</td><td>45%</td><td><span class="pill ok">정상</span></td></tr>
</tbody>
</table>
</div>
</div>
<div class="tablecard">
<header>센서 이벤트 로그 <span class="count-pill">30건 기준</span></header>
<div class="twrap">
<table class="t">
<thead><tr><th>#</th><th>시각</th><th>센서</th><th>위치</th><th>이벤트</th></tr></thead>
<tbody>
<tr class="hot"><td>212</td><td>09:42:02</td><td>3번 센서</td><td>기계실 B</td><td><span class="pill leak">누수감지</span></td></tr>
<tr><td>211</td><td>09:00:00</td><td>3번 센서</td><td>기계실 B</td><td>정기보고</td></tr>
<tr><td>210</td><td>09:00:00</td><td>4번 센서</td><td>배관실</td><td>정기보고</td></tr>
<tr><td>209</td><td>06:12:44</td><td>4번 센서</td><td>배관실</td><td>장비시작</td></tr>
<tr><td>208</td><td>2026-06-09 15:21</td><td>4번 센서</td><td>배관실</td><td><span class="pill ok">누수복구</span></td></tr>
</tbody>
</table>
</div>
</div>
<div class="tablecard">
<header>SMS 발송 이력 <span class="count-pill">최근 20건</span></header>
<div class="twrap">
<table class="t">
<thead><tr><th>#</th><th>발송시각</th><th>센서</th><th>수신자</th><th>상태</th><th>결과</th></tr></thead>
<tbody>
<tr><td>88</td><td>09:42:18</td><td>3번 센서</td><td>010-****-1234 외 3명</td><td><span class="pill ok">성공</span></td><td style="color:var(--ink-3)">누수 경보 발송</td></tr>
<tr><td>87</td><td>09:00:31</td><td>2번 센서</td><td>010-****-5678 외 1명</td><td><span class="pill ok">성공</span></td><td style="color:var(--ink-3)">고온 경보 발송</td></tr>
<tr><td>86</td><td>2026-07-15 22:10</td><td>2번 센서</td><td>010-****-5678 외 1명</td><td><span class="pill ok">성공</span></td><td style="color:var(--ink-3)">고온 복구 안내</td></tr>
<tr class="hot"><td>85</td><td>2026-07-14 11:02</td><td>1번 센서</td><td>010-****-1234</td><td><span class="pill err">실패</span></td><td style="color:var(--ink-3)">잔액 부족 오류</td></tr>
</tbody>
</table>
</div>
</div>
<div class="panels" style="margin-bottom:18px">
<section class="panel">
<h3>30일 평균 온습도 <span style="font-weight:400;color:var(--ink-3);font-size:.75rem">· 센서별</span></h3>
<dl class="rows">
<div><dt>1번 센서 (전산실 A)</dt><dd>24.6°C / 50.8% · 4,120건</dd></div>
<div><dt>2번 센서 (서버실)</dt><dd>27.9°C / 44.2% · 698건</dd></div>
<div><dt>3번 센서 (기계실 B)</dt><dd>22.1°C / 58.3% · 4,088건</dd></div>
</dl>
<p class="note">전 센서 혼합 평균은 위치별 환경 차이를 뭉개므로 센서별로 분리 표기.</p>
</section>
<section class="panel">
<h3>30일 SMS 발송 통계</h3>
<div class="stats">
<div class="stat"><span>총 발송</span><strong>31건</strong></div>
<div class="stat"><span>온습도 경보</span><strong class="w">12건</strong></div>
<div class="stat"><span>누수감지</span><strong class="e">8건</strong></div>
<div class="stat"><span>복구</span><strong>7건</strong></div>
<div class="stat"><span>오프라인</span><strong>3건</strong></div>
<div class="stat"><span>발송 실패</span><strong class="e">1건</strong></div>
</div>
<p class="note">종류별 상세 집계와 월별 분석은 월간 보고에서 확인하세요.</p>
</section>
</div>
</section>
<!-- ═══════════ 탭: 시스템/인쇄 (플레이스홀더) ═══════════ -->
<section class="tabpane" id="pane-system">
<div class="panel" style="text-align:center;padding:48px;color:var(--ink-3)">시스템 점검 탭 (목업 범위 외)</div>
</section>
<section class="tabpane" id="pane-print">
<div class="panel" style="text-align:center;padding:48px;color:var(--ink-3)">인쇄용 전체 보기 (목업 범위 외)</div>
</section>
</main>
<script>
(function () {
'use strict';
var tabs = document.querySelectorAll('.tab[data-tab]');
tabs.forEach(function (t) {
t.addEventListener('click', function (e) {
e.preventDefault();
tabs.forEach(function (o) { o.classList.remove('active'); });
t.classList.add('active');
document.querySelectorAll('.tabpane').forEach(function (p) { p.classList.remove('active'); });
var pane = document.getElementById('pane-' + t.dataset.tab);
if (pane) { pane.classList.add('active'); }
});
});
})();
</script>
</body>
</html>

View file

@ -104,7 +104,7 @@ systemctl enable --now unattended-upgrades 2>/dev/null || true
echo "[3/6] Tightening local permissions..."
# Secret files owner-only (contains API_KEY). If not found, warn instead of silently skipping.
SECRET_FOUND=0
for f in /home/pi/raspberry_pi4/config_local.py /home/pi/raspberry_pi4_sht30/config_local.py; do
for f in /home/pi/raspberry_pi4/config_local.py /home/pi/raspberry_pi4_sht30/config_local.py /home/pi/raspberry_pi4_pms7003/config_local.py; do
if [ -f "$f" ]; then chmod 600 "$f"; chown pi:pi "$f"; echo " 600: $f"; SECRET_FOUND=1; fi
done
[ "${SECRET_FOUND}" = 1 ] || echo "[WARN] config_local.py not found at standard paths — if the deploy path differs," \

View file

@ -3,8 +3,10 @@
#
# python tools/make_deploy_bundles.py
#
# Creates deploy/ at the repo root and generates, for the 1 server + 3 Pis, a
# folder each that is operable by just "copy -> install.sh -> systemctl enable".
# Creates deploy/ at the repo root and generates, for the 1 server + Pi nodes + display,
# a folder each. Each Pi node is a COMBINED fresh install (초기화 후 새 설치): one folder
# bundles every service that node runs (leak / SHT30 / PMS7003 subdirs) behind a single
# "copy -> bash setup.sh" that installs, registers, verifies, then hardens once.
# - API_KEY is auto-generated on first run (stored in deploy/.secrets/api_key.txt,
# reused on later runs) and injected identically into the server and all 3 Pis.
# - The admin password is likewise auto-generated + bcrypt-hashed (needs php) and injected.
@ -37,29 +39,47 @@ DOMAIN = "https://pores.kr"
TEXT_EXTS = {".sh", ".py", ".php", ".service", ".sql", ".md", ".txt", ".example", ".html", ".css"}
EXCLUDE_NAMES = {"__pycache__", ".gitignore", "config_local.py"}
# -- service catalog ----------------------------------------------------------
# Each sensor service is a self-contained source tree. The service subdir inside a node
# bundle is named after its SOURCE dir on purpose: the stock systemd unit files ship with
# WorkingDirectory=/home/pi/<src> and ExecStart=/home/pi/<src>/venv/bin/python, so when the
# subdir is deployed to /home/pi/<sub> the units work unchanged (no path edits needed).
SERVICE_SPECS = {
"leak": {"src": "raspberry_pi4", "unit": "leak-sensor", "sub": "raspberry_pi4"},
"sht30": {"src": "raspberry_pi4_sht30", "unit": "sht30-sensor", "sub": "raspberry_pi4_sht30"},
"pms7003": {"src": "raspberry_pi4_pms7003", "unit": "pms7003-sensor", "sub": "raspberry_pi4_pms7003"},
}
BUNDLES = [
# (folder name, source, kind)
("01_server_cafe24", None, "server"),
("02_pi_a_leak_id1", "raspberry_pi4", "leak"),
("03_pi_b_sht30_id2", "raspberry_pi4_sht30", "sht30"),
("04_pi_c_leak_id3", "raspberry_pi4", "leak"),
("02_pi_a_node_id1", None, "node"),
("03_pi_b_sht30_id2", None, "node"),
("04_pi_c_node_id3", None, "node"),
("06_pi_zero2w_display", "raspberry_pi_zero2w_display", "display"),
]
# Per-node operational metadata. A 'node' runs ONE combined fresh install (초기화 후 새 설치)
# covering every service in meta["services"]; each service gets its own subdir + config_local.py.
PI_META = {
"02_pi_a_leak_id1": {
"device_id": "rpi4-leak-01", "location": "Site 1 (Server Room)",
"sensors": None, # config.py default (sensor_id 1)
"service": "leak-sensor",
"02_pi_a_node_id1": {
"device_id": "rpi4-node-01", "location": "Site 1 (Server Room)",
"sensor_id": 1, "report_interval": 600,
"services": ["leak", "sht30", "pms7003"],
},
"03_pi_b_sht30_id2": {
"device_id": "rpi4-sht30-01", "location": "Site 1 (Server Room)",
"sensors": None, # config.py default (sensor_id 2)
"service": "sht30-sensor",
"sensor_id": 2, "report_interval": 3600,
"services": ["sht30"],
},
"04_pi_c_leak_id3": {
"device_id": "rpi4-leak-02", "location": "Site 3 (New)",
"sensors": '[\n {"id": 3, "gpio": 17, "name": "Sensor 3 (New Site)"},\n]',
"service": "leak-sensor",
"04_pi_c_node_id3": {
"device_id": "rpi4-node-03", "location": "Site 3 (New)",
"sensor_id": 3, "report_interval": 600,
"services": ["leak", "sht30", "pms7003"],
},
"06_pi_zero2w_display": {
"device_id": "rpi0-display-01", "location": "Office",
"service": "display-dashboard", # single-flat special case (read-only, reports nothing)
},
}
@ -121,6 +141,141 @@ def write_lf(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8", newline="\n")
def write_service_config(sub_dir: Path, svc: str, meta: dict) -> None:
"""Write a per-service config_local.py inside a node's service subdir.
All services on a node share DEVICE_ID/DEVICE_LOCATION; each carries the node's sensor_id."""
device_id = meta["device_id"]
location = meta["location"]
sid = meta["sensor_id"]
header = ("# =============================================================================\n"
f"# config_local.py - {device_id} / {svc} service (auto-generated, do not commit)\n"
"# =============================================================================\n")
if svc == "leak":
body = (f'API_URL = "{API_URL}"\n'
f'PHOTO_UPLOAD_URL = "{PHOTO_UPLOAD_URL}"\n'
f'API_KEY = "{API_KEY}"\n'
f'DEVICE_ID = "{device_id}"\n'
f'DEVICE_LOCATION = "{location}"\n'
f'SENSORS = [\n {{"id": {sid}, "gpio": 17, "name": "Sensor {sid} (Leak)"}},\n]\n')
elif svc == "sht30":
body = (f'API_URL = "{API_URL}"\n'
f'API_KEY = "{API_KEY}"\n'
f'DEVICE_ID = "{device_id}"\n'
f'DEVICE_LOCATION = "{location}"\n'
f'SENSOR_ID = {sid}\n'
f'SENSOR_NAME = "Sensor {sid} (SHT30)"\n'
f'REPORT_INTERVAL = {meta["report_interval"]}\n')
elif svc == "pms7003":
body = (f'API_URL = "{API_URL}"\n'
f'API_KEY = "{API_KEY}"\n'
f'DEVICE_ID = "{device_id}"\n'
f'DEVICE_LOCATION = "{location}"\n'
f'SENSOR_ID = {sid}\n'
f'SENSOR_NAME = "Sensor {sid} (PMS7003)"\n'
f'REPORT_INTERVAL = {meta["report_interval"]}\n'
f'UART_PORT = "/dev/serial0"\n')
else:
fail(f"unknown service: {svc}")
write_lf(sub_dir / "config_local.py", header + body)
def build_node_setup_sh(meta: dict) -> str:
"""One combined setup.sh for a multi-service node: install every service, register + enable all
units (single daemon-reload), verify each (test_connection.py), then harden ONCE at the very end.
Run as pi (never sudo). set -euo pipefail: any failure stops BEFORE hardening -> SSH stays up."""
specs = [SERVICE_SPECS[s] for s in meta["services"]]
subs = " + ".join(sp["sub"] for sp in specs)
install_block = "\n".join(
f'echo " -> {sp["sub"]}"\n( cd "{sp["sub"]}" && chmod +x install.sh && ./install.sh )'
for sp in specs)
copy_block = "\n".join(
f'sudo cp "{sp["sub"]}/systemd/{sp["unit"]}.service" /etc/systemd/system/'
for sp in specs)
enable_block = "\n".join(f'sudo systemctl enable --now {sp["unit"]}' for sp in specs)
verify_block = "\n".join(
f'echo " -> {sp["sub"]}"\n'
f'( cd "{sp["sub"]}" && source venv/bin/activate && python3 test_connection.py && deactivate )'
for sp in specs)
return f'''#!/usr/bin/env bash
# =============================================================================
# setup.sh - combined fresh install for {meta["device_id"]} ({subs})
# Run as the pi user from /home/pi: bash setup.sh (do NOT run with sudo - sudo is used internally)
# The service subdirs must sit at /home/pi/<subdir> (the stock systemd units reference those paths).
# Order: install every service -> register + enable all -> verify all -> harden ONCE (SSH lockdown).
# On any failure set -e stops BEFORE hardening -> SSH stays up for debugging.
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")"
if [ "$(id -u)" -eq 0 ]; then
echo "[ERROR] Run as the pi user without sudo: bash setup.sh" >&2
exit 1
fi
echo "== [1/4] Install packages + venv for each service =="
{install_block}
echo "== [2/4] Register + enable systemd services (one daemon-reload) =="
{copy_block}
sudo systemctl daemon-reload
{enable_block}
echo "== [3/4] Server connectivity check per service (stops here on failure - SSH stays up) =="
# shellcheck disable=SC1091
{verify_block}
echo "== [4/4] Hardening (last - SSH gets locked down!) =="
sudo bash harden_pi.sh
echo ""
echo "All services installed, verified and hardened."
echo "I2C + UART were enabled during install - reboot once to apply: sudo reboot"
'''
def build_node_setup_md(name: str, src: str, meta: dict) -> str:
"""SETUP.md for a combined multi-service node (fresh full install)."""
specs = [SERVICE_SPECS[s] for s in meta["services"]]
subs = ", ".join(sp["sub"] for sp in specs)
svc_list = " + ".join(meta["services"])
wiring = " / ".join(f"`{sp['sub']}/WIRING.html`" for sp in specs)
return f'''# {name} install - {meta["device_id"]} (combined fresh install / 초기화 후 새 설치)
Fresh full install of a Pi-4 node running **{len(specs)}** service(s): **{svc_list}**.
Prerequisites: Raspberry Pi OS **Bookworm 64-bit**, user **pi**, SSH enabled (set in Imager).
Each service subfolder's `config_local.py` is already filled in (API_KEY / URL / device id / sensor id).
If the install location changes, edit only `DEVICE_LOCATION` in each subfolder's `config_local.py`.
Wiring / pinout: open {wiring} in a browser (self-contained - viewable offline).
## One-shot combined install (recommended)
The service subfolders must end up at **/home/pi/{subs}** - the stock systemd units reference
those exact paths. Flatten the node bundle straight into `/home/pi`:
```bash
# 1. Copy the node bundle to the Pi, then flatten it into /home/pi
scp -r {name} pi@<PI_IP>:/home/pi/
ssh pi@<PI_IP>
mv ~/{name}/* ~/ && rmdir ~/{name}
# 2. All at once: install every service -> register + enable -> verify -> harden
cd ~ && bash setup.sh
# (do NOT prefix with sudo - sudo is used internally)
```
`setup.sh` installs every service, registers + enables all units (single daemon-reload), verifies each
one, and **stops before hardening if any verify fails** (SSH stays alive so you can see the cause).
Hardening (SSH lockdown) runs **once** at the very end, only after all services verify.
Warning: the final hardening step **fully disables SSH.** If you are running over SSH, the session
drops right after completion and cannot reconnect - further maintenance needs an on-site monitor + keyboard.
I2C (SHT30) and UART (PMS7003) are enabled during install, so finish with a **single reboot**: `sudo reboot`.
'''
# -- leak guard first - create the ignore file before writing any secrets ------
# (Double defense together with the root .gitignore's deploy/ entry. Guarantees the
# order so that even if the script fails mid-generation, secrets under deploy/ are
@ -143,7 +298,14 @@ PHOTO_UPLOAD_URL = f"{BASE_URL}/api/photo_upload.php"
CRON_SECRET = hmac.new(API_KEY.encode(), b"cron", hashlib.sha256).hexdigest()
# -- remove existing bundles then regenerate (.secrets is preserved) ----------
for name, _, _ in BUNDLES:
# The USB-migration model + the leak-only single-service nodes are retired in favour of combined
# per-node fresh installs. Sweep the legacy folder names a previous run may have left (the 05_/07_
# migration bundles and the pre-rename 02_/04_ leak nodes) so deploy/ never ships stale dirs.
LEGACY_DIRS = [
"05_migration_leak_add_sht30", "07_migration_leak_add_pms7003",
"02_pi_a_leak_id1", "04_pi_c_leak_id3",
]
for name in [n for n, _, _ in BUNDLES] + LEGACY_DIRS:
d = DEPLOY / name
if d.exists():
shutil.rmtree(d)
@ -152,32 +314,30 @@ for name, _, _ in BUNDLES:
for name, src, kind in BUNDLES:
if kind == "server":
continue
dst = DEPLOY / name
copy_tree(ROOT / src, dst)
shutil.copy2(ROOT / "tools" / "harden_pi.sh", dst / "harden_pi.sh")
meta = PI_META[name]
sensors_block = ""
if meta["sensors"]:
sensors_block = (
"\n# Sensor list for this device - overrides the config.py default (sensor_id 1)\n"
f"SENSORS = {meta['sensors']}\n"
)
write_lf(dst / "config_local.py", f'''# =============================================================================
if kind == "display":
# Display node stays a single-flat special case (unchanged): flat copy of its source
# tree + a derived STATUS_KEY (never API_KEY) + its own simple install->verify->harden setup.sh.
dst = DEPLOY / name
copy_tree(ROOT / src, dst)
shutil.copy2(ROOT / "tools" / "harden_pi.sh", dst / "harden_pi.sh")
# Least privilege: the display gets only the derived STATUS_KEY
# (= HMAC(API_KEY,'status')), never API_KEY itself. HMAC is one-way, so a
# stolen display (easiest device to grab physically) yields read-only access
# and cannot forge measurements or trigger SMS.
status_key = hmac.new(API_KEY.encode(), b"status", hashlib.sha256).hexdigest()
write_lf(dst / "config_local.py", f'''# =============================================================================
# config_local.py - operational values for {meta["device_id"]} (auto-generated, do not commit)
# =============================================================================
API_URL = "{API_URL}"
{'PHOTO_UPLOAD_URL = "' + PHOTO_UPLOAD_URL + '"' if kind == "leak" else '# (the SHT30 module has no photo upload)'}
API_KEY = "{API_KEY}"
DEVICE_ID = "{meta["device_id"]}"
DEVICE_LOCATION = "{meta["location"]}"
{sensors_block}''')
# -- one-shot install wrapper: install -> register service -> verify -> harden --
# If verification (test_connection.py) fails, set -e stops before hardening ->
# SSH stays alive for debugging. Hardening (SSH lockdown) only runs on success.
write_lf(dst / "setup.sh", f'''#!/usr/bin/env bash
STATUS_URL = "{BASE_URL}/api/status_summary.php"
STATUS_KEY = "{status_key}"
DEVICE_NAME = "{meta["device_id"]}"
''')
# -- one-shot install wrapper: install -> register service -> verify -> harden --
# If verification (test_connection.py) fails, set -e stops before hardening ->
# SSH stays alive for debugging. Hardening (SSH lockdown) only runs on success.
write_lf(dst / "setup.sh", f'''#!/usr/bin/env bash
# =============================================================================
# setup.sh - one-shot install for {meta["device_id"]} (install->service->verify->harden)
# Run as the pi user: bash setup.sh (do NOT run with sudo - sudo is used internally)
@ -211,10 +371,10 @@ echo ""
echo "Install, verify and hardening complete. A final reboot is recommended: sudo reboot"
''')
write_lf(dst / "SETUP.md", f'''# {name} install - {meta["device_id"]}
write_lf(dst / "SETUP.md", f'''# {name} install - {meta["device_id"]}
Prerequisites: Raspberry Pi OS **Bookworm 64-bit**, user **pi**, SSH enabled (set in Imager).
`config_local.py` is already filled in (API_KEY/URL/device ID). If the install location changes, edit only DEVICE_LOCATION.
`config_local.py` is already filled in (STATUS_URL/STATUS_KEY/device name).
Wiring / pinout: open `WIRING.html` in the same folder in a browser (self-contained - viewable offline).
@ -234,23 +394,22 @@ cd /home/pi/{src} && bash setup.sh
Warning: stage 4 hardening **fully disables SSH.** If you are running over SSH, the session
drops right after completion and cannot reconnect - further maintenance needs an on-site monitor + keyboard. Finish with `sudo reboot`.
## Manual (step by step) - if something goes wrong
```bash
cd /home/pi/{src}
chmod +x install.sh && ./install.sh # 1. install
sudo cp systemd/{meta["service"]}.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now {meta["service"]} # 2. service
source venv/bin/activate && python3 test_connection.py # 3. verify (signed 200)
sudo bash harden_pi.sh # 4. harden (SSH lockdown)
```
**Hardening policy** (`harden_pi.sh`): fully disable SSH, block all inbound (nftables),
remove unneeded services (avahi/bluetooth/cups etc.), automatic security patches, `config_local.py` mode 600.
This system is outbound-only, so no inbound is needed. To re-enable, create an empty
`ssh` file on the SD boot partition (physical access required).
''')
continue
# -- combined multi-service node (fresh full install / 초기화 후 새 설치) --------
# One node dir gets a subdir per service (named after its source tree so the stock systemd
# units resolve /home/pi/<sub> unchanged), plus a single harden_pi.sh at the node root and
# one combined setup.sh that installs -> registers -> verifies every service then hardens once.
node = DEPLOY / name
node.mkdir(parents=True)
shutil.copy2(ROOT / "tools" / "harden_pi.sh", node / "harden_pi.sh")
for svc in meta["services"]:
spec = SERVICE_SPECS[svc]
copy_tree(ROOT / spec["src"], node / spec["sub"])
write_service_config(node / spec["sub"], svc, meta)
write_lf(node / "setup.sh", build_node_setup_sh(meta))
write_lf(node / "SETUP.md", build_node_setup_md(name, src, meta))
# -- server bundle ------------------------------------------------------------
server = DEPLOY / "01_server_cafe24"
@ -308,6 +467,9 @@ return [
// -- leak module sensor_id (Site 1 + new Site 3) --
'LEAK_SENSOR_IDS' => [1, 3],
'METRIC_SENSOR_IDS' => [1, 2, 3],
'PM_SENSOR_IDS' => [1, 3],
'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [1 => 1200, 3 => 1200], // keep leak nodes 1&3 at 20-min offline detection despite being in METRIC_SENSOR_IDS
// -- per-sensor display labels (location + managing entity) shown in SMS/dashboard --
'SENSOR_LABELS' => [
@ -357,22 +519,26 @@ Warning: **`.secrets/` must be backed up** - it is lost on `git clean -fdx`, re-
a new API_KEY is issued and **mismatches every already-deployed server & Pi** (all nodes reject ingestion).
Right after building, copy `.secrets/` to offline storage (USB etc.).
Each node bundle is a **combined fresh install (초기화 후 새 설치)**: one folder carries every service
for that Pi (each in its own `raspberry_pi4[_x]/` subdir) plus a single `setup.sh` + `harden_pi.sh`.
| folder | target | sensor_id | remaining manual setup |
|---|---|---|---|
| 01_server_cafe24 | cafe24 web hosting | - | DB account, SMS account/number, one-time TOTP |
| 02_pi_a_leak_id1 | Pi-A leak + camera | 1 | none |
| 02_pi_a_node_id1 | Pi-A node: leak + SHT30 + PMS7003 | 1 | none |
| 03_pi_b_sht30_id2 | Pi-B SHT30 temp/humidity | 2 | none |
| 04_pi_c_leak_id3 | Pi-C leak + camera (new site) | 3 | none |
| 04_pi_c_node_id3 | Pi-C node: leak + SHT30 + PMS7003 (new site) | 3 | none |
| 06_pi_zero2w_display | Pi Zero 2 W + ST7789V mini status display (read-only) | - | wiring per WIRING.html |
## Order
1. `01_server_cafe24/SETUP.md` - server first (Pi verification depends on the server)
2. Common OS for all 3 Pis: Raspberry Pi Imager -> **Bookworm 64-bit**, user `pi`, SSH on, WiFi/wired configured
3. Each Pi folder's `SETUP.md` (copy -> install.sh -> systemctl; can be done in parallel)
2. Common OS for all Pis: Raspberry Pi Imager -> **Bookworm 64-bit**, user `pi`, SSH on, WiFi/wired configured
3. Each Pi folder's `SETUP.md` (nodes: flatten the folder into `/home/pi` -> `bash setup.sh`; can be done in parallel)
## Notes
- Server address: `{BASE_URL}` - the domain is DOMAIN at the top of `tools/make_deploy_bundles.py`; the path folder name is in `.secrets/web_dir.txt`, edit then re-run.
- For offline (air-gapped) install: on one internet-connected Pi run each module's `./vendor.sh`, then
copy the generated `wheelhouse/`+`debs/` into the other Pi bundle folders (install.sh switches to offline automatically).
- For offline (air-gapped) install: on one internet-connected Pi run each service subdir's `./vendor.sh`, then
copy the generated `wheelhouse/`+`debs/` into the matching subdir of the other node bundles (install.sh switches to offline automatically).
- Full deployment plan: `docs/DEPLOYMENT_PLAN_RPI4x3.md`
''')
@ -388,5 +554,8 @@ print(" Regenerating issues a new API_KEY that mismatches all already-depl
print(f" API_KEY : {API_KEY[:8]}... (deploy/.secrets/api_key.txt)")
print(f" admin password : deploy/.secrets/admin_password.txt")
print(f" server URL : {BASE_URL}")
for name, _, _ in BUNDLES:
print(f" - deploy/{name}")
for name, _, kind in BUNDLES:
if kind == "node":
print(f" - deploy/{name} (services: {', '.join(PI_META[name]['services'])})")
else:
print(f" - deploy/{name}")