Compare commits

..

32 commits

Author SHA1 Message Date
cw2007
6da90ccb36 docs(index): 화재 전환·SMS 단문화·배선도 정정 세션 기록
2046b2f(배선/리포트) + f7fbeac(화재/단문/고착/대시보드) 기록. 운영 잔여작업 명시.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Dj4n17ZvGAhga6D4pNRjn
2026-07-19 23:35:52 +09:00
cw2007
f7fbeacbdd feat: PM 급상승을 화재의심으로 전환 + SMS 단문화 + 센서고착 검출 비활성
서버실/항온항습: 여과·양압이라 평상 PM≈0 → PM 급상승을 화재 조기징후로 본다.
- 임계 하향: METRIC_PM25_HIGH 75→35, METRIC_PM10_HIGH 150→75 (연기 조기감지).
- SMS 태그 [미세먼지경보] → [화재의심]. 대시보드 라벨 통일(경보=화재의심, PM 측정=화재감시).
  metric_status(high_pm25/10) 코드·값·판정 로직 불변 — 표시단만 변경.
- SMS 단문화: 전 본문을 EUC-KR 90B 이내로 축약(관리주체 제거, 날짜 m-d H:i, 태그+위치 1줄).
  실측 45~70B ≤ 90B → 게이트웨이(Cafe24 smsType=S) 단문 절단 해소.
- 센서고착(live-but-stuck) 검출 기본 비활성(STUCK_DETECT_ENABLED=false):
  항온항습 문서고는 온습도 원래 평탄 → 평탄값을 고착으로 오탐하던 [센서고착] 문자 차단.
- 테스트: 태그·기본임계 단언 갱신. 회귀 스위트 PASS(9 run/0 fail), php -l 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Dj4n17ZvGAhga6D4pNRjn
2026-07-19 23:34:57 +09:00
cw2007
2046b2f292 docs(wiring): 누수 저항식 2선 프로브·SHT30 모듈 직결로 배선도 정정
- 누수 §4 배선도: 3핀(VCC/SIG/GND) → 2전극 프로브(전극 A→3V3, 전극 B→GPIO17).
  10MΩ 풀다운은 GPIO17↔GND 보드 부품(센서 선 아님)으로 명시. 펌웨어(ACTIVE_LOW=False) 일치.
  §2 범례·§3 핀표·§8 통합표 GND 문구도 "풀다운용(센서 선 아님)"으로 통일.
- SHT30 §4: 외부 풀업 R1/R2·SDA hop 제거 → VCC/GND/SDA/SCL 4선 직결(내장 풀업). §3 표 문구 갱신.
- 납품 리포트(센서 형태·내용): 누수 "2선 저항식 프로브", SHT30 "모듈 직결" 반영.
- 브라우저 렌더 검증: 두 §4 텍스트·선 겹침 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Dj4n17ZvGAhga6D4pNRjn
2026-07-19 23:34:27 +09:00
cw2007
b9d47000e9 docs(index): setup.sh 위치-독립화 + Pi 노드 zip 기록 2026-07-19 13:38:16 +09:00
cw2007
3fda1f2af6 fix(deploy): 노드/디스플레이 setup.sh 위치-독립화 — flatten 불필요
systemd 유닛의 /home/pi/<subdir> 경로를 설치 시점에 setup.sh가 $BASE=$(pwd) 로 sed 치환.
번들 폴더를 이름 그대로 /home/pi 아래 어디에 두든(USB 통째 복사 포함) 동작 → flatten 단계 제거.

- build_node_setup_sh: copy_block 을 sed "s#/home/pi/#$BASE/#g" | sudo tee 로 교체, BASE 추가
- display setup.sh: 동일 방식(+ 기존 유닛 경로 버그 수정 — /home/pi/display 하드코딩이
  실제 설치경로와 불일치해 서비스 기동 실패하던 것을 $BASE 치환으로 해소)
- SETUP.md(노드·디스플레이): flatten 지침 제거, USB '폴더째 복사 후 cd·실행' 로 갱신
2026-07-19 13:37:20 +09:00
cw2007
a1bd51672d docs(index): 노드 통합 커밋·서버 번들 v2606 빌드 기록 + 운영 잔여작업 2026-07-19 13:17:25 +09:00
cw2007
f5313a6493 chore(gitignore): dist/ 배포판 zip 커밋 차단 (API 키 포함) 2026-07-19 13:16:43 +09:00
cw2007
90b93f818c chore(deploy+docs): 노드 번들 생성기·하드닝 갱신 + 설계문서
- tools/make_deploy_bundles.py: PMS7003·Zero2W 디스플레이 노드 번들 생성 반영(폐쇄망 동봉)
- tools/harden_pi.sh: 하드닝 스크립트 갱신
- docs/superpowers: 노드 센서 통합·스모크 파일럿·대시보드 다크 설계/계획 문서
- docs/claude-config: IoT 납품·검증 스킬/룰 참조본
- review/: 프로젝트 리뷰·UI v2 목업
2026-07-19 10:48:09 +09:00
cw2007
6f8bf9413c feat(dashboard): v2 재테마 + status_summary API + 미세먼지 카드
참고 대시보드 미학(대형 숫자 KPI·플랫 카드)으로 재테마하고, 통합 센서(누수+온습도+미세먼지)를
유형 통합 그리드로 표시. PM2.5/PM10 카드·트렌드·이력을 추가.

- php/assets/dash-v2.css: 후행 캐스케이드 오버라이드(기존 파일 무수정 가역 방식)
- php/api/status_summary.php: 상태 요약 폴링 엔드포인트(try/catch·LIMIT·제네릭 에러 JSON)
- dashboard_data.php: PM 카드 데이터·결로/예보 메트릭 정합
- tabs/live·trend·history: 미세먼지 표시·추세·이력 반영
2026-07-19 10:47:27 +09:00
cw2007
42a6ee4bb0 feat(display): Zero 2 W ST7789 상태 디스플레이 노드
Pi Zero 2 W + ST7789 패널에 노드 상태(누수/온습도/미세먼지·오프라인·정체)를 상시 표시하는
디스플레이 노드 신설. 배포 전 렌더 검증 원칙에 따라 합성 최악 프레임 11종을 패널 실해상도로
render_preview/ 에 PNG 로 선렌더.

- st7789_driver.py · display_dashboard.py · render_preview.py(합성 프레임 생성)
- install.sh · vendor.sh · systemd/display-dashboard.service · 배선(WIRING)·연결테스트
- render_preview/01~11: 요약/통합/메트릭/오프라인/stale/legacy-무PM 최악 케이스
2026-07-19 10:47:00 +09:00
cw2007
ec78be0bda feat(pms7003): 누수 노드에 미세먼지(PM2.5/PM10) 통합
센서1·3(누수 노드)에 PMS7003 를 온습도와 동일 sensor_id 로 통합하고 metric_type='pms7003'
로 구분해 수집한다. LED 는 스위칭 없이 상시 점등 방식으로 확정.

- raspberry_pi4_pms7003/: PMS7003 노드 풀세트(드라이버·센서·install·systemd·vendor·배선·연결테스트·프레임 단위테스트)
- 서버: evaluate_metric_thresholds() 에 pm25/pm10 선택 인자 추가(온습도 전용 호출 후방호환),
  sensor_data.php 수집 경로에 pms7003 metric 반영, config.php/예시에 PM 임계 추가
- SQL: migration_pms7003.sql · schema_sht30.sql 정합, tools/migration_leak_add_{pms7003,sht30} 마이그레이터
- tests/pm_threshold_test.php + run_tests.sh 러너 등록
- .gitignore: 노드별 config_local.py 커밋 차단
2026-07-19 10:46:33 +09:00
cw2007
86960a8586 test(threshold): 결로 판정 단언을 운영설정 독립으로 가드
condensation_is_clear(18,92)===false 단언이 결로경보 활성(METRIC_DEWPOINT_MARGIN_C>0)
전제였으나, 로컬 설정이 결로경보를 끄면(=0) is_clear 가 항상 true 로 단락돼 오탐 실패.
바로 아래 evaluate_metric_thresholds 결로 단언과 동일하게 MARGIN>0 가드로 감싸 어떤
운영 임계에서도 green 유지(파일 상단 '설정 독립' 원칙 정합).
2026-07-19 10:45:59 +09:00
cw2007
992d0f4428 fix(sht30): 온습도 보고 주기를 노드별로 — 누수쪽 10분, Pi-B 1시간
REPORT_INTERVAL 을 config_local 로 오버라이드 가능하게 확장(양의 정수 검증).
누수 노드 co-센서(id 1/3)는 config_local 에서 600s(10분, 누수 노드 주기와 정합),
독립 Pi-B(sensor2)는 config.py 기본 3600s(1시간) 유지. 하드코딩 대신 config_local
로 노드별 지정 → config.py 드리프트 방지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nd7QvbgEWCjSQtUYnGNvvV
2026-07-15 10:27:14 +09:00
cw2007
0b1e86c84a feat(sht30): 누수 노드에 온습도 동일-id 통합 (소스·서버예시·테스트·문서)
누수 노드(Pi-A=sensor_id 1, Pi-C=sensor_id 3)에 SHT30 온습도를 "같은
sensor_id"로 보고하는 구성을 반영. 노드는 기존 sht30_sensor.py를 별도
systemd 서비스로 얹고(불안정한 누수 코드 무변경), config_local로 SENSOR_ID만 지정.

- raspberry_pi4_sht30/config.py: config_local로 SENSOR_ID/SENSOR_NAME 오버라이드
  지원(미지정 시 기본 2 유지 — Pi-B 하위호환)
- php/config.local.example.php: METRIC_SENSOR_IDS=[1,2,3] 예시 +
  SENSOR_HEARTBEAT_TIMEOUT_SEC[1,3]=1800(1·3이 온습도 목록에 들어가도 누수
  오프라인 감지 속도 유지)
- tests/heartbeat_timeout_test.php: 동일-id 통합 시 1·3 누수 타임아웃 유지 검증
- docs/superpowers/{specs,plans}: 설계 문서 + 구현 계획

설치 패키지(deploy/* — gitignored)는 온디스크 산출물로 별도 반영(sht30 서브폴더,
install.sh 체이닝, WIRING/SETUP/README, 서버 SETUP 절차).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nd7QvbgEWCjSQtUYnGNvvV
2026-07-15 10:16:49 +09:00
cw2007
2a469dc3cc feat(alert): 누수 감지/복구 디바운스 · 온습도 1시간 주기 · 센서별 수신자 그룹
누수 감지↔복구 플래핑 SMS 폭탄 방지, 온습도 측정 주기 1시간화(부수 오탐
차단), 센서별 SMS 수신자 그룹 지정 3종을 함께 반영한다.

1) 누수 디바운스 (서버 전용, 펌웨어 무변경)
   - leak_sensor_status 에 leak_confirmed / leak_pending_since 컬럼 추가.
   - leak_debounce_decide(): 단일 대칭 타이머 순수 함수. raw 상태가 확인창
     동안 지속돼야 확정하고 확정 엣지에서만 SMS 1회. dry 블립<복구창이면
     복구 미확정 → 확정 유지 → 재감지·복구 SMS 둘 다 차단(폭탄 원천 봉쇄).
   - apply_leak_debounce()/sweep_pending_leak_transitions(): 센서 네임드 락으로
     직렬화, cron sweep 으로 보고 끊겨도 확정 해소. 사고(leak_incident)
     open/close 도 확정 엣지로 이동(월간보고 사고건수 정합). raw 이벤트는
     leak_sensor_log 에 그대로 보존(플래핑 진단).
   - 컬럼 없는 구배포는 레거시 즉시 발송으로 graceful degrade.
   - 확인창 기본: 감지 60s / 복구 180s (config.local.php 조정).

2) 온습도 1시간 주기 + 센서별 히트비트 타임아웃
   - raspberry_pi4_sht30/config.py REPORT_INTERVAL 300→3600.
   - heartbeat_timeout_for(): 명시 오버라이드 > 온습도 기본(7500s) > 전역(1200s).
     cron_heartbeat / dashboard 오프라인 판정을 센서별로. 매시간 오탐 제거,
     누수 오프라인은 20분 유지.
   - STUCK_DETECT_SAMPLES 6→3(1시간 주기 ≈3시간), 관련 주석 정정.

3) 센서별 수신자 그룹
   - SENSOR_RECIPIENTS 맵 + recipients_for_sensor() 전역 폴백.
   - sms_dispatch_to_recipients 가 센서별로 수신자 해소(에스컬레이션 TIER2
     명시 전달은 유지, 헬스/잔액은 전역).

대시보드: 누수 카드 '확인중(디바운스)' 배지, 시스템 탭에 확인창·센서별
타임아웃·수신자 그룹 표시.

마이그레이션: sql/migration_leak_debounce.sql (컬럼 2개), schema_sht30.sql 갱신.
테스트: leak_debounce(12) / recipients_group / heartbeat_timeout 순수 단위 추가.
설계: docs/superpowers/specs/2026-07-13-leak-debounce-sensor-config-design.md
2026-07-13 20:57:17 +09:00
cw2007
01ed3164a6 fix(report): SMS 신뢰성 게이지 색을 지표 자체 기준으로(종합 판정과 분리)
경영 요약의 SMS 신뢰성 게이지가 종합 판정 색($verdict_color)을 그대로 써서,
누수 등으로 판정이 '경보'면 SMS 전달 100%여도 적색 링으로 표시되던 문제 수정.
rc_reliability_color(pct, fail) 도입 — 실패 0=네이비(완전) / 경미=황(주의) /
저하=적(경보). 게이지·카드 액센트를 이 색으로. 판정 씰은 종합 판정 색 유지.
2026-07-11 17:01:28 +09:00
cw2007
06f96a8d09 feat(report): 월간 보고서 PDF 저장 전용 A4 문서 뷰
다크 대시보드를 그대로 인쇄하던 '단순 프린트지'를 화이트 A4 전문 보고서로 대체.
?format=print 로 브라우저 print-to-PDF 하면 표지·요약·차트·서명을 갖춘 정식 문서가
나온다. 설계 3안(경영진 브리프/보안증적/비주얼 애널리틱스) 병렬 → 종합 → 구현.
실제 브라우저 렌더로 6개 섹션 전부 검증.

구성:
- 표지 마스트헤드: 대형 대상월 + 관제 판정 씰(SVG 원형 도장 — 데이터 자동 판정
  安定/注意/警報)이 시그니처. 러닝 헤더/푸터(대외비·문서번호·시스템).
- 01 경영 요약: 헤드라인 KPI 4(SMS신뢰성 게이지·총경보·누수·측정) + 자동 총평 내러티브.
- 02 월간 지표: 경보·이벤트 / 온습도 측정 2열 정의형.
- 03 추이(인라인 SVG): 일별 평균 온·습도(안전/고온/저온 밴드·임계선), 일별 경보 스택
  막대(온습도=네이비 / 누수=적색 사선 해치 — 흑백 인쇄 구분).
- 04 관제 지점별 상세 카드, 05 누수 사고 로그(지속시간·복구), 06 기준·주석 및 서명란.

구현:
- 집계를 monthly_report_data.php(공유 include)로 추출 — HTML 조회·CSV·PDF 뷰가 단일
  소스 사용(회귀 0: HTML 표 3행·요약 11카드·CSV 3행 렌더 확인). 일별 추이·누수 사고·
  판정 파생은 인쇄 뷰 전용으로 추가.
- monthly_report_print.php(A4 뷰) + report_charts.php(인라인 SVG 차트) + assets/report-print.css
  (@page A4·여백·page-break·running header/footer·print-color-adjust). app.css 다크 토큰과
  격리(.report-paper 스코프, report-print.css 단독 링크).
- 폐쇄망 준수: 외부 CDN/JS/폰트/PDF 라이브러리 0, 시스템 폰트, 인라인 SVG.
- 보안: 신규 include 3파일 직접접근 가드 + .htaccess FilesMatch 차단(monthly_report.php
  본체는 진입 페이지라 제외). sensor_label 라벨·htmlspecialchars 이스케이프.
2026-07-11 16:56:02 +09:00
cw2007
553e59deb8 fix(report): 월간 센서별 요약을 현재 기준(라벨·누수·sensor_id 그룹)으로 정합
센서별 요약이 예전 보고명/이상값을 보이던 문제 수정. MockPDO 하네스로 CSV
출력 검증(각 센서 1행, 누수 컬럼, 범위 반영).

- 센서 표시명/위치를 sensor_label()(SENSOR_LABELS 관리주체 라벨)로 통일 —
  대시보드·SMS 와 동일 기준. 기존엔 Pi 보고 원본 sensor_name/device_location 을
  그대로 노출(예전 값).
- 센서별 집계를 GROUP BY sensor_id 로만 묶음(기존: sensor_id+sensor_name/location).
  과거 보고명이 달라져도 한 센서가 여러 행으로 쪼개지지 않음(이상 행 제거).
- 누수 감지/복구를 월간 요약·센서별 표·CSV 에 반영(누수 모듈 통합 기준).
  쿼리는 이미 leak_alert/leak_recovery 를 가져왔으나 표시에서 누락돼 있었음.
- 정렬을 온습도 경보 + 누수 감지 합계 기준으로. 범위 문구에 '누수 감지' 추가.
- 미사용·구식 report_sms_tags() 제거(누수 태그 누락된 dead 매핑).
2026-07-11 12:39:39 +09:00
cw2007
d9ed29ba3d style(dashboard): 참고 대시보드 미학으로 재테마 — 대형 숫자 KPI·플랫 카드
참고 이미지(Geckoboard형 KPI / 인디고+골드 애널리틱스) 방향으로 시각 정리.
실제 렌더(브라우저 스크린샷)로 대시보드 4탭 + 공용 login 확인.

- KPI 스트립을 '대형 숫자 + 소형 대문자 라벨' 위계로 재구성. 상태는 라벨 앞
  상태 dot(도형+색) + 값 색 + 텍스트로 3중 인코딩(상단 그라디언트 스트립 제거).
  타일 패딩·높이 확대로 여백 확보.
- 카드 상단 3px 그라디언트 스트립 제거(.card·.kpi) → 참고처럼 평평하고 조용한
  카드. .card 상태색은 값 색상 + 미묘한 테두리 틴트로 유지(월간보고·보안증적 공용).
- 토큰: 라운드(r-lg 16→18) 상향, 그림자를 소프트·확산형으로, 장식 골드 액센트
  토큰(--accent-gold) 추가. 공용 7페이지가 토큰으로 일관 이동.

알림 스트립 좌측 액센트 바와 login/status-hero 상단 액센트는 의미있는 알림·
아이덴티티 어포던스로 유지.
2026-07-11 12:13:15 +09:00
cw2007
83f7dae6e8 fix(dashboard): 센서 카드 정렬 — 유형 통합 그리드로 sparse/줄 안맞음 해소
실제 렌더(MockPDO 하네스 + 브라우저 스크린샷)로 확인한 레이아웃 결함 수정:

- 온습도/누수 센서를 별도 섹션(각 1~2개 카드)으로 나눠 auto-fill 그리드에
  띄우던 것을 단일 '센서 현황' 그리드로 통합. sensor_id 순, 유형은 카드 우상단
  칩(온습도/누수)으로 구분. auto-fill→auto-fit 로 바꿔 카드가 행을 균등하게
  채우고 높이가 정렬됨(센서 1~2개여도 '덩그러니 빈 행' 사라짐).
- 추이·통계 위젯: '경보 최다 센서' 리더보드를 span 12(가로 리더보드)로 바꿔
  3타일 다음 행에 타일 하나만 남던 문제 해소.
- 센서 카드에 sc-head/sc-type 스타일 추가, 예보줄을 카드 하단 고정(margin-top:auto).

app.css 는 기존 규칙 유지 + 추가/조정만(공용 7페이지 영향 없음: .sensor-grid·
.trend-widgets·.sc-* 는 대시보드 전용 셀렉터).
2026-07-11 11:52:31 +09:00
cw2007
f063060e2d refactor(dashboard): 탭 구조 전면 재개편(실시간/추이/이력/시스템)
단일 1594줄 스크롤 페이지를 상시 영역 + 4탭 서버렌더(?tab=) 구조로 재개편한다.
5인 리뷰 패널(정보구조·관제도메인·UX·구현·접근성) 종합 스펙 기반.

상시 영역(전 탭 공통):
- 시스템 상태 히어로 — 누수(is_leak)·미복구 사고를 FORECAST_ENABLED 와 무관하게
  반영(기존: 누수 중에도 '정상 감시 중' 표시 가능한 결함). 경보 시 센서명·위치·
  현재값·경보 시작 시각·SMS 발송 결과를 한 줄로 명시.
- 알림 스트립(SMS 실패/미복구 누수/수집 거부 1줄 요약 + 탭 딥링크)
- 대응 필요 패널(플레이북·담당자) — 안전 알림은 탭과 무관하게 상시 노출
- 통합 KPI 스트립 6타일 — 모바일 퀵타일·듀얼 KPI·게이지 배지를 하나로 통합
- 탭바(nav+aria-current, 경보 배지)

탭:
- 실시간 현황: 온습도/누수 카드(예보 여유 상시 표시), 미복구 사고, SMS 채널·오늘 요약
- 추이·통계: 온습도 추이 차트, 7일 경보 추이, 경보 종류 도넛(단위 명기), 가동률, 리더보드
- 이력 조회: 누수 사고·측정·이벤트·SMS 이력, 30일 센서별 평균 온습도, SMS 발송 통계
- 시스템 점검: 자가진단, 수집 거부 관측성, SMS 채널 점검, 플레이북 사본, 임계 설정 요약
- ?tab=all: 인쇄용 전체 렌더(details 강제 펼침, 자동 새로고침 없음)

구현:
- 데이터 계층 분리(dashboard_data.php): 전역 vs 탭 전용 쿼리 — 로드당 쿼리 ~50→전역
  14+탭 5~8개(약 60% 절감). ops_checks 파일 캐시(TTL 120초, 시스템 탭은 우회).
- 탭 partial(tabs/*.php)은 dash_render_tab() 함수 스코프 격리(EXTR_SKIP)로 렌더.
- 30초 자동 새로고침은 URL 쿼리 보존으로 탭·기간 유지. 스크롤/details 복원은 탭별 키.
- 폐쇄망 유지: 외부 CDN/JS 없음(인라인 SVG). app.css 는 기존 규칙 불변, 추가만.
- 보안: dashboard_data·forecast·tabs 를 .htaccess FilesMatch + tabs/.htaccess +
  PHP 직접접근 가드로 삼중 차단.

security_hardening_test: 대시보드 표면을 분리 구조(셸+데이터+탭)에 맞춰 검사하도록 보정.
2026-07-11 11:23:14 +09:00
cw2007
0966f6d32b feat(sensor): 센서별 표시 라벨(위치·관리주체) 서버측 오버라이드
SENSOR_LABELS(config.local.php) 로 센서별 위치·관리주체를 서버에서 지정하고,
sensor_label() 헬퍼로 SMS·대시보드 표시를 통일한다(Pi 재배포 불필요). SMS 본문
라벨을 '센서'→'관리주체'로 변경. 배포 번들에 3개 센서(서버실 안/문서고/밖) 예시
SENSOR_LABELS 를 주입.
2026-07-11 11:22:40 +09:00
cw2007
a72ea18130 i18n(pi): Raspberry Pi 번들 소스 한글 → 영어 (콘솔 mojibake 해소)
헤드리스 Pi 콘솔/journalctl에서 한글이 깨져 보이는 문제로, Pi 번들(02/03/04)에 들어가는 모든 소스의 사람-대상 텍스트를 영어로 전환. 코드·식별자·설정키·경로·숫자·hex·서명 스킴·f-string 플레이스홀더는 불변.

- raspberry_pi4/, raspberry_pi4_sht30/: 로그·주석·문서·센서명·위치 영어화
- tools/make_deploy_bundles.py: 생성 템플릿(config_local.py/setup.sh/SETUP.md/PI_META 위치) 영어화
- tools/harden_pi.sh, tools/set_network.sh: echo/주석 영어화
- WIRING.html: 영어화 + SHT30 전압 경고 정정 반영
- 서버 번들(01, php/)은 한글 유지(브라우저 대시보드, Pi에서 안 깨짐)
- 전 파이썬/셸 문법검사 통과, JSON 본문 서명 무영향
2026-07-10 10:44:13 +09:00
cw2007
3c8e6d6957 docs(sht30): 온습도 2번 모듈 문서를 STM32 기본 → Raspberry Pi 기본으로 정합
실제 배포는 Raspberry Pi 4(raspberry_pi4_sht30)인데 다수 문서가 STM32를
"현재 단일보드"로 서술하던 드리프트를 교정(README/SYSTEM_OVERVIEW/
DEPLOYMENT_PLAN은 이미 Pi 기준).

- SHT30_SENSOR2_SETUP.md: Pi 기본 전면 재작성, STM32는 마이그레이션 대안
- README.md / wiring_diagram.md / INSTALL_PI_SERVER.md: Pi 기본으로 재구성
- ops_checks.php / security_evidence.php: /etc/sht30-sensor.env → config_local.py
- api/sensor_data.php: 헤더 예시 device_id rpi4-sht30-01
- examples/*.html: device_id 교정 + 구식 env 예제 폐기 배너
- 전압 경고 정정: "5V=소자 손상"은 틀림(모듈 2.4~5.5V). Pi에서 3.3V 쓰는
  이유는 Pi GPIO 3.3V 전용(5V 시 SDA/SCL 5V → Pi 손상)
2026-07-10 10:42:31 +09:00
cw2007
9f0e048e49 feat(sht30): 벤더 예제(P4422-2) 기반 단발 측정 명령 0x2400 전환
라즈베리파이 SHT30 드라이버 측정 명령을 0x2C06(clock stretching ENABLED,
주석은 "비활성"이라 모순)에서 0x2400(clock stretching DISABLED)으로 교정.
드라이버의 write→대기→read 접근 패턴 및 Pi BCM I2C 클럭스트레치 버그와
정합하며, 벤더 예제 ArtronShop_SHT3x(P4422-2)의 무-스트레치 방식과 일치.
CRC-8 검증은 유지(벤더 예제는 생략). 변환식·주소(0x44)·프레임 동일.
STM32 펌웨어는 0x2C06 유지(HAL 스트레치 지원).

- P4422-2/: 벤더 SHT3x 아두이노 예제(ArtronShop_SHT3x) 벤더링
- 서명/서버 프로토콜 무영향. 현장 하드웨어 실측 검증 필요(시뮬은 명령 무관)
2026-07-10 10:42:10 +09:00
cw2007
79af8ef530 feat(deploy): 하드닝 후 현장 콘솔용 고정 IP 설정 헬퍼(set_network.sh)
harden_pi.sh 실행 후 SSH·콘솔 자동로그인이 꺼져, 네트워크 재설정은 현장
물리 콘솔에서 해야 한다. NetworkManager(nmcli) 기반으로 고정 IP/서브넷(CIDR)/
게이트웨이/DNS 를 한 번에 설정하는 헬퍼를 추가한다.

- 대화형/비대화형(--ip/--gw/--dns/--yes), 현재 확인(--show), DHCP 복귀(--dhcp).
- 유선 연결/인터페이스 자동 감지, IPv4·CIDR·게이트웨이 서브넷 검증, 적용 전
  이전 설정 백업, 적용 후 IP/라우팅/게이트웨이 ping/서버 test_connection 검증.
- 하드닝 방화벽 불변(인바운드 drop/아웃바운드 accept라 고정 IP로 서버 POST 정상).
- 폐쇄망 안전: nmcli 로컬 설정만, 네트워크 미사용.

배포 스테이징(deploy/*)은 .gitignore 대상이라 정본은 tools/ 에 둔다.
2026-07-09 13:43:38 +09:00
cw2007
7fa39406d3 feat(observability): 수집 거부 출처 IP 로그 + 대시보드 노출
수집 거부 카운터는 사유별 숫자만 남겨, "우리 노드 오설정 vs 외부 스캐너"를
구분할 수 없었다. 서명불일치 2건이 우리 노드의 키 불일치인지 인터넷 프로브인지
대시보드만으론 판별 불가했다.

- config.php: 최근 거부 이벤트 링버퍼(var/ingest_reject_events.json, 상한
  INGEST_REJECT_EVENTS_MAX=50, flock 기반 DoS 안전) 추가. record_ingest_rejection()
  이 모든 거부에서 출처 IP(REMOTE_ADDR, 형식검증)를 자동 기록한다. 본문 유래
  값(device_id 등)은 신뢰불가 → ingest_sanitize_token() 으로 제어문자 제거·절단
  (로그 인젝션 방어). var/ 는 .htaccess 로 웹 접근 차단됨.
- api/sensor_data.php: 서명 거부 시 서명모드(raw=키불일치/none=헤더스트립)와
  device_id 를 컨텍스트로 함께 남겨 출처 판별을 돕는다.
- dashboard.php: '수집 거부' 위젯 아래 '최근 거부 출처' 표(시각·IP·사유·모드·
  device_id) 노출. 모든 필드 htmlspecialchars.
- tests/ingest_reject_log_test.php: 로그 인젝션 제거·IP 형식검증 회귀 추가,
  run_tests.sh 등록. security_hardening_test.php 소스 회귀 5종 추가.

주: config.php 에는 작업 트리에 있던 선행 변경(METRIC_SENSOR_IDS — 대시보드
온습도 센서카드 목록)이 함께 포함된다.
2026-07-09 13:43:12 +09:00
cw2007
0728e45894 fix(sms): 경보 쿨다운을 에피소드 인지로 전환 — 복구 직후 재발 누락 수정
누수/온습도 경보 쿨다운이 경보 태그만 조회해, 직전에 [누수복구]/[정상복귀]가
있었어도 이전 경보 발송 시각만 보고 억제했다. 그 결과 복구 직후 재발한 새 사고
(별개 에피소드)가 쿨다운 창(기본 30분) 동안 통째로 누락됐다.

- alert_cooldown_suppresses(): 경보/복구 중 "최신"이 경보이고(=미복구) 쿨다운
  이내일 때만 억제하는 순수 헬퍼 추가. 최신이 복구면 새 에피소드로 즉시 발송.
- send_leak_alert_sms / send_metric_alert_sms: 쿨다운 조회를 (경보|복구) 최신
  1건 기준으로 교체하고 위 헬퍼로 판정. 지속 경보 폭주 방지는 그대로 유지.
- tests/cooldown_episode_test.php: 복구 직후 재발 발송 등 9종 회귀 추가,
  run_tests.sh 에 등록.
- tests/security_hardening_test.php: dew_point_c 의 forecast.php 단일출처 이동에
  맞춰 낡은 단언 갱신(정의는 forecast.php, sms_send.php 는 사용 확인).

작업 트리에 있던 dew_point_c 중복정의 제거(forecast.php 단일출처화)도 sms_send.php
변경에 함께 포함된다.
2026-07-09 12:41:05 +09:00
cw2007
baf5b68fb8 fix: 개요 경보 집계를 측정 기준으로 전환 — 하단 리스트와 정합
상단 개요 위젯이 SMS 발송(leak_sms_log)을 세어 하단 측정 이력·이벤트 로그와
숫자가 어긋나던 문제 수정. 실제 센서 데이터로 소스 일원화:
- 도넛/리더보드/게이지 스파크/추이: leak_sensor_metric.metric_status(비정상 측정)
  + leak_sensor_log leak_alert(누수 이벤트) 기준
- "경보 발생 vs 복구" → "경보 발생 추이(온습도 vs 누수)"로 재정의
  (측정 기준엔 복구 측정 개념이 없어 두 위험 유형 발생량 비교로 변경)
- 30일 운영 요약은 SMS 기준 유지(라벨에 "SMS" 명시 — 발송 통계는 별개 지표)

검증: php -l 통과, 스텁DB 전체 렌더 exit 0(경고 0)·태그 균형(div 61/61)·존 4개.
2026-07-06 18:22:07 +09:00
cw2007
e392d6475b feat: 대시보드 관제 개요 벤토 재편 + 샘플 대시보드 콘텐츠 위젯 5종
샘플 대시보드(고객지원/방문분석) 콘텐츠를 누수·온습도 도메인으로 이식하고,
평면 나열이던 섹션을 관계·위치가 드러나는 4개 라벨 존으로 재구성한다.

콘텐츠 위젯(전부 기존 DB 집계, 외부 라이브러리 0 · 인라인 SVG · 폐쇄망 안전):
- 가동률 게이지 + 7일 경보 스파크라인 + 상태 배지
- 듀얼 KPI(경보 센서 / 오프라인)
- 경보 종류 도넛(30일, $summary 재사용)
- 경보 최다 센서 리더보드(30일 GROUP BY)
- 경보 발생 vs 복구 7일 이벤트율 라인차트
- 온습도 추이: 기간 토글(오늘/3일/7일, ?range 링크) + 피크 마커

정보구조:
- 4개 존(관제 개요 / 실시간 상태 / 추세 분석 / 운영 이력) — eyebrow 라벨 + 하드라인
- 관제 개요 = 12칼럼 벤토 그리드(기존 ops-grid 대체, 자가진단 편입)
- 중복 카드 제거(등록센서/최근온습도/현재시각 → 게이지·히어로·요약 흡수)

검증: php -l 통과, 스텁DB 전체 렌더 exit 0(경고 0)·태그 균형·빈데이터 우아,
채운데이터 프리뷰 스크린샷 확인. 신규 CSS는 네임스페이스 셀렉터만(타 페이지 영향 0).
2026-07-06 10:58:39 +09:00
cw2007
031a0c0fdf feat: 대시보드 예보·대응 정보 (예방 신호 + 조치 가이드)
기존 측정 데이터만으로 "경보가 터지기 전"을 보여주고, 위험 시 무엇을 할지
안내한다. 새 센서·ML·DB 스키마 변경 없음(폐쇄망 정합).

신규 php/forecast.php — 순수 계산 함수(DB·출력 없음, 단위테스트 용이):
- dew_point_c(Magnus 이슬점), linear_trend(최소자승 회귀), threshold_eta_min
  (임계 도달 예상 분), headroom_state(여유 3단계), forecast_metric/freshness,
  risk_items(대응 섹션용 정규화 위험 목록).
tests/forecast_test.php — 31 검사(이슬점 알려진값·회귀·ETA·여유·신선도·위험 엣지).

config.php — FORECAST_*(지평/최소점/근접여유/급변 기준) + RESPONSE_PLAYBOOKS
(상태별 대응 체크리스트) + RESPONSE_CONTACTS. 전부 config.local.php 오버라이드.

dashboard.php — 센서별 최근점으로 예보 계산 →
- 카드 예보줄(상시): 임계 여유·추세 ETA·이슬점 결로·변화율(근접/초과만 강조).
- 대응 필요 섹션(위험 시만): 조건·ETA + 상태별 권장 조치 체크리스트 + 담당자.
기존 offline 판정을 단일 진실원으로 사용, 누수 노드 중복 없음.

app.css — .forecast-chip(near/over 상태 3중 인코딩)·.response-panel·.playbook·
.contact (토큰 기반, 라이트/다크 자동). run_tests.sh 에 forecast_test 등록.

검증: forecast_test 31/0, php -l clean(7페이지+forecast), 헤드리스 렌더 확인.
2026-07-05 14:02:48 +09:00
cw2007
e0f0f0963a feat: cafe24 관리자 UI "Slate NOC Console" 디자인시스템 통합 + 적대리뷰 18건 반영
관리자 사이트 7페이지의 파편화된 인라인 스타일(색상 리터럴 160개, 페이지별
<style>)을 단일 디자인시스템으로 통합.

신규 php/assets/app.css — "Slate NOC Console":
- CSS 변수 단일 토큰(라이트/다크 prefers-color-scheme/인쇄 강제라이트).
- 한글 시스템폰트 우선(폐쇄망 웹폰트 미로드 → 오프라인 일관 렌더), 외부 리소스 0.
- 상태 3중 인코딩(색+도형[점/좌측레일/글리프]+텍스트) — 색맹·흑백 인쇄 대비.
- WCAG AA 대비(토큰 쌍 실측), 본문/캡션 하한 .8125rem.

7페이지 리팩터(dashboard/login/monthly_report/security_evidence/setup_wizard/
setup_mfa/blocked): 인라인 <style> 제거→app.css 링크, PHP 색 삼항→클래스,
생 hex 0, PHP 로직 무변경. make_deploy_bundles.py: TEXT_EXTS += .css.

적대적 다차원 리뷰(미학·CSS아키텍처·통합일관성·a11y·slop) 확정 18건 반영:
- [HIGH] .ok 배너 셀렉터가 .badge.ok/.quick-tile.ok 와 충돌 → 정상 배지가
  사각형으로 깨지던 것 .banner-ok 네임스페이스로 수정.
- 자동새로고침 일시정지 토글(WCAG 2.2.1)+스크롤/details 복원, 차트 임계선+
  최신값 마커, 다크 헤더 슬레이트화, 그림자 절제, 정상 레일 중립화(예외만 컬러),
  지표칩 중립화+저습 .dry 분리, 진단 severity 텍스트 라벨, forced-colors 포커스,
  <main> 랜드마크, role=alert/status, ink-3 대비 상향, 인쇄 칩색 제거 등.

검증: 7페이지 php -l clean, 생 hex 0, 외부참조 0. 라이트/다크 헤드리스 렌더 확인.
2026-07-04 22:32:16 +09:00
138 changed files with 16591 additions and 2844 deletions

6
.gitignore vendored
View file

@ -5,6 +5,9 @@
# ── 운영 비밀값 / 로컬 설정 (절대 커밋 금지) ──────────────────────────────
php/config.local.php
firmware/**/secrets.h
raspberry_pi_zero2w_display/config_local.py
raspberry_pi4/config_local.py
raspberry_pi4_sht30/config_local.py
*.env
*.env.*
!*.env.example
@ -16,6 +19,9 @@ _backup/
# .gitignore 도 만들지만, 생성 실패 시에도 새지 않도록 루트에서 이중 차단) ──
deploy/
# ── 배포판 zip: 서버 번들(API 키·관리자 비밀번호 포함)을 담으므로 절대 커밋 금지 ──
dist/
# ── 런타임 상태 (접속 IP·감사로그·브루트포스 카운터·하트비트 throttle) ────
php/var/*
!php/var/.gitkeep

View file

@ -0,0 +1,38 @@
# ArtronShop_SHT3x
Arduino library of SHT30, SHT31, SHT35 Digital humidity and temperature sensor
## Examples
```C++
#include <Arduino.h>
#include <Wire.h>
#include <ArtronShop_SHT3x.h>
ArtronShop_SHT3x sht3x(0x44, &Wire); // ADDR: 0 => 0x44, ADDR: 1 => 0x45
void setup() {
Serial.begin(115200);
Wire.begin();
while (!sht3x.begin()) {
Serial.println("SHT3x not found !");
delay(1000);
}
}
void loop() {
if (sht3x.measure()) {
Serial.print("Temperature: ");
Serial.print(sht3x.temperature(), 1);
Serial.print(" *C\tHumidity: ");
Serial.print(sht3x.humidity(), 1);
Serial.print(" %RH");
Serial.println();
} else {
Serial.println("SHT3x read error");
}
delay(1000);
}
```

View file

@ -0,0 +1,29 @@
#include <Arduino.h>
#include <Wire.h>
#include <ArtronShop_SHT3x.h>
ArtronShop_SHT3x sht3x(0x44, &Wire); // ADDR: 0 => 0x44, ADDR: 1 => 0x45
void setup() {
Serial.begin(115200);
Wire.begin();
while (!sht3x.begin()) {
Serial.println("SHT3x not found !");
delay(1000);
}
}
void loop() {
if (sht3x.measure()) {
Serial.print("Temperature: ");
Serial.print(sht3x.temperature(), 1);
Serial.print(" *C\tHumidity: ");
Serial.print(sht3x.humidity(), 1);
Serial.print(" %RH");
Serial.println();
} else {
Serial.println("SHT3x read error");
}
delay(1000);
}

View file

@ -0,0 +1,22 @@
#######################################
# Syntax Coloring Map For
#######################################
#######################################
# Datatypes (KEYWORD1)
#######################################
ArtronShop_SHT3x KEYWORD1
#######################################
# Methods and Functions (KEYWORD2)
#######################################
begin KEYWORD2
measure KEYWORD2
temperature KEYWORD2
humidity KEYWORD2
#######################################
# Constants (LITERAL1)
#######################################

View file

@ -0,0 +1,9 @@
name=ArtronShop_SHT3x
version=1.0.0
author=ArtronShop Co.,Ltd.
maintainer=Sonthaya.NT <sonthaya.nt@artronshop.co.th>
sentence=Arduino library of SHT3x Digital humidity and temperature sensor
paragraph=Arduino library of SHT3x Digital humidity and temperature sensor
category=Sensors
url=https://github.com/ArtronShop/ArtronShop_SHT3x
architectures=*

View file

@ -0,0 +1,57 @@
#include "ArtronShop_SHT3x.h"
ArtronShop_SHT3x::ArtronShop_SHT3x(uint8_t addr, TwoWire *wire) : _addr(addr), _wire(wire) {
// -----
}
bool ArtronShop_SHT3x::begin() {
// periodic data acquisition mode
this->_wire->beginTransmission(this->_addr);
this->_wire->write(0x27); // mps => 10
this->_wire->write(0x37); // Repeatability => High
return this->_wire->endTransmission() == 0;
}
bool ArtronShop_SHT3x::measure() {
this->_wire->beginTransmission(this->_addr);
this->_wire->write(0xE0);
this->_wire->write(0x00);
if (this->_wire->endTransmission(false) != 0) {
this->_t = -99;
this->_h = -99;
return false;
}
if (this->_wire->requestFrom(this->_addr, (uint8_t)6) != 6) {
this->_t = -99;
this->_h = -99;
return false;
}
uint8_t rx_bytes[6];
memset(rx_bytes, 0, 6);
this->_wire->readBytes(rx_bytes, 6);
uint16_t t_ticks = ((uint16_t)rx_bytes[0] << 8) | rx_bytes[1];
// checksum_t = rx_bytes[2];
uint16_t rh_ticks = ((uint16_t)rx_bytes[3] << 8) | rx_bytes[4];
// checksum_rh = rx_bytes[5];
this->_t = -45.0f + (175.0f * (t_ticks / 65535.0f));
this->_h = 100 * (rh_ticks / 65535.0f);
if (this->_h > 100) {
this->_h = 100;
}
if (this->_h < 0) {
this->_h = 0;
}
return true;
}
float ArtronShop_SHT3x::temperature() {
return this->_t;
}
float ArtronShop_SHT3x::humidity() {
return this->_h;
}

View file

@ -0,0 +1,25 @@
#ifndef __ARTRONSHOP_SHT3X_H__
#define __ARTRONSHOP_SHT3X_H__
#include <Arduino.h>
#include <Wire.h>
#define SHT3X_ADDR0 (0x44)
#define SHT3X_ADDR1 (0x45)
class ArtronShop_SHT3x {
private:
uint8_t _addr;
TwoWire *_wire = NULL;
float _t, _h;
public:
ArtronShop_SHT3x(uint8_t addr = SHT3X_ADDR0, TwoWire *wire = &Wire) ;
bool begin() ;
bool measure() ;
float temperature() ;
float humidity() ;
};
#endif

View file

@ -0,0 +1,29 @@
#include <Arduino.h>
#include <Wire.h>
#include <ArtronShop_SHT3x.h>
ArtronShop_SHT3x sht3x(0x44, &Wire); // ADDR: 0 => 0x44, ADDR: 1 => 0x45
void setup() {
Serial.begin(115200);
Wire.begin();
while (!sht3x.begin()) {
Serial.println("SHT3x not found !");
delay(1000);
}
}
void loop() {
if (sht3x.measure()) {
Serial.print("Temperature: ");
Serial.print(sht3x.temperature(), 1);
Serial.print(" *C\tHumidity: ");
Serial.print(sht3x.humidity(), 1);
Serial.print(" %RH");
Serial.println();
} else {
Serial.println("SHT3x read error");
}
delay(1000);
}

57
docs/INDEX.md Normal file
View file

@ -0,0 +1,57 @@
# 프로젝트 진행 INDEX
작업 단락(커밋·빌드·배포·결정) 완료 기록. 완료 결과만.
## 2026-07-19
- **미커밋 노드 통합 작업 정리 + 커밋** (feat/pi-c-sensor3-leak-node)
- `86960a8` test(threshold): 결로 판정 단언 운영설정 독립 가드 (테스트 스위트 RED→green)
- `ec78be0` feat(pms7003): 누수 노드 미세먼지(PM2.5/PM10) 통합 — Pi 노드+서버 수집+SQL+마이그레이션
- `42a6ee4` feat(display): Zero 2 W ST7789 상태 디스플레이 노드 (렌더 11종 선검증)
- `6f8bf94` feat(dashboard): v2 재테마 + status_summary API + 미세먼지 카드
- `90b93f8` chore(deploy+docs): 번들 생성기·하드닝·설계문서
- `f5313a6` chore(gitignore): dist/ 배포판 zip 차단
- 테스트: 9 run / 0 skip / **PASS**. secret 유출 없음(config_local.py·config.local.php gitignore 확인).
- **배포판 빌드 (서버 번들)**
- 산출물: `dist/leak-sms-server-v2606-20260719.zip` (VERSION=v2606, 40파일, 166KB)
- API_KEY 보존: prefix `bbf737d8…` 전/후 동일 — 배포노드 orphan 없음
- 서버 번들 PHP 전수 `php -l` 통과. `status_summary.php`·`dash-v2.css` 반영. schema.sql 멱등+pms7003 컬럼 포함.
- 배포 방식: **수동(체크리스트)** · DB **초기화 후 새 설치** 결정
- 체크리스트: `dist/DEPLOY-CHECKLIST-server-v2606.md`
- **배포 setup.sh 위치-독립화 (flatten 제거)** — `3fda1f2`
- systemd 유닛 `/home/pi/<sub>` 경로를 설치 시 `$BASE=$(pwd)` 로 sed 치환 → 번들 폴더를 이름
그대로 /home/pi 아래 어디든(USB 통째 복사) 두고 실행 가능. flatten 단계 삭제.
- 부수 버그 수정: zero2w 유닛 `/home/pi/display` 하드코딩이 실제 설치경로와 불일치(서비스
기동 실패)하던 것 해소.
- 검증: BASE 시뮬레이션으로 유닛 경로 정합 확인, 4개 번들 setup.sh 전부 BASE 주입,
88개 스크립트 CRLF/BOM 0(전부 LF).
- 산출물: `dist/leak-sms-pi-nodes-v2606-20260719.zip` (4개 Pi 번들, 127파일, 406KB)
- 잔여 작업(운영):
- [ ] cafe24 서버 업로드 + DB 초기화/schema.sql (운영자 수동 — 체크리스트 참조)
- [ ] 노드 4종 배포 (USB: 폴더째 /home/pi 복사 → `cd /home/pi/<folder> && bash setup.sh`)
- [ ] `feat/pi-c-sensor3-leak-node` push / PR
- [ ] `1.png`·`2.png` (repo 루트 스트레이 스크린샷) 정리 결정
- **센서 형태·내용 보고서 (납품 HTML)**
- 산출물: `docs/센서_형태_내용_보고서-v2606-20260719.html` (자기완결 단일 파일, 125KB)
- 내용: 누수(e-con 5C-08/GPIO17)·SHT30(I2C)·PMS7003(UART)·카메라(CSI)·디스플레이(ST7789V/SPI)
형태·인터페이스·핀·서버 임계값 + 노드 구성(id1/2/3). 배선도 6종(핀맵+회로도) base64 SVG 임베드.
- 소스: WIRING.html 인라인 SVG 6종 추출 → xmlns 주입·`&rarr;` 엔티티 정정·클래스 CSS를
presentation 속성으로 인라인화(주석 스트립 포함)·viewBox 아웃워드 패딩.
- 검증: 브라우저 실렌더 6종 전부 정상(레일 회색·사용핀 색상·라벨 판독), XML well-formed,
수평 오버플로 없음. 무관 대시보드 PNG(1·2.png) 미포함 확인.
- **화재 전환·SMS 단문화·배선도 정정** (feat/pi-c-sensor3-leak-node)
- `2046b2f` docs(wiring): 누수 저항식 2선 프로브(전극 A→3V3, 전극 B→GPIO17; 10MΩ 풀다운=보드 부품)·
SHT30 모듈 4선 직결(외부 풀업 제거)로 배선도 §4 + 납품 리포트 정정. 브라우저 렌더 텍스트/선 겹침 0.
- `f7fbeac` feat: PM 급상승 → **화재의심** 전환. 서버실/항온항습 화재 프록시(여과·양압 평상 PM≈0).
- 임계 하향 75/150 → **35/75**(연기 조기감지), SMS 태그 `[미세먼지경보]`→`[화재의심]`.
- SMS 단문화: 전 본문 EUC-KR ≤90B(관리주체 제거·날짜 `m-d H:i`·태그+위치 1줄). 실측 45~70B → 단문 절단 해소.
- 센서고착 검출 기본 비활성(`STUCK_DETECT_ENABLED=false`): 항온항습 문서고 평탄값 오탐 차단.
- 대시보드 라벨 통일(경보=화재의심, PM 측정=화재감시). `metric_status`(high_pm25/10) 코드·값·판정 불변 — 표시단만.
- 결정: PMS7003 = **화재 보조 조기징후**(광학 먼지센서, 인증 화재감지기 아님 — 연기/열 감지기 병행 권장).
- 검증: 회귀 스위트 **PASS(9 run/0 fail)**, 편집 PHP `php -l` 통과.
- 잔여(운영): `php/` 트리 cafe24 업로드 · 배선 HTML Pi 번들 재생성 · 배포 후 대시보드 브라우저 확인.

View file

@ -255,4 +255,4 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php
- SMS 수신자 번호
- 관리자 계정과 원문 비밀번호, `ADMIN_TOTP_SECRET`
- 서버 업로드 경로
- 장비 ID(`stm32-sht30-01`)와 설치 위치
- 장비 ID(`rpi4-sht30-01`, 배포=Raspberry Pi 4)와 설치 위치

View file

@ -1,32 +1,32 @@
# 서버실 온습도(SHT30) 모니터링 시스템 v2606
Sensirion SHT30 온습도 센서, STM32F407VGT6(FreeRTOS) 펌웨어, Cafe24 PHP/MySQL, Cafe24 SMS를 이용한 서버실 온습도 24시간 모니터링 및 임계 초과 SMS 알림 시스템입니다. 측정값은 5분 주기로 서버에 전송되어 웹 대시보드에 표시되고, 고온/저온/고습/저습 임계를 초과하면 SMS로 통보합니다. 펌웨어는 유선 Ethernet(LAN8720 RMII) + 온칩 TLS(mbedTLS HTTPS)로 서버에 보고합니다.
Sensirion SHT30 온습도 센서를 **Raspberry Pi 4 Model B(Python)**로 I2C 측정하여 Cafe24 PHP/MySQL 서버에 5분 주기로 보고하고, 웹 대시보드 표시와 고온/저온/고습/저습 임계 초과 SMS 알림을 제공하는 시스템입니다. Pi 는 유선 Ethernet(또는 WiFi) + HTTPS(raw-body HMAC 서명)로 보고합니다. (STM32F407VGT6 베어메탈 펌웨어는 별도의 마이그레이션 트랙 `firmware/` — 하드웨어 미검증, 현재 배포와 무관.)
## 구성
```text
[SHT30] --I2C--> [STM32F407VGT6 (sht30_fw)] --유선 Ethernet/TLS--> [Cafe24 PHP API]
[SHT30] --I2C--> [Raspberry Pi 4 (raspberry_pi4_sht30, Python)] --Ethernet/TLS--> [Cafe24 PHP API]
|
[MySQL DB]
|
[Cafe24 SMS / Dashboard]
```
- 단일 보드 `sht30_fw` (`sensor_id=2`, `device_id=stm32-sht30-01`).
- SHT30 → STM32 I2C1(PB6 SCL / PB7 SDA, 주소 0x44, 3.3V/GND, 4.7kΩ 풀업). 상세 배선은 [wiring_diagram.md](./wiring_diagram.md) 및 [firmware/docs/HARDWARE.md](../firmware/docs/HARDWARE.md) §3 참조.
- 측정 주기: 5분(펌웨어 `APP_SHT30_REPORT_INTERVAL_SEC=300`). 최초 1회 startup, 이후 periodic.
- 인증: 펌웨어가 `X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`(raw-body) 헤더로 서명하고 서버가 검증합니다. 관리자 로그인은 비밀번호 해시 + TOTP MFA + 감사로그로 보호합니다.
- Raspberry Pi 4 노드 `raspberry_pi4_sht30`(= `deploy/03_pi_b_sht30_id2`) — `sensor_id=2`, `device_id=rpi4-sht30-01`.
- SHT30 → Pi I2C 버스 1(GPIO2 SDA / GPIO3 SCL, 주소 0x44, VCC=3.3V/GND, 4.7kΩ 풀업). 상세 배선은 [SHT30_SENSOR2_SETUP.md](./SHT30_SENSOR2_SETUP.md) 및 `deploy/03_pi_b_sht30_id2/WIRING.html` 참조. (STM32 대안 배선 PB6/PB7 은 [wiring_diagram.md](./wiring_diagram.md).)
- 측정 주기: 5분(`raspberry_pi4_sht30/config.py REPORT_INTERVAL=300`). 최초 1회 startup, 이후 periodic.
- 인증: Pi 가 `X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`(raw-body) 헤더로 서명하고 서버가 검증합니다. 관리자 로그인은 비밀번호 해시 + TOTP MFA + 감사로그로 보호합니다.
## 폴더 구조
```text
docs/
README.md # 설치/운영 허브 문서
INSTALL_PI_SERVER.md # STM32 펌웨어 및 Cafe24 서버 설치 설명서
INSTALL_PI_SERVER.md # Cafe24 서버 설치 설명서 (Pi/STM32 공통)
OPERATIONS_SECURITY_CHECKLIST.md # 월간 운영·보안 점검표
SECURITY_EVIDENCE_PACKAGE.md # 보안대책서 첨부자료 패키지
wiring_diagram.md # SHT30 I2C 배선도
SHT30_SENSOR2_SETUP.md # SHT30 설치 가이드(STM32 기본 / RPi 대안)
SHT30_SENSOR2_SETUP.md # SHT30 설치 가이드(Raspberry Pi 4 기본 / STM32 마이그레이션 대안)
solutions/ # 실행-검토 사이클에서 얻은 교훈/반복 방지 기록
firmware/ # STM32F407VGT6 베어메탈 펌웨어 (sht30_fw)
common/ # 이식 코어(sha256/sig/jsonbody/sht30_convert/httpapi/net 등)

View file

@ -1,6 +1,9 @@
# SHT30 온습도 센서 설치 가이드
# SHT30 온습도 센서 설치 가이드 (2번 모듈, sensor_id=2)
서버실 온습도 모니터링을 위해 Sensirion SHT30(한진데이터 P4422-3, I2C) 센서를 설치하고 서버에 연동하는 가이드입니다. 기본 구성은 STM32F407VGT6 펌웨어(`sht30_fw`, `sensor_id=2`, `device_id=stm32-sht30-01`)이며, 폐쇄망의 다른 환경을 위한 Raspberry Pi I2C 대안도 함께 설명합니다.
서버실 온습도 모니터링을 위해 Sensirion SHT30(한진데이터 P4422-3, I2C) 센서를 설치하고 서버에 연동하는 가이드입니다. **현재 배포 구성은 Raspberry Pi 4 Model B + Python**(`raspberry_pi4_sht30/` = `deploy/03_pi_b_sht30_id2/`, `device_id=rpi4-sht30-01`)입니다. STM32F407 펌웨어(`firmware/`)는 **별도의 마이그레이션 트랙**(하드웨어 미검증)으로, 실제 필드 배포와 무관합니다 — §3 참고.
> 기준 문서: 루트 [`README.md`](../README.md), [`SYSTEM_OVERVIEW.md`](../SYSTEM_OVERVIEW.md), [`docs/DEPLOYMENT_PLAN_RPI4x3.md`](./DEPLOYMENT_PLAN_RPI4x3.md) §5.
> 현장 인쇄용 배선·핀맵 도해: `deploy/03_pi_b_sht30_id2/WIRING.html` (브라우저로 열람 — 자체 완결형, 오프라인 가능).
## 1. 서버 준비
@ -22,85 +25,93 @@
API는 `sht30` 측정을 수신하면 `leak_sensor_status`를 갱신하고 측정값을 `leak_sensor_metric`에 저장합니다. 서버가 `METRIC_*` 임계로 고온/저온/고습/저습을 판정하고, 초과 시 종류별 30분 쿨다운으로 SMS를 발송합니다.
## 2. 기본 구성 — STM32F407VGT6 (`sht30_fw`)
> 서버는 수집 시 `device_id`/`app_version`/`metric_type` 문자열을 **검증하지 않습니다**(HMAC 서명 + 필수필드 + `sensor_id` 범위 1..255 + 타임스탬프 신선도만 확인). 따라서 라즈베리파이 클라이언트(`rpi4-sht30-01`, `app_version=v2606-sht30-rpi4`)가 그대로 수용됩니다.
### 2.1 I2C 배선
## 2. 기본 구성 — Raspberry Pi 4 (I2C)
SHT30을 STM32 I2C1 버스에 연결합니다(상세는 [wiring_diagram.md](./wiring_diagram.md), [firmware/docs/HARDWARE.md](../firmware/docs/HARDWARE.md) §3).
소스: `deploy/03_pi_b_sht30_id2/`(= `raspberry_pi4_sht30/`). 근거는 해당 README.md, SETUP.md, WIRING.html, config.py, install.sh, systemd/sht30-sensor.service.
### 2.1 I2C 배선 (Pi 4 40핀 헤더, 버스 1)
상세 도해는 `deploy/03_pi_b_sht30_id2/WIRING.html`(SVG 핀맵 + 스키매틱 + 체크리스트)을 브라우저로 여십시오.
```text
SHT30 VDD -> STM32 3.3V
SHT30 GND -> STM32 GND
SHT30 SCL -> STM32 PB6 (I2C1_SCL, AF4) + 4.7kΩ 풀업 -> 3.3V
SHT30 SDA -> STM32 PB7 (I2C1_SDA, AF4) + 4.7kΩ 풀업 -> 3.3V
SHT30 ADDR -> GND (주소 0x44; VDD 면 0x45)
SHT30 VCC -> Raspberry Pi 3.3V (물리 핀 1) ★ Pi 에서는 5V(핀 2·4) 금지 (모듈은 2.4~5.5V OK나, 5V 전원 시 SDA/SCL 5V → 3.3V 전용 Pi GPIO 손상)
SHT30 GND -> Raspberry Pi GND (핀 6; 대체 9·14·20·25·30·34·39)
SHT30 SDA -> Raspberry Pi GPIO2 / SDA1 (물리 핀 3) + 4.7kΩ 풀업 -> 3.3V
SHT30 SCL -> Raspberry Pi GPIO3 / SCL1 (물리 핀 5) + 4.7kΩ 풀업 -> 3.3V
SHT30 ADDR -> GND (주소 0x44; VDD면 0x45)
```
- 7-bit 주소 `0x44`(`APP_SHT30_I2C_ADDR`), I2C 100kHz 권장.
- 명령 `0x2C06`(high-repeatability) → 약 20ms 대기 → 6바이트 read.
- 7-bit 주소 `0x44`(ADDR→GND). `config.py`의 `I2C_ADDR`와 반드시 일치.
- I2C 풀업 4.7kΩ ×2 — **SHT30 모듈에 온보드 풀업이 있으면 생략**.
- 명령 `0x2C06`(high-repeatability) → 약 20ms 대기 → 6바이트 read (드라이버 `sht30_driver.py`).
### 2.2 펌웨어 설정·빌드 (폐쇄망)
### 2.2 설정값 준비 (`config_local.py`)
```bash
cp firmware/common/secrets.h.example firmware/common/secrets.h
# secrets.h 의 APP_API_KEY 를 서버 config.local.php 의 API_KEY 와 동일하게 채운다.
cd /home/pi/raspberry_pi4_sht30
cp config_local.example.py config_local.py
# config_local.py 를 열어 채운다:
# API_URL = https://<서버>/raspi_leck_detecter/api/sensor_data.php (1번 모듈과 동일 엔드포인트)
# API_KEY = 서버 config.local.php 의 API_KEY 와 바이트 단위로 동일
# DEVICE_ID = rpi4-sht30-01
# DEVICE_LOCATION = 온습도 측정 위치(예: 서버실)
```
| 위치 | 설정 |
|---|---|
| `firmware/common/secrets.h` | `APP_API_KEY` = 서버 `API_KEY` |
| `firmware/common/app_config.h` | `APP_API_HOST`, `APP_API_PATH`(`/raspi_leck_detecter/api/sensor_data.php`), 네트워크 |
| `firmware/common/app_config.h` | `APP_SHT30_REPORT_INTERVAL_SEC`(기본 300초), `APP_SHT30_I2C_ADDR`(0x44) |
| `firmware/certs/server_ca.c` | 자리표시자 CA → 실제 Cafe24 루트 CA |
`config_local.py`가 없으면 실행 시 **명확한 오류로 즉시 중단**(fail closed)됩니다. `config_local.py`·`API_KEY`는 **절대 커밋하지 마세요**(`.gitignore` 처리됨). `sensor_id`(=2)/`sensor_name`은 `config.py` 기본값을 씁니다.
벤더링·빌드·플래시 절차는 [firmware/docs/BUILD_OFFLINE.md](../firmware/docs/BUILD_OFFLINE.md)를 따릅니다. `sht30_fw` 타깃을 빌드해 보드에 플래시합니다.
> ⚠️ 설정은 **Python `config_local.py`** 로만 합니다. 과거 문서에 있던 `/etc/sht30-sensor.env` 환경파일 방식은 이 라즈베리파이 모듈에서 **사용하지 않습니다**.
### 2.3 동작 확인
### 2.3 설치 (폐쇄망 오프라인 + 온라인 폴백)
USART3(PD8/PD9, 115200) 콘솔 로그에서 확인합니다.
- I2C1 초기화 및 SHT30 0x44 응답, 첫 측정값
- 네트워크/SNTP/TLS 핸드셰이크 → 서버 200 응답 → `startup` 보고
- 이후 5분 주기 `periodic` 측정 보고
## 3. 대안 구성 — Raspberry Pi I2C (STM32 외 대안)
STM32 보드를 쓸 수 없는 환경에서는 Raspberry Pi 40핀 헤더 I2C 버스 1을 사용할 수 있습니다.
```text
SHT30 VCC -> Raspberry Pi 3.3V (pin 1)
SHT30 GND -> Raspberry Pi GND
SHT30 SDA -> Raspberry Pi GPIO2 / SDA1 / pin 3
SHT30 SCL -> Raspberry Pi GPIO3 / SCL1 / pin 5
SHT30 ADDR -> GND (주소 0x44; 점퍼 변경 시 0x45)
```
I2C를 먼저 활성화합니다.
원샷:
```bash
sudo raspi-config # Interface Options -> I2C -> enable
sudo apt-get update
sudo apt-get install -y python3-venv python3-smbus i2c-tools
cd /home/pi/raspberry_pi4_sht30 && bash setup.sh # 설치→서비스 등록→검증→하드닝 (sudo 붙이지 말 것)
```
센서가 보이는지 확인합니다.
또는 단계별:
```bash
i2cdetect -y 1 # 44 또는 45 가 보여야 함
chmod +x install.sh && ./install.sh # apt(i2c-tools 등) + I2C 자동 활성화 + venv(requests/smbus2) + 로그
sudo reboot # I2C 를 새로 켠 경우 1회 (usermod -aG i2c 반영에도 필요)
```
환경 파일 `/etc/sht30-sensor.env`를 생성하고 다음 값을 채웁니다.
- 폐쇄망: 인터넷 되는 **동일 aarch64** 머신에서 `./vendor.sh`로 `wheelhouse/`+`debs/` 번들 → 타깃 Pi로 복사 후 `./install.sh`(자동 오프라인).
```text
API_URL=https://your-domain.example/raspi_leck_detecter/api/sensor_data.php
API_KEY=서버와 동일한 API_KEY
DEVICE_ID=rpi-sht30-01
DEVICE_LOCATION=온습도 측정 위치
SHT30_SENSOR_ID=2
SHT30_I2C_ADDR=0x44
### 2.4 systemd 자동 실행
```bash
sudo cp systemd/sht30-sensor.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now sht30-sensor
sudo systemctl status sht30-sensor
journalctl -u sht30-sensor -f
```
> 대안 구성에서도 `sensor_id=2`, raw-body 서명(`X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`), 5분 측정 주기는 동일합니다. 임계 판정과 SMS는 모두 서버에서 수행하므로 측정 송신부만 환경에 맞게 구성하면 됩니다.
유닛 특성: `User=pi`, **`SupplementaryGroups=i2c`**(`/dev/i2c-1` 접근), `Restart=always`. `install.sh`는 유닛을 자동 설치하지 않으므로 이 단계 누락 금지.
### 2.5 동작 확인
```bash
i2cdetect -y 1 # 0x44(또는 0x45)가 보여야 함
source venv/bin/activate
python3 test_sensor.py # CRC/포맷 자가점검 + 측정 1회
python3 test_connection.py # 서명 POST 200(연결/서명), 403=API_KEY 불일치, 400=NTP/타임스탬프
```
> ⚠️ `test_sensor.py`는 하드웨어 없이도 **시뮬레이션으로 통과**합니다. 반드시 `i2cdetect`로 0x44를 먼저 확인하고, 서비스 로그에 시뮬레이션 WARN이 없는지 점검하세요(시뮬레이션 폴백이 배선 오류를 가릴 수 있음).
- Pi 시각은 **NTP 동기** 필수(서버 timestamp 신선도 ±600초 — Raspberry Pi OS 기본 활성).
- 이후 5분 주기 `periodic` 측정 보고.
## 3. 대안(계획) 구성 — STM32F407VGT6 (`sht30_fw`)
STM32F407 베어메탈 펌웨어(`firmware/`)는 RPi → STM32 전환을 목표로 한 **별도 마이그레이션 트랙**입니다. `docs/stm32f407_migration_plan.md` 기준 **코드는 완성되었으나 실제 보드 빌드·플래시·HIL 시험은 미검증**(서버 host/CA/API키/SNTP가 모두 placeholder)이며, 현재 필드에는 배포되어 있지 않습니다. 이 경로를 실제로 채택하려면 `firmware/docs/HARDWARE.md`·`firmware/docs/BUILD_OFFLINE.md`를 따라 하드웨어를 확정·검증해야 합니다.
- I2C1 배선(참고): SHT30 SCL → STM32 **PB6**(I2C1_SCL, AF4), SDA → **PB7**(I2C1_SDA, AF4), 각 4.7kΩ 풀업 → 3.3V, ADDR → GND(0x44). VCC는 **3.3V**(5V 금지).
- 보고 식별자: `device_id=stm32-sht30-01`, `app_version=v2606-sht30`, `sensor_id=2`. 서버는 이 문자열을 검증하지 않으므로 Pi/STM32 어느 쪽이든 유효 서명이면 동일하게 수용합니다.
## 4. 대시보드 결과

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

@ -142,7 +142,7 @@
</article>
<article class="sensor">
<h3>2번 센서 (SHT30 온습도)</h3>
<small>서버실 상단 랙 / rpi-sht30-01</small><br>
<small>서버실 상단 랙 / rpi4-sht30-01</small><br>
<span class="badge">정상</span>
<div class="metric"><span>온도 24.3C</span><span>습도 48.1%</span></div>
<div class="last">측정: 2026-05-21 09:29:18</div>

View file

@ -336,12 +336,18 @@
<a href="#trouble">문제 대처</a>
</nav>
<div style="margin:16px 0;padding:14px 16px;border:2px solid #e03131;border-radius:10px;background:#fff5f5;color:#7a1f1f;">
⚠️ <strong>구버전 예제 안내.</strong> 이 문서는 <code>/etc/sht30-sensor.env</code> 환경파일 방식으로 작성된 <em>구식</em> 예제입니다.
실제 배포 구성은 라즈베리파이 <code>config_local.py</code>(Python) 방식이며 장비 ID는 <code>rpi4-sht30-01</code>입니다.
최신 정확한 절차는 <a href="../SHT30_SENSOR2_SETUP.md">SHT30_SENSOR2_SETUP.md</a> 및 <code>deploy/03_pi_b_sht30_id2/</code>를 따르세요.
</div>
<section id="prepare">
<h2>1. 준비물</h2>
<div class="grid">
<div class="tile">
<strong>라즈베리파이 1대</strong>
<span>온습도 전용 장비. 장비 ID 예시는 `rpi-sht30-01`.</span>
<span>온습도 전용 장비. 장비 ID 예시는 `rpi4-sht30-01`.</span>
</div>
<div class="tile">
<strong>SHT30 P4422-3 센서</strong>

View file

@ -0,0 +1,445 @@
# 누수 노드 SHT30 온습도 통합 구현 계획
> **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:** 기존 누수 노드(Pi-A=id1, Pi-C=id3)에 SHT30 온습도 센서를 물리적으로 추가하고, 온습도를 같은 sensor_id(1,3)로 서버에 보고 — 대시보드·경보·설치 패키지 전부 반영.
**Architecture:** 노드마다 기존 누수 서비스는 무변경, `sht30_sensor.py`를 두 번째 systemd 서비스로 얹는다(별도 디렉터리 `/home/pi/sht30`). sht30 코드는 그대로 재사용하고 config_local로 `SENSOR_ID`만 1/3로 지정. 서버는 코드 변경 없이 config.local 값(METRIC_SENSOR_IDS·heartbeat override)만 조정 — 대시보드/보고서는 이미 `METRIC_SENSOR_IDS` 동적 순회.
**Tech Stack:** Python 3.11(라즈베리 파이 4, Bookworm), smbus2(I2C), PHP(cafe24 서버), systemd, bash 설치 스크립트(폐쇄망 오프라인 번들).
## Global Constraints
- **폐쇄망(air-gapped):** 모든 설치 산출물은 자립형. 네트워크 미사용, 번들 없으면 명확히 FLAG. wheel/deb는 build 머신에서 vendor.sh로 사전 다운로드. (기존 install.sh/vendor.sh 패턴 유지)
- **동일 sensor_id:** 온습도는 새 id 없이 누수와 같은 id(Pi-A=1, Pi-C=3). Pi-B(id=2)는 절대 변경 없음.
- **누수 코드 무변경:** `leak_sensor.py` 및 누수 런타임 경로(`/home/pi/raspberry_pi4`) 손대지 않음.
- **라벨 일치:** 각 노드에서 sht30 config_local의 `DEVICE_LOCATION`은 같은 노드 누수 config_local의 `DEVICE_LOCATION`과 **문자열 동일**(같은 sensor_id upsert 라벨 플랩 방지).
- **HMAC 서명:** sht30 config_local의 `API_KEY`는 서버 config.local.php의 `API_KEY`와 완전 동일.
- **비밀값:** `config_local.py`/`config.local.php`(실값)는 에이전트가 직접 수정/커밋하지 않음. 예시(`*.example`)와 문서만 반영, 실값 라인은 응답/문서에 제시.
---
### Task 1: sht30 config.py — config_local로 SENSOR_ID/SENSOR_NAME 오버라이드 지원
**목적:** 누수 노드의 sht30가 tracked `config.py`(SENSOR_ID=2 기본) 편집 없이 config_local만으로 id=1/3 보고. 누수 config.py의 SENSORS 오버라이드 패턴과 동일. Pi-B는 config_local에 SENSOR_ID 미지정 → 기본 2 유지(하위호환).
**Files:**
- Modify: `raspberry_pi4_sht30/config.py` (끝에 오버라이드 블록 추가)
- Modify: `deploy/03_pi_b_sht30_id2/config.py` (동일 블록 — 소스와 동기화)
**Interfaces:**
- Produces: `config.SENSOR_ID` / `config.SENSOR_NAME` 가 config_local 값으로 대체 가능(검증 포함). `sht30_sensor.py`가 `from config import SENSOR_ID, SENSOR_NAME` 로 소비(무변경).
- [ ] **Step 1: `raspberry_pi4_sht30/config.py` 끝(현재 마지막 줄 = config_local import 블록 이후)에 추가**
```python
# ─────────────────────────────────────────────
# Optional override — per-device values from config_local.py.
# Editing the tracked config.py per device causes deployment drift, so a co-located
# node (e.g. leak node reporting temp/humidity under the SAME sensor_id 1 or 3)
# overrides SENSOR_ID / SENSOR_NAME here instead. If config_local has neither,
# the defaults above (SENSOR_ID=2) are kept — backward compatible for Pi-B.
# ─────────────────────────────────────────────
try:
from config_local import SENSOR_ID # noqa: F811 — per-device sensor_id override
except ImportError:
pass
try:
from config_local import SENSOR_NAME # noqa: F811 — per-device display name override
except ImportError:
pass
# Validate (fail closed) — a typo must not silently start with a wrong/plausible id.
if not (isinstance(SENSOR_ID, int) and 1 <= SENSOR_ID <= 255):
raise RuntimeError(f"SENSOR_ID must be int 1..255 (check config_local.py): {SENSOR_ID!r}")
if not (isinstance(SENSOR_NAME, str) and SENSOR_NAME):
raise RuntimeError("SENSOR_NAME must be a non-empty string (check config_local.py).")
```
- [ ] **Step 2: 같은 블록을 `deploy/03_pi_b_sht30_id2/config.py` 끝에도 추가** (소스와 동일 — 두 파일 내용 일치 확인)
- [ ] **Step 3: 오버라이드 동작 검증(수동 스모크 — 이 repo의 파이썬 테스트 관례는 수동 스크립트)**
Run (임시 config_local 생성해 import 확인):
```bash
cd raspberry_pi4_sht30
python3 - <<'PY'
# 오버라이드 로직 단위 확인(실 config_local 없이): 값 대입+검증 규칙만.
ns = {'SENSOR_ID': 2, 'SENSOR_NAME': 'Sensor 2 (SHT30)'}
ns['SENSOR_ID'] = 1 # config_local override 흉내
assert isinstance(ns['SENSOR_ID'], int) and 1 <= ns['SENSOR_ID'] <= 255
assert isinstance(ns['SENSOR_NAME'], str) and ns['SENSOR_NAME']
print("override ok:", ns['SENSOR_ID'])
PY
```
Expected: `override ok: 1`
- [ ] **Step 4: Commit**
```bash
git add raspberry_pi4_sht30/config.py deploy/03_pi_b_sht30_id2/config.py
git commit -m "feat(sht30): config_local 로 SENSOR_ID/SENSOR_NAME 오버라이드(동일-id 공용 노드 대비)"
```
---
### Task 2: deploy/02 (Pi-A) 에 sht30 서브폴더 추가 — SENSOR_ID=1
**Files:**
- Create dir: `deploy/02_pi_a_leak_id1/sht30/`
- Create(복사): `sht30/sht30_sensor.py`, `sht30/sht30_driver.py`, `sht30/wire.py`, `sht30/config.py`, `sht30/test_sensor.py`, `sht30/test_connection.py`, `sht30/requirements.txt`, `sht30/vendor.sh`, `sht30/install.sh`, `sht30/systemd/sht30-sensor.service` — 전부 `deploy/03_pi_b_sht30_id2/`에서 **verbatim 복사** (config.py는 Task 1 반영본)
- Create: `sht30/config_local.example.py`, `sht30/config_local.py`
**Interfaces:**
- Produces: `/home/pi/sht30` 로 설치될 자립형 sht30 installable. Task 4(install.sh 체이닝)가 소비.
- [ ] **Step 1: deploy/03 sht30 파일 verbatim 복사**
```bash
cd deploy/02_pi_a_leak_id1
mkdir -p sht30/systemd
cp ../03_pi_b_sht30_id2/sht30_sensor.py sht30/
cp ../03_pi_b_sht30_id2/sht30_driver.py sht30/
cp ../03_pi_b_sht30_id2/wire.py sht30/
cp ../03_pi_b_sht30_id2/config.py sht30/
cp ../03_pi_b_sht30_id2/test_sensor.py sht30/
cp ../03_pi_b_sht30_id2/test_connection.py sht30/
cp ../03_pi_b_sht30_id2/requirements.txt sht30/
cp ../03_pi_b_sht30_id2/vendor.sh sht30/
cp ../03_pi_b_sht30_id2/install.sh sht30/
cp ../03_pi_b_sht30_id2/systemd/sht30-sensor.service sht30/systemd/
```
- [ ] **Step 2: `sht30/systemd/sht30-sensor.service` 의 경로를 `/home/pi/sht30` 로 수정**
`WorkingDirectory` 와 `ExecStart` 두 줄을 다음으로 변경:
```ini
WorkingDirectory=/home/pi/sht30
ExecStart=/home/pi/sht30/venv/bin/python sht30_sensor.py
```
(나머지 sandbox/그룹 설정은 그대로 — `SupplementaryGroups=i2c` 유지)
- [ ] **Step 3: `sht30/config_local.example.py` 작성**
```python
# =============================================================================
# config_local.example.py — 누수 노드(Pi-A, sensor_id=1)에 얹는 SHT30 온습도 서비스 설정
# cp config_local.example.py config_local.py 후 실값 입력. config_local.py 는 커밋 금지.
#
# ★ 동일 sensor_id: 이 온습도는 같은 노드 누수 센서와 "같은" sensor_id(=1)로 보고한다.
# ★ 라벨 일치: DEVICE_LOCATION 은 같은 노드 누수 config_local.py 의 DEVICE_LOCATION 과
# "문자열 동일"해야 한다(같은 id 를 두 서비스가 upsert → 라벨 플랩 방지).
# ★ API_KEY 는 서버 config.local.php 의 API_KEY 와 완전 동일(HMAC 서명).
# =============================================================================
API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php"
API_KEY = "CHANGE_ME"
DEVICE_ID = "rpi4-a-sht30"
DEVICE_LOCATION = "Server Room" # ★ 누수(id=1) DEVICE_LOCATION 과 동일하게
SENSOR_ID = 1 # ★ 누수와 동일 id 로 온습도 보고
SENSOR_NAME = "Sensor 1 (SHT30)"
```
- [ ] **Step 4: `sht30/config_local.py` 작성(실배포 기본값 — 실값은 현장에서 교체, chmod 600은 install.sh가)**
Step 3 내용과 동일하게 `config_local.py` 생성. `.gitignore` 가 `config_local.py` 를 제외하는지 확인 — 제외되면 예시만 커밋 대상(실값 파일 미커밋).
- [ ] **Step 5: Commit**
```bash
git add deploy/02_pi_a_leak_id1/sht30
git commit -m "feat(pi-a): 누수 노드에 SHT30 온습도 서비스 서브폴더 추가(sensor_id=1)"
```
---
### Task 3: deploy/04 (Pi-C) 에 sht30 서브폴더 추가 — SENSOR_ID=3
Task 2와 동일 절차, `deploy/04_pi_c_leak_id3/sht30/` 로. 차이는 config_local(.example).py:
- [ ] **Step 1: 복사** — Task 2 Step 1을 `cd deploy/04_pi_c_leak_id3` 로 동일 수행.
- [ ] **Step 2: service 경로** — Task 2 Step 2와 동일(`/home/pi/sht30`).
- [ ] **Step 3+4: config_local(.example).py** — Task 2와 동일하되:
```python
DEVICE_ID = "rpi4-c-sht30"
DEVICE_LOCATION = "New Site" # ★ 누수(id=3) DEVICE_LOCATION 과 동일하게
SENSOR_ID = 3 # ★ 누수와 동일 id
SENSOR_NAME = "Sensor 3 (SHT30)"
```
(API_URL/API_KEY는 Task 2와 동일 형식)
- [ ] **Step 5: Commit**
```bash
git add deploy/04_pi_c_leak_id3/sht30
git commit -m "feat(pi-c): 누수 노드에 SHT30 온습도 서비스 서브폴더 추가(sensor_id=3)"
```
---
### Task 4: deploy/02 install.sh — sht30 서비스 설치 체이닝 + 서비스 enable
**Files:**
- Modify: `deploy/02_pi_a_leak_id1/install.sh` (마지막 완료 메시지 직전에 단계 추가)
**Interfaces:**
- Consumes: Task 2의 `sht30/` (자립형 install.sh 포함).
- Produces: `install.sh` 1회 실행으로 누수 + 온습도 두 서비스 설치·기동.
- [ ] **Step 1: `deploy/02_pi_a_leak_id1/install.sh` 의 최종 `echo "==... Installation complete"` 블록 바로 앞에 삽입**
```bash
# 5) SHT30 온습도 co-센서 설치 (동일 sensor_id=1, /home/pi/sht30 로 설치)
if [ -d sht30 ]; then
echo "[5/5] Installing SHT30 temp/humidity co-sensor (sensor_id 1)..."
SHT30_DEST="/home/pi/sht30"
sudo mkdir -p "${SHT30_DEST}"
# 코드/번들 배치(설치 대상 디렉터리로 복사 — 폐쇄망: 네트워크 미사용)
sudo cp -r sht30/. "${SHT30_DEST}"/
sudo chown -R "${SERVICE_USER}:${SERVICE_USER}" "${SHT30_DEST}"
# sht30 자체 install.sh 로 위임(venv + I2C 활성화 + 로그 + 서비스 설치). OFFLINE 전파.
( cd "${SHT30_DEST}" && SERVICE_USER="${SERVICE_USER}" OFFLINE="${OFFLINE}" bash install.sh )
# systemd 서비스 등록/기동
sudo cp "${SHT30_DEST}/systemd/sht30-sensor.service" /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable sht30-sensor
sudo systemctl restart sht30-sensor
echo " sht30-sensor 서비스 등록/기동 완료"
else
echo "[5/5] sht30/ 폴더 없음 — 온습도 co-센서 설치 건너뜀"
fi
```
- [ ] **Step 2: 문법 검증**
Run: `bash -n deploy/02_pi_a_leak_id1/install.sh`
Expected: (출력 없음 = 문법 OK)
- [ ] **Step 3: Commit**
```bash
git add deploy/02_pi_a_leak_id1/install.sh
git commit -m "feat(pi-a): install.sh 에 SHT30 온습도 서비스 설치 체이닝(누수+온습도 1회 설치)"
```
---
### Task 5: deploy/04 install.sh — 동일 체이닝 (sensor_id 3)
Task 4와 동일. 삽입 스니펫에서 주석 "sensor_id 1"→"sensor_id 3"만 변경.
- [ ] **Step 1:** `deploy/04_pi_c_leak_id3/install.sh` 에 Task 4 Step 1 스니펫 삽입(주석 id 3).
- [ ] **Step 2:** `bash -n deploy/04_pi_c_leak_id3/install.sh` → 문법 OK.
- [ ] **Step 3: Commit**
```bash
git add deploy/04_pi_c_leak_id3/install.sh
git commit -m "feat(pi-c): install.sh 에 SHT30 온습도 서비스 설치 체이닝(sensor_id=3)"
```
---
### Task 6: deploy/02 배선/문서 — WIRING.html + SETUP.md + README.md
**Files:**
- Modify: `deploy/02_pi_a_leak_id1/WIRING.html` (SHT30 I2C 배선 섹션 추가)
- Modify: `deploy/02_pi_a_leak_id1/SETUP.md` (이중 서비스 설치·확인 절차)
- Modify: `deploy/02_pi_a_leak_id1/README.md` (개요에 온습도 추가)
- [ ] **Step 1: `WIRING.html` — 누수 배선 다음에 SHT30 I2C 섹션 삽입**
`</body>` 직전(또는 마지막 섹션 뒤)에 삽입:
```html
<section>
<h2>SHT30 온습도 (동일 노드, sensor_id 1)</h2>
<p>누수 센서와 <b>같은 Pi</b>에 SHT30(P4422-3)을 I2C로 추가. 온습도는 누수와 <b>같은 sensor_id(1)</b>로 보고한다. 누수 GPIO17(핀11)과 <b>핀 충돌 없음</b>.</p>
<table>
<thead><tr><th>SHT30</th><th>Pi 40핀</th><th>비고</th></tr></thead>
<tbody>
<tr><td>VCC</td><td>3.3V (핀 1)</td><td>★ 반드시 3.3V. 5V(핀 2·4) 금지 — I2C 라인 5V로 끌려 Pi 손상</td></tr>
<tr><td>SDA</td><td>GPIO2 / SDA1 (핀 3)</td><td>온보드 풀업 없으면 4.7kΩ→3.3V</td></tr>
<tr><td>SCL</td><td>GPIO3 / SCL1 (핀 5)</td><td>온보드 풀업 없으면 4.7kΩ→3.3V</td></tr>
<tr><td>GND</td><td>GND (핀 6 또는 9)</td><td></td></tr>
<tr><td>ADDR</td><td>GND</td><td>0x44 (config.py I2C_ADDR 와 일치)</td></tr>
</tbody>
</table>
</section>
```
- [ ] **Step 2: `SETUP.md` — 온습도 설치/확인 절차 추가**
다음 섹션을 문서 말미에 추가:
```markdown
## SHT30 온습도 co-센서 (동일 sensor_id 1)
이 노드는 누수 + 온습도 두 서비스를 함께 돌린다.
1. 배선: WIRING.html 의 "SHT30 온습도" 표대로(VCC=3.3V, SDA=핀3, SCL=핀5, GND=핀6, ADDR=GND).
2. 온습도 설정: `sht30/config_local.py` 에서 API_URL/API_KEY/DEVICE_LOCATION 을 실값으로.
- ★ `DEVICE_LOCATION` 은 누수 `config_local.py` 의 값과 문자열 동일.
- `SENSOR_ID=1` 은 그대로(누수와 동일 id).
3. 설치: `bash install.sh` 1회 → 누수 + 온습도 서비스 모두 설치·기동(SHT30는 /home/pi/sht30).
4. 확인:
- `i2cdetect -y 1` → 0x44 표시
- `sudo systemctl status leak-sensor sht30-sensor` → 둘 다 active
- 서버 대시보드 1번 카드에 누수 상태 + 온습도 표시
5. I2C 활성화 직후면 `sudo reboot` 필요할 수 있음(install.sh 안내).
```
- [ ] **Step 3: `README.md`** — 개요 문단에 "이 노드는 누수(id 1) + SHT30 온습도(같은 id 1)를 함께 보고한다" 한 줄 추가.
- [ ] **Step 4: Commit**
```bash
git add deploy/02_pi_a_leak_id1/WIRING.html deploy/02_pi_a_leak_id1/SETUP.md deploy/02_pi_a_leak_id1/README.md
git commit -m "docs(pi-a): SHT30 온습도 배선/설치 문서 반영"
```
---
### Task 7: deploy/04 배선/문서 — 동일 (sensor_id 3)
Task 6과 동일하되 모든 "sensor_id 1"→"3", DEVICE_LOCATION 안내는 누수(id=3) 값 기준.
- [ ] **Step 1~3:** Task 6 Step 1~3을 `deploy/04_pi_c_leak_id3/` 파일에 id=3으로 반영.
- [ ] **Step 4: Commit**
```bash
git add deploy/04_pi_c_leak_id3/WIRING.html deploy/04_pi_c_leak_id3/SETUP.md deploy/04_pi_c_leak_id3/README.md
git commit -m "docs(pi-c): SHT30 온습도 배선/설치 문서 반영(sensor_id=3)"
```
---
### Task 8: 서버 — config.local.example.php + deploy/01 SETUP 반영
**Files:**
- Modify: `php/config.local.example.php`
- Modify: `deploy/01_server_cafe24/asmdkwqdmqwertn/config.local.php`(시크릿 — **에이전트 미수정**, 값만 SETUP·응답에 제시)
- Modify: `deploy/01_server_cafe24/SETUP.md`
**Interfaces:**
- Produces: 대시보드가 1·3을 온습도 카드로도 표시(METRIC_SENSOR_IDS), 누수 오프라인 감지 속도 유지(heartbeat override).
- [ ] **Step 1: `php/config.local.example.php` 의 `LEAK_SENSOR_IDS` 아래 `METRIC_SENSOR_IDS` 예시 추가 + heartbeat override 주석 갱신**
`'LEAK_SENSOR_IDS' => [1],` 다음에 삽입:
```php
// 온습도(SHT30) sensor_id 목록 — 대시보드 온습도 카드 표시 대상 (기본 [2]).
// 누수 노드(1·3)에 SHT30 을 얹어 "같은 sensor_id" 로 온습도를 보고하는 구성에서는
// 1·3 도 포함한다: [1, 2, 3]. (누수 카드 + 온습도 카드가 같은 지점에 함께 표시됨)
'METRIC_SENSOR_IDS' => [1, 2, 3],
```
그리고 기존 `SENSOR_HEARTBEAT_TIMEOUT_SEC` 주석 예시를 실효 오버라이드로 교체:
```php
// 센서별 오프라인 판정 타임아웃(무수신 초). ★ 1·3 은 온습도 목록에도 들어가지만
// 누수 노드이므로 metric 기본(7500s)으로 느슨해지면 안 된다 → 누수 타임아웃 유지 위해
// 명시 오버라이드한다(30분). (미지정 시 온습도로 간주되어 오프라인 감지가 늦어짐)
'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [1 => 1800, 3 => 1800],
```
- [ ] **Step 2: `deploy/01_server_cafe24/SETUP.md` 에 온서버 config.local.php 변경 절차 추가**
```markdown
## 누수 노드 온습도(SHT30) 통합 반영 (동일 sensor_id)
누수 노드(1·3)에 SHT30 를 얹어 같은 sensor_id 로 온습도를 보고하면, 서버 config.local.php 에
아래를 반영한다(코드 변경 없음 — 설정만):
'METRIC_SENSOR_IDS' => [1, 2, 3],
'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [1 => 1800, 3 => 1800],
- METRIC_SENSOR_IDS: 1·3 을 온습도 카드 대상에 포함(대시보드/월간보고 자동 반영).
- SENSOR_HEARTBEAT_TIMEOUT_SEC: 1·3 을 누수 타임아웃(30분)으로 고정(온습도 완화 방지).
- 온습도 임계 경보 수신자는 기존 SENSOR_RECIPIENTS[1]/[3] 그대로 사용.
반영 후 대시보드에서 1·3 카드에 누수 + 온습도가 함께 뜨는지 확인.
```
- [ ] **Step 3: Commit**
```bash
git add php/config.local.example.php deploy/01_server_cafe24/SETUP.md
git commit -m "docs(server): 누수 노드 동일-id 온습도 통합 — METRIC_SENSOR_IDS/heartbeat 예시·SETUP 반영"
```
---
### Task 9: 서버 테스트 — heartbeat override(1·3) 케이스 추가
**Files:**
- Modify: `tests/heartbeat_timeout_test.php`
**Interfaces:**
- Consumes: `resolve_heartbeat_timeout($sid, $overrides, $metricIds, $metricDefault, $globalDefault)` (config.php 순수 함수).
- [ ] **Step 1: 기존 테스트 파일 문법 확인**
Run: `php -l tests/heartbeat_timeout_test.php`
Expected: `No syntax errors detected`
- [ ] **Step 2: 실패 테스트 추가 — 1·3 이 METRIC 목록에 있어도 override 로 누수 타임아웃 반환**
`tests/heartbeat_timeout_test.php` 에 다음 검증 추가(파일의 assert 패턴에 맞춰 삽입):
```php
// 동일-id 온습도 통합: 1·3 이 METRIC_SENSOR_IDS 에 포함돼도, 명시 오버라이드가
// metric 기본(7500)보다 우선하여 누수 타임아웃(1800)을 반환해야 한다.
$overrides = [1 => 1800, 3 => 1800];
$metricIds = [1, 2, 3];
assert(resolve_heartbeat_timeout(1, $overrides, $metricIds, 7500, 1200) === 1800); // 누수 유지
assert(resolve_heartbeat_timeout(3, $overrides, $metricIds, 7500, 1200) === 1800); // 누수 유지
assert(resolve_heartbeat_timeout(2, $overrides, $metricIds, 7500, 1200) === 7500); // 순수 온습도
echo "[OK] heartbeat override(1,3) with metric-membership\n";
```
- [ ] **Step 3: 실행 → 통과 확인**
Run: `php tests/heartbeat_timeout_test.php`
Expected: 기존 출력 + `[OK] heartbeat override(1,3) with metric-membership`, 종료코드 0
- [ ] **Step 4: Commit**
```bash
git add tests/heartbeat_timeout_test.php
git commit -m "test(server): 동일-id 온습도 통합 시 1·3 누수 heartbeat 타임아웃 유지 검증"
```
---
### Task 10: 통합 검증 (대시보드 중복집합 + 라벨/스키마) — 코드 수정은 발견 시만
**Files:**
- Verify: `php/dashboard_data.php`(≈208–223, 503–506), `php/monthly_report_print.php`, `php/forecast.php`
- Modify(발견 시만): 위 파일 중 동일 id가 LEAK∩METRIC 일 때 깨지는 부분
- [ ] **Step 1: 카드 맵 로직 확인 — sensor_id 가 LEAK·METRIC 양쪽에 있어도 정상**
`php/dashboard_data.php` 208–230행을 읽고, `LEAK_SENSOR_IDS`와 `METRIC_SENSOR_IDS` 순회가 서로 독립적으로 카드를 만드는지(중복 집합 1·3에서 예외/누락 없는지) 확인. 누수 카드와 온습도 카드가 **각각** 생성되면 정상.
- [ ] **Step 2: 월간보고/예보에서 동일 id 취급 확인**
`php/monthly_report_print.php`(LEAK_SENSOR_IDS·METRIC_SENSOR_IDS 사용부), `php/forecast.php`(METRIC 순회)에서 1·3이 온습도로도 집계될 때 누수 데이터와 충돌/중복 표기가 없는지 확인.
- [ ] **Step 3: 문제 발견 시에만 최소 수정** — 없으면 수정 없이 다음.
- [ ] **Step 4: 통합 스모크(로컬 서버 있으면) — sensor_id=1 metric POST → 저장·표시·임계 경로**
가능하면 로컬/스테이징에서 sensor_id=1 로 sht30 periodic POST(서명 포함) → `leak_sensor_metric`에 1행 저장 + `leak_sensor_status`의 is_leak 보존 + 대시보드 1번 카드에 온습도 표시 확인. (운영 서버 직접 검증은 사용자 몫 — SETUP 절차로 안내.)
- [ ] **Step 5: Commit(수정 있었을 때만)**
```bash
git add -A
git commit -m "fix(server): 동일-id LEAK∩METRIC 대시보드/보고 표기 정합(발견 시)"
```
---
## Self-Review (작성자 체크)
- **Spec 커버리지:** A(서버 설정/문서)=Task 8·9·10, B(노드 sht30 서비스)=Task 1·2·3·4·5, 배선/설치문서=Task 6·7. 스펙의 모든 절 대응 태스크 존재. ✓
- **Placeholder:** "적절히 처리" 류 없음. 각 스텝 실제 코드/명령/기대출력 포함. ✓
- **타입/이름 정합:** `resolve_heartbeat_timeout` 시그니처, `SENSOR_ID`/`SENSOR_NAME`/`DEVICE_LOCATION`/`METRIC_SENSOR_IDS`/`SENSOR_HEARTBEAT_TIMEOUT_SEC` 명칭 전 태스크 일관. ✓
- **비밀값:** 실 config_local.py/config.local.php 는 예시·문서만, 실값 라인은 제시(에이전트 미커밋). ✓

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,218 @@
# 누수 디바운스 · 온습도 1시간 주기 · 센서별 수신자 그룹 설계
- 날짜: 2026-07-13
- 브랜치: feat/pi-c-sensor3-leak-node
- 대상: leak_sms_v3 (통합 모니터링 — 누수 1번 모듈 / SHT30 온습도 2번 모듈, 단일 Cafe24 서버)
## 배경 · 목표
운영 중 세 가지 요구가 나왔다.
1. **누수 감지↔복구 플래핑 SMS 폭탄 방지.** 센서에 닿는 물이 애매하면 GPIO가 leak/dry 사이를 진동한다(펌웨어 500ms 바운스로도 못 막는 초 단위 채터링). 현재 서버는 `[누수복구]` 뒤 재감지를 "새 에피소드"로 보고 쿨다운을 무시해 즉시 발송하므로, 사이클마다 `[누수감지]`+`[누수복구]` 2건이 폭탄처럼 쏟아진다.
2. **온습도(SHT30) 측정 주기를 1시간으로.** 현재 5분(`REPORT_INTERVAL=300`).
3. **센서별 SMS 수신자 그룹 지정.** 현재 `SMS_RECIPIENTS` 전역 하나뿐.
**폐쇄망 제약:** 모든 운영 설정은 `config.local.php`(또는 환경변수)로 주입한다. 관리자용 설정 UI는 없다(읽기 전용 표시만). DB 스키마 변경은 마이그레이션 SQL 파일로 전달하고, 컬럼 부재 시 graceful degrade(구배포에서 자동 비활성)를 유지한다.
**공통 원칙:** `leak_sms_log.message`의 태그 문자열이 사실상의 경보 상태 키다(별도 상태 테이블 없음). 태그는 `config.php`의 `SMS_TAG_*` 상수 단일 출처. 원천 이벤트 로그(`leak_sensor_log`)는 진단을 위해 raw 그대로 보존한다.
---
## 기능 1 — 누수 감지/복구 디바운스 (서버 전용, 펌웨어 무변경)
### 결정된 접근
**디바운스/확인 지연.** 상태 전이를 즉시 발송하지 않고, 확인창 동안 지속되어야 확정한다. 펌웨어는 이미 누수 지속 중 `periodic`을 10초 주기로 재보고(`LEAK_REPORT_INTERVAL=10`)하므로, 서버가 이 신호로 확정 타이머를 전진시킨다. **펌웨어 변경 불필요.**
### 상태 저장 (leak_sensor_status에 컬럼 2개 추가)
| 컬럼 | 타입 | 의미 |
|---|---|---|
| `leak_confirmed` | `TINYINT(1) NOT NULL DEFAULT 0` | 디바운스로 **확정**된 누수 상태. SMS·`leak_incident`·대시보드 헤드라인의 기준. |
| `leak_pending_since` | `DATETIME NULL DEFAULT NULL` | raw 상태(`is_leak`)가 확정 상태와 어긋나기 시작한 시각. `NULL`=안정(raw==confirmed). |
`is_leak`(raw 최신)은 그대로 두고, 확정 상태를 별도로 둔다.
### 순수 판정 함수 (단위 테스트 대상)
`alert_cooldown_suppresses()`와 같은 위치(`sms_send.php`)에 DB/네트워크 없는 순수 함수를 둔다.
```
leak_debounce_decide(
int $raw, // 0/1 현재 raw 상태
int $confirmed, // 0/1 현재 확정 상태
?int $pendingSinceTs, // 미확정 전이 시작 Unix (없으면 null)
int $alertConfirmSec, // raw=1 확정 임계
int $recoveryConfirmSec,// raw=0 확정 임계
int $now // 현재 Unix (테스트 주입)
): array // ['confirmed'=>int, 'pending_since'=>?int, 'sms'=>null|'leak'|'recovery']
```
판정표 (단일 타이머, 대칭):
| 조건 | confirmed | pending_since | sms |
|---|---|---|---|
| `raw == confirmed` | 유지 | `null`(해제) | `null` |
| `raw != confirmed`, pending 없음 | 유지 | `now`(시작) | `null` |
| `raw != confirmed`, 경과 `< threshold` | 유지 | 유지 | `null` |
| `raw != confirmed`, 경과 `≥ threshold` | `raw`(확정) | `null`(해제) | raw=1→`leak`, raw=0→`recovery` |
`threshold = (raw==1) ? alertConfirmSec : recoveryConfirmSec`.
**왜 이 설계가 폭탄을 원천 차단하나:** 누수 지속 중 짧은 dry 블립이 와도(raw 0→1→0…), dry 구간이 `recoveryConfirmSec`(기본 180s)보다 짧으면 복구가 확정되지 않는다 → 확정 상태는 계속 `leak` 유지 → **재감지 SMS도, 복구 SMS도 안 나간다.** 진짜 해소(연속 dry ≥ 180s) 때만 `[누수복구]` 1건. 진짜 신규 누수(연속 leak ≥ 60s)만 `[누수감지]` 1건.
### DB 래퍼 · 통합 지점
`apply_leak_debounce(PDO $db, int $sensor_id, int $raw, array $sensor_info): bool`
1. `(센서)` 단위 네임드 락 `GET_LOCK("leak_debounce_{sid}", 0)`으로 read-decide-update 직렬화(동시 보고 경쟁 방지, 미지원 시 락 없이 진행).
2. `leak_sensor_status`에서 `leak_confirmed`, `UNIX_TIMESTAMP(leak_pending_since)` 읽기.
3. `leak_debounce_decide(...)` 호출.
4. `UPDATE leak_sensor_status SET leak_confirmed=?, leak_pending_since=?`.
5. `sms==='leak'` → `leak_incident` open(미개봉 시, 최근 `leak_alert` log_id 링크) + `send_leak_alert_sms()`.
`sms==='recovery'` → `leak_incident` 최근 미복구 close + `send_leak_recovery_sms()`.
6. SMS 발송 여부 반환.
기존 `send_leak_alert_sms()`/`send_leak_recovery_sms()`(포맷·발송·로그·자체 쿨다운 백스톱)는 그대로 재사용한다. 이제 이들을 호출하는 **결정**만 raw 이벤트가 아니라 확정 엣지에서 내려온다.
**호출 게이트 (api/sensor_data.php):** `leak_alert`/`leak_recovery` 이벤트이거나 `sensor_id ∈ LEAK_SENSOR_IDS`인 보고에서 `apply_leak_debounce($db, $sid, $is_leak, $sensor_info)` 호출. SHT30(비누수) 센서는 스킵. 누수 센서의 `periodic`(is_leak=1)이 확정 타이머를 전진시킨다.
**cron sweep:** `sweep_pending_leak_transitions(PDO $db)` — `leak_pending_since IS NOT NULL`인 센서를 raw=`is_leak`로 재평가해 보고가 끊겨도(예: 복구 대기 중 10분 주기, 장비 무응답) 확정을 해소한다. `check_offline_sensors_throttled()`의 실행 블록(5분 쓰로틀)에 추가.
### leak_incident (사고 추적)을 확정 기준으로
기존은 raw `leak_alert`에 open, raw `leak_recovery`에 close → 플래핑 시 1초짜리 사고가 다수 생겨 월간보고서 누수 건수가 부풀었다. 사고 open/close를 **확정 엣지**로 옮겨(위 5단계) 사고 이력이 확정 에피소드만 담게 한다. raw 이벤트는 `leak_sensor_log`에 그대로 남아 플래핑 진단이 가능하다.
### graceful degrade
`leak_status_has_debounce_cols(PDO $db): bool`(1회 캐시, information_schema 조회)로 컬럼 존재를 확인한다. **없으면 레거시 즉시 발송 경로**(현행 `leak_alert`→즉시 SMS + raw 기준 incident)를 그대로 탄다. 즉 마이그레이션 미적용 배포에서도 동작은 종전과 동일하고 디바운스만 비활성.
배포 시 이미 누수 중(`is_leak=1`)인 센서: `leak_confirmed` 초기 0 → 다음 보고에서 pending 시작 → 60s 후 `[누수감지]` 1회 재확정. (허용 가능한 엣지, 사실상 재확인.)
### 설정 상수 (config.php)
```
LEAK_DEBOUNCE_ENABLED (bool, 기본 true) // 마스터 스위치 (false면 레거시 즉시)
LEAK_ALERT_CONFIRM_SEC (int, 기본 60) // 감지 확인창
LEAK_RECOVERY_CONFIRM_SEC (int, 기본 180) // 복구 확인창
```
### 대시보드 표시
- `dashboard_data.php`: `leak_active_count`·히어로 판정을 컬럼 존재 시 `leak_confirmed` 기준으로(=SMS와 헤드라인 일치). 컬럼 부재 시 기존 `is_leak` 폴백.
- 누수 상태 카드: `leak_pending_since`가 세팅되고 raw가 확정과 어긋나면 **"확인중(디바운스)"** 배지 노출 — 관리자가 "감지됐는데 왜 SMS 안 왔나"를 즉시 이해.
- 시스템 탭 `config_rows`: `['누수 확인 지연', "감지 60s / 복구 180s"]` 행 추가.
---
## 기능 2 — 온습도 1시간 주기 + 센서별 히트비트 타임아웃
### 펌웨어
`raspberry_pi4_sht30/config.py`: `REPORT_INTERVAL = 300 → 3600` (기본값 자체 변경, 주석 갱신). `config_local.py`로 현장 오버라이드 가능.
### 센서별 오프라인 타임아웃
전역 단일 `HEARTBEAT_TIMEOUT_SEC`이 5분 주기 전제였으므로 1시간 온습도 센서를 매시간 오프라인 오탐한다. 히트비트 타임아웃을 센서별로 해소한다.
`heartbeat_timeout_for(int $sid): int` (config.php):
1. `SENSOR_HEARTBEAT_TIMEOUT_SEC[$sid]` 존재 → 그 값 (명시 오버라이드)
2. `$sid ∈ METRIC_SENSOR_IDS` → `METRIC_HEARTBEAT_TIMEOUT_SEC` (기본 7500 ≈ 1시간 2회 누락 + 여유)
3. 그 외(누수 등) → `HEARTBEAT_TIMEOUT_SEC` (기본 1200 유지)
`check_offline_sensors()` 수정: 단일 인터벌 WHERE 대신 센서를 순회하며 센서별 타임아웃으로 판정·선점한다. 선점 UPDATE의 `last_seen < NOW() - INTERVAL :timeout SECOND`도 해당 센서 타임아웃을 바인딩. (원자적 0→1 선점·복귀 시 오탐 방지 로직은 유지.)
`dashboard_data.php` freshness(현 line 334 `HEARTBEAT_TIMEOUT_SEC`)도 `heartbeat_timeout_for($sid)`로 교체.
### 부수 영향 정리
- `STUCK_DETECT_SAMPLES`: 6(×5분=30분) → 1시간에선 6시간. 기본 **3**(=3시간 평탄)으로 낮추고 주석 갱신. config로 조정 가능.
- `FORECAST_RECENT_POINTS`(주석 "≈1시간")·급변 기준 등 예보 상수 주석을 1시간 주기 기준으로 갱신(예보는 대시보드 전용·SMS 무관 — 로직 변경 없이 주석·의미만 정정, YAGNI).
- config.php 히트비트 섹션 주석(5분/10분 서술)을 1시간/센서별로 정정.
- 누수 오프라인은 여전히 20분 내 감지(중요도 유지).
### 설정 상수 (config.php)
```
METRIC_HEARTBEAT_TIMEOUT_SEC (int, 기본 7500) // 온습도 센서 오프라인 판정
SENSOR_HEARTBEAT_TIMEOUT_SEC (map, 기본 []) // sid=>sec 명시 오버라이드
STUCK_DETECT_SAMPLES (int, 기본 3) // 6→3
```
### 대시보드 표시
시스템 탭 `config_rows`의 `['오프라인 판정', ...]`을 센서별로: 예 `누수 20분 / 온습도 125분 무수신`.
---
## 기능 3 — 센서별 수신자 그룹 (config.local.php 맵)
### 설정 파싱 (config.php)
`SENSOR_LABELS`와 동일 패턴으로 `SENSOR_RECIPIENTS` 맵을 파싱·검증한다.
```
'SENSOR_RECIPIENTS' => [
1 => ['010-1111-1111', '010-2222-2222'], // 누수 → 시설팀
3 => ['010-3333-3333'], // 3번 → 고객센터
]
```
- 키: 1..255 정수 sid. 값: 문자열 배열 또는 쉼표문자열(정제: `preg_replace('/\D+/','')`, 빈 항목 제거).
- 유효 번호가 하나도 없는 그룹은 버림(전역 폴백되도록).
- `define('SENSOR_RECIPIENTS', $parsed)`.
### 해소 헬퍼
`recipients_for_sensor(int $sid): array` (config.php) → `SENSOR_RECIPIENTS[$sid] ?? SMS_RECIPIENTS`. `sid=0`(헬스/잔액/시스템)은 그룹 없음 → 전역 폴백.
### 발송 통합 (sms_send.php)
`sms_dispatch_to_recipients(...)`의 `$targets = $recipients ?? SMS_RECIPIENTS`를 `$targets = $recipients ?? recipients_for_sensor((int)($sensor_data['sensor_id'] ?? 0))`로 변경. 명시 전달(에스컬레이션 TIER2)은 그대로 우선. 잔액경고(`maybe_warn_sms_balance`)·일일헬스는 시스템 전역이라 `SMS_RECIPIENTS` 유지.
**에스컬레이션 TIER2:** 전역 `SMS_RECIPIENTS_TIER2` 유지(YAGNI — 센서별 2차 그룹은 이번 범위 밖).
### 대시보드 표시
시스템 탭 `config_rows`: 센서별 수신자 그룹 요약 행 추가(번호는 `mask_phone()`로 마스킹). 예 `['수신자 · 1번', '010-****-1111 외 1명']`, 미지정 센서는 `전역(N명)` 표기.
---
## DB 마이그레이션
`sql/migration_leak_debounce.sql` 신규 + canonical `sql/schema_sht30.sql` & `deploy/01_server_cafe24/sql/schema.sql`의 `leak_sensor_status` 정의 갱신.
```sql
ALTER TABLE `leak_sensor_status`
ADD COLUMN `leak_confirmed` TINYINT(1) NOT NULL DEFAULT 0
COMMENT '디바운스 확정 누수 상태(SMS/사고추적 기준)',
ADD COLUMN `leak_pending_since` DATETIME NULL DEFAULT NULL
COMMENT '미확정 전이 시작 시각(NULL=안정)';
```
멱등(`ADD COLUMN IF NOT EXISTS` 미지원 MySQL 대비 재실행 시 오류 무시 안내 주석 포함). 컬럼 부재 배포는 graceful degrade로 계속 동작.
## 테스트 (무프레임워크 PHP — 기존 tests/ 스타일)
- `tests/leak_debounce_test.php`: `leak_debounce_decide` 순수함수.
- 플래핑(leak→dry→leak, 각 구간 < 확인창) → sms 없음, 확정 불변.
- 지속 누수(연속 leak ≥ 60s) → 1회 `leak` 확정.
- 확정 누수 중 짧은 dry(<180s) 블립 → 복구 확정 안 됨, sms 없음.
- 진짜 복구(연속 dry ≥ 180s) → 1회 `recovery`.
- pending 시작/해제/유지 경계값.
- `tests/recipients_group_test.php`: `recipients_for_sensor` (그룹 히트/전역 폴백/sid=0/빈 그룹 버림).
- `tests/heartbeat_timeout_test.php`: `heartbeat_timeout_for` (명시>METRIC>전역 우선순위).
- 기존 `tests/cooldown_episode_test.php`·`threshold_sms_test.php` 회귀 확인(레거시 경로·metric 경보 불변).
## 배포 · 폐쇄망 노트
1. SQL 마이그레이션을 서버에서 1회 실행(`SOURCE .../migration_leak_debounce.sql`).
2. `php/config.local.php`에 필요 시 `SENSOR_RECIPIENTS`, `SENSOR_HEARTBEAT_TIMEOUT_SEC` 등 추가(선택 — 미설정 시 전역/기본 폴백).
3. 온습도 Pi 노드: `raspberry_pi4_sht30/config.py` 갱신 반영(재배포) 또는 `config_local.py`에 `REPORT_INTERVAL=3600`.
4. `deploy/01_server_cafe24/asmdkwqdmqwertn/` 미러(php 배포본)에 동일 변경 반영.
## 범위 밖 (YAGNI)
- 온습도(metric) 경보 디바운스 — 이미 히스테리시스+쿨다운 보유, 1시간 주기로 채터링 사실상 소멸.
- 센서별 TIER2 에스컬레이션 그룹.
- 관리자 설정 편집 UI(폐쇄망 — config.local.php 유지).
- 플래핑 횟수 기반 별도 억제(디바운스로 충분).

View file

@ -0,0 +1,140 @@
# 설계: 누수 노드에 SHT30 온습도 통합 (동일 sensor_id)
**날짜:** 2026-07-15
**브랜치:** feat/pi-c-sensor3-leak-node
**상태:** 설계 승인됨 (사용자 확인 완료)
## 목표
기존 누수 노드(Pi-A=sensor_id 1, Pi-C=sensor_id 3)에 SHT30(P4422-3) 온습도 센서를
물리적으로 추가하고, 온습도를 **같은 sensor_id(1, 3)** 로 서버에 보고한다. 대시보드·경보·
월간보고·설치 패키지까지 전부 반영한다. Pi-B(온습도 전용, sensor_id 2)는 건드리지 않는다.
## 핵심 결정 (확정)
1. **동일 sensor_id.** 온습도는 새 id를 만들지 않고 누수와 같은 id(1, 3)로 보고한다.
- `leak_sensor_status`(sensor_id 키)와 `leak_sensor_metric`(sensor_id+측정시각 키)는
테이블이 분리돼 있어, 한 sensor_id가 누수 상태 + 온습도 측정을 동시에 가질 수 있다.
- 새 id/재번호 없음. Pi-B(2) 유지.
2. **노드: 별도 서비스.** 불안정한 누수 코드(`leak_sensor.py`)는 건드리지 않는다.
기존 `sht30_sensor.py`를 **두 번째 systemd 서비스**로 얹는다. sht30 코드는 무변경 —
config의 `SENSOR_ID`만 노드별(1/3)로 설정.
3. **라벨:** 서버 SENSOR_LABELS 오버라이드 없이, 노드가 보고하는 device_location/
sensor_name을 그대로 쓴다. 단, 같은 sensor_id를 두 서비스가 upsert하므로 라벨이
흔들리지 않게 **두 서비스의 device_location을 노드별로 동일하게** 맞춘다.
4. **경보/수신자:** 전역 METRIC_* 임계로 서버가 고온/고습 등 판정 → SMS. 수신자는
기존 SENSOR_RECIPIENTS의 1·3 그룹을 그대로 사용(추가 매핑 불필요).
## 아키텍처 / 데이터 흐름
```
Pi-A (한 대)
├ leak-sensor.service → leak_sensor.py → POST (sensor_id=1) leak_alert/recovery/periodic (누수, 온습도 없음)
└ sht30-sensor.service → sht30_sensor.py → POST (sensor_id=1) periodic (metric_type=sht30, 온·습도)
Pi-C (한 대): 위와 동일, sensor_id=3
Pi-B: 변경 없음 (sensor_id=2)
서버 sensor_data.php:
- sensor_id=1 상태 upsert: 누수 이벤트만 is_leak 갱신, sht30 periodic은 is_leak 보존(기존 로직)
- sht30 payload → leak_sensor_metric(sensor_id=1) 저장 + 임계 판정 → 온습도 SMS(SENSOR_RECIPIENTS[1])
- last_seen 은 두 서비스가 모두 갱신
대시보드: sensor_id=1 → 누수 카드(LEAK_SENSOR_IDS) + 온습도 카드(METRIC_SENSOR_IDS)
```
## 변경 범위
### A. 서버 (코드 변경 없음 — 설정/문서/예시만)
서버 코드는 이미 동적(대시보드/추이/월간보고 전부 `METRIC_SENSOR_IDS` 순회, 스키마는
임의 sensor_id 수용)이라 **코드 수정 불필요.** 설정과 예시/문서만 반영.
1. `php/config.local.example.php`
- `METRIC_SENSOR_IDS` 예시를 `[1, 2, 3]` 로(누수 노드 1·3도 온습도 카드 표시).
- `SENSOR_HEARTBEAT_TIMEOUT_SEC` 예시에 `[1 => 1800, 3 => 1800]` 추가 — 1·3을 온습도
목록에 넣으면 오프라인 타임아웃이 metric 기본(7500s)으로 느슨해지므로, 누수 노드는
빠른 오프라인 감지를 유지하도록 명시 오버라이드.
- 동일 sensor_id에 누수+온습도를 함께 두는 구성 설명 주석 추가.
2. `deploy/01_server_cafe24/asmdkwqdmqwertn/config.local.php`(온서버 시크릿 — 접근 불가)
- 실제 반영 값을 사용자에게 제시:
`METRIC_SENSOR_IDS => [1,2,3]`, `SENSOR_HEARTBEAT_TIMEOUT_SEC => [1=>1800, 3=>1800]`.
- 파일이 시크릿이라 에이전트가 직접 수정하지 않고, 추가할 라인을 SETUP 문서/응답에 명시.
3. `deploy/01_server_cafe24/SETUP.md`: 위 config.local 변경 절차 문서화.
**검증 항목(구현 중 확인):** `php/dashboard_data.php`의 카드 맵(≈208–223행)이 sensor_id를
LEAK/METRIC 양쪽에 두어도(중복 집합) 정상 렌더되는지, `monthly_report*`/`forecast.php`가
동일 id를 누수+온습도로 다뤄도 깨지지 않는지 확인. 문제 시 최소 수정.
### B. 노드 — 각 누수 노드에 sht30 서비스 추가 (deploy/02, deploy/04)
각 노드 배포 폴더에 `sht30/` 하위 폴더를 추가한다(기존 누수 파일/경로는 불변 =
`/home/pi/raspberry_pi4` 그대로). sht30 파일은 `raspberry_pi4_sht30/`에서 복사(코드 무변경).
노드별 `sht30/` 구성 파일:
- `sht30_sensor.py`, `sht30_driver.py`, `wire.py` — 소스에서 복사(무변경)
- `config.py` — 소스에서 복사(무변경; SENSOR_ID 기본값은 config_local에서 오버라이드)
- `config_local.example.py` — 템플릿
- `config_local.py` — 노드별 실값:
- Pi-A(02): `SENSOR_ID=1`, `SENSOR_NAME`=누수와 동일, `DEVICE_ID="rpi4-a-sht30"`,
`DEVICE_LOCATION`=Pi-A 누수 노드와 **동일 문자열**, API_URL/API_KEY=서버와 동일
- Pi-C(04): `SENSOR_ID=3`, 나머지 동일 패턴(`DEVICE_ID="rpi4-c-sht30"`)
- `requirements.txt` — `smbus2` 포함(소스와 동일)
- `systemd/sht30-sensor.service` — `WorkingDirectory=/home/pi/sht30`,
`ExecStart=/home/pi/sht30/venv/bin/python sht30_sensor.py`, `SupplementaryGroups=i2c`
- `test_sensor.py`, `test_connection.py` — 소스에서 복사
- `vendor.sh` — 오프라인 번들(smbus2 휠 + i2c-tools deb)
각 누수 노드 폴더의 설치/문서 갱신:
- `install.sh` — 기존 누수 설치에 더해 **sht30 서비스 설치 단계 추가**:
- `sht30/`를 `/home/pi/sht30`로 설치(venv 생성, requirements 설치, 로그파일
`/var/log/sht30_sensor.log` 생성, config_local 권한 600)
- **I2C 활성화**: `sudo raspi-config nonint do_i2c 0` (오프라인 가능). 실패 시
`/boot/firmware/config.txt`(Bookworm) 또는 `/boot/config.txt`에 `dtparam=i2c_arm=on`
보장. i2c 그룹에 SERVICE_USER 추가.
- `sht30-sensor.service` 설치 + enable + start
- 폐쇄망 규칙 준수: 네트워크 미사용, 번들 없으면 명확히 FLAG(기존 install.sh 패턴 유지)
- `WIRING.html` — 기존 누수 배선에 **SHT30 I2C 배선 추가**:
VCC→3.3V(핀1), SDA→GPIO2(핀3), SCL→GPIO3(핀5), GND→핀6/9, ADDR→GND=0x44.
누수 GPIO17(핀11)과 무충돌 명시. "반드시 3.3V, 5V 금지" 경고 포함.
- `SETUP.md` / `README.md` — 이중 서비스(누수+온습도) 설치·확인 절차, 동일 sensor_id 설명.
### C. 소스 트리 (참고 — 정합성)
`raspberry_pi4_sht30/`(sht30 소스)와 `raspberry_pi4/`(누수 소스)는 코드 변경 없음.
`php/config.local.example.php`만 소스에서 갱신(위 A-1). deploy가 1차 산출물이므로
deploy 폴더를 정본으로 반영하고, 소스 예시 파일은 동기화한다.
## 에러 처리 / 엣지
- **라벨 플랩:** 두 서비스가 같은 sensor_id로 sensor_name/device_location upsert →
마지막 쓰기가 이김. 노드별로 두 config의 device_location을 동일 문자열로 맞춰 방지.
(sensor_name은 서버 표시에 영향 최소지만 역시 일치 권장.)
- **오프라인 타임아웃:** A-1/A-2의 `SENSOR_HEARTBEAT_TIMEOUT_SEC` 오버라이드로 1·3의
누수 오프라인 감지 속도 유지(온습도 목록 편입에 의한 완화 방지).
- **is_leak 보존:** sht30 periodic(비-누수 이벤트)은 서버 upsert에서 is_leak 보존(기존
`IF(:is_leak_event, VALUES(is_leak), is_leak)` 로직) → 온습도 보고가 누수 상태를 덮지 않음.
- **SHT30 통신 실패:** `sht30_sensor.py`가 크래시 없이 재시도(기존 동작). 누수 서비스와
독립 프로세스라 상호 영향 없음.
- **I2C 미활성:** smbus2/I2C 불가 시 sht30는 시뮬레이션 폴백(기존). install.sh가 I2C를
활성화하고, 미활성 시 로그로 안내.
## 테스트
- **서버(PHP 단위/통합):**
- recipients: sensor_id 1·3의 온습도 경보가 SENSOR_RECIPIENTS[1]/[3]로 발송되는지.
- heartbeat: `heartbeat_timeout_for(1)`/(3)이 오버라이드로 누수 타임아웃(1800s)을
반환(metric 7500s 아님)하는지 — `resolve_heartbeat_timeout` 단위 테스트 케이스 추가.
- 통합: sensor_id=1 metric POST(periodic) → leak_sensor_metric 저장 + is_leak 보존 +
임계 초과 시 SMS 경로.
- 대시보드: LEAK∩METRIC(=1,3)에서 카드 렌더 정상(중복 집합) 스모크.
- **노드:**
- `sht30/test_sensor.py`: I2C 판독(또는 시뮬).
- `sht30/test_connection.py`: SENSOR_ID=1/3로 서버 POST 200 + 서명 검증.
- **오프라인 설치:** 네트워크 차단 상태에서 install.sh가 두 서비스 + I2C 활성화까지
무network 완료되는지(폐쇄망 검증).
## 비목표 (YAGNI)
- Pi-B(sensor_id 2) 재번호/변경 없음.
- 누수 라인 100nF 콘덴서/하드웨어 필터 — 별개 이슈(이번 범위 밖).
- 센서별 경보 on/off 토글 등 신규 서버 기능 없음(전역 임계 그대로).
- 누수 4~10분 복구 지연(리포트 굶주림) 수정 — 별개 이슈(이번 범위 밖).

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

@ -1,8 +1,13 @@
# SHT30 온습도 센서 ↔ STM32F407VGT6 I2C 배선도
# SHT30 온습도 센서 I2C 배선도 (Raspberry Pi 4 기본 / STM32 변형)
Sensirion SHT30(한진데이터 P4422-3 모듈)을 STM32F407VGT6의 I2C1 버스에 연결합니다. 단일 보드 `sht30_fw`(`sensor_id=2`, `device_id=stm32-sht30-01`)가 5분 주기로 온습도를 측정해 유선 Ethernet/TLS로 서버에 보고합니다.
Sensirion SHT30(한진데이터 P4422-3 모듈) I2C 배선 요약입니다.
상세 핀맵·전원·클럭원은 [firmware/docs/HARDWARE.md](../firmware/docs/HARDWARE.md) §3을 따릅니다. 본 문서는 SHT30 I2C 결선 요약입니다.
> **현재 배포 = Raspberry Pi 4 Model B.** Pi 에서는 SHT30 을 40핀 헤더 I2C 버스 1에 연결합니다:
> **VCC→3.3V(핀 1), GND→핀 6, SDA→GPIO2/핀 3, SCL→GPIO3/핀 5, ADDR→GND(0x44)**, SDA/SCL 4.7kΩ 풀업→3.3V(모듈 내장 시 생략).
> 상세 도해는 `deploy/03_pi_b_sht30_id2/WIRING.html`(SVG 핀맵) 및 [SHT30_SENSOR2_SETUP.md](./SHT30_SENSOR2_SETUP.md) §2.1 을 보세요.
> `sensor_id=2`, `device_id=rpi4-sht30-01`, `raspberry_pi4_sht30/config.py REPORT_INTERVAL=300`(5분).
>
> **아래 §는 STM32F407VGT6 변형**(마이그레이션 트랙 `firmware/`, 하드웨어 미검증·현재 배포와 무관)의 I2C1(PB6/PB7) 배선입니다. 상세 핀맵·전원·클럭원은 [firmware/docs/HARDWARE.md](../firmware/docs/HARDWARE.md) §3.
## 센서 동작 개요

File diff suppressed because one or more lines are too long

View file

@ -21,7 +21,10 @@
</IfModule>
# ─── 민감 PHP 파일 직접 접근 차단 ─────────────────────────
<FilesMatch "^(config|config\.local|config\.local\.example|sms_send|incidents|ops_checks|setup_hash|test_mobile)\.php$">
# (dashboard_data·forecast·live·trend·history·system·monthly_report_data·
# monthly_report_print·report_charts 는 include 전용 — tabs/.htaccess 와 이중 차단.
# monthly_report.php 본체는 진입 페이지이므로 차단 대상 아님)
<FilesMatch "^(config|config\.local|config\.local\.example|sms_send|incidents|ops_checks|setup_hash|test_mobile|dashboard_data|forecast|live|trend|history|system|monthly_report_data|monthly_report_print|report_charts)\.php$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>

View file

@ -5,7 +5,7 @@
//
// [요청 형식 — 온습도(SHT30, 2번 모듈)] POST application/json
// {
// "device_id": "stm32-sht30-01",
// "device_id": "rpi4-sht30-01", // 배포=Raspberry Pi 4 (STM32 펌웨어면 "stm32-sht30-01")
// "device_location": "서버실",
// "sensor_id": 2,
// "sensor_name": "2번 센서 (SHT30)",
@ -15,7 +15,7 @@
// "temperature_c": 24.50,
// "humidity_percent": 48.30,
// "metric_status": "normal", // 펌웨어 plausibility 판정(참고용; 서버가 재판정)
// "app_version": "v2606-sht30"
// "app_version": "v2606-sht30-rpi4" // Pi 배포 (STM32 펌웨어면 "v2606-sht30"); 서버는 검증 안 함
// }
//
// [요청 형식 — 누수(1번 모듈)] POST application/json
@ -178,7 +178,9 @@ if ($has_raw_sig) {
}
if (!$sig_ok) {
error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?') . " (mode={$sig_mode})");
record_ingest_rejection('signature');
// 출처 식별: 서명모드(raw=키불일치 / none=헤더스트립)와 본문 device_id(신뢰불가)를 함께
// 링에 남긴다 → 대시보드에서 '우리 노드 오설정 vs 외부 스캐너' 판별.
record_ingest_rejection('signature', ['mode' => $sig_mode, 'device_id' => (string)($data['device_id'] ?? '')]);
json_response(['status' => 'error', 'message' => '인증 실패'], 403);
}
@ -240,13 +242,25 @@ if ($humidity_percent !== null && ($humidity_percent < 0 || $humidity_percent >
$humidity_percent = null;
}
$has_metric_payload = ($metric_type !== '') && ($temperature_c !== null || $humidity_percent !== null);
// PMS7003 미세먼지(µg/m³ 대기환산 정수). 누수 노드에 온습도와 동일 sensor_id 로 통합되며
// metric_type='pms7003' 로 구분된다(온습도 행에서는 NULL, PM 행에서는 온습도가 NULL).
// 물리 범위(0..1000)를 벗어난 값은 결측(null)으로 처리한다(온도와 동일한 방어).
$pm1_0 = isset($data['pm1_0']) && is_numeric($data['pm1_0']) ? (int)$data['pm1_0'] : null;
$pm2_5 = isset($data['pm2_5']) && is_numeric($data['pm2_5']) ? (int)$data['pm2_5'] : null;
$pm10 = isset($data['pm10']) && is_numeric($data['pm10']) ? (int)$data['pm10'] : null;
if ($pm1_0 !== null && ($pm1_0 < 0 || $pm1_0 > 1000)) { $pm1_0 = null; }
if ($pm2_5 !== null && ($pm2_5 < 0 || $pm2_5 > 1000)) { $pm2_5 = null; }
if ($pm10 !== null && ($pm10 < 0 || $pm10 > 1000)) { $pm10 = null; }
$has_metric_payload = ($metric_type !== '')
&& ($temperature_c !== null || $humidity_percent !== null
|| $pm1_0 !== null || $pm2_5 !== null || $pm10 !== null);
// ── 서버측 임계 판정 (config.php METRIC_*) ─────────────────
// 펌웨어 metric_status 는 물리 plausibility 판정이라 운영 경보로는 신뢰하지 않고,
// 서버가 운영 임계로 다시 판정하여 leak_sensor_metric.metric_status 에 저장한다.
$metric_eval = $has_metric_payload
? evaluate_metric_thresholds($temperature_c, $humidity_percent)
? evaluate_metric_thresholds($temperature_c, $humidity_percent, $pm2_5, $pm10)
: ['breaches' => [], 'status' => 'normal'];
$metric_status = $metric_eval['status'];
@ -264,6 +278,10 @@ $log_id = null;
try {
$db = get_db();
// 누수 디바운스 사용 여부(마스터 스위치 + 컬럼 존재). 컬럼 없는 구배포는 레거시
// 즉시 발송 경로로 graceful degrade. 아래 사고추적/누수 SMS 분기에서 참조.
$use_debounce = LEAK_DEBOUNCE_ENABLED && leak_status_has_debounce_cols($db);
// ── 재전송(replay) 멱등 선점 ──
// 인증·신선도를 통과한 서명을 nonce 로 원자적 기록한다. 같은 서명 본문이 시계오차
// 창 안에서 다시 오면(펌웨어 재시도/캡처 재생) 중복 저장·중복 SMS 없이 멱등 200.
@ -342,11 +360,13 @@ try {
$metric_stmt = $db->prepare("
INSERT INTO leak_sensor_metric
(device_id, device_location, sensor_id, sensor_name,
metric_type, temperature_c, humidity_percent, metric_status,
metric_type, temperature_c, humidity_percent,
pm1_0, pm2_5, pm10, metric_status,
measured_at, created_at)
VALUES
(:device_id, :device_location, :sensor_id, :sensor_name,
:metric_type, :temperature_c, :humidity_percent, :metric_status,
:metric_type, :temperature_c, :humidity_percent,
:pm1_0, :pm2_5, :pm10, :metric_status,
:measured_at, NOW())
");
$metric_stmt->execute([
@ -357,17 +377,22 @@ try {
':metric_type' => $metric_type,
':temperature_c' => $temperature_c,
':humidity_percent' => $humidity_percent,
':pm1_0' => $pm1_0,
':pm2_5' => $pm2_5,
':pm10' => $pm10,
':metric_status' => $metric_status,
':measured_at' => $sensor_dt,
]);
$metric_saved = true;
}
// ── 누수 사고(에피소드) 추적 (leak_incident) ──────────
// ── 누수 사고(에피소드) 추적 (leak_incident) — 레거시(디바운스 미사용) 경로 ──
// leak_alert: 미복구 사고가 없으면 새 사고를 연다(중복 leak_alert 는 새로 열지 않음).
// leak_recovery: 같은 센서의 가장 최근 미복구 사고를 닫는다(recovered_at/복구 log_id 채움).
// leak_incident 테이블이 없으면(누수 미통합 서버) graceful 하게 건너뛴다.
if ($is_leak_event && api_table_exists($db, 'leak_incident')) {
// 디바운스 사용 시엔 사고 open/close 를 '확정 엣지'(apply_leak_debounce)로 옮겨 처리하므로
// 여기서는 건너뛴다(raw 이벤트마다 사고가 열려 월간보고서가 부풀지 않게).
if (!$use_debounce && $is_leak_event && api_table_exists($db, 'leak_incident')) {
if ($event_type === 'leak_alert') {
$open = $db->prepare("
SELECT id FROM leak_incident
@ -447,6 +472,10 @@ if ($has_metric_payload) {
$value = condensation_margin_c($temperature_c, $humidity_percent); // 표시값=결로마진(℃)
} elseif (in_array($kind, ['high_temp', 'low_temp'], true)) {
$value = $temperature_c;
} elseif ($kind === 'high_pm25') {
$value = $pm2_5; // 표시값=PM2.5(µg/m³)
} elseif ($kind === 'high_pm10') {
$value = $pm10; // 표시값=PM10(µg/m³)
} else {
$value = $humidity_percent;
}
@ -454,8 +483,14 @@ if ($has_metric_payload) {
$sms_sent = true;
}
}
} elseif (metric_was_alerting($db, $sensor_id) && metric_is_recovered($temperature_c, $humidity_percent)) {
// 직전까지 경보 상태였고 이제 (히스테리시스 포함) 정상으로 복귀 → 복귀 SMS 1회
} elseif (($temperature_c !== null || $humidity_percent !== null)
&& metric_was_alerting($db, $sensor_id)
&& metric_is_recovered($temperature_c, $humidity_percent)) {
// 직전까지 경보 상태였고 이제 (히스테리시스 포함) 정상으로 복귀 → 복귀 SMS 1회.
// 복귀 판정/메시지는 온습도(SHT30)에 한정한다. PMS 보고(온습도 NULL)는 이 분기를
// 타면 안 된다 — 온습도 결측을 '정상 복귀'로 오인해 온습도 경보 중에도 거짓 복귀
// SMS 가 나가는 것을 막는다(같은 sensor_id 에 PMS·SHT30 이 별도 행으로 공존하므로).
// 미세먼지 경보에는 별도 복귀 SMS 가 없다(값이 임계 아래로 내려가면 재발송이 멈출 뿐).
$sms_sent = send_metric_recovery_sms(
$sensor_info,
(float)($temperature_c ?? 0.0),
@ -465,10 +500,17 @@ if ($has_metric_payload) {
}
// ── 누수 경보 / 복구 SMS (1번 모듈) ────────────────────────
// leak_alert: [누수감지] SMS(쿨다운). 누수 지속 중 주기 보고가 와도 쿨다운으로 1회.
// leak_recovery: 직전에 누수 경보가 있었을 때만 [누수복구] SMS 발송.
// sms_send.php 에 헬퍼가 없거나(미통합 배포) 함수 미정의면 graceful 하게 건너뛴다.
if ($is_leak_event) {
// [디바운스 경로] 감지/복구 전이를 확인창 동안 지속돼야 확정하고, 확정 엣지에서만 SMS 1회.
// 감지↔복구 채터링(플래핑)이 SMS 폭탄으로 번지는 것을 원천 차단한다. 누수 센서의 주기
// 보고(periodic, is_leak 포함)도 확정 타이머를 전진시키므로 이벤트/주기 모두에서 호출한다.
// [레거시 경로] 디바운스 컬럼이 없거나 비활성이면 종전대로 즉시 발송(쿨다운/복구 판정).
if ($use_debounce) {
if ($is_leak_event || in_array($sensor_id, LEAK_SENSOR_IDS, true)) {
if (apply_leak_debounce($db, $sensor_id, $is_leak, $sensor_info)) {
$sms_sent = true;
}
}
} elseif ($is_leak_event) {
if ($event_type === 'leak_alert' && function_exists('send_leak_alert_sms')) {
if (send_leak_alert_sms($sensor_info)) {
$sms_sent = true;

261
php/api/status_summary.php Normal file
View file

@ -0,0 +1,261 @@
<?php
// =============================================================================
// api/status_summary.php — 미니 상태 표시기(Pi Zero 2 W + ST7789V)용 읽기 전용 요약 JSON
//
// 인증: X-Status-Key 헤더 = hash_hmac('sha256', 'status', API_KEY)
// · cron_heartbeat.php 의 CRON_SECRET 과 동일한 파생 방식(별도 시크릿 배포 불필요,
// 표시기는 API_KEY 로 로컬 계산). 헤더 방식이라 access log 에 키가 남지 않는다.
// · 기본 API_KEY 미교체 상태면 파생키도 예측 가능 → 접근 자체를 차단(fail closed).
// 쓰기 없음(SELECT 전용) · 세션/쿠키 없음 · GET 전용.
//
// 응답(요약): { ts, system:{state,online,total,unrecovered,sms_balance,today_alerts},
// sensors:[{id,name,location,leak_node,metric_node,pm_node,offline,leak,pending,
// th_status,metric_status(=th_status 하위호환 별칭),pm_status,
// temp,hum,pm25,pm10,pm1_0,last_seen,history,pm_history,leak_events}] }
// · state 어휘(우선순위): leak > pm(미세먼지) > alert(온습도) > offline > ok
// · 온습도/미세먼지는 같은 sensor_id 아래 metric_type('sht30' vs 'pms7003')로 분리 —
// temp/hum/th_status 는 sht30 행에서만, pm25/pm10/pm1_0/pm_status 는 pms7003 행에서만.
// =============================================================================
require_once __DIR__ . '/../config.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'GET') {
http_response_code(405);
echo json_encode(['error' => 'method_not_allowed']);
exit;
}
// ── 레이트 리밋 (IP별 고정창 60초/30회 — 표시기 정상 폴링은 분당 2회) ─────────
// login.php 와 동일하게 php/var JSON + LOCK_EX. 파일 오류 시엔 차단하지 않는다
// (모니터링 표시기가 스로틀 저장소 문제로 죽는 것보다 서비스 지속이 우선).
$rl_file = __DIR__ . '/../var/status_rate.json';
$rl_ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$rl_now = time();
$rl_win = $rl_now - ($rl_now % 60);
$rl = @json_decode((string)@file_get_contents($rl_file), true);
$rl = is_array($rl) ? $rl : [];
$rec = $rl[$rl_ip] ?? ['n' => 0, 'win' => $rl_win];
if (($rec['win'] ?? 0) !== $rl_win) { $rec = ['n' => 0, 'win' => $rl_win]; }
$rec['n']++;
// 창이 바뀐 다른 IP 기록은 청소(파일 무한 성장 방지)
$rl = array_filter($rl, fn($r) => ($r['win'] ?? 0) === $rl_win);
$rl[$rl_ip] = $rec;
@file_put_contents($rl_file, json_encode($rl), LOCK_EX);
if ($rec['n'] > 30) {
http_response_code(429);
header('Retry-After: ' . (60 - ($rl_now % 60)));
echo json_encode(['error' => 'rate_limited']);
exit;
}
// ── 인증 (fail closed) ────────────────────────────────────────────────────────
if (!defined('API_KEY') || API_KEY === '' || API_KEY === 'change-this-api-key') {
http_response_code(503);
echo json_encode(['error' => 'server_not_configured']);
exit;
}
$status_secret = hash_hmac('sha256', 'status', API_KEY);
$given = (string)($_SERVER['HTTP_X_STATUS_KEY'] ?? '');
if ($given === '' || !hash_equals($status_secret, $given)) {
http_response_code(403);
echo json_encode(['error' => 'forbidden']);
exit;
}
// ── 데이터 수집 (대시보드 판정 규칙과 동일 기준) ─────────────────────────────
// DB 예외는 전부 잡아 generic 500 으로 — display_errors=On 호스팅에서도 내부정보 비노출.
try {
$db = get_db();
$status_rows = $db->query("SELECT * FROM leak_sensor_status ORDER BY sensor_id")->fetchAll();
$sensor_ids = array_map(fn($r) => (int)$r['sensor_id'], $status_rows);
// 최신 측정을 (sensor_id, metric_type) 단위로 가져온다 — 누수 노드(1·3)는 온습도(sht30)와
// 미세먼지(pms7003)를 같은 sensor_id 로 보고하므로, metric_type 무시하고 MAX(id) 로 뽑으면
// 두 행이 서로를 덮어써(sht30 행은 pm NULL, pms 행은 temp/hum NULL) 한쪽이 사라진다.
// → metric_type 별 MAX(id) 로 뽑아 sht30/pms 버킷에 각각 담는다. 레거시 metric_type='' 은 sht30.
$latest_sht30 = []; // sensor_id => 최신 sht30(온습도) 행
$latest_pms = []; // sensor_id => 최신 pms7003(미세먼지) 행
foreach ($db->query("
SELECT sm.*
FROM leak_sensor_metric sm
INNER JOIN (
SELECT sensor_id, metric_type, MAX(id) AS latest_id
FROM leak_sensor_metric GROUP BY sensor_id, metric_type
) latest ON latest.latest_id = sm.id
")->fetchAll() as $mr) {
$sid = (int)$mr['sensor_id'];
$mt = (string)($mr['metric_type'] ?? '');
if ($mt === 'pms7003') {
$latest_pms[$sid] = $mr;
} else {
// 'sht30' 또는 레거시 '' — 온습도로 취급. sht30·'' 가 둘 다 있으면 최신(id 큰) 것 유지.
if (!isset($latest_sht30[$sid]) || (int)$mr['id'] > (int)$latest_sht30[$sid]['id']) {
$latest_sht30[$sid] = $mr;
}
}
}
// ── 24시간 추세(시간당 평균, 최대 24포인트) — 센서별 쿼리로
// (sensor_id, measured_at) 복합 인덱스를 태워 풀스캔 방지(30초 폴링 엔드포인트) ──
$history = []; // sensor_id => [ ['t'=>'HH:MM','temp'=>float|null,'hum'=>float|null], ... ]
$pm_history = []; // sensor_id => [ ['t'=>'HH:MM','pm25'=>int], ... ] (pms7003 시간당 평균)
// AVG 는 컬럼별로 NULL 을 무시하므로, 같은 시간버킷에 온습도(sht30 행)·미세먼지(pms 행)가
// 섞여 있어도 서로를 오염시키지 않는다(sht30 행은 pm2_5=NULL, pms 행은 temp/hum=NULL).
// pm2_5 를 같은 쿼리에 얹어 추가 쿼리 없이 (sensor_id, measured_at) 인덱스를 그대로 태운다.
$hist_stmt = $db->prepare("
SELECT DATE_FORMAT(measured_at, '%Y-%m-%d %H:00:00') AS bucket,
AVG(temperature_c) AS temp,
AVG(humidity_percent) AS hum,
AVG(pm2_5) AS pm25
FROM leak_sensor_metric
WHERE sensor_id = :sid AND measured_at >= NOW() - INTERVAL 24 HOUR
GROUP BY bucket
ORDER BY bucket
LIMIT 25
");
foreach ($sensor_ids as $sid) {
$hist_stmt->execute([':sid' => $sid]);
foreach ($hist_stmt->fetchAll() as $h) {
$t = substr($h['bucket'], 11, 5); // HH:MM
$history[$sid][] = [
't' => $t,
'temp' => $h['temp'] !== null ? round((float)$h['temp'], 1) : null,
'hum' => $h['hum'] !== null ? round((float)$h['hum'], 0) : null,
];
if ($h['pm25'] !== null) { // 미세먼지 값이 있는 버킷만
$pm_history[$sid][] = ['t' => $t, 'pm25' => (int)round((float)$h['pm25'])];
}
}
}
// 누수 이벤트 타임라인 — 플래핑 센서가 응답을 무한정 키우지 않도록 상한(최신 우선)
$leak_events = []; // sensor_id => ['HH:MM', ...] — 최근 24h leak_alert 발생 시각
foreach ($db->query("
SELECT sensor_id, sensor_time
FROM leak_sensor_log
WHERE event_type = 'leak_alert' AND sensor_time >= NOW() - INTERVAL 24 HOUR
ORDER BY sensor_time DESC
LIMIT 200
")->fetchAll() as $e) {
$leak_events[(int)$e['sensor_id']][] = substr((string)$e['sensor_time'], 11, 5);
}
foreach ($leak_events as &$ev_list) { $ev_list = array_reverse($ev_list); } // 시간순 복원
unset($ev_list);
$now = time();
$sensors = [];
$offline_count = 0;
foreach ($status_rows as $s) {
$sid = (int)$s['sensor_id'];
$elapsed = $now - strtotime($s['last_seen'] ?? '2000-01-01');
$offline = ((int)($s['offline_alerted'] ?? 0) === 1) || ($elapsed >= heartbeat_timeout_for($sid));
if ($offline) { $offline_count++; }
// 누수 확정(디바운스) 기준 — 대시보드/SMS 와 동일. 컬럼 부재 구배포는 raw 폴백.
$raw_leak = (int)($s['is_leak'] ?? 0);
$leak = array_key_exists('leak_confirmed', $s) ? (int)$s['leak_confirmed'] : $raw_leak;
$pending = array_key_exists('leak_confirmed', $s)
&& (($s['leak_pending_since'] ?? null) !== null) && ($raw_leak !== $leak);
[$location, $name] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null);
// 최신 측정을 metric_type 별로 분리 — 온습도(sht30 행)와 미세먼지(pms 행)가 같은
// sensor_id 아래 서로를 덮어쓰지 않게 한다. temp/hum/th_status 는 sht30 행에서만,
// pm25/pm10/pm1_0/pm_status 는 pms 행에서만 취한다.
$sht = $latest_sht30[$sid] ?? null;
$pms = $latest_pms[$sid] ?? null;
// 온습도 대표 상태(결로 포함) — sht30 행의 저장 metric_status.
$th_status = $sht['metric_status'] ?? 'normal';
// 미세먼지 상태 — pms 행의 저장 metric_status(정의상 pms 행은 temp/hum NULL 이라
// high_pm25/high_pm10/normal 만 나온다). 계약 보장을 위해 PM 값만 허용, 그 외는 normal.
$pm_status_raw = $pms['metric_status'] ?? 'normal';
$pm_status = in_array($pm_status_raw, ['high_pm25', 'high_pm10'], true) ? $pm_status_raw : 'normal';
$sensors[] = [
'id' => $sid,
'name' => $name,
'location' => $location,
'leak_node' => in_array($sid, LEAK_SENSOR_IDS, true),
'metric_node' => in_array($sid, METRIC_SENSOR_IDS, true),
'pm_node' => in_array($sid, PM_SENSOR_IDS, true),
'offline' => $offline,
'leak' => $leak,
'pending' => $pending,
'th_status' => $th_status, // 온습도(+결로) 상태(sht30 행)
'metric_status' => $th_status, // 하위호환 별칭(= th_status)
'pm_status' => $pm_status, // 미세먼지 상태(normal|high_pm25|high_pm10)
'temp' => isset($sht['temperature_c']) && $sht['temperature_c'] !== null ? (float)$sht['temperature_c'] : null,
'hum' => isset($sht['humidity_percent']) && $sht['humidity_percent'] !== null ? (float)$sht['humidity_percent'] : null,
'pm25' => isset($pms['pm2_5']) && $pms['pm2_5'] !== null ? (int)$pms['pm2_5'] : null,
'pm10' => isset($pms['pm10']) && $pms['pm10'] !== null ? (int)$pms['pm10'] : null,
'pm1_0' => isset($pms['pm1_0']) && $pms['pm1_0'] !== null ? (int)$pms['pm1_0'] : null,
'last_seen' => $s['last_seen'] ?? null,
'history' => $history[$sid] ?? [], // 24h 시간당 추세(온/습도)
'pm_history' => $pm_history[$sid] ?? [], // 24h 시간당 추세(PM2.5)
'leak_events' => $leak_events[$sid] ?? [], // 24h 누수 이벤트 시각
];
}
$unrecovered = 0;
if (table_exists_api($db, 'leak_incident')) {
$unrecovered = (int)$db->query("SELECT COUNT(*) FROM leak_incident WHERE recovered_at IS NULL")->fetchColumn();
}
$today_alerts = (int)$db->query("
SELECT COUNT(*) FROM leak_sensor_metric
WHERE metric_status <> 'normal' AND measured_at >= CURDATE()
")->fetchColumn()
+ (int)$db->query("
SELECT COUNT(*) FROM leak_sensor_log
WHERE event_type = 'leak_alert' AND sensor_time >= CURDATE()
")->fetchColumn();
// 시스템 종합 상태 — 표시기 배경색 판정용. 5단 우선순위: 누수 > 미세먼지 > 온습도 > 오프라인 > 정상
// (leak > pm > alert(온습도 th) > offline > ok). 오프라인 센서의 경보는 신선하지 않으므로 제외.
$leak_active = count(array_filter($sensors, fn($x) => $x['leak'] === 1 && !$x['offline']));
$pm_alert = count(array_filter($sensors, fn($x) => $x['pm_status'] !== 'normal' && !$x['offline']));
$th_alert = count(array_filter($sensors, fn($x) => $x['th_status'] !== 'normal' && !$x['offline']));
if ($leak_active > 0 || $unrecovered > 0) { $state = 'leak'; }
elseif ($pm_alert > 0) { $state = 'pm'; }
elseif ($th_alert > 0) { $state = 'alert'; }
elseif ($offline_count > 0) { $state = 'offline'; }
else { $state = 'ok'; }
echo json_encode([
'ts' => date('Y-m-d H:i:s'),
'system' => [
'state' => $state,
'online' => count($sensors) - $offline_count,
'total' => count($sensors),
'unrecovered' => $unrecovered,
'sms_balance' => latest_sms_balance($db),
'today_alerts' => $today_alerts,
],
'sensors' => $sensors,
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
// 내부 오류 상세(쿼리/테이블명 등)는 응답에 싣지 않는다 — 로그로만.
error_log('[status_summary] ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'internal']);
exit;
}
// 로컬 헬퍼 — dashboard_data.php(관리 화면 전용)를 끌어오지 않기 위한 최소 복제.
function table_exists_api(PDO $db, string $table): bool {
try {
$st = $db->prepare("
SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = :t
");
$st->execute([':t' => $table]);
return (int)$st->fetchColumn() > 0;
} catch (Throwable $e) {
return false;
}
}

651
php/assets/app.css Normal file
View file

@ -0,0 +1,651 @@
/* =============================================================================
* app.css — 통합 모니터링 관리자 디자인 시스템 "Slate NOC Console" (Dark)
* 다크 우선 관제 콘솔. 초대형 tabular 수치가 히어로. 7개 페이지 공용.
* 상태 3중 인코딩(색+도형+텍스트) 유지. 인쇄는 강제 라이트.
* ============================================================================= */
:root {
--bg: #0f1533;
--surface: #191f42;
--surface-2: #232a55;
--raise: #2b3366;
--ink: #e9edff;
--ink-2: #98a2cf;
--ink-3: #6f7cae;
--line: #2b3364;
--line-2: #212850;
--primary: #6c7bff;
--primary-600: #8f9bff;
--on-primary: #0b1030;
--primary-tint: #1d2450;
--header-bg: #141a3d;
--header-ink: var(--ink);
--normal: #35d6a0; --normal-ink: #74ecc0; --normal-bg: rgba(53,214,160,.13);
--leak: #ff5470; --leak-ink: #ff90a5; --leak-bg: rgba(255,84,112,.14);
--warn: #ffb23e; --warn-ink: #ffcd7b; --warn-bg: rgba(255,178,62,.13);
--offline:#7885b8; --offline-ink:#9aa6d4; --offline-bg:rgba(120,133,184,.14);
--hot-ink: #ff9a7a; --hot-bg: rgba(255,120,90,.14);
--cold-ink: #5fd6ee; --cold-bg: rgba(63,199,230,.13);
--humid-ink: var(--warn-ink); --humid-bg: var(--warn-bg);
--dry-ink: #b0b8ff; --dry-bg: rgba(120,130,255,.14);
--chart-temp: #ff8a5c;
--chart-hum: #3fc7e6;
--chart-grid: var(--line);
--font: "Malgun Gothic","Apple SD Gothic Neo",system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans KR",sans-serif;
--mono: ui-monospace,SFMono-Regular,Consolas,"D2Coding",monospace;
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px; --sp-8: 32px;
--r-sm: 8px; --r: 12px; --r-lg: 18px; --r-pill: 999px;
/* 소프트·확산형 그림자(참고 대시보드처럼 카드가 표면 위에 살짝 떠 보이게) */
--shadow: 0 2px 10px rgba(0,0,0,.30);
--shadow-lg: 0 16px 40px rgba(0,0,0,.48);
--ring: 0 0 0 3px rgba(108,123,255,.45);
/* 장식 골드 액센트(참고 2.png 인디고+골드) — 상태색 --warn 과 별개의 장식 역할 */
--accent-gold: #ffc24b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body {
font-family: var(--font);
background:
radial-gradient(1200px 500px at 15% -8%, rgba(108,123,255,.10), transparent 60%),
radial-gradient(1000px 460px at 92% -4%, rgba(63,199,230,.07), transparent 60%),
var(--bg);
background-attachment: fixed;
color: var(--ink); line-height: 1.55; font-size: 1rem; -webkit-font-smoothing: antialiased;
}
.val, .metric-chip, .report-stat strong, td.num, .num, .status-clock,
.card .val, .quick-tile strong { font-variant-numeric: tabular-nums; }
a { color: var(--primary-600); }
:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: var(--r-sm); }
img { max-width: 100%; }
h1 { font-size: 1.375rem; font-weight: 800; line-height: 1.25; letter-spacing: -.01em; }
h2 { font-size: 1.0625rem; font-weight: 700; color: var(--ink); margin: var(--sp-6) 0 var(--sp-3); letter-spacing: .01em; }
.panel h2, .status-hero h2 { margin-top: 0; }
label, .label { font-size: .875rem; font-weight: 600; }
.caption, small { font-size: .8125rem; color: var(--ink-3); }
.container > h2 { display: flex; align-items: center; gap: var(--sp-3); }
.container > h2::before { content: ""; width: 3px; height: 1.05em; border-radius: 2px; background: linear-gradient(180deg, var(--primary), var(--chart-hum)); }
header {
background: var(--header-bg); color: var(--header-ink); padding: var(--sp-4) var(--sp-6);
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4);
border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20;
}
header h1 { font-size: 1.25rem; color: var(--header-ink); display: flex; align-items: center; gap: var(--sp-3); }
header h1::before { content: ""; width: 10px; height: 10px; border-radius: 50%; background: var(--normal); box-shadow: 0 0 0 4px rgba(53,214,160,.18); }
.top-actions { display: flex; gap: var(--sp-2); align-items: center; flex-wrap: wrap; }
.top-actions a { color: var(--ink-2); font-size: .8125rem; font-weight: 600; text-decoration: none; background: var(--surface-2); border: 1px solid var(--line); padding: 7px 14px; border-radius: var(--r-sm); }
.top-actions a:hover { background: var(--raise); color: var(--ink); border-color: var(--primary); }
.container { max-width: 1360px; margin: var(--sp-6) auto var(--sp-8); padding: 0 var(--sp-5); }
.ops-grid { display: grid; grid-template-columns: minmax(0,1.5fr) minmax(300px,.9fr); gap: var(--sp-4); align-items: start; margin-bottom: var(--sp-6); }
.cards { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: var(--sp-4); margin-bottom: var(--sp-4); }
.cards.flush { margin-bottom: var(--sp-6); }
.sensor-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px,1fr)); gap: var(--sp-4); margin-bottom: var(--sp-6); }
.panel, .card, .chart-panel, .leak-status-card, .empty-note { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); }
.panel { padding: var(--sp-5); }
.chart-panel { padding: var(--sp-5); margin-bottom: var(--sp-6); }
.card { padding: var(--sp-5) var(--sp-5) var(--sp-6); position: relative; }
.card > div:first-child { color: var(--ink-2); font-size: .78rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }
.card span { color: var(--ink-2); font-size: .8125rem; }
.card .val { font-size: clamp(2.4rem, 4.2vw, 3.4rem); font-weight: 800; line-height: 1.02; margin-top: var(--sp-2); color: var(--ink); letter-spacing: -.02em; }
.card .val.compact { font-size: clamp(1.6rem, 2.6vw, 2rem); }
.card .val.clock { font-size: clamp(1.1rem, 2vw, 1.45rem); font-weight: 700; color: var(--ink-2); letter-spacing: 0; }
.card .val.ok-color { color: var(--normal-ink); }
.card .val.err-color { color: var(--leak-ink); }
.card .val.warn-color { color: var(--warn-ink); }
/* 상태색은 .val 색상으로 인코딩(상단 스트립 제거 — 참고 대시보드 스타일의 평평한 카드) */
.card.ok, .card:has(.val.ok-color) { border-color: rgba(53,214,160,.32); }
.card.err, .card:has(.val.err-color) { border-color: rgba(255,84,112,.34); }
.card.warn, .card:has(.val.warn-color) { border-color: rgba(255,178,62,.32); }
.card .val.ok-color::before, .card .val.err-color::before, .card .val.warn-color::before { content: none; }
.status-hero { border-radius: var(--r-lg); padding: var(--sp-6); margin-bottom: var(--sp-5); display: grid; grid-template-columns: minmax(0,1fr) auto; gap: var(--sp-4); align-items: center; color: var(--ink); background: linear-gradient(180deg, rgba(53,214,160,.08), transparent 70%), var(--surface); border: 1px solid var(--line); position: relative; overflow: hidden; }
.status-hero::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: linear-gradient(90deg, var(--normal), var(--chart-hum)); }
.status-hero h2 { font-size: 1.4rem; font-weight: 800; display: flex; align-items: center; gap: var(--sp-3); }
.status-hero h2::before { content: ""; width: .78em; height: .78em; border-radius: 50%; background: var(--normal); flex: none; box-shadow: 0 0 0 5px rgba(53,214,160,.16); }
.status-hero p { color: var(--ink-2); font-size: .95rem; margin-top: 4px; }
.status-hero.warning { background: linear-gradient(180deg, rgba(255,178,62,.09), transparent 70%), var(--surface); }
.status-hero.danger { background: linear-gradient(180deg, rgba(255,84,112,.12), transparent 72%), var(--surface); }
.status-hero.warning::before { background: var(--warn); }
.status-hero.danger::before { background: var(--leak); }
.status-hero.warning h2::before { background: var(--warn); box-shadow: 0 0 0 5px rgba(255,178,62,.16); }
.status-hero.danger h2::before { background: var(--leak); box-shadow: 0 0 0 5px rgba(255,84,112,.18); animation: hero-pulse 1.6s ease-in-out infinite; }
@keyframes hero-pulse { 0%,100% { box-shadow: 0 0 0 4px rgba(255,84,112,.10); } 50% { box-shadow: 0 0 0 8px rgba(255,84,112,.24); } }
@media (prefers-reduced-motion: reduce) { .status-hero.danger h2::before { animation: none; } }
.status-clock { text-align: right; font-size: .8125rem; color: var(--ink-3); white-space: nowrap; font-variant-numeric: tabular-nums; }
.sensor-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); padding: var(--sp-5); box-shadow: var(--shadow); transition: transform .12s ease, border-color .12s ease; }
.sensor-card:hover { transform: translateY(-2px); border-color: var(--raise); }
.sensor-card strong { font-size: 1rem; font-weight: 700; }
.sensor-card small { color: var(--ink-3); }
.sensor-card.alert { border-color: var(--leak); background: linear-gradient(180deg, var(--leak-bg), transparent 60%), var(--surface); }
.sensor-card.offline { border-color: var(--offline); background: linear-gradient(180deg, var(--offline-bg), transparent 60%), var(--surface); }
.sensor-card.pending { border-color: var(--warn); background: linear-gradient(180deg, var(--warn-bg), transparent 60%), var(--surface); }
.badge-row { margin-top: var(--sp-3); }
.badge, .pill { display: inline-flex; align-items: center; gap: .4em; border-radius: var(--r-pill); padding: 4px 11px; font-size: .75rem; font-weight: 700; line-height: 1.4; background: var(--normal-bg); color: var(--normal-ink); border: 1px solid transparent; }
.badge::before, .pill::before { content: ""; width: .5em; height: .5em; border-radius: 50%; background: currentColor; flex: none; }
.badge.ok, .pill.ok { background: var(--normal-bg); color: var(--normal-ink); }
.badge.alert,.pill.alert { background: var(--leak-bg); color: var(--leak-ink); }
.badge.offline,.pill.offline{ background: var(--offline-bg); color: var(--offline-ink); }
.badge.pending,.pill.pending{ background: var(--warn-bg); color: var(--warn-ink); }
.pill.high_temp { background: var(--hot-bg); color: var(--hot-ink); }
.pill.low_temp { background: var(--cold-bg); color: var(--cold-ink); }
.pill.high_humidity{ background: var(--humid-bg); color: var(--humid-ink); }
.pill.low_humidity { background: var(--dry-bg); color: var(--dry-ink); }
.pill.leak, .pill.leak_alert { background: var(--leak-bg); color: var(--leak-ink); }
.pill.leak_recovery { background: var(--normal-bg); color: var(--normal-ink); }
.metric-line { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-3); }
.metric-chip { display: inline-flex; align-items: center; border-radius: var(--r-pill); background: var(--surface-2); color: var(--ink-2); padding: 5px 12px; font-size: .82rem; font-weight: 700; }
.metric-chip.hot { background: var(--hot-bg); color: var(--hot-ink); }
.metric-chip.cold { background: var(--cold-bg); color: var(--cold-ink); }
.metric-chip.humid { background: var(--humid-bg); color: var(--humid-ink); }
.metric-chip.dry { background: var(--dry-bg); color: var(--dry-ink); }
.metric-time { color: var(--ink-3); font-size: .75rem; margin-top: var(--sp-2); font-variant-numeric: tabular-nums; }
.metric-time.stale { color: var(--offline-ink); }
.diag-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); margin: 0 0 var(--sp-3); }
.diag-head h2 { margin: 0; }
.diag-pill { font-size: .75rem; font-weight: 700; padding: 4px 11px; border-radius: var(--r-pill); white-space: nowrap; }
.diag-pill.ok { background: var(--normal-bg); color: var(--normal-ink); }
.diag-pill.warn { background: var(--warn-bg); color: var(--warn-ink); }
.diag-pill.crit { background: var(--leak-bg); color: var(--leak-ink); }
.diag-counts { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-3); }
.dc { flex: 1; text-align: center; font-size: .72rem; color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r); padding: 8px 4px; }
.dc b { display: block; font-size: 1.3rem; font-weight: 800; color: var(--ink); line-height: 1.2; margin-bottom: 1px; font-variant-numeric: tabular-nums; }
.dc.ok b { color: var(--normal-ink); }
.dc.warn b { color: var(--warn-ink); }
.dc.crit b { color: var(--leak-ink); }
.diag-issues { list-style: none; margin: 0 0 var(--sp-2); padding: 0; display: grid; gap: var(--sp-2); }
.diag-issue { display: grid; grid-template-columns: auto minmax(0,1fr); gap: var(--sp-2); align-items: start; border: 1px solid var(--line); border-radius: var(--r); padding: 9px 11px; background: var(--surface-2); }
.diag-issue.critical { background: var(--leak-bg); border-color: rgba(255,84,112,.3); }
.diag-issue.warn { background: var(--warn-bg); border-color: rgba(255,178,62,.28); }
.diag-dot { width: 9px; height: 9px; border-radius: 50%; margin-top: 5px; background: var(--warn); flex: none; }
.diag-issue.critical .diag-dot { background: var(--leak); }
.diag-issue strong { display: block; font-size: .8125rem; color: var(--ink); }
.diag-issue span { color: var(--ink-2); font-size: .78rem; line-height: 1.4; }
.diag-ok { border-top: 1px solid var(--line); margin-top: var(--sp-1); padding-top: var(--sp-2); }
.diag-ok > summary { cursor: pointer; font-size: .8125rem; color: var(--ink-2); font-weight: 600; list-style: none; display: flex; align-items: center; gap: 6px; padding: 2px 0; }
.diag-ok > summary::-webkit-details-marker { display: none; }
.diag-ok > summary::before { content: "\25B8"; color: var(--ink-3); font-size: .7rem; }
.diag-ok[open] > summary::before { content: "\25BE"; }
.diag-ok-grid { list-style: none; margin: var(--sp-2) 0 0; padding: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 7px 12px; }
.diag-ok-grid li { display: flex; align-items: center; gap: 6px; font-size: .75rem; color: var(--ink-2); min-width: 0; }
.diag-ok-grid li span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.diag-ok-grid i { width: 7px; height: 7px; border-radius: 50%; background: var(--normal); flex: none; }
.panel-note { color: var(--ink-3); font-size: .78rem; margin-top: var(--sp-3); line-height: 1.5; }
.report-grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: var(--sp-3); }
.report-stat { border: 1px solid var(--line); border-radius: var(--r); padding: var(--sp-4); background: var(--surface-2); }
.report-stat span { display: block; color: var(--ink-2); font-size: .75rem; margin-bottom: 4px; }
.report-stat strong { font-size: 1.35rem; font-weight: 800; }
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; margin-bottom: var(--sp-6); border-radius: var(--r-lg); }
table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; }
.table-wrap table { margin-bottom: 0; }
th { background: var(--surface-2); font-size: .8125rem; font-weight: 700; color: var(--ink-2); padding: 11px 14px; text-align: left; border-bottom: 1px solid var(--line); white-space: nowrap; }
td { padding: 10px 14px; border-bottom: 1px solid var(--line-2); font-size: .875rem; color: var(--ink); }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: var(--surface-2); }
.alert-row td { background: var(--leak-bg); }
.alert-row:hover td { background: var(--leak-bg); }
.ok-color, .err-color, .warn-color { font-weight: 700; }
.ok-color { color: var(--normal-ink); }
.err-color { color: var(--leak-ink); }
.warn-color{ color: var(--warn-ink); }
.check.ok, .check-ok { color: var(--normal-ink); font-weight: 700; }
.check.fail, .check-fail { color: var(--leak-ink); font-weight: 700; }
.check.ok::before, .check-ok::before { content: "\2713\00a0"; }
.check.fail::before, .check-fail::before { content: "\2715\00a0"; }
.chart-legend { display: flex; gap: var(--sp-4); flex-wrap: wrap; margin: var(--sp-2) 0 var(--sp-3); font-size: .8125rem; color: var(--ink-2); }
.chart-legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend-swatch { width: 16px; height: 4px; border-radius: 2px; display: inline-block; }
.legend-swatch.temp { background: var(--chart-temp); }
.legend-swatch.hum { background: var(--chart-hum); }
.chart-svg-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.chart-empty, .empty-note { color: var(--ink-3); padding: var(--sp-6); text-align: center; }
.empty-note { padding: var(--sp-5); }
.leak-status-card { padding: var(--sp-5); transition: transform .12s ease, border-color .12s ease; }
.leak-status-card:hover { transform: translateY(-2px); border-color: var(--raise); }
.leak-status-card.leak { border-color: var(--leak); background: linear-gradient(180deg, var(--leak-bg), transparent 60%), var(--surface); }
.leak-status-card.offline { border-color: var(--offline); background: linear-gradient(180deg, var(--offline-bg), transparent 60%), var(--surface); }
.leak-thumbs { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.leak-thumbs a { display: inline-block; line-height: 0; }
.leak-thumbs img { width: 64px; height: 64px; object-fit: cover; border-radius: var(--r-sm); border: 1px solid var(--line); background: var(--surface-2); }
.mobile-quick { display: none; }
.quick-grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: var(--sp-2); margin-bottom: var(--sp-3); }
.quick-tile { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r); padding: 12px; text-decoration: none; color: var(--ink); min-height: 74px; }
.quick-tile span { display: block; color: var(--ink-2); font-size: .75rem; margin-bottom: 5px; }
.quick-tile strong { font-size: 1.5rem; font-weight: 800; line-height: 1; }
.quick-tile.danger { border-color: var(--leak); background: var(--leak-bg); }
.quick-tile.warn { border-color: var(--warn); background: var(--warn-bg); }
.quick-tile.ok { border-color: var(--normal); background: var(--normal-bg); }
.quick-links { display: flex; gap: var(--sp-2); overflow-x: auto; -webkit-overflow-scrolling: touch; padding-bottom: 6px; margin-bottom: var(--sp-4); }
.quick-links a { flex: 0 0 auto; color: var(--ink); background: var(--surface-2); border: 1px solid var(--line); text-decoration: none; border-radius: var(--r-pill); padding: 8px 13px; font-size: .8125rem; font-weight: 700; }
.mobile-detail { margin-bottom: var(--sp-6); }
.mobile-detail summary { display: none; }
.refresh { float: right; font-size: .8125rem; color: var(--ink-2); font-weight: 400; }
.refresh-toggle { background: var(--surface-2); color: var(--ink-2); border: 1px solid var(--line); border-radius: var(--r-pill); padding: 3px 10px; font-size: .75rem; cursor: pointer; margin-left: 8px; }
.forecast { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-3); padding-top: var(--sp-3); border-top: 1px solid var(--line-2); }
.forecast-chip { display: inline-flex; align-items: center; gap: .35em; border-radius: var(--r-pill); padding: 3px 10px; font-size: .75rem; font-weight: 700; line-height: 1.5; background: var(--warn-bg); color: var(--warn-ink); }
.forecast-chip::before { content: ""; width: .45em; height: .45em; border-radius: 50%; background: currentColor; flex: none; }
.forecast-chip.near { background: var(--warn-bg); color: var(--warn-ink); }
.forecast-chip.over { background: var(--leak-bg); color: var(--leak-ink); }
.forecast-note { font-size: .75rem; color: var(--ink-3); }
.response-panel { background: linear-gradient(180deg, var(--leak-bg), transparent 60%), var(--surface); border: 1px solid var(--leak); border-radius: var(--r-lg); padding: var(--sp-5); margin-bottom: var(--sp-5); }
.response-panel h2 { margin: 0 0 var(--sp-3); display: flex; align-items: center; gap: var(--sp-2); }
.response-count { font-size: .8125rem; font-weight: 700; color: var(--leak-ink); background: var(--leak-bg); border-radius: var(--r-pill); padding: 3px 11px; }
.response-item { border-top: 1px solid var(--line-2); padding: var(--sp-3) 0; }
.response-item:first-of-type { border-top: none; padding-top: 0; }
.response-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-2); }
.response-head strong { font-size: .95rem; }
.response-detail { color: var(--ink-2); font-size: .875rem; }
.playbook { margin-top: var(--sp-2); background: var(--surface-2); border-radius: var(--r-sm); padding: var(--sp-3) var(--sp-4); }
.playbook-title { font-size: .8125rem; font-weight: 700; color: var(--ink-2); margin-bottom: 4px; }
.playbook-steps { margin: 0; padding-left: 1.4em; }
.playbook-steps li { font-size: .875rem; line-height: 1.7; }
.response-contacts { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); margin-top: var(--sp-4); padding-top: var(--sp-3); border-top: 1px solid var(--line-2); }
.contacts-label { font-size: .8125rem; font-weight: 700; color: var(--ink-2); }
.contact { font-size: .875rem; }
.contact a { font-weight: 700; }
.login-box { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); padding: var(--sp-8) var(--sp-6); box-shadow: var(--shadow-lg); width: 100%; max-width: 400px; position: relative; overflow: hidden; }
.login-box::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: linear-gradient(90deg, var(--primary), var(--chart-hum)); }
.login-box h1 { color: var(--ink); margin-bottom: var(--sp-2); text-align: center; }
.login-box p.sub { font-size: .875rem; color: var(--ink-2); text-align: center; margin-bottom: var(--sp-6); }
.form-group { margin-bottom: var(--sp-4); }
.form-group label { display: block; color: var(--ink-2); margin-bottom: 6px; }
.form-group input, input[type=text], input[type=password], textarea { width: 100%; padding: 11px 14px; border: 1px solid var(--line); border-radius: var(--r); font-size: .95rem; font-family: inherit; color: var(--ink); background: var(--surface-2); outline: none; }
.form-group input:focus, input:focus, textarea:focus { border-color: var(--primary); box-shadow: var(--ring); }
@media (forced-colors: active) { input:focus, textarea:focus { outline: 2px solid; outline-offset: 1px; } }
.btn { display: inline-block; width: 100%; padding: 12px; background: var(--primary); color: var(--on-primary); border: none; border-radius: var(--r); font-size: 1rem; font-weight: 800; cursor: pointer; text-align: center; }
.btn:hover { background: var(--primary-600); }
.btn:disabled { background: var(--offline); cursor: not-allowed; opacity: .6; }
.btn.secondary { background: var(--surface-2); color: var(--ink); border: 1px solid var(--line); }
.error, .expired, .banner-ok { padding: 11px 14px; border-radius: var(--r); font-size: .875rem; margin-bottom: var(--sp-4); border: 1px solid var(--line); border-left-width: 4px; }
.error { background: var(--leak-bg); color: var(--leak-ink); border-left-color: var(--leak); }
.expired { background: var(--warn-bg); color: var(--warn-ink); border-left-color: var(--warn); }
.banner-ok { background: var(--normal-bg); color: var(--normal-ink); border-left-color: var(--normal); }
.secret, code, .mono { font-family: var(--mono); background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r-sm); padding: 2px 6px; word-break: break-all; color: var(--ink); }
.secret { display: block; padding: 12px; font-size: 1.05rem; letter-spacing: .06em; }
/* ===== 개요 벤토(존) — 관련 위젯 그룹핑으로 섹션 관계·위치 시각화 (샘플 콘텐츠 반영) ===== */
.zone { margin-top: var(--sp-8); }
.zone-label { display: flex; align-items: center; gap: var(--sp-3); font-size: .72rem; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; color: var(--ink-3); margin-bottom: var(--sp-4); }
.zone-label::before { content: ""; width: 16px; height: 3px; border-radius: 2px; flex: none; background: linear-gradient(90deg, var(--primary), var(--chart-hum)); }
.zone-label b { color: var(--ink-2); font-weight: 800; letter-spacing: .04em; }
.zone-label .en { color: var(--ink-3); font-weight: 700; }
.zone-label::after { content: ""; flex: 1; height: 1px; background: linear-gradient(90deg, var(--line) 0%, var(--line-2) 60%, transparent 100%); }
.zone > h2:first-of-type { margin-top: var(--sp-2); }
.overview { display: grid; grid-template-columns: repeat(12, minmax(0,1fr)); gap: var(--sp-4); align-items: stretch; margin-bottom: var(--sp-4); }
.overview > * { min-width: 0; }
.ov-gauge { grid-column: span 3; }
.ov-kpis { grid-column: span 2; display: grid; grid-template-rows: 1fr 1fr; gap: var(--sp-4); }
.ov-donut { grid-column: span 3; }
.ov-board { grid-column: span 4; }
.ov-trend { grid-column: span 8; }
.ov-diag { grid-column: span 4; }
.ov-diag.panel { margin: 0; }
.ov-tile { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); padding: var(--sp-5); display: flex; flex-direction: column; }
.ov-tile h3 { font-size: .74rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-2); margin: 0 0 var(--sp-3); display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.ov-tile h3 .sub { font-size: .68rem; font-weight: 600; color: var(--ink-3); text-transform: none; letter-spacing: 0; }
/* 가동률 게이지 */
.gauge-body { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; justify-content: center; }
.gauge-ring { position: relative; width: 138px; max-width: 100%; }
.gauge-ring svg { width: 100%; height: auto; display: block; }
.gauge-ring .g-track { fill: none; stroke: var(--surface-2); stroke-width: 12; }
.gauge-ring .g-val { fill: none; stroke: var(--normal); stroke-width: 12; stroke-linecap: round; }
.gauge-ring.warn .g-val { stroke: var(--warn); }
.gauge-ring.crit .g-val { stroke: var(--leak); }
.gauge-num { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.gauge-num b { font-size: 2rem; font-weight: 800; line-height: 1; letter-spacing: -.02em; color: var(--ink); font-variant-numeric: tabular-nums; }
.gauge-num small { font-size: .68rem; color: var(--ink-3); margin-top: 3px; }
.gauge-foot { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); padding-top: var(--sp-3); margin-top: var(--sp-1); border-top: 1px solid var(--line-2); }
.gauge-foot .lbl { font-size: .7rem; color: var(--ink-3); }
.spark { width: 84px; height: 30px; display: block; }
.spark polyline { fill: none; stroke: var(--warn); stroke-width: 1.6; stroke-linejoin: round; stroke-linecap: round; }
.spark .sp-dot { fill: var(--warn); }
.g-badge { display: inline-flex; align-items: center; gap: .3em; font-size: .7rem; font-weight: 800; padding: 2px 9px; border-radius: var(--r-pill); background: var(--warn-bg); color: var(--warn-ink); }
.g-badge.ok { background: var(--normal-bg); color: var(--normal-ink); }
.g-badge.crit { background: var(--leak-bg); color: var(--leak-ink); }
/* 개요 KPI 소형 타일(듀얼) */
.ov-kpi { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); padding: var(--sp-4) var(--sp-5); position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; }
.ov-kpi::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: var(--primary); }
.ov-kpi.err::before { background: var(--leak); }
.ov-kpi.warn::before { background: var(--warn); }
.ov-kpi.ok::before { background: var(--normal); }
.ov-kpi .k-lbl { font-size: .72rem; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--ink-2); }
.ov-kpi .k-val { font-size: clamp(1.7rem, 2.6vw, 2.2rem); font-weight: 800; line-height: 1.05; letter-spacing: -.02em; font-variant-numeric: tabular-nums; margin-top: 2px; }
.ov-kpi .k-val u { text-decoration: none; font-size: .9rem; color: var(--ink-3); font-weight: 700; margin-left: 1px; }
.ov-kpi.err .k-val { color: var(--leak-ink); }
.ov-kpi.warn .k-val { color: var(--warn-ink); }
.ov-kpi.ok .k-val { color: var(--normal-ink); }
/* 도넛 (경보 종류 구성) */
.donut-body { display: flex; align-items: center; gap: var(--sp-4); flex: 1; }
.donut-ring { position: relative; flex: none; width: 116px; }
.donut-ring svg { width: 100%; height: auto; display: block; }
.donut-ring .d-track { fill: none; stroke: var(--surface-2); stroke-width: 15; }
.donut-ring circle.seg { fill: none; stroke-width: 15; }
.donut-num { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.donut-num b { font-size: 1.5rem; font-weight: 800; line-height: 1; color: var(--ink); font-variant-numeric: tabular-nums; }
.donut-num small { font-size: .64rem; color: var(--ink-3); }
.donut-legend { display: grid; gap: 6px; flex: 1; min-width: 0; }
.dl { display: flex; align-items: center; gap: 7px; font-size: .76rem; color: var(--ink-2); }
.dl i { width: 9px; height: 9px; border-radius: 3px; flex: none; }
.dl .dl-c { margin-left: auto; font-weight: 800; color: var(--ink); font-variant-numeric: tabular-nums; }
.donut-empty { color: var(--ink-3); font-size: .82rem; text-align: center; flex: 1; align-self: center; }
/* 리더보드 (경보 최다 센서) */
.board { display: grid; gap: 9px; flex: 1; align-content: center; }
.board-row { display: grid; grid-template-columns: 1.1em minmax(0,1fr) auto; align-items: center; gap: var(--sp-3); }
.board-rank { font-size: .74rem; font-weight: 800; color: var(--ink-3); font-variant-numeric: tabular-nums; text-align: right; }
.board-row:nth-child(1) .board-rank { color: var(--warn-ink); }
.board-main { min-width: 0; }
.board-name { font-size: .82rem; font-weight: 600; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.board-bar { height: 6px; border-radius: var(--r-pill); background: var(--surface-2); margin-top: 4px; overflow: hidden; }
.board-bar > span { display: block; height: 100%; border-radius: var(--r-pill); background: linear-gradient(90deg, var(--primary), var(--chart-hum)); }
.board-row:nth-child(1) .board-bar > span { background: linear-gradient(90deg, var(--warn), var(--chart-temp)); }
.board-cnt { font-size: .95rem; font-weight: 800; color: var(--ink); font-variant-numeric: tabular-nums; }
.board-cnt small { font-size: .68rem; color: var(--ink-3); font-weight: 600; }
.board-empty { color: var(--ink-3); font-size: .82rem; text-align: center; align-self: center; flex: 1; }
/* 개요 트렌드 (경보 발생 vs 복구) */
.ov-trend .trend-svg { width: 100%; height: auto; display: block; }
.ov-trend .plot-bg { fill: var(--surface-2); stroke: var(--line); }
.ov-trend .grid-line { stroke: var(--chart-grid); }
.ov-trend .ax { fill: var(--ink-3); }
.ov-trend .line-alert { stroke: var(--leak); }
.ov-trend .line-recover { stroke: var(--normal); }
.ov-trend .line-metric { stroke: var(--warn); }
.ov-trend .dot-alert { fill: var(--leak); }
.ov-trend .dot-metric { fill: var(--warn); }
.mini-legend { display: flex; gap: var(--sp-4); flex-wrap: wrap; font-size: .78rem; color: var(--ink-2); margin-bottom: var(--sp-2); }
.mini-legend span { display: inline-flex; align-items: center; gap: 6px; }
.mini-legend i { width: 15px; height: 3px; border-radius: 2px; }
/* 추세 차트 기간 토글(링크) + 피크 마커 */
.chart-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-3); }
.range-toggle { display: inline-flex; gap: 2px; background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r-pill); padding: 2px; }
.range-toggle a { font-size: .76rem; font-weight: 700; text-decoration: none; color: var(--ink-2); padding: 4px 13px; border-radius: var(--r-pill); }
.range-toggle a[aria-current="true"] { background: var(--primary); color: var(--on-primary); }
.chart-svg-wrap .peak-marker { fill: var(--warn); }
.chart-svg-wrap .peak-label { fill: var(--warn-ink); font-weight: 700; }
.chart-svg-wrap .peak-flag { fill: var(--surface-2); stroke: var(--warn); }
/* 개요 벤토 반응형 */
@media (max-width: 1080px) {
.ov-gauge, .ov-donut { grid-column: span 6; }
.ov-kpis { grid-column: span 6; grid-template-rows: none; grid-template-columns: 1fr 1fr; }
.ov-board { grid-column: span 6; }
.ov-trend, .ov-diag { grid-column: span 12; }
}
@media (max-width: 620px) {
.overview > * { grid-column: span 12 !important; }
.ov-kpis { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 768px) {
header { flex-direction: column; align-items: flex-start; position: static; }
.top-actions { margin-top: var(--sp-3); overflow-x: auto; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; }
.top-actions a { flex: 0 0 auto; }
.status-hero { grid-template-columns: 1fr; }
.status-clock { text-align: left; }
.mobile-quick { display: block; }
.ops-grid { grid-template-columns: 1fr; }
.cards { grid-template-columns: 1fr; }
.report-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }
.card, .sensor-card { min-width: 0; }
header h1 { font-size: 1.1rem; }
th, td { padding: 9px 10px; }
td { font-size: .8125rem; }
.mobile-detail { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; }
.mobile-detail summary { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 14px; color: var(--ink); font-weight: 700; cursor: pointer; list-style: none; }
.mobile-detail summary::-webkit-details-marker { display: none; }
.mobile-detail summary span { color: var(--ink-2); font-size: .8125rem; font-weight: 600; }
.mobile-detail .table-wrap { margin-bottom: 0; border-top: 1px solid var(--line); border-radius: 0; }
}
@media (max-width: 480px) {
.container { padding: 0 var(--sp-3); }
header { padding: var(--sp-3) var(--sp-4); }
.quick-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }
.sensor-grid { grid-template-columns: 1fr; }
.card { padding: var(--sp-4); }
.col-hide-xs { display: none; }
.report-grid { grid-template-columns: 1fr; }
.login-box { padding: var(--sp-6) var(--sp-5); margin: 0 var(--sp-3); }
th, td { font-size: .8125rem; }
}
@media print {
:root {
--bg: #fff; --surface: #fff; --surface-2: #fff; --raise: #fff;
--ink: #000; --ink-2: #333; --ink-3: #444; --line: #999; --line-2: #ccc;
--primary: #14315e; --on-primary: #fff; --primary-tint: #fff;
--normal-bg: #fff; --leak-bg: #fff; --warn-bg: #fff; --offline-bg: #fff;
--cold-bg: #fff; --dry-bg: #fff; --hot-bg: #fff; --humid-bg: #fff;
--shadow: none; --shadow-lg: none;
--normal-ink:#0a6b39; --leak-ink:#b11f1f; --warn-ink:#8a5a00;
}
body { background: #fff !important; color: #000; }
header, .top-actions, .refresh, .mobile-quick, .btn { display: none !important; }
.card::before, .status-hero::before, .login-box::before { display: none; }
.panel, .card, table, .chart-panel, .status-hero { box-shadow: none; border: 1px solid #999; page-break-inside: avoid; break-inside: avoid; }
.badge, .pill { border: 1px solid currentColor; }
}
/* =============================================================================
* 대시보드 탭 재개편 추가 컴포넌트 (기존 규칙 불변 — 추가만)
* 탭바 / 탭 배지 / 알림 스트립 / 통합 KPI 스트립 / 히어로 상세줄 / 미니 패널 /
* 범주형 차트 토큰(--cat-*) / 시스템 점검 그리드 / 인쇄 보강
* ============================================================================= */
:root {
/* 범주형 차트 토큰 — 경보 종류 도넛·범례(인라인 hex 치환, 인쇄에서 진한 값으로 교체) */
--cat-hot: #ff8a5c;
--cat-humid: #ffb23e;
--cat-leak: #ff5470;
--cat-cold: #5fd6ee;
--cat-dry: #b0b8ff;
}
/* ── 탭바 (?tab= 서버 렌더 링크 — nav + aria-current) ───────────────────────── */
.tabbar {
display: flex; align-items: stretch; gap: var(--sp-1);
background: var(--header-bg); border: 1px solid var(--line); border-radius: var(--r-lg);
padding: var(--sp-1); margin: var(--sp-5) 0 var(--sp-4);
overflow-x: auto; -webkit-overflow-scrolling: touch;
}
.tabbar .tab-link {
flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px;
color: var(--ink-2); font-size: .9rem; font-weight: 700; text-decoration: none;
padding: 10px 18px; border-radius: var(--r); position: relative; white-space: nowrap;
}
.tabbar .tab-link:hover { color: var(--ink); background: var(--surface-2); }
.tabbar .tab-link.active {
color: var(--ink); background: var(--surface-2);
}
.tabbar .tab-link.active::after {
content: ""; position: absolute; left: 14px; right: 14px; bottom: 4px; height: 2px;
border-radius: 2px; background: linear-gradient(90deg, var(--primary), var(--chart-hum));
}
.tab-badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 1.5em; padding: 1px 7px; border-radius: var(--r-pill);
font-size: .72rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.5;
}
.tab-badge.warn { background: var(--warn-bg); color: var(--warn-ink); }
.tab-badge.crit { background: var(--leak-bg); color: var(--leak-ink); }
.tabbar .tab-print {
flex: 0 0 auto; margin-left: auto; align-self: center;
color: var(--ink-3); font-size: .78rem; font-weight: 600; text-decoration: none;
padding: 8px 12px; border-radius: var(--r); white-space: nowrap;
}
.tabbar .tab-print:hover, .tabbar .tab-print.active { color: var(--ink); background: var(--surface-2); }
.print-note { color: var(--ink-2); font-size: .8125rem; margin-bottom: var(--sp-4); }
/* ── 알림 스트립 (즉시 조치 1줄 — 상세는 해당 탭 딥링크) ────────────────────── */
.alert-strip {
display: flex; align-items: baseline; gap: var(--sp-3); flex-wrap: wrap;
border: 1px solid var(--line); border-left-width: 4px; border-radius: var(--r);
padding: 10px 14px; margin-bottom: var(--sp-3);
text-decoration: none; color: var(--ink); background: var(--surface);
}
.alert-strip strong { font-size: .9rem; }
.alert-strip span { color: var(--ink-2); font-size: .8125rem; flex: 1 1 auto; min-width: 0; }
.alert-strip em { font-style: normal; font-size: .8125rem; font-weight: 700; color: var(--primary-600); white-space: nowrap; }
.alert-strip.danger { border-left-color: var(--leak); background: linear-gradient(90deg, var(--leak-bg), transparent 45%), var(--surface); }
.alert-strip.danger strong { color: var(--leak-ink); }
.alert-strip.warn { border-left-color: var(--warn); background: linear-gradient(90deg, var(--warn-bg), transparent 45%), var(--surface); }
.alert-strip.warn strong { color: var(--warn-ink); }
.alert-strip:hover { border-color: var(--primary); }
/* ── 히어로 상세줄 (경보 중 센서별 값·시작 시각·SMS 발송 결과) ──────────────── */
.hero-lines { list-style: none; margin: var(--sp-3) 0 0; padding: 0; display: grid; gap: 6px; }
.hero-lines li {
font-size: .875rem; font-weight: 600; color: var(--ink);
display: flex; align-items: baseline; gap: 8px;
font-variant-numeric: tabular-nums;
}
.hero-lines li::before { content: "\25B8"; color: var(--leak-ink); flex: none; }
/* ── 통합 KPI 스트립 (6타일 · 탭 딥링크 · 색+액센트+텍스트 3중 인코딩) ──────── */
.kpi-strip {
display: grid; grid-template-columns: repeat(6, minmax(0,1fr)); gap: var(--sp-3);
margin-bottom: var(--sp-2);
}
.kpi {
display: flex; flex-direction: column; justify-content: center; gap: 6px;
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg);
box-shadow: var(--shadow); padding: var(--sp-4) var(--sp-5); min-height: 104px;
text-decoration: none; color: var(--ink); transition: border-color .12s ease, transform .12s ease;
}
.kpi:hover { border-color: var(--primary); transform: translateY(-2px); }
/* 상태 3중 인코딩: 라벨 앞 상태 dot(도형+색) + 값 색 + 텍스트 — 상단 스트립 없이 */
.kpi .kpi-lbl { display: flex; align-items: center; gap: 7px; color: var(--ink-2); font-size: .72rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }
.kpi .kpi-lbl::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--normal); flex: none; }
.kpi .kpi-val { font-size: clamp(1.6rem, 2.8vw, 2.15rem); font-weight: 800; line-height: 1.05; letter-spacing: -.025em; font-variant-numeric: tabular-nums; }
.kpi .kpi-sub { color: var(--ink-3); font-size: .72rem; font-variant-numeric: tabular-nums; }
.kpi.warn .kpi-lbl::before { background: var(--warn); }
.kpi.err .kpi-lbl::before { background: var(--leak); }
.kpi.ok .kpi-val { color: var(--ink); }
.kpi.warn .kpi-val { color: var(--warn-ink); }
.kpi.err .kpi-val { color: var(--leak-ink); }
/* ── 미니 패널 (live 탭: SMS 채널 상태 / 오늘 경보 요약) ────────────────────── */
.mini-panels { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--sp-4); margin-bottom: var(--sp-6); }
.mini-panel h3, .sub-h3 { font-size: .875rem; font-weight: 700; color: var(--ink); margin: 0 0 var(--sp-3); }
.sub-h3 { margin-top: var(--sp-4); }
.mini-rows { display: grid; gap: var(--sp-2); margin: 0; }
.mini-rows > div {
display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-3);
border-bottom: 1px solid var(--line-2); padding-bottom: var(--sp-2);
}
.mini-rows > div:last-child { border-bottom: none; padding-bottom: 0; }
.mini-rows dt { color: var(--ink-2); font-size: .8125rem; font-weight: 600; }
.mini-rows dd { margin: 0; font-size: .95rem; font-weight: 800; font-variant-numeric: tabular-nums; text-align: right; }
/* ── 예보 칩: 여유(safe) 상시 표시용 중립 스타일 ────────────────────────────── */
.forecast-chip.safe { background: var(--surface-2); color: var(--ink-2); }
/* ── 통합 센서 카드: 헤더(이름+유형칩) / 카드 내부 세로 리듬 정렬 ─────────────── */
.sensor-card { display: flex; flex-direction: column; }
.sc-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.sc-head strong { font-size: 1rem; font-weight: 700; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sc-type { flex: none; font-size: .68rem; font-weight: 800; letter-spacing: .03em; padding: 2px 8px; border-radius: var(--r-pill); background: var(--surface-2); color: var(--ink-2); border: 1px solid var(--line); }
.sc-type.metric { background: rgba(63,199,230,.13); color: var(--cold-ink); }
.sc-type.leak { background: var(--offline-bg); color: var(--offline-ink); }
.sensor-card small { display: block; margin-top: 2px; }
/* 예보줄을 카드 하단에 고정(카드 높이가 그리드 행 기준으로 늘어나도 자연스럽게) */
.sensor-card .forecast { margin-top: auto; }
/* ── 범주형 차트 클래스 (도넛 세그먼트 stroke / 범례 스와치 background) ─────── */
.donut-ring circle.seg.cat-hot { stroke: var(--cat-hot); }
.donut-ring circle.seg.cat-humid { stroke: var(--cat-humid); }
.donut-ring circle.seg.cat-leak { stroke: var(--cat-leak); }
.donut-ring circle.seg.cat-cold { stroke: var(--cat-cold); }
.donut-ring circle.seg.cat-dry { stroke: var(--cat-dry); }
.dl i.cat-hot { background: var(--cat-hot); }
.dl i.cat-humid { background: var(--cat-humid); }
.dl i.cat-leak { background: var(--cat-leak); }
.dl i.cat-cold { background: var(--cat-cold); }
.dl i.cat-dry { background: var(--cat-dry); }
.donut-note { margin-top: var(--sp-2); }
/* ── trend 탭 위젯 그리드 (기존 .overview 12열 위에서 배치) ──────────────────
1행: 경보 추이·도넛·게이지 각 span 4 (=12). 2행: 리더보드 span 12 (가로 리더보드
— 타일 하나만 다음 행에 덩그러니 남는 것 방지). */
.trend-widgets > .ov-tile { grid-column: span 4; }
.trend-widgets > .ov-board { grid-column: span 12; }
.trend-widgets > .ov-board .board { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
@media (max-width: 1080px) {
.trend-widgets > .ov-tile { grid-column: span 6; }
.trend-widgets > .ov-board { grid-column: span 12; }
}
@media (max-width: 620px) {
.trend-widgets > .ov-tile, .trend-widgets > .ov-board { grid-column: span 12; }
}
/* ── system 탭 패널 그리드 ──────────────────────────────────────────────────── */
.system-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--sp-4); align-items: start; }
.system-grid > .panel { margin: 0; }
.playbook-item { border-top: 1px solid var(--line-2); padding: var(--sp-2) 0; }
.playbook-item:first-of-type { border-top: none; padding-top: 0; }
.playbook-item summary { cursor: pointer; font-size: .875rem; font-weight: 700; color: var(--ink); padding: 4px 0; }
.playbook-item .playbook-steps { margin: var(--sp-2) 0 var(--sp-2); padding-left: 1.6em; }
.playbook-item .playbook-steps li { font-size: .875rem; line-height: 1.7; }
.link-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); }
.link-list li { font-size: .875rem; color: var(--ink-2); }
.link-list a { font-weight: 700; }
/* ── 반응형: KPI 3×2 / 탭바 모바일 sticky(모바일은 header 가 static) ─────────── */
@media (max-width: 900px) {
.kpi-strip { grid-template-columns: repeat(3, minmax(0,1fr)); }
.mini-panels { grid-template-columns: 1fr; }
.system-grid { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.tabbar { position: sticky; top: 0; z-index: 18; border-radius: 0; margin-left: calc(-1 * var(--sp-5)); margin-right: calc(-1 * var(--sp-5)); border-left: none; border-right: none; }
}
@media (max-width: 480px) {
.kpi-strip { grid-template-columns: repeat(2, minmax(0,1fr)); }
.tabbar { margin-left: calc(-1 * var(--sp-3)); margin-right: calc(-1 * var(--sp-3)); }
}
/* ── 인쇄 보강: 탐색 요소 숨김 + 범주 토큰 고대비 값 ─────────────────────────── */
@media print {
:root {
--cat-hot: #b34a1f; --cat-humid: #8a5a00; --cat-leak: #b11f1f;
--cat-cold: #0f5e74; --cat-dry: #3b4aa0;
}
.tabbar, .print-note { display: none !important; }
.alert-strip, .kpi { box-shadow: none; border: 1px solid #999; }
.kpi::before { display: none; }
.alert-strip em { display: none; }
}

237
php/assets/dash-v2.css Normal file
View file

@ -0,0 +1,237 @@
/* =============================================================================
* dash-v2.css — dashboard.php 전용 오버레이 (app.css 뒤에 로드)
* 목업 review/ui_mockup_v2.html 반영: 글래스 툴바 · 스프링 모션 · 세그먼트 탭 ·
* 결합 센서 카드(누수+온습도) · 카운트다운 링 · 카드형 테이블 헤더.
* app.css 는 7페이지 공용이므로 건드리지 않는다. 인쇄 규칙은 app.css 것을 계승.
* 폐쇄망: 외부 리소스 0.
* ============================================================================= */
:root {
--spring: cubic-bezier(.32,.72,.28,1); /* 감쇠 스프링 근사 */
}
/* ── 글래스 툴바: 반투명 재질 + sticky, 콘텐츠가 아래로 스크롤 ─────────────── */
header {
background: rgba(20,26,61,.78);
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
}
.top-actions a {
transition: background .18s var(--spring), color .15s, border-color .15s, transform .15s var(--spring);
}
.top-actions a:active { transform: scale(.96); }
/* ── 새로고침 카운트다운 링 ─────────────────────────────────────────────────── */
.refresh { display: inline-flex; align-items: center; gap: 8px; }
.refresh-ring { width: 15px; height: 15px; flex: none; }
.refresh-ring circle {
fill: none; stroke: var(--primary-600); stroke-width: 2.5;
stroke-dasharray: 38; stroke-linecap: round;
transform: rotate(-90deg); transform-origin: center;
animation: dash-countdown 30s linear infinite;
}
@keyframes dash-countdown { from { stroke-dashoffset: 0; } to { stroke-dashoffset: 38; } }
.refresh.paused .refresh-ring circle { animation-play-state: paused; opacity: .35; }
.refresh-toggle { transition: transform .15s var(--spring), background .15s; }
.refresh-toggle:active { transform: scale(.93); }
/* ── 세그먼트 탭바(iOS segmented control) ───────────────────────────────────── */
.tabbar { border-radius: var(--r-pill); padding: 4px; gap: 3px; }
.tabbar .tab-link {
border-radius: var(--r-pill); padding: 8px 18px;
transition: background .22s var(--spring), color .15s, transform .15s var(--spring);
}
.tabbar .tab-link:active { transform: scale(.96); }
.tabbar .tab-link.active {
color: #fff;
background: linear-gradient(135deg, var(--primary), #8f6bff);
box-shadow: 0 4px 14px rgba(108,123,255,.35);
}
.tabbar .tab-link.active::after { content: none; }
.tabbar .tab-link.active .tab-badge.crit { background: rgba(255,255,255,.22); color: #fff; }
.tabbar .tab-print { border-radius: var(--r-pill); }
/* ── 인터랙션 공통: 즉각 반응(:active) + 부상(hover) 스프링 ─────────────────── */
.kpi, .sensor-card, .leak-status-card, .alert-strip, .quick-tile {
transition: transform .2s var(--spring), border-color .2s, box-shadow .2s, background .2s;
}
.kpi:hover, .sensor-card:hover, .leak-status-card:hover {
transform: translateY(-3px); box-shadow: var(--shadow-lg);
}
.kpi:active, .sensor-card:active, .alert-strip:active { transform: translateY(-1px) scale(.985); }
.alert-strip:hover { transform: translateX(3px); }
/* ── 결합 센서 카드 (누수+온습도) ────────────────────────────────────────────
상태 우선순위: 오프라인 > 누수 확정 > 확인중 > 온습도 경보 > 정상 */
.sensor-card { position: relative; overflow: hidden; }
.sensor-card::before {
content: ""; position: absolute; inset: 0 0 auto 0; height: 3px;
background: var(--normal); opacity: .85;
}
.sensor-card.alert::before { background: var(--leak); }
.sensor-card.leak::before { background: var(--chart-hum); }
.sensor-card.pending::before { background: var(--warn); }
.sensor-card.offline::before { background: var(--offline); }
.sensor-card.offline { opacity: .78; }
.sensor-card.leak {
border-color: rgba(63,199,230,.4);
background: linear-gradient(180deg, rgba(63,199,230,.10), transparent 60%), var(--surface);
animation: dash-leak-glow 1.8s ease-in-out infinite;
}
@keyframes dash-leak-glow {
50% { box-shadow: 0 0 26px rgba(63,199,230,.30); }
}
/* 유형 칩 2개(결합) 나란히 */
.sc-types { display: flex; gap: 4px; flex: none; }
/* 상태 라인: 점 + 텍스트 (badge-row 대체 — 기존 배지도 유지 호환) */
.status-line { display: flex; align-items: center; gap: 7px; font-size: .8125rem; font-weight: 700; margin-top: var(--sp-2); }
.status-line .sdot { width: 8px; height: 8px; border-radius: 50%; background: var(--normal); box-shadow: 0 0 8px var(--normal); flex: none; }
.status-line.ok { color: var(--normal-ink); }
.status-line.err { color: var(--leak-ink); }
.status-line.err .sdot { background: var(--leak); box-shadow: 0 0 8px var(--leak); animation: dash-pulse 1.2s ease-in-out infinite; }
.status-line.leakst { color: var(--cold-ink); }
.status-line.leakst .sdot { background: var(--chart-hum); box-shadow: 0 0 8px var(--chart-hum); animation: dash-pulse 1.2s ease-in-out infinite; }
.status-line.pend { color: var(--warn-ink); }
.status-line.pend .sdot { background: var(--warn); box-shadow: 0 0 8px var(--warn); }
.status-line.off { color: var(--offline-ink); }
.status-line.off .sdot { background: var(--offline); box-shadow: none; }
@keyframes dash-pulse { 50% { opacity: .4; } }
/* 온습도 큰 수치 2열 */
.vals { display: flex; gap: var(--sp-5); margin-top: var(--sp-3); }
.vals .v { flex: 1; min-width: 0; }
.vals .v .n {
font-size: 1.7rem; font-weight: 800; letter-spacing: -.03em; line-height: 1.08;
font-variant-numeric: tabular-nums; color: var(--ink);
}
.vals .v .n small { font-size: .85rem; color: var(--ink-2); font-weight: 600; letter-spacing: 0; }
.vals .v .l { font-size: .7rem; color: var(--ink-3); margin-top: 2px; }
.vals .v.temp .n { color: var(--chart-temp); }
.vals .v.hum .n { color: var(--chart-hum); }
.vals .v .n.hot { text-shadow: 0 0 14px rgba(255,138,92,.5); }
.vals .v .n.cold { color: var(--cold-ink); }
.vals .v .n.humid { text-shadow: 0 0 14px rgba(255,178,62,.5); }
.vals .v .n.dry { color: var(--dry-ink); }
/* 24h 스파크라인 */
.spark24 { width: 100%; height: 34px; display: block; margin-top: var(--sp-2); }
.spark24 polyline { fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.spark24 .sp-t { stroke: var(--chart-temp); opacity: .85; }
.spark24 .sp-h { stroke: var(--chart-hum); opacity: .85; stroke-dasharray: 5 3; }
/* 누수 행(결합 카드 내부): 경과·SMS·사진 */
.leak-row {
display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
margin-top: var(--sp-3); padding-top: var(--sp-3); border-top: 1px solid var(--line-2);
}
.leak-row .leak-fact { font-size: .8125rem; color: var(--ink-2); font-variant-numeric: tabular-nums; }
.leak-row .leak-fact b { color: var(--ink); font-weight: 800; }
.leak-row .leak-thumbs { margin-left: auto; }
.leak-row .leak-thumbs img {
width: 46px; height: 36px; object-fit: cover;
transition: transform .18s var(--spring);
}
.leak-row .leak-thumbs a:hover img { transform: scale(1.5); }
/* 누수 카드 하단 물결(장식) */
.leak-wave { position: absolute; left: 0; right: 0; bottom: 0; height: 42px; opacity: .14; pointer-events: none; }
.leak-wave path { fill: var(--chart-hum); }
/* 카드 푸터: 최종확인 + 보고 주기 */
.sc-foot {
display: flex; justify-content: space-between; gap: var(--sp-2);
font-size: .72rem; color: var(--ink-3); margin-top: var(--sp-3);
font-variant-numeric: tabular-nums;
}
.sc-foot .stale { color: var(--leak-ink); font-weight: 700; }
/* ── 이력 테이블: 카드형 헤더(테이블 위 캡션 스트립) ────────────────────────── */
h2.table-title { margin-bottom: 0; font-size: .9375rem; }
.table-title {
display: flex; align-items: center; gap: var(--sp-2);
background: var(--surface-2); border: 1px solid var(--line); border-bottom: none;
border-radius: var(--r-lg) var(--r-lg) 0 0; padding: 12px 16px;
font-size: .9375rem; font-weight: 700;
}
.table-title .count-pill {
margin-left: auto; font-size: .7rem; font-weight: 700; color: var(--ink-2);
background: var(--surface); border: 1px solid var(--line);
padding: 2px 10px; border-radius: var(--r-pill); font-variant-numeric: tabular-nums;
}
.table-title + .table-wrap,
.table-title + details.mobile-detail .table-wrap { border-radius: 0 0 var(--r-lg) var(--r-lg); }
.table-title + .table-wrap table,
.table-title + details.mobile-detail table { border-radius: 0 0 var(--r-lg) var(--r-lg); border-top: none; }
.table-title .h2-sub { margin-left: auto; font-size: .72rem; font-weight: 600; color: var(--ink-3); font-variant-numeric: tabular-nums; }
/* ── 히어로 미세 조정: 큰 제목 음수 트래킹 ──────────────────────────────────── */
.status-hero h2 { letter-spacing: -.02em; }
.kpi .kpi-val, .card .val, .gauge-num b { letter-spacing: -.03em; }
/* ── 미세먼지(PM) 계통 색상·요소 (온습도 orange 와 구분되는 magenta/violet 계열) ──────
PMS7003 미세먼지 경보가 온습도 경보로 오독되지 않도록 별도 색 계통을 부여한다. PM 토큰은
app.css 에 없으므로 여기서 정의(dash-v2.css 는 app.css 뒤 로드 — 재정의 아님, additive). */
:root {
--pm: #d24fd6; /* 미세먼지 대표 액센트(도넛·칩·라인) */
--pm-ink: #eca3ea; /* 어두운 배경 위 텍스트용 밝은 변형 */
--pm-bg: rgba(210,79,214,.15);
--pm25: #e85fbf; /* PM2.5 계열(magenta-pink) */
--pm10: #a06bff; /* PM10 계열(violet) */
}
/* 유형 칩(미세먼지) — sc-type.metric(청록)·sc-type.leak(회색)과 구분 */
.sc-type.pm { background: var(--pm-bg); color: var(--pm-ink); }
/* 카드 상태(미세먼지 경보) — 상단 바 + 배경 그라디언트(온습도 alert 와 별개 색) */
.sensor-card.pm { border-color: rgba(210,79,214,.4); background: linear-gradient(180deg, var(--pm-bg), transparent 60%), var(--surface); }
.sensor-card.pm::before { background: var(--pm); }
/* 상태 라인(미세먼지 경보) */
.status-line.pmst { color: var(--pm-ink); }
.status-line.pmst .sdot { background: var(--pm); box-shadow: 0 0 8px var(--pm); animation: dash-pulse 1.2s ease-in-out infinite; }
/* 미세먼지 큰 수치 2열 — 온습도 vals 재사용, 색만 PM 계열. 위반 시 hot 글로우(magenta) */
.vals.pm-vals { margin-top: var(--sp-2); }
.vals .v.pm25 .n { color: var(--pm25); }
.vals .v.pm10 .n { color: var(--pm10); }
.pm-vals .v .n.hot { text-shadow: 0 0 14px rgba(210,79,214,.55); }
/* 상태 pill(미세먼지/결로) — 이력·측정 표의 상태 배지 */
.pill.high_pm25 { background: var(--pm-bg); color: var(--pm-ink); }
.pill.high_pm10 { background: var(--pm-bg); color: var(--pm-ink); }
.pill.condensation_risk { background: var(--warn-bg); color: var(--warn-ink); }
/* 추이 차트(미세먼지) — dashboard.php 인라인 <style> 에 PM 규칙이 없어 여기서 정의 */
.chart-svg-wrap .axis-pm { fill: var(--pm); }
.chart-svg-wrap .line-pm25 { stroke: var(--pm25); }
.chart-svg-wrap .line-pm10 { stroke: var(--pm10); }
.legend-swatch.pm25 { background: var(--pm25); }
.legend-swatch.pm10 { background: var(--pm10); }
/* 7일 경보 추이 라인(미세먼지) + 도넛 세그먼트·범례(미세먼지) */
.ov-trend .line-pm { stroke: var(--pm); }
.donut-ring circle.seg.cat-pm { stroke: var(--pm); }
.dl i.cat-pm { background: var(--pm); }
/* ── 접근성 ─────────────────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.sensor-card.leak { animation: none; }
.status-line .sdot { animation: none !important; }
.refresh-ring circle { animation: none; }
.kpi, .sensor-card, .leak-status-card, .alert-strip, .quick-tile,
.tabbar .tab-link, .top-actions a, .refresh-toggle { transition: none; }
.kpi:hover, .sensor-card:hover, .leak-status-card:hover, .alert-strip:hover { transform: none; }
}
@media (prefers-reduced-transparency: reduce) {
header { background: var(--header-bg); backdrop-filter: none; -webkit-backdrop-filter: none; }
}
/* ── 인쇄: 장식·모션 요소 제거(app.css 강제 라이트 계승) ─────────────────────── */
@media print {
.leak-wave, .refresh-ring, .spark24 { display: none !important; }
.sensor-card, .sensor-card.leak { animation: none; box-shadow: none; }
.status-line .sdot { animation: none; box-shadow: none; border: 1px solid currentColor; }
.vals .v .n { text-shadow: none; }
}

285
php/assets/report-print.css Normal file
View file

@ -0,0 +1,285 @@
/* =============================================================================
* report-print.css — 월간 운영 보고서(PDF 저장 전용) A4 화이트 문서 스타일
*
* monthly_report_print.php 만 링크한다(app.css 미링크 — 다크 셸 토큰과 격리).
* 폐쇄망: 자기호스팅 로컬 파일. 외부 폰트/CDN 0 — 시스템 폰트만.
* 화면(@media screen)에서는 회색 캔버스 위에 뜬 흰 A4 종이로 미리보기(WYSIWYG),
* 인쇄(@media print)에서는 캔버스·툴바가 벗겨지고 종이가 곧 PDF 가 된다.
*
* 대상 브라우저: Chrome / Edge(Chromium) — 고정 러닝 헤더/푸터·색 보존 안정.
* FLAG: Chromium print-to-PDF 는 @page 마진박스 counter(page) 미지원 + 폐쇄망
* JS 폴리필 금지 → 라이브 페이지번호 미탑재. 고정 푸터에 문서번호·생성일시·대외비를
* 매 장 반복해 추적성을 확보하고, 화면 노트로 인쇄 대화상자의 머리글/바닥글 옵션을 안내.
* ============================================================================= */
.report-paper {
/* 라이트 고정 팔레트 — app.css @media print 토큰과 값 정합(흑백/컬러 안전) */
--paper-ink: #1a1f2b;
--paper-sub: #5b6472;
--paper-navy: #14315e;
--paper-gold: #9a7b28;
--hair: #d9dee6;
--hair-strong: #c2cad6;
--ok: #0a6b39;
--warn: #8a5a00;
--alert: #b11f1f;
--band-safe: #e7f0ea;
--band-hot: #f6e3dc;
--band-cold: #dfeef2;
--paper-bg-soft: #f4f6f9;
}
/* 색 보존 강제(네이비 밴드·골드 룰·판정 씰·차트 획 유실 방지) */
.report-paper,
.report-paper * {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* ── @page: A4 세로 + 여백 ─────────────────────────────────────────────────── */
@page {
size: A4 portrait;
margin: 18mm 16mm 20mm;
}
@page :first {
margin-top: 22mm; /* 표지 상단 여유 */
}
/* ── 종이 본체 ─────────────────────────────────────────────────────────────── */
.report-paper {
font-family: "Malgun Gothic", "맑은 고딕", "Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
color: var(--paper-ink);
background: #fff;
line-height: 1.6;
font-size: 10.5pt;
font-variant-numeric: tabular-nums;
-webkit-font-smoothing: antialiased;
}
.report-paper h1, .report-paper h2, .report-paper h3 { margin: 0; font-weight: 800; }
.report-paper p { margin: 0; }
.report-paper .num { font-variant-numeric: tabular-nums; }
/* ── 러닝 헤더/푸터(position:fixed — Chromium 이 매 장 반복 렌더) ─────────────── */
.run-header, .run-footer {
position: fixed;
left: 0; right: 0;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 8pt;
color: var(--paper-sub);
letter-spacing: .02em;
}
.run-header { top: 0; border-bottom: 0.6pt solid var(--hair); padding-bottom: 3pt; }
.run-footer { bottom: 0; border-top: 0.6pt solid var(--hair); padding-top: 3pt; }
.run-header .cls, .run-footer .cls { color: var(--alert); font-weight: 700; }
.run-header b { color: var(--paper-navy); font-weight: 800; }
/* ── 표지 마스트헤드 ───────────────────────────────────────────────────────── */
.cover { position: relative; padding-top: 4mm; }
.cover-eyebrow {
font-size: 9.5pt; font-weight: 700; letter-spacing: .35em;
color: var(--paper-navy); margin-bottom: 6mm;
}
.cover-eyebrow::before {
content: ""; display: inline-block; width: 26pt; height: 3pt; vertical-align: middle;
background: var(--paper-gold); margin-right: 8pt; border-radius: 2pt;
}
.cover-title {
font-size: 27pt; line-height: 1.15; color: var(--paper-navy);
letter-spacing: -.01em; margin-bottom: 3mm; max-width: 70%;
}
.cover-sub { font-size: 11pt; color: var(--paper-sub); margin-bottom: 10mm; }
.cover-month {
font-size: 58pt; font-weight: 800; color: var(--paper-navy);
line-height: 1; letter-spacing: -.02em;
}
.cover-period { font-size: 10.5pt; color: var(--paper-sub); margin-top: 2mm; }
.cover-seal { position: absolute; top: 0; right: 0; text-align: center; }
.cover-seal .seal-cap { font-size: 8pt; color: var(--paper-sub); margin-top: 2pt; }
/* 관제 대상 3개소 스트립 */
.cover-scope {
display: flex; gap: 6mm; margin-top: 10mm; padding-top: 6mm;
border-top: 1pt solid var(--hair-strong);
}
.scope-item { flex: 1; }
.scope-item .k { font-size: 8.5pt; color: var(--paper-sub); letter-spacing: .1em; }
.scope-item .v { font-size: 12pt; font-weight: 800; color: var(--paper-navy); }
.scope-item .t { font-size: 8.5pt; color: var(--paper-sub); }
/* 문서 통제 블록 */
.doc-control {
margin-top: 12mm; border: 1pt solid var(--hair-strong); border-radius: 2pt;
display: grid; grid-template-columns: repeat(3, 1fr); overflow: hidden;
}
.doc-control > div { padding: 3.2mm 4mm; border-right: 0.6pt solid var(--hair); border-bottom: 0.6pt solid var(--hair); }
.doc-control > div:nth-child(3n) { border-right: none; }
.doc-control .dk { font-size: 8pt; color: var(--paper-sub); letter-spacing: .08em; margin-bottom: 1pt; }
.doc-control .dv { font-size: 10pt; font-weight: 700; color: var(--paper-ink); }
.doc-control .dv.cls { color: var(--alert); }
/* ── 섹션 제목 ─────────────────────────────────────────────────────────────── */
.section { margin-top: 8mm; }
.section-title {
font-size: 13pt; color: var(--paper-navy); padding-bottom: 2mm;
border-bottom: 1.6pt solid var(--paper-navy); margin-bottom: 4mm;
display: flex; align-items: baseline; gap: 8pt;
break-after: avoid; page-break-after: avoid;
}
.section-title .en { font-size: 8.5pt; font-weight: 600; color: var(--paper-sub); letter-spacing: .12em; }
.section-title .no {
font-size: 9pt; font-weight: 800; color: #fff; background: var(--paper-navy);
border-radius: 2pt; padding: 1pt 6pt; letter-spacing: .05em;
}
/* ── 경영 요약 KPI ─────────────────────────────────────────────────────────── */
.kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4mm; }
.kpi-card {
border: 1pt solid var(--hair-strong); border-radius: 2pt; padding: 3.5mm 4mm;
break-inside: avoid; page-break-inside: avoid; position: relative;
}
.kpi-card::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3pt; background: var(--paper-navy); }
.kpi-card.is-alert::before { background: var(--alert); }
.kpi-card.is-warn::before { background: var(--warn); }
.kpi-card.is-ok::before { background: var(--ok); }
.kpi-card .kpi-k { font-size: 8.5pt; color: var(--paper-sub); letter-spacing: .04em; }
.kpi-card .kpi-v { font-size: 21pt; font-weight: 800; color: var(--paper-navy); line-height: 1.1; }
.kpi-card.is-alert .kpi-v { color: var(--alert); }
.kpi-card .kpi-s { font-size: 8.5pt; color: var(--paper-sub); }
.kpi-gauge-wrap { text-align: center; }
.exec-summary {
margin-top: 4mm; border: 1pt solid var(--hair-strong); border-left: 3pt solid var(--paper-navy);
border-radius: 2pt; padding: 3.5mm 4.5mm; background: var(--paper-bg-soft);
break-inside: avoid; page-break-inside: avoid;
}
.exec-summary .verdict-line { font-size: 11pt; font-weight: 800; color: var(--paper-navy); }
.exec-summary .verdict-line.is-alert { color: var(--alert); }
.exec-summary .verdict-line.is-warn { color: var(--warn); }
.exec-summary .verdict-line.is-ok { color: var(--ok); }
.exec-summary p { font-size: 10pt; color: var(--paper-ink); margin-top: 1.5mm; }
/* ── 정의형 지표 표(경보·이벤트 / 온습도) ──────────────────────────────────── */
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6mm; }
.metric-block { break-inside: avoid; page-break-inside: avoid; }
.metric-block h3 { font-size: 10pt; color: var(--paper-navy); margin-bottom: 2mm; }
table.def { width: 100%; border-collapse: collapse; font-size: 9.5pt; }
table.def th, table.def td { padding: 2mm 3mm; border-bottom: 0.6pt solid var(--hair); text-align: left; }
table.def th { color: var(--paper-sub); font-weight: 600; width: 55%; }
table.def td { text-align: right; font-weight: 700; color: var(--paper-ink); font-variant-numeric: tabular-nums; }
table.def td.is-alert { color: var(--alert); }
/* ── 일반 데이터 표(지점별 등) ─────────────────────────────────────────────── */
table.grid { width: 100%; border-collapse: collapse; font-size: 9pt; }
table.grid thead { display: table-header-group; } /* 페이지 넘김 시 머리행 반복 */
table.grid th, table.grid td { padding: 2mm 2.5mm; border: 0.6pt solid var(--hair); text-align: center; }
table.grid th { background: var(--paper-navy); color: #fff; font-weight: 700; font-size: 8.5pt; }
table.grid td.l { text-align: left; }
table.grid tbody tr:nth-child(even) td { background: var(--paper-bg-soft); }
table.grid td.is-alert { color: var(--alert); font-weight: 800; }
/* ── 지점별 카드 ───────────────────────────────────────────────────────────── */
.loc-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4mm; }
.loc-card {
border: 1pt solid var(--hair-strong); border-radius: 2pt; padding: 3.5mm 4mm;
break-inside: avoid; page-break-inside: avoid;
}
.loc-card .lc-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2mm; padding-bottom: 2mm; border-bottom: 0.8pt solid var(--hair); }
.loc-card .lc-name { font-size: 11.5pt; font-weight: 800; color: var(--paper-navy); }
.loc-card .lc-loc { font-size: 8.5pt; color: var(--paper-sub); }
.pill {
font-size: 8pt; font-weight: 800; padding: 1pt 6pt; border-radius: 8pt;
border: 0.8pt solid currentColor;
}
.pill.is-ok { color: var(--ok); }
.pill.is-warn { color: var(--warn); }
.pill.is-alert { color: var(--alert); }
.loc-card dl { display: grid; grid-template-columns: auto 1fr; gap: 1mm 3mm; margin: 0; font-size: 9pt; }
.loc-card dt { color: var(--paper-sub); }
.loc-card dd { margin: 0; text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
.loc-card dd.is-alert { color: var(--alert); }
/* ── 차트 패널 ─────────────────────────────────────────────────────────────── */
.chart-panel {
border: 1pt solid var(--hair-strong); border-radius: 2pt; padding: 4mm;
margin-top: 3mm; break-inside: avoid; page-break-inside: avoid;
}
.chart-panel h3 { font-size: 10pt; color: var(--paper-navy); margin-bottom: 1mm; }
.chart-panel .chart-cap { font-size: 8.5pt; color: var(--paper-sub); margin-bottom: 2mm; }
.chart-empty { padding: 8mm; text-align: center; color: var(--paper-sub); font-size: 9.5pt; border: 0.8pt dashed var(--hair-strong); border-radius: 2pt; }
.chart-legend-row { display: flex; flex-wrap: wrap; gap: 4mm; margin-top: 2mm; font-size: 8.5pt; color: var(--paper-sub); }
.chart-legend-row span { display: inline-flex; align-items: center; gap: 4pt; }
.lg-sw { width: 11pt; height: 8pt; border-radius: 1pt; display: inline-block; }
.lg-sw.hatch { background: repeating-linear-gradient(45deg, var(--alert) 0 2pt, #fff 2pt 3.5pt); border: 0.5pt solid var(--alert); }
.lg-line { width: 16pt; height: 3pt; border-radius: 2pt; display: inline-block; }
.lg-line.dash { background: repeating-linear-gradient(90deg, currentColor 0 4pt, transparent 4pt 7pt) !important; }
.lg-band { width: 12pt; height: 9pt; border-radius: 1pt; display: inline-block; border: 0.5pt solid var(--hair-strong); }
/* ── 누수 사고 로그 ────────────────────────────────────────────────────────── */
.incident-none {
border: 1pt solid var(--ok); border-radius: 2pt; padding: 4mm 4.5mm;
color: var(--ok); font-weight: 700; font-size: 10pt; background: var(--band-safe);
}
/* ── 기준·주석 + 서명란 ────────────────────────────────────────────────────── */
.basis-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6mm; }
.basis-block { break-inside: avoid; page-break-inside: avoid; }
.basis-block h3 { font-size: 9.5pt; color: var(--paper-navy); margin-bottom: 2mm; }
.basis-block ul { margin: 0; padding-left: 4.5mm; font-size: 9pt; color: var(--paper-ink); }
.basis-block li { margin-bottom: 1mm; }
.basis-block li b { color: var(--paper-navy); }
.sign-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6mm; margin-top: 8mm; break-inside: avoid; page-break-inside: avoid; }
.sign-box { border: 1pt solid var(--hair-strong); border-radius: 2pt; }
.sign-box .sk { font-size: 8.5pt; color: var(--paper-sub); padding: 2mm 3mm; border-bottom: 0.6pt solid var(--hair); background: var(--paper-bg-soft); }
.sign-box .sv { height: 16mm; }
.sign-box .sn { font-size: 8pt; color: var(--paper-sub); padding: 1.5mm 3mm; border-top: 0.6pt solid var(--hair); text-align: right; }
/* ── 페이지 브레이크 ───────────────────────────────────────────────────────── */
.page-break { break-before: page; page-break-before: always; }
/* ── 화면 미리보기(회색 캔버스 위 흰 종이) ─────────────────────────────────── */
@media screen {
body { margin: 0; background: #e9eaee; }
.report-canvas { padding: 24px 12px 64px; }
.report-paper {
width: 210mm; max-width: 100%; min-height: 297mm; margin: 0 auto;
padding: 22mm 16mm 20mm; background: #fff; box-sizing: border-box;
box-shadow: 0 6px 28px rgba(20, 30, 60, .18);
}
/* 화면에서는 고정 헤더/푸터를 종이 안쪽에 흐름 배치(겹침 방지) */
.run-header, .run-footer { position: static; margin: 0 auto; width: 210mm; max-width: 100%; }
.run-header { margin-bottom: 6mm; }
.run-footer { margin-top: 6mm; }
.print-note {
width: 210mm; max-width: 100%; margin: 0 auto 16px; padding: 10px 14px;
background: #fff8e6; border: 1px solid #e6c766; border-radius: 6px;
font-size: 12px; color: #6b5410; box-sizing: border-box;
font-family: "Malgun Gothic", "맑은 고딕", "Apple SD Gothic Neo", sans-serif;
}
.print-note b { color: #8a5a00; }
.print-note a { color: #14315e; }
.screen-toolbar {
width: 210mm; max-width: 100%; margin: 0 auto 12px; display: flex; gap: 8px; flex-wrap: wrap;
box-sizing: border-box;
}
.screen-toolbar a, .screen-toolbar button {
font: inherit; font-size: 13px; font-weight: 700; padding: 8px 14px; border-radius: 6px;
border: 1px solid #14315e; background: #14315e; color: #fff; cursor: pointer; text-decoration: none;
font-family: "Malgun Gothic", "맑은 고딕", "Apple SD Gothic Neo", sans-serif;
}
.screen-toolbar a.ghost { background: #fff; color: #14315e; }
}
/* ── 인쇄(종이가 곧 PDF) ───────────────────────────────────────────────────── */
@media print {
html, body { margin: 0; background: #fff; }
.report-canvas { padding: 0; background: #fff; }
.report-paper {
width: auto; max-width: none; min-height: 0; margin: 0; padding: 0;
background: #fff; box-shadow: none;
}
.print-note, .screen-toolbar, .screen-only { display: none !important; }
/* 고정 헤더/푸터와 본문 겹침 방지 여백 */
.report-body { padding-top: 6mm; padding-bottom: 6mm; }
}

View file

@ -7,10 +7,15 @@ $time = date('c');
?>
<!doctype html><html lang="ko"><head>
<meta charset="utf-8"><title>접근 거부</title>
<link rel="stylesheet" href="assets/app.css">
<meta name="robots" content="noindex"><meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{margin:0;display:grid;place-items:center;min-height:100vh;background:#faf7f2;font-family:system-ui,-apple-system,"Noto Sans KR",sans-serif;color:#222}.card{max-width:520px;background:#fff;border:1px solid #eee;border-radius:16px;padding:28px;box-shadow:0 10px 24px rgba(0,0,0,.06);text-align:center}h1{margin:0 0 8px;font-size:22px}.small{color:#666;font-size:13px;margin-top:14px}code{background:#f0f0f0;padding:2px 6px;border-radius:6px}</style>
</head><body><div class="card">
<style>
/* 페이지 전용: 단독 안내 카드 중앙 정렬(대시보드 셸 없음) */
body { min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4); }
.blocked-card { text-align: center; }
</style>
</head><body><div class="login-box blocked-card">
<h1>접근이 거부되었습니다</h1>
<p>관리자에게 연락하세요.</p>
<p class="small">요청 ID: <code><?=htmlspecialchars($id)?></code></p>
<p class="sub">관리자에게 연락하세요.</p>
<p class="caption">요청 ID: <code><?=htmlspecialchars($id)?></code></p>
</div></body></html>

View file

@ -28,6 +28,14 @@ return [
'01000000000',
],
// 센서별 수신자 그룹 — 지정한 센서의 경보 SMS 는 그 그룹으로만 발송된다. 지정 없는
// 센서는 위 전역 SMS_RECIPIENTS 로 폴백한다(헬스/잔액 등 시스템 통지도 전역 사용).
// 예: 누수(1번)는 시설팀, 3번은 고객센터로. 비우면(기본) 전역만 사용.
'SENSOR_RECIPIENTS' => [
// 1 => ['010-1111-1111', '010-2222-2222'],
// 3 => ['010-3333-3333'],
],
'ADMIN_USER' => 'admin',
'ADMIN_PASSWORD_HASH' => '$2y$10$replace.with.password_hash.output',
// Base32 TOTP secret. MUST be left EMPTY here and set to a UNIQUE generated value in
@ -45,6 +53,38 @@ return [
// 신규 누수 지점(예: Pi-C, sensor_id 3) 투입 시: [1, 3]
'LEAK_SENSOR_IDS' => [1],
// 온습도(SHT30) sensor_id 목록 — 대시보드 온습도 카드 표시 대상 (기본 [2]).
// 누수 노드(1·3)에 SHT30 을 얹어 "같은 sensor_id" 로 온습도를 보고하는 구성에서는
// 1·3 도 포함한다: [1, 2, 3]. (같은 지점에 누수 카드 + 온습도 카드가 함께 표시됨)
'METRIC_SENSOR_IDS' => [1, 2, 3],
// 미세먼지(PMS7003) sensor_id 목록 — 대시보드가 해당 센서 카드에 미세먼지 블록
// (PM2.5/PM10 µg/m³)을 표시할 대상 (기본 [1, 3]).
// ★ 동일-id 통합 토폴로지: 누수 노드(1·3)는 하나의 sensor_id 아래 누수 + 온습도(SHT30)
// + 미세먼지(PMS7003)를 metric_type('sht30' vs 'pms7003')로 구분해 보고한다. 2번은
// 온습도 단독. 따라서 운영 토폴로지는:
// LEAK_SENSOR_IDS = [1, 3] (누수)
// METRIC_SENSOR_IDS = [1, 2, 3] (온습도)
// PM_SENSOR_IDS = [1, 3] (미세먼지)
// 대시보드는 metric_type 별 최신값을 각각 버킷팅하므로, 같은 sensor_id 에서 온습도와
// 미세먼지가 서로를 덮어쓰지 않는다. 신규 미세먼지 지점은 여기서 오버라이드(문자열 "1,3" 허용).
'PM_SENSOR_IDS' => [1, 3],
// 누수 감지/복구 디바운스(감지↔복구 플래핑 SMS 폭탄 방지). 상태가 확인창 동안 지속돼야
// 확정하고 확정 엣지에서만 SMS 1회. 아래는 기본값(필요 시 조정). sql/migration_leak_debounce.sql
// 적용 필요(컬럼 없으면 레거시 즉시 발송으로 자동 폴백).
// 'LEAK_DEBOUNCE_ENABLED' => true,
// 'LEAK_ALERT_CONFIRM_SEC' => 60, // 감지 확인창(초)
// 'LEAK_RECOVERY_CONFIRM_SEC' => 180, // 복구 확인창(초)
// 센서별 오프라인 판정 타임아웃(무수신 초). 온습도(SHT30)는 1시간 주기라 전역 20분으론
// 매시간 오탐 → 온습도 센서는 METRIC_HEARTBEAT_TIMEOUT_SEC(기본 7500s≈125분) 자동 적용.
// 특정 센서만 다르게 하려면 SENSOR_HEARTBEAT_TIMEOUT_SEC 로 명시 오버라이드.
// 'METRIC_HEARTBEAT_TIMEOUT_SEC' => 7500,
// ★ 동일-id 온습도 통합: 1·3 은 위 METRIC_SENSOR_IDS 에도 들어가지만 누수 노드이므로
// metric 기본(7500s)으로 느슨해지면 안 됨 → 누수 타임아웃(30분) 유지하도록 명시 오버라이드.
'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [1 => 1800, 3 => 1800],
// 온습도 임계 경보 (서버 판정 → SMS). 운영 환경에 맞게 조정.
'METRIC_TEMP_HIGH_C' => 27, // 고온 임계(℃)
'METRIC_TEMP_LOW_C' => 10, // 저온 임계(℃)
@ -53,12 +93,23 @@ return [
'METRIC_DEWPOINT_MARGIN_C' => 0, // 결로(이슬점) 경보 비활성(0 이면 판정 분기 스킵)
'METRIC_ALERT_COOLDOWN_SEC' => 1800, // 동일 종류 경보 재발송 억제(초, 기본 30분)
// 예보·대응(대시보드 예방 신호 + 조치 가이드). 전부 기본값으로 동작 — 필요 시만 override.
// FORECAST_ENABLED(true), FORECAST_WARN_MARGIN_TEMP_C(2.0), FORECAST_WARN_MARGIN_RH(8.0),
// FORECAST_HORIZON_MIN(180), FORECAST_RATE_TEMP_C_PER_10MIN(3.0) 등. config.php 참고.
// 대응 담당자(대시보드 '대응 필요' 섹션에 표시). 비우면 담당자 블록 생략.
'RESPONSE_CONTACTS' => [
// ['role' => '시설담당', 'name' => '홍길동', 'phone' => '010-0000-0000'],
// ['role' => '야간당직', 'name' => '김철수', 'phone' => '010-0000-0000'],
],
// 상태별 대응 체크리스트는 config.php 의 기본 플레이북을 사용. 현장 절차로 바꾸려면
// 'RESPONSE_PLAYBOOKS' => ['high_temp' => ['title'=>'고온 대응','steps'=>['...']], ...] 로 통째 override.
// 미복귀 경보 2차(야간) 에스컬레이션 수신자. 비우면 비활성(opt-in).
'SMS_RECIPIENTS_TIER2' => [],
'ESCALATE_AFTER_SEC' => 1800, // 경보 미복귀 30분 후 2차 통지
// 아래는 기본값으로 동작(필요 시에만 override):
// SMS_BALANCE_WARN_THRESHOLD(30건), HEALTH_SMS_HOUR(9시),
// STUCK_DETECT_SAMPLES(6), SMS_RETRY_OFFLINE(true)
// STUCK_DETECT_SAMPLES(3 = 온습도 1시간 주기 기준 ≈3시간), SMS_RETRY_OFFLINE(true)
'SMS_LOG_RETENTION_DAYS' => 365,
'SENSOR_LOG_RETENTION_DAYS' => 365,

View file

@ -9,6 +9,11 @@
define('APP_VERSION', 'v2606');
// ─── 운영 오류 노출 차단 (공유호스팅은 display_errors=On 이 흔함) ───
// 스택트레이스/쿼리/경로가 HTTP 응답에 실리지 않도록 전 엔드포인트 공통 차단.
ini_set('display_errors', '0');
ini_set('log_errors', '1');
$local_config_file = __DIR__ . '/config.local.php';
$local_config = is_file($local_config_file) ? require $local_config_file : [];
@ -43,6 +48,8 @@ function api_key_is_default(): bool {
// 수신 본문 timestamp 허용 시계 오차(초). 이 범위를 벗어난(과거/미래) 서명 요청은
// 재전송(replay) 방어를 위해 거부한다. SHT30 보고 주기(5분) 대비 넉넉히 둔다.
define('INGEST_MAX_CLOCK_SKEW_SEC', (int)cfg('INGEST_MAX_CLOCK_SKEW_SEC', 600));
// 최근 거부 이벤트 링버퍼 크기(출처 IP 로그). 무단 접근 출처 식별용, 파일 무한증가 방지.
define('INGEST_REJECT_EVENTS_MAX', (int)cfg('INGEST_REJECT_EVENTS_MAX', 50));
// 수신 본문 최대 크기(바이트). 정상 디바이스 본문은 ~1KB 미만. 인증 전 대용량
// 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 를 차단한다.
@ -70,7 +77,59 @@ $sms_recipients = cfg('SMS_RECIPIENTS', ['01000000000']);
if (is_string($sms_recipients)) {
$sms_recipients = array_filter(array_map('trim', explode(',', $sms_recipients)));
}
define('SMS_RECIPIENTS', $sms_recipients);
define('SMS_RECIPIENTS', array_values($sms_recipients));
// ─── 센서별 수신자 그룹 (config.local.php 맵) ────────────────────────────────
// 센서(sensor_id)별로 SMS 를 받을 수신자 그룹을 지정한다. 지정이 없는 센서는 전역
// SMS_RECIPIENTS 로 폴백한다(SENSOR_LABELS 와 동일 원칙 — Pi 재배포 없이 config.local.php
// 만 바꾸면 됨). 값은 문자열 배열 또는 쉼표문자열(숫자만 정제). 예:
// 'SENSOR_RECIPIENTS' => [
// 1 => ['010-1111-1111', '010-2222-2222'], // 누수 → 시설팀
// 3 => ['010-3333-3333'], // 3번 → 고객센터
// ]
$sensor_recipients_raw = cfg('SENSOR_RECIPIENTS', []);
$sensor_recipients = [];
if (is_array($sensor_recipients_raw)) {
foreach ($sensor_recipients_raw as $sid => $list) {
$sid = (int)$sid;
if ($sid < 1 || $sid > 255) {
continue; // 유효 sensor_id(1..255) 만
}
if (is_string($list)) {
$list = explode(',', $list);
}
if (!is_array($list)) {
continue;
}
$nums = [];
foreach ($list as $num) {
$d = preg_replace('/\D+/', '', (string)$num);
if ($d !== '') {
$nums[] = $d;
}
}
if ($nums !== []) { // 유효 번호가 하나도 없으면 버림(전역 폴백되게)
$sensor_recipients[$sid] = $nums;
}
}
}
define('SENSOR_RECIPIENTS', $sensor_recipients);
/**
* 센서별 수신자 그룹 해소(순수 로직). 그룹 지정 시 그 그룹, 없으면 전역 폴백.
* 테스트 용이성을 위해 상수 의존 없는 순수 함수로 분리한다.
*/
function resolve_sensor_recipients(int $sid, array $groups, array $global): array {
return $groups[$sid] ?? $global;
}
/**
* 이 센서의 SMS 수신자 목록. SENSOR_RECIPIENTS[sid] 우선, 없으면 전역 SMS_RECIPIENTS.
* sid=0(헬스/잔액/시스템 통지)은 그룹이 없으므로 전역으로 폴백한다.
*/
function recipients_for_sensor(int $sid): array {
return resolve_sensor_recipients($sid, SENSOR_RECIPIENTS, SMS_RECIPIENTS);
}
// Cafe24 SMS('S' 타입)는 EUC-KR 기준 약 90바이트까지만 단문으로 발송된다. 초과하면
// 게이트웨이가 잘라/거부하므로, 발송 직전에 EUC-KR 바이트 기준으로 안전하게 자른다
@ -96,6 +155,7 @@ const SMS_TAG_SENSOR_STUCK = '[센서고착]'; // 측정값 고착(live-bu
const SMS_TAG_ESCALATION = '[에스컬레이션]'; // 미복귀 경보 2차(야간) 통지
const SMS_TAG_LEAK_ALERT = '[누수감지]'; // 1번 모듈(누수) 누수 발생
const SMS_TAG_LEAK_RECOVERY = '[누수복구]'; // 1번 모듈(누수) 누수 해소(복구)
const SMS_TAG_HIGH_PM = '[화재의심]'; // PMS7003 PM 급상승(연기 추정) — 화재 조기징후. 서버실/항온항습 화재 프록시
// 경보 종류(metric kind) → 태그 매핑(단일 출처). evaluate_metric_thresholds 의
// breach 종류와 1:1 대응한다.
@ -104,13 +164,16 @@ const SMS_METRIC_TAGS = [
'low_temp' => SMS_TAG_LOW_TEMP,
'high_humidity' => SMS_TAG_HIGH_HUMIDITY,
'low_humidity' => SMS_TAG_LOW_HUMIDITY,
// PMS7003 화재의심(PM): PM2.5·PM10 두 종류가 같은 태그([화재의심])로 매핑된다.
'high_pm25' => SMS_TAG_HIGH_PM,
'high_pm10' => SMS_TAG_HIGH_PM,
];
/**
* leak_sms_log 태그별 건수 집계 SELECT 절(단일 출처). 대시보드/월간보고서가 공유한다.
* 태그 문자열은 위 SMS_TAG_* 상수에서만 가져오므로, 태그를 바꿔도 모든 집계가 한
* 곳에서 따라온다(여러 파일의 LIKE 문자열이 어긋나 무성 장애 나는 것 방지).
* 반환 컬럼: high_temp, low_temp, high_humidity, low_humidity, recovery,
* 반환 컬럼: high_temp, low_temp, high_humidity, low_humidity, high_pm, recovery,
* device_offline, device_online, leak_alert, leak_recovery, sms_fail
*/
function sms_summary_select_sql(): string {
@ -119,6 +182,7 @@ function sms_summary_select_sql(): string {
. " SUM(message LIKE '" . SMS_TAG_LOW_TEMP . "%') AS low_temp,\n"
. " SUM(message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%') AS high_humidity,\n"
. " SUM(message LIKE '" . SMS_TAG_LOW_HUMIDITY . "%') AS low_humidity,\n"
. " SUM(message LIKE '" . SMS_TAG_HIGH_PM . "%') AS high_pm,\n"
. " SUM(message LIKE '" . SMS_TAG_RECOVERY . "%') AS recovery,\n"
. " SUM(message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%') AS device_offline,\n"
. " SUM(message LIKE '" . SMS_TAG_DEVICE_ONLINE . "%') AS device_online,\n"
@ -141,10 +205,37 @@ define('METRIC_RH_HYSTERESIS', (float)cfg('METRIC_RH_HYSTERESIS', 3.0));
// 동일 종류 경보 재발송 억제 쿨다운(초). 기본 30분.
define('METRIC_ALERT_COOLDOWN_SEC', (int)cfg('METRIC_ALERT_COOLDOWN_SEC', 1800));
// ─── 미세먼지(PMS7003) 임계 경보 (PM2.5/PM10 → SMS) ────────────────────────────
// PMS7003 은 누수 노드(sensor_id 1·3)에 온습도(SHT30)와 함께 동일 sensor_id 로 통합되어
// metric_type='pms7003' 로 보고한다. 값은 대기환산 µg/m³ 정수(0..1000). 아래 운영 임계를
// 초과하면 api/sensor_data.php 가 [화재의심] SMS 를 보낸다(종류별 쿨다운은 온습도와
// 동일하게 METRIC_ALERT_COOLDOWN_SEC 재사용). 임계 판정은 Pi 가 아니라 서버에서 하므로
// 폐쇄망에서도 재배포 없이 config.local.php 로 조정할 수 있다.
//
// 화재 프록시 기준(서버실/항온항습): 여과·양압 환경이라 평상 PM ≈ 0. PM 급상승 = 외부
// 유입 아니면 연소 연기 → 화재 조기징후로 본다. 대기질 "나쁨"(75/150)까지 기다리면 연기가
// 이미 자욱해 늦으므로, 조기감지용으로 낮춘 값을 쓴다. (PMS7003 은 광학 먼지센서지 인증
// 화재감지기가 아니다 — 보조 조기징후로만 쓰고 인증 연기/열 감지기를 병행할 것.)
define('METRIC_PM25_HIGH', (float)cfg('METRIC_PM25_HIGH', 35)); // PM2.5 화재의심 임계(µg/m³) — 조기감지용 하향
define('METRIC_PM10_HIGH', (float)cfg('METRIC_PM10_HIGH', 75)); // PM10 화재의심 임계(µg/m³) — 조기감지용 하향
// 복구(정상 회복) 히스테리시스: 임계 안쪽으로 이 여유만큼 들어와야 복구로 간주(경계 채터링 방지)
define('METRIC_PM_HYSTERESIS', (float)cfg('METRIC_PM_HYSTERESIS', 10)); // µg/m³
// 누수(leak) 경보 재발송 억제 쿨다운(초). 1번 모듈은 누수 지속 중 주기(periodic) 보고를
// 계속 보내므로, 같은 누수 에피소드에서 [누수감지] SMS 가 반복 발송되지 않도록 한다. 기본 30분.
define('LEAK_ALERT_COOLDOWN_SEC', (int)cfg('LEAK_ALERT_COOLDOWN_SEC', 1800));
// ─── 누수 감지/복구 디바운스 (감지↔복구 플래핑 SMS 폭탄 방지) ──────────────────
// 센서에 닿는 물이 애매하면 GPIO 가 leak/dry 사이를 초 단위로 진동(채터링)한다. 상태
// 전이를 즉시 발송하지 않고, 확인창(감지/복구) 동안 raw 상태가 지속돼야 "확정"하고
// 확정 엣지에서만 SMS 를 1회 보낸다. 누수 지속 중 짧은 dry 블립(< 복구 확인창)은 복구를
// 확정하지 못하므로, 확정 상태가 유지되어 재감지·복구 SMS 가 둘 다 나가지 않는다(폭탄 차단).
// leak_sensor_status.leak_confirmed / leak_pending_since 컬럼으로 상태를 저장한다.
// 해당 컬럼이 없는 구배포에서는 자동으로 레거시(즉시 발송) 경로로 graceful degrade.
define('LEAK_DEBOUNCE_ENABLED', (bool)cfg('LEAK_DEBOUNCE_ENABLED', true));
define('LEAK_ALERT_CONFIRM_SEC', (int)cfg('LEAK_ALERT_CONFIRM_SEC', 60)); // 감지 확인창(초)
define('LEAK_RECOVERY_CONFIRM_SEC', (int)cfg('LEAK_RECOVERY_CONFIRM_SEC', 180)); // 복구 확인창(초)
// ─── 결로(이슬점) 경보 ────────────────────────────────────────────────────────
// 온도·습도가 각각 정상이어도 (현재온도 − 이슬점)=결로마진 이 좁으면 차가운 표면에
// 결로가 생긴다(장비 손상). 마진이 이 임계 이하이면 결로주의 경보를 보낸다.
@ -152,6 +243,37 @@ define('LEAK_ALERT_COOLDOWN_SEC', (int)cfg('LEAK_ALERT_COOLDOWN_SEC', 1800));
define('METRIC_DEWPOINT_MARGIN_C', (float)cfg('METRIC_DEWPOINT_MARGIN_C', 3.0));
define('METRIC_DEWPOINT_HYSTERESIS_C', (float)cfg('METRIC_DEWPOINT_HYSTERESIS_C', 1.0));
// ─── 예보·대응 (대시보드 예방 신호 + 조치 가이드) ─────────────────────────────
// 기존 측정 데이터만으로 "경보가 터지기 전"을 대시보드에 표시한다(php/forecast.php).
// 임계 여유·추세 ETA·이슬점 마진·변화율을 계산하고, 위험 시 상태별 대응 체크리스트를
// 보여준다. DB 스키마 변경 없음(전부 기존 leak_sensor_metric/status 로 산출).
define('FORECAST_ENABLED', (bool)cfg('FORECAST_ENABLED', true));
define('FORECAST_RECENT_POINTS', (int)cfg('FORECAST_RECENT_POINTS', 12)); // 회귀에 쓸 최근 측정 수(≈1시간)
define('FORECAST_MIN_POINTS', (int)cfg('FORECAST_MIN_POINTS', 6)); // ETA 산출 최소 점수
define('FORECAST_MIN_R2', (float)cfg('FORECAST_MIN_R2', 0.5)); // 추세 신뢰(결정계수) 하한
define('FORECAST_HORIZON_MIN', (int)cfg('FORECAST_HORIZON_MIN', 180)); // ETA 표시 지평(분)
define('FORECAST_WARN_MARGIN_TEMP_C', (float)cfg('FORECAST_WARN_MARGIN_TEMP_C', 2.0)); // 온도 여유 근접 기준(℃)
define('FORECAST_WARN_MARGIN_RH', (float)cfg('FORECAST_WARN_MARGIN_RH', 8.0)); // 습도 여유 근접 기준(%)
define('FORECAST_RATE_TEMP_C_PER_10MIN', (float)cfg('FORECAST_RATE_TEMP_C_PER_10MIN', 3.0)); // 급변 기준(℃/10분)
define('FORECAST_RATE_RH_PER_10MIN', (float)cfg('FORECAST_RATE_RH_PER_10MIN', 10.0)); // 급변 기준(%/10분)
// 상태별 대응 체크리스트(플레이북). 조건 키 → ['title'=>, 'steps'=>[...]].
// config.local.php 에서 'RESPONSE_PLAYBOOKS' 로 통째 오버라이드 가능(현장 절차 반영).
$default_playbooks = [
'leak' => ['title' => '누수 대응', 'steps' => ['현장 즉시 확인', '해당 구역 급수 밸브 차단', '누수 지점·범위 사진 확인', '전기 설비 침수 여부 안전 점검', '조치 후 복구 확인']],
'high_temp' => ['title' => '고온 대응', 'steps' => ['냉방·환기 동작 확인', '실외기/흡배기·필터 막힘 점검', '발열 장비·부하 확인', '필요 시 임시 냉각']],
'low_temp' => ['title' => '저온 대응', 'steps' => ['난방 동작 확인', '외기 유입·틈새 점검', '동결 위험 배관 확인']],
'high_humidity' => ['title' => '고습 대응', 'steps' => ['제습기 동작 확인', '누수·침수 여부 확인', '환기 상태 점검']],
'low_humidity' => ['title' => '저습 대응', 'steps' => ['가습 설비 확인', '정전기 민감 장비 점검']],
'condensation' => ['title' => '결로 위험 대응', 'steps' => ['차가운 표면·배관 결로 확인', '제습·환기로 이슬점 마진 확보', '장비 표면 물기 점검']],
'offline' => ['title' => '오프라인 대응', 'steps' => ['장비 전원·상태 표시등 확인', '유선/무선 네트워크 연결 확인', 'NTP 시각 동기 확인', '필요 시 장비 재부팅', '복구 후 수신 재개 확인']],
];
define('RESPONSE_PLAYBOOKS', cfg('RESPONSE_PLAYBOOKS', $default_playbooks));
// 대응 담당자 연락처: [['role'=>'시설담당','name'=>'홍길동','phone'=>'010-...'], ...].
// 비면(기본) 대응 섹션에서 연락처 블록을 생략한다. config.local.php 에서 채운다.
define('RESPONSE_CONTACTS', cfg('RESPONSE_CONTACTS', []));
// ─── SMS 잔액(잔여 건수) 사전경보 ─────────────────────────────────────────────
// Cafe24 응답의 잔여 건수가 이 값 이하로 내려가면 1회 '[SMS 잔액부족]' 경고를 보낸다
// (경보 채널이 잔액 소진으로 조용히 죽는 것을 사전 방지). 쿨다운으로 반복을 억제.
@ -182,8 +304,14 @@ define('ESCALATE_AFTER_SEC', (int)cfg('ESCALATE_AFTER_SEC', 1800));
// 센서 고착(live-but-stuck) 검출: 최근 STUCK_DETECT_SAMPLES 측정의 표준편차가
// STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장(같은 값 반복)을 의심해 알린다.
// (하트비트는 '수신 끊김'만 본다 — I2C 는 살아있는데 값이 고착된 무성실패를 놓침.)
define('STUCK_DETECT_ENABLED', (bool)cfg('STUCK_DETECT_ENABLED', true));
define('STUCK_DETECT_SAMPLES', (int)cfg('STUCK_DETECT_SAMPLES', 6)); // 6 × 5분 = 30분
//
// 기본 비활성: 대상이 항온항습 문서고라 온습도가 원래 거의 평탄하다. 고착 검출을 켜면
// 정상 평탄값을 '고착'으로 오탐해 [센서고착] 문자가 반복된다. 변동이 있는 환경에서만
// config.local.php 로 STUCK_DETECT_ENABLED=true 로 켠다.
define('STUCK_DETECT_ENABLED', (bool)cfg('STUCK_DETECT_ENABLED', false));
// 온습도 보고 주기가 1시간이므로 표본 수가 크면 고착 감지가 너무 늦다. 기본 3(= 3회 연속
// 완전 평탄 ≈ 3시간). 주기를 바꾸면 config.local.php 로 조정.
define('STUCK_DETECT_SAMPLES', (int)cfg('STUCK_DETECT_SAMPLES', 3)); // 3 × 1시간 ≈ 3시간
define('STUCK_DETECT_EPSILON', (float)cfg('STUCK_DETECT_EPSILON', 0.01)); // 분해능 이하
define('STUCK_ALERT_COOLDOWN_SEC', (int)cfg('STUCK_ALERT_COOLDOWN_SEC', 21600)); // 6시간
@ -191,15 +319,57 @@ define('STUCK_ALERT_COOLDOWN_SEC', (int)cfg('STUCK_ALERT_COOLDOWN_SEC', 21600));
// 운영에서는 반드시 false. tests/chaos_drill.php 가 일시적으로 켜서 검증한다.
define('SMS_FORCE_FAIL', (bool)cfg('SMS_FORCE_FAIL', false));
// ─── Heartbeat (장비 오프라인 감지) ────────────────────────
// SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300), 누수 모듈
// 정상 보고 주기는 10분(raspberry_pi4/config.py REPORT_INTERVAL=600). 기본 오프라인
// 판정 20분은 SHT30 4회 / 누수 2회 누락에 해당한다. config.local.php 로 조정 가능.
// ⚠️ 이 타임아웃은 전 센서 공통 — 어떤 모듈의 보고 주기보다도 길어야 오탐이 없다.
define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200));
// ─── Heartbeat (장비 오프라인 감지) — 센서별 타임아웃 ──────────────────────────
// 온습도(SHT30) 보고 주기는 1시간(raspberry_pi4_sht30/config.py REPORT_INTERVAL=3600),
// 누수 모듈 정상 보고 주기는 10분(raspberry_pi4/config.py REPORT_INTERVAL=600). 보고 주기가
// 센서마다 크게 다르므로 오프라인 판정 타임아웃을 센서별로 해소한다(전역 단일값이면
// 온습도 1시간 주기가 20분 타임아웃에 매시간 오프라인 오탐, 또는 전역을 1시간+로 올리면
// 누수 노드 다운을 늦게 감지).
// heartbeat_timeout_for(sid): 명시 오버라이드 > 온습도(METRIC) 기본 > 전역 기본.
// ⚠️ 각 값은 해당 센서의 보고 주기보다 넉넉히 길어야 오탐이 없다.
define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200)); // 전역 기본(누수 등 20분)
// 온습도 센서(METRIC_SENSOR_IDS) 오프라인 판정 기본. 1시간 주기 2회 누락 + 여유 ≈ 125분.
define('METRIC_HEARTBEAT_TIMEOUT_SEC', (int)cfg('METRIC_HEARTBEAT_TIMEOUT_SEC', 7500));
// 센서별 명시 오버라이드 맵: [sensor_id => 초]. 예: 'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [3 => 900]
$sensor_hb_raw = cfg('SENSOR_HEARTBEAT_TIMEOUT_SEC', []);
$sensor_hb = [];
if (is_array($sensor_hb_raw)) {
foreach ($sensor_hb_raw as $sid => $sec) {
$sid = (int)$sid;
$sec = (int)$sec;
if ($sid >= 1 && $sid <= 255 && $sec > 0) {
$sensor_hb[$sid] = $sec;
}
}
}
define('SENSOR_HEARTBEAT_TIMEOUT_SEC', $sensor_hb);
define('HEARTBEAT_CHECK_INTERVAL_SEC', (int)cfg('HEARTBEAT_CHECK_INTERVAL_SEC', 300)); // 오프라인 체크 실행 주기
define('CRON_SECRET', hash_hmac('sha256', 'cron', API_KEY)); // cron HTTP 접근용 시크릿(API_KEY 파생, HMAC)
/**
* 센서별 오프라인 판정 타임아웃 해소(순수 로직). 명시 오버라이드 > 온습도 기본 > 전역 기본.
* 테스트 용이성을 위해 상수 의존 없는 순수 함수로 분리한다.
*/
function resolve_heartbeat_timeout(int $sid, array $overrides, array $metricIds, int $metricDefault, int $globalDefault): int {
if (array_key_exists($sid, $overrides)) {
return (int)$overrides[$sid];
}
if (in_array($sid, $metricIds, true)) {
return $metricDefault;
}
return $globalDefault;
}
/**
* 이 센서의 오프라인 판정 타임아웃(초). cron_heartbeat / dashboard 신선도 판정 공용.
*/
function heartbeat_timeout_for(int $sid): int {
return resolve_heartbeat_timeout(
$sid, SENSOR_HEARTBEAT_TIMEOUT_SEC, METRIC_SENSOR_IDS,
METRIC_HEARTBEAT_TIMEOUT_SEC, HEARTBEAT_TIMEOUT_SEC
);
}
// ─── 누수 모듈 sensor_id 목록 ────────────────────────────
// 대시보드 누수 상태 카드 표시 대상. 신규 누수 지점 추가 시 config.local.php 로
// 오버라이드한다. 예: 'LEAK_SENSOR_IDS' => [1, 3] (문자열 "1,3" 도 허용)
@ -216,6 +386,82 @@ $leak_sensor_ids = array_values(array_filter(
));
define('LEAK_SENSOR_IDS', $leak_sensor_ids !== [] ? $leak_sensor_ids : [1]);
// ─── 온습도(SHT30) 센서 sensor_id 목록 ───────────────────
// 상단 "센서 현재 상태" 카드에 항상 표시할 온습도 센서. 누수 카드와 동일 원칙 —
// 아직 데이터가 없어도(미수신) placeholder 카드로 노출해, 기대 센서가 화면에서
// 조용히 사라지지 않게 한다(예: SHT30 최초 기동 전). 기본 [2](2번=SHT30 온습도).
// 신규 온습도 지점은 config.local.php 로 오버라이드한다. 예: [2, 4] (문자열 "2,4" 허용)
$metric_sensor_ids = cfg('METRIC_SENSOR_IDS', [2]);
if (is_string($metric_sensor_ids)) {
$metric_sensor_ids = explode(',', $metric_sensor_ids);
}
$metric_sensor_ids = array_values(array_filter(
array_map('intval', (array)$metric_sensor_ids),
static fn($id) => $id >= 1 && $id <= 255
));
define('METRIC_SENSOR_IDS', $metric_sensor_ids !== [] ? $metric_sensor_ids : [2]);
// ─── 미세먼지(PMS7003) 센서 sensor_id 목록 ───────────────
// 누수 노드(1·3)에 PMS7003 을 얹어 "같은 sensor_id" 로 metric_type='pms7003' 미세먼지를
// 보고하는 구성의 표시 대상. 누수/온습도 목록과 동일 정제(intval, 1..255). 기본 [1, 3].
// 신규 미세먼지 지점은 config.local.php 로 오버라이드한다. 예: [1, 3] (문자열 "1,3" 허용).
// !defined 가드 — 다른 스트림이 먼저 정의했을 수 있으므로 재정의 fatal 을 피한다(좌표화).
if (!defined('PM_SENSOR_IDS')) {
$pm_sensor_ids = cfg('PM_SENSOR_IDS', [1, 3]);
if (is_string($pm_sensor_ids)) {
$pm_sensor_ids = explode(',', $pm_sensor_ids);
}
$pm_sensor_ids = array_values(array_filter(
array_map('intval', (array)$pm_sensor_ids),
static fn($id) => $id >= 1 && $id <= 255
));
define('PM_SENSOR_IDS', $pm_sensor_ids !== [] ? $pm_sensor_ids : [1, 3]);
}
// ─── 센서별 표시 라벨 오버라이드 (서버측) ────────────────────
// SMS·대시보드에 표시할 센서별 "위치"와 "관리주체(표시명)"를 서버에서 지정한다
// (Pi 재배포 없이 config.local.php 만 바꾸면 됨). 지정이 없으면 Pi 가 보고한
// device_location / sensor_name 을 그대로 쓴다. 예:
// 'SENSOR_LABELS' => [
// 1 => ['location' => '서버실(안)', 'name' => '정보기반'],
// 2 => ['location' => '문서고', 'name' => '조직혁신'],
// 3 => ['location' => '서버실(밖)', 'name' => '고객센터'],
// ]
$sensor_labels_raw = cfg('SENSOR_LABELS', []);
$sensor_labels = [];
if (is_array($sensor_labels_raw)) {
foreach ($sensor_labels_raw as $sid => $lbl) {
$sid = (int)$sid;
if ($sid < 1 || $sid > 255 || !is_array($lbl)) {
continue;
}
$entry = [];
if (isset($lbl['location']) && is_string($lbl['location']) && $lbl['location'] !== '') {
$entry['location'] = $lbl['location'];
}
if (isset($lbl['name']) && is_string($lbl['name']) && $lbl['name'] !== '') {
$entry['name'] = $lbl['name'];
}
if ($entry !== []) {
$sensor_labels[$sid] = $entry;
}
}
}
define('SENSOR_LABELS', $sensor_labels);
/**
* 센서 표시 라벨 결정: SENSOR_LABELS(서버 오버라이드) 우선, 없으면 보고값(Pi) 폴백.
* SMS·대시보드 등 사람이 보는 모든 곳에서 센서의 위치/관리주체를 이 헬퍼로 얻는다.
*
* @return array{0:string,1:string} [위치, 관리주체(표시명)]
*/
function sensor_label(int $sensor_id, ?string $reported_location, ?string $reported_name): array {
$ov = SENSOR_LABELS[$sensor_id] ?? [];
$loc = $ov['location'] ?? ((($reported_location ?? '') !== '') ? $reported_location : '알 수 없음');
$name = $ov['name'] ?? ((($reported_name ?? '') !== '') ? $reported_name : "센서{$sensor_id}");
return [$loc, $name];
}
// ─── 대시보드 세션 인증 ──────────────────────────────────
define('ADMIN_USER', cfg('ADMIN_USER', 'admin'));
define('ADMIN_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시
@ -305,7 +551,11 @@ function ingest_rejection_path(): string {
return __DIR__ . '/var/ingest_rejections.json';
}
function record_ingest_rejection(string $reason): void {
function record_ingest_rejection(string $reason, array $ctx = []): void {
// 출처 IP 포함 최근 거부 이벤트를 별도 링 파일에 남긴다(카운터와 독립 — 하나가
// 실패해도 다른 하나는 기록). '무단 접근' 출처(우리 노드 vs 외부 스캐너) 식별용.
record_ingest_reject_event($reason, $ctx);
$fh = @fopen(ingest_rejection_path(), 'c+');
if ($fh === false) {
return; // 카운터 실패가 수집 처리를 막아선 안 됨(best-effort)
@ -354,6 +604,80 @@ function read_ingest_rejections(int $days = 1): array {
return $totals;
}
// ─── 수집 거부 이벤트 링 (출처 IP 포함 — 무단 접근 식별) ──────────────────────
// 카운터(사유별 숫자)만으론 "우리 노드 오설정 vs 외부 스캐너"를 구분 못 한다. 최근 거부
// 몇 건을 출처 IP·사유·서명모드와 함께 별도 파일 링버퍼에 남겨, 대시보드/ops 가 출처를
// 즉시 분별한다. 인증 전 경로라 DB 대신 파일 flock(위조 폭주에도 부담 작음), 개수 상한
// (INGEST_REJECT_EVENTS_MAX)으로 무한증가를 막는다. 본문 유래 값(device_id 등)은 신뢰
// 불가 → 로그 인젝션 방지를 위해 제어문자 제거·절단한다. var/ 는 .htaccess 로 웹 차단됨.
function ingest_reject_events_path(): string {
return __DIR__ . '/var/ingest_reject_events.json';
}
// 신뢰 가능한 출처 IP(REMOTE_ADDR). 형식 검증 실패/부재는 '?'. XFF 는 위조 가능하므로
// 신뢰 출처로 쓰지 않는다(참고용은 record 에서 별도 'fwd' 필드로만 남긴다).
function ingest_client_ip(): string {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
return (is_string($ip) && filter_var($ip, FILTER_VALIDATE_IP)) ? $ip : '?';
}
// 로그 인젝션/제어문자 방지: 인쇄가능 문자만 남기고(제어문자→공백) 최대 길이로 절단.
function ingest_sanitize_token(string $s, int $max = 64): string {
$s = preg_replace('/[[:cntrl:]]+/u', ' ', $s);
$s = trim((string)$s);
if ($max > 0) {
$s = function_exists('mb_substr') ? mb_substr($s, 0, $max) : substr($s, 0, $max);
}
return $s;
}
// 최근 거부 이벤트 1건을 링버퍼(별도 파일)에 추가한다(best-effort, DoS 안전).
// $ctx: ['mode'=>서명모드, 'device_id'=>본문 device_id(신뢰불가)] — 선택.
function record_ingest_reject_event(string $reason, array $ctx = []): void {
$fh = @fopen(ingest_reject_events_path(), 'c+');
if ($fh === false) {
return;
}
if (flock($fh, LOCK_EX)) {
$raw = stream_get_contents($fh);
$events = ($raw !== false && $raw !== '') ? json_decode($raw, true) : [];
if (!is_array($events)) { $events = []; }
$fwd = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
$events[] = [
't' => date('Y-m-d H:i:s'),
'ip' => ingest_client_ip(),
'reason' => ingest_sanitize_token($reason, 24),
'mode' => isset($ctx['mode']) ? ingest_sanitize_token((string)$ctx['mode'], 12) : '',
'dev' => isset($ctx['device_id']) ? ingest_sanitize_token((string)$ctx['device_id'], 32) : '',
'fwd' => (is_string($fwd) && $fwd !== '') ? ingest_sanitize_token($fwd, 48) : '',
];
$max = (int)INGEST_REJECT_EVENTS_MAX;
if ($max > 0 && count($events) > $max) {
$events = array_slice($events, -$max);
}
ftruncate($fh, 0);
rewind($fh);
fwrite($fh, (string)json_encode($events, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
fflush($fh);
flock($fh, LOCK_UN);
}
fclose($fh);
}
// 최근 거부 이벤트 최신순 $limit 건. 대시보드 '최근 거부 출처' 표에 사용.
function read_ingest_reject_events(int $limit = 20): array {
$file = ingest_reject_events_path();
if (!is_file($file)) {
return [];
}
$events = json_decode((string)@file_get_contents($file), true);
if (!is_array($events)) {
return [];
}
$events = array_reverse($events); // 최신 먼저
return $limit > 0 ? array_slice($events, 0, $limit) : $events;
}
// ─── 서명 검증 (레거시: 본문 필드 기반, DEPRECATED) ───────
// 디코드된 데이터를 ksort 후 재직렬화하여 해시한다. 과거 RPi Python 클라이언트
// 전용의 하위 호환 경로이며, 현재 운영 클라이언트(STM32 펌웨어)는 사용하지 않는다.

View file

@ -22,19 +22,22 @@ require_once __DIR__ . '/sms_send.php';
function check_offline_sensors(): int {
$db = get_db();
$stmt = $db->prepare("
SELECT * FROM leak_sensor_status
WHERE last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND)
AND offline_alerted = 0
");
$stmt->execute([':timeout' => HEARTBEAT_TIMEOUT_SEC]);
$offline_sensors = $stmt->fetchAll();
// 전 센서를 읽어 센서별 타임아웃(heartbeat_timeout_for)으로 오프라인을 판정한다. 보고
// 주기가 센서마다 달라(온습도 1시간 / 누수 10분) 전역 단일 타임아웃으로는 온습도 오탐
// 또는 누수 지연이 생긴다. silent_sec 로 무수신 경과를 함께 읽어 PHP 에서 비교한다.
$rows = $db->query("
SELECT sensor_id, sensor_name, device_id, device_location,
last_seen, offline_alerted,
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS silent_sec
FROM leak_sensor_status
")->fetchAll();
// 각 센서를 SMS 발송 전에 "원자적으로 선점"한다(0→1 전이가 한 프로세스에서만
// 성공). 여러 진입점(sensor_data 쓰로틀, 로그인 시 즉시 체크, 대시보드)이
// 동시에 실행돼도 오프라인 SMS 가 중복 발송되지 않도록 한다.
// last_seen 조건을 함께 둬, SELECT 와 UPDATE 사이에 장비가 복귀 보고를 하면
// (last_seen 갱신) 선점이 실패하여 오탐 오프라인 SMS 가 나가지 않도록 한다.
// 타임아웃은 센서별로 바인딩한다.
$claim = $db->prepare("
UPDATE leak_sensor_status SET offline_alerted = 1
WHERE sensor_id = :sensor_id
@ -44,10 +47,18 @@ function check_offline_sensors(): int {
$count = 0;
foreach ($offline_sensors as $sensor) {
foreach ($rows as $sensor) {
if ((int)$sensor['offline_alerted'] !== 0) {
continue; // 이미 오프라인 알림 발송됨
}
$sid = (int)$sensor['sensor_id'];
$timeout = heartbeat_timeout_for($sid);
if ($sensor['silent_sec'] === null || (int)$sensor['silent_sec'] < $timeout) {
continue; // 이 센서의 타임아웃 이내 → 온라인
}
$claim->execute([
':sensor_id' => $sensor['sensor_id'],
':timeout' => HEARTBEAT_TIMEOUT_SEC,
':sensor_id' => $sid,
':timeout' => $timeout,
]);
if ($claim->rowCount() === 0) {
// 다른 동시 실행이 이미 선점했거나, 그 사이 장비가 복귀 보고를 했다.
@ -140,6 +151,12 @@ function check_offline_sensors_throttled(): void {
} catch (\Throwable $e) {
error_log("[Heartbeat] 에스컬레이션/고착 검출 실패: " . $e->getMessage());
}
// 누수 디바운스: 보고가 끊겨도 미확정 전이(복구/감지)를 시간 경과로 확정 해소.
try {
sweep_pending_leak_transitions(get_db());
} catch (\Throwable $e) {
error_log("[Heartbeat] 누수 디바운스 sweep 실패: " . $e->getMessage());
}
}
}
@ -174,8 +191,9 @@ if ($_is_direct) {
maybe_send_daily_health_sms(get_db());
maybe_escalate_alerts(get_db());
maybe_detect_stuck_sensors(get_db());
sweep_pending_leak_transitions(get_db());
} catch (\Throwable $e) {
error_log("[Heartbeat] 헬스/에스컬레이션/고착 실패: " . $e->getMessage());
error_log("[Heartbeat] 헬스/에스컬레이션/고착/디바운스 실패: " . $e->getMessage());
}
if (!$is_cli) {

File diff suppressed because it is too large Load diff

1025
php/dashboard_data.php Normal file

File diff suppressed because it is too large Load diff

272
php/forecast.php Normal file
View file

@ -0,0 +1,272 @@
<?php
// =============================================================================
// forecast.php — 예방·예측 신호 계산 (순수 함수: DB·출력·전역상태 없음)
//
// 대시보드가 기존 측정 데이터만으로 "경보가 터지기 전"을 보여주기 위한 계산부.
// - 임계 여유(headroom): 현재값과 임계 사이 거리 + 3단계(safe/near/over)
// - 추세 ETA: 최근점 선형회귀로 임계 도달 예상 시간(분)
// - 이슬점 결로 마진: Magnus 식 Td → margin = T - Td
// - 변화율 급변: 단기 기울기로 급상승/급락 플래그
// - 신선도: last_seen 경과 → 오프라인 임박
// - 위험 항목(risk items): 대응 섹션에 뿌릴 정규화된 목록
//
// 모든 함수는 인자로 데이터·임계·튜닝을 받는다(상수 참조 없음 → 단위 테스트 용이).
// 임계 비활성(<=0 또는 명시 무효값)은 호출부가 걸러 넘긴다(config 규칙 존중).
// =============================================================================
/**
* 이슬점(℃) — Magnus-Tetens. rh 는 0<rh<=100.
* 유효하지 않으면 null.
*/
function dew_point_c(?float $t, ?float $rh): ?float
{
if ($t === null || $rh === null || $rh <= 0.0 || $rh > 100.0) {
return null;
}
$a = 17.62;
$b = 243.12;
$gamma = log($rh / 100.0) + ($a * $t) / ($b + $t);
if ($a - $gamma == 0.0) {
return null;
}
return ($b * $gamma) / ($a - $gamma);
}
/**
* 최소자승 선형회귀. points = [[x, y], ...] (x·y float).
* 반환 ['slope'=>Δy/Δx, 'intercept'=>, 'r2'=>0..1, 'n'=>] 또는 null(점<2, x 분산 0).
*/
function linear_trend(array $points): ?array
{
$n = count($points);
if ($n < 2) {
return null;
}
$sx = $sy = $sxx = $sxy = $syy = 0.0;
foreach ($points as $p) {
$x = (float)$p[0];
$y = (float)$p[1];
$sx += $x; $sy += $y; $sxx += $x * $x; $sxy += $x * $y; $syy += $y * $y;
}
$denom = $n * $sxx - $sx * $sx;
if (abs($denom) < 1e-9) {
return null; // x 분산 0 (모두 같은 시각) → 기울기 미정
}
$slope = ($n * $sxy - $sx * $sy) / $denom;
$intercept = ($sy - $slope * $sx) / $n;
$ss_tot = $syy - ($sy * $sy) / $n;
$ss_res = 0.0;
foreach ($points as $p) {
$e = (float)$p[1] - ($intercept + $slope * (float)$p[0]);
$ss_res += $e * $e;
}
$r2 = $ss_tot > 1e-12 ? max(0.0, 1.0 - $ss_res / $ss_tot) : 1.0;
return ['slope' => $slope, 'intercept' => $intercept, 'r2' => $r2, 'n' => $n];
}
/**
* 임계 도달 ETA(분). cur 현재값, thr 임계, slope_per_min 분당 변화, horizon_min 지평.
* 임계 방향으로 수렴 중이고 지평 내일 때만 양수 분, 아니면 null.
*/
function threshold_eta_min(float $cur, float $thr, float $slope_per_min, float $horizon_min): ?float
{
if (abs($slope_per_min) < 1e-6) {
return null; // 정체 → 도달 안 함
}
$eta = ($thr - $cur) / $slope_per_min;
if ($eta <= 0.0 || $eta > $horizon_min) {
return null; // 이미 넘음/멀어짐 or 지평 밖
}
return $eta;
}
/**
* 여유 상태 3단계. margin = 현재값과 임계 거리(임계 안 넘었으면 양수).
* over: 이미 임계 초과, near: warn_margin 이내 근접, safe: 여유.
*/
function headroom_state(float $margin, float $warn_margin): string
{
if ($margin <= 0.0) {
return 'over';
}
if ($margin <= $warn_margin) {
return 'near';
}
return 'safe';
}
/**
* points 를 [분(minutes-from-first), value] 로 변환(회귀 x축). null 값은 제외.
* recent = [['t'=>epoch_sec, vkey=>?float], ...] 오래된→최신.
*/
function _series_minutes(array $recent, string $vkey): array
{
$pts = [];
$t0 = null;
foreach ($recent as $r) {
$v = $r[$vkey] ?? null;
if ($v === null) {
continue;
}
$t = (int)($r['t'] ?? 0);
if ($t0 === null) {
$t0 = $t;
}
$pts[] = [($t - $t0) / 60.0, (float)$v];
}
return $pts;
}
/**
* 한 온습도 센서의 예보 계산.
* $temp/$hum: 현재값(없으면 null). $recent: [['t'=>epoch,'temp'=>,'hum'=>],...] 오래된→최신.
* $c: 튜닝/임계 배열 — temp_high, temp_low, rh_high, rh_low(각 null=비활성),
* dew_margin(>0 시 결로 판정), warn_temp, warn_rh, horizon_min, min_points, min_r2,
* rate_temp_10, rate_rh_10.
* 반환: ['headroom'=>[...], 'trend'=>[...], 'dew'=>..|null, 'rate'=>..].
*/
function forecast_metric(?float $temp, ?float $hum, array $recent, array $c): array
{
$out = ['headroom' => [], 'trend' => ['temp' => null, 'hum' => null], 'dew' => null, 'rate' => []];
// ── 임계 여유 ──────────────────────────────────────────────
$band = [
['key' => 'high_temp', 'label' => '고온', 'cur' => $temp, 'thr' => $c['temp_high'] ?? null, 'unit' => '℃', 'above' => true, 'warn' => $c['warn_temp'] ?? 2.0],
['key' => 'low_temp', 'label' => '저온', 'cur' => $temp, 'thr' => $c['temp_low'] ?? null, 'unit' => '℃', 'above' => false, 'warn' => $c['warn_temp'] ?? 2.0],
['key' => 'high_humidity', 'label' => '고습', 'cur' => $hum, 'thr' => $c['rh_high'] ?? null, 'unit' => '%', 'above' => true, 'warn' => $c['warn_rh'] ?? 8.0],
['key' => 'low_humidity', 'label' => '저습', 'cur' => $hum, 'thr' => $c['rh_low'] ?? null, 'unit' => '%', 'above' => false, 'warn' => $c['warn_rh'] ?? 8.0],
];
foreach ($band as $b) {
if ($b['cur'] === null || $b['thr'] === null) {
continue; // 값 없거나 임계 비활성
}
// above=고온/고습: margin = thr - cur (양수면 아직 여유). below=저온/저습: margin = cur - thr.
$margin = $b['above'] ? ($b['thr'] - $b['cur']) : ($b['cur'] - $b['thr']);
$out['headroom'][] = [
'key' => $b['key'], 'label' => $b['label'], 'thr' => $b['thr'], 'unit' => $b['unit'],
'margin' => round($margin, 1), 'state' => headroom_state($margin, (float)$b['warn']),
];
}
// ── 추세 ETA + 변화율 ──────────────────────────────────────
$minPts = (int)($c['min_points'] ?? 6);
$minR2 = (float)($c['min_r2'] ?? 0.5);
$horizon = (float)($c['horizon_min'] ?? 180.0);
foreach (['temp' => [$temp, $c['temp_high'] ?? null, $c['temp_low'] ?? null],
'hum' => [$hum, $c['rh_high'] ?? null, $c['rh_low'] ?? null]] as $vk => $vv) {
[$cur, $thrHigh, $thrLow] = $vv;
if ($cur === null) {
continue;
}
$series = _series_minutes($recent, $vk);
if (count($series) < $minPts) {
continue;
}
$tr = linear_trend($series);
if ($tr === null) {
continue;
}
$slope = $tr['slope']; // 분당
$dir = abs($slope) < 1e-4 ? 'flat' : ($slope > 0 ? 'up' : 'down');
$eta = null;
if ($tr['r2'] >= $minR2) {
if ($slope > 0 && $thrHigh !== null) {
$m = threshold_eta_min($cur, (float)$thrHigh, $slope, $horizon);
if ($m !== null) { $eta = ['key' => $vk === 'temp' ? 'high_temp' : 'high_humidity', 'min' => (int)round($m)]; }
} elseif ($slope < 0 && $thrLow !== null) {
$m = threshold_eta_min($cur, (float)$thrLow, $slope, $horizon);
if ($m !== null) { $eta = ['key' => $vk === 'temp' ? 'low_temp' : 'low_humidity', 'min' => (int)round($m)]; }
}
}
$out['trend'][$vk] = ['slope_min' => round($slope, 3), 'dir' => $dir, 'r2' => round($tr['r2'], 2), 'eta' => $eta];
// ── 변화율 급변(최근 ~10분 환산) ──
$rateKey = $vk === 'temp' ? 'rate_temp_10' : 'rate_rh_10';
$rateThr = (float)($c[$rateKey] ?? ($vk === 'temp' ? 3.0 : 10.0));
$per10 = $slope * 10.0;
if (abs($per10) >= $rateThr) {
$out['rate'][$vk] = ['per10' => round($per10, 1), 'spike' => $slope > 0 ? 'rise' : 'drop'];
}
}
// ── 이슬점 결로 마진 ───────────────────────────────────────
$td = dew_point_c($temp, $hum);
if ($td !== null && $temp !== null) {
$margin = $temp - $td;
$dewCfg = (float)($c['dew_margin'] ?? 0.0);
$out['dew'] = [
'td' => round($td, 1),
'margin' => round($margin, 1),
'risk' => ($dewCfg > 0.0 && $margin <= $dewCfg),
'enabled' => ($dewCfg > 0.0),
];
}
return $out;
}
/**
* 신선도/오프라인 임박. last_seen_ts=epoch(없으면 null), now=epoch, timeout=하트비트 초.
* warn_ratio: timeout 대비 이 비율 넘으면 임박(기본 0.5).
* 반환 ['elapsed_sec','elapsed_min','offline','imminent'].
*/
function forecast_freshness(?int $last_seen_ts, int $now, int $timeout, float $warn_ratio = 0.5): array
{
if ($last_seen_ts === null || $timeout <= 0) {
return ['elapsed_sec' => null, 'elapsed_min' => null, 'offline' => false, 'imminent' => false];
}
$elapsed = max(0, $now - $last_seen_ts);
return [
'elapsed_sec' => $elapsed,
'elapsed_min' => (int)round($elapsed / 60),
'offline' => $elapsed >= $timeout,
'imminent' => ($elapsed < $timeout) && ($elapsed >= $timeout * $warn_ratio),
];
}
/**
* 대응 섹션용 위험 항목 목록. sensor(sensor_id·name·location·is_leak) + forecast(metric) + freshness 를 받아
* [['condition'=>, 'severity'=>'critical|warn', 'sensor_id'=>, 'name'=>, 'location'=>, 'detail'=>, 'eta_min'=>?], ...] 정규화.
* 이미 발생한 것(critical) + 임박한 것(warn) 모두 포함. 대시보드가 이 목록으로 플레이북/담당자를 붙인다.
*/
function risk_items(array $sensor, array $forecast, array $freshness): array
{
$risks = [];
$meta = ['sensor_id' => $sensor['sensor_id'] ?? 0, 'name' => $sensor['name'] ?? '', 'location' => $sensor['location'] ?? ''];
// 오프라인(critical) / 임박(warn)
if (!empty($freshness['offline'])) {
$risks[] = $meta + ['condition' => 'offline', 'severity' => 'critical', 'detail' => "마지막 수신 {$freshness['elapsed_min']}분 전", 'eta_min' => null];
} elseif (!empty($freshness['imminent'])) {
$risks[] = $meta + ['condition' => 'offline', 'severity' => 'warn', 'detail' => "수신 지연 {$freshness['elapsed_min']}분 — 오프라인 임박", 'eta_min' => null];
}
// 누수(critical)
if (!empty($sensor['is_leak'])) {
$risks[] = $meta + ['condition' => 'leak', 'severity' => 'critical', 'detail' => '누수 감지', 'eta_min' => null];
}
// 임계 초과(critical) / 근접(warn) — headroom 기반
foreach ($forecast['headroom'] ?? [] as $h) {
if ($h['state'] === 'over') {
$risks[] = $meta + ['condition' => $h['key'], 'severity' => 'critical', 'detail' => "{$h['label']} 임계 초과 (현재 여유 {$h['margin']}{$h['unit']})", 'eta_min' => null];
} elseif ($h['state'] === 'near') {
$eta = null;
foreach (['temp', 'hum'] as $vk) {
$e = $forecast['trend'][$vk]['eta'] ?? null;
if ($e && $e['key'] === $h['key']) { $eta = $e['min']; break; }
}
$detail = "{$h['label']} 임계 근접 (여유 {$h['margin']}{$h['unit']})" . ($eta !== null ? " · 현 추세 ~{$eta}분 뒤 도달" : '');
$risks[] = $meta + ['condition' => $h['key'], 'severity' => 'warn', 'detail' => $detail, 'eta_min' => $eta];
}
}
// 결로(warn) — config 활성 시
if (!empty($forecast['dew']['risk'])) {
$risks[] = $meta + ['condition' => 'condensation', 'severity' => 'warn', 'detail' => "결로 위험 (이슬점 마진 {$forecast['dew']['margin']}℃)", 'eta_min' => null];
}
return $risks;
}

View file

@ -162,62 +162,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$is_locked && !$configuration_erro
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그인 - 누수감지 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title>
<link rel="stylesheet" href="assets/app.css">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #333;
display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-box { background: #fff; border-radius: 12px; padding: 40px 36px;
box-shadow: 0 4px 24px rgba(0,0,0,.1); width: 100%; max-width: 400px; }
.login-box h1 { font-size: 1.4rem; color: #1a56db; margin-bottom: 8px; text-align: center; }
.login-box p.sub { font-size: .85rem; color: #64748b; text-align: center; margin-bottom: 28px; }
.form-group { margin-bottom: 18px; }
.form-group label { display: block; font-size: .85rem; color: #475569; margin-bottom: 6px; font-weight: 600; }
.form-group input { width: 100%; padding: 10px 14px; border: 1px solid #d1d5db; border-radius: 8px;
font-size: .95rem; outline: none; transition: border-color .2s; }
.form-group input:focus { border-color: #1a56db; box-shadow: 0 0 0 3px rgba(26,86,219,.1); }
.btn { width: 100%; padding: 12px; background: #1a56db; color: #fff; border: none;
border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer;
transition: background .2s; }
.btn:hover { background: #1347b8; }
.btn:disabled { background: #94a3b8; cursor: not-allowed; }
.error { background: #fef2f2; color: #991b1b; padding: 10px 14px; border-radius: 8px;
font-size: .85rem; margin-bottom: 18px; border: 1px solid #fecaca; }
.expired { background: #fefce8; color: #854d0e; padding: 10px 14px; border-radius: 8px;
font-size: .85rem; margin-bottom: 18px; border: 1px solid #fde68a; }
/* --- 폰 (≤480px) --- */
@media (max-width: 480px) {
.login-box { padding: 28px 20px; margin: 0 12px; }
.login-box h1 { font-size: 1.2rem; }
.login-box p.sub { font-size: .8rem; margin-bottom: 20px; }
.form-group input { font-size: .9rem; padding: 9px 12px; }
.btn { font-size: .95rem; padding: 11px; }
}
/* --- 초소형 (≤360px) --- */
@media (max-width: 360px) {
.login-box { padding: 24px 16px; margin: 0 8px; }
.login-box h1 { font-size: 1.1rem; }
.form-group input { font-size: .85rem; padding: 8px 10px; }
.btn { font-size: .9rem; padding: 10px; }
}
/* 로그인 페이지 전용: 카드 화면 중앙 정렬 (그 외 크롬/폼 스타일은 app.css) */
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: var(--sp-4); }
</style>
</head>
<body>
<div class="login-box">
<main class="login-box">
<h1>누수감지 모니터링 <?= htmlspecialchars(APP_VERSION) ?></h1>
<p class="sub">대시보드에 접근하려면 로그인하세요.</p>
<?php if ($expired): ?>
<div class="expired">세션이 만료되었습니다. 다시 로그인해주세요.</div>
<div class="expired" role="status">세션이 만료되었습니다. 다시 로그인해주세요.</div>
<?php endif; ?>
<?php if ($error): ?>
<div class="error"><?= htmlspecialchars($error) ?></div>
<div class="error" role="alert"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<?php if ($configuration_error): ?>
<div class="error">관리자 비밀번호 해시 또는 MFA 인증앱 비밀키가 설정되지 않았습니다. config.local.php의 ADMIN_PASSWORD_HASH와 ADMIN_TOTP_SECRET을 확인하세요.</div>
<div class="error" role="alert">관리자 비밀번호 해시 또는 MFA 인증앱 비밀키가 설정되지 않았습니다. config.local.php의 ADMIN_PASSWORD_HASH와 ADMIN_TOTP_SECRET을 확인하세요.</div>
<?php endif; ?>
<form method="POST" action="">
@ -240,6 +205,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$is_locked && !$configuration_erro
</div>
<button type="submit" class="btn" <?= ($is_locked || $configuration_error) ? 'disabled' : '' ?>>로그인</button>
</form>
</div>
</main>
</body>
</html>

View file

@ -1,10 +1,14 @@
<?php
// =============================================================================
// monthly_report.php - 월간 운영 보고서 (SHT30 온습도 전용)
// monthly_report.php - 월간 운영 보고서 (온습도 + 누수 통합)
//
// 집계 범위: 온습도 임계 경보(고온/저온/고습/저습), 정상복귀, 장비 오프라인/복구,
// SMS 실패, 측정 건수, 평균/최저/최고 온도·습도.
// 데이터 출처: leak_sms_log(message 태그 prefix 기준), leak_sensor_metric(온습도 측정 이력).
// 집계 범위: 온습도 임계 경보(고온/저온/고습/저습), 누수 감지/복구, 정상복귀,
// 장비 오프라인/복구, SMS 실패, 측정 건수, 평균/최저/최고 온도·습도.
// 데이터 출처: leak_sms_log(message 태그 prefix 기준 — config.php sms_summary_select_sql),
// leak_sensor_metric(온습도 측정 이력).
// 센서 표시명/위치는 대시보드·SMS 와 동일하게 sensor_label()(SENSOR_LABELS 오버라이드,
// config.local.php)로 통일한다. 센서별 집계는 sensor_id 로만 묶어(과거 보고명이 달라도
// 한 센서가 여러 행으로 쪼개지지 않게) 현재 관리주체 라벨로 표시한다.
// =============================================================================
require_once __DIR__ . '/config.php';
@ -22,180 +26,11 @@ if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
}
admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(대시보드 외 페이지에서도 강제)
function month_range(string $month): array {
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
$month = date('Y-m');
}
$start = DateTime::createFromFormat('Y-m-d H:i:s', $month . '-01 00:00:00');
if (!$start) {
$start = new DateTime('first day of this month 00:00:00');
}
$end = clone $start;
$end->modify('first day of next month');
return [$month, $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s')];
}
// SMS 메시지 태그(prefix) → 집계 키 매핑. 태그 문자열은 config.php 의 SMS_TAG_*
// 단일 출처 상수에서 가져온다(sms_send.php 가 발송 시 붙이는 태그와 자동 일치).
function report_sms_tags(): array {
return [
'high_temp' => SMS_TAG_HIGH_TEMP,
'low_temp' => SMS_TAG_LOW_TEMP,
'high_humidity' => SMS_TAG_HIGH_HUMIDITY,
'low_humidity' => SMS_TAG_LOW_HUMIDITY,
'recovery' => SMS_TAG_RECOVERY,
'device_offline' => SMS_TAG_DEVICE_OFFLINE,
'device_online' => SMS_TAG_DEVICE_ONLINE,
];
}
function round_or_null($value, int $precision = 1): ?float {
return $value !== null ? round((float)$value, $precision) : null;
}
function fmt_num(?float $value, string $unit = ''): string {
if ($value === null) {
return '-';
}
return htmlspecialchars(rtrim(rtrim(number_format($value, 1), '0'), '.')) . $unit;
}
// CSV 수식 주입(formula injection) 방지: 자유 텍스트 셀이 =,+,-,@,탭,CR 로 시작하면
// 선행 작은따옴표로 무력화한다(Excel/Sheets 가 수식으로 실행하지 않도록).
// 숫자 셀(음수 온도 등)은 호출하지 않는다 — 값이 깨지지 않도록.
function csv_cell($value): string {
$s = (string)$value;
if ($s !== '' && in_array($s[0], ['=', '+', '-', '@', "\t", "\r"], true)) {
return "'" . $s;
}
return $s;
}
$db = get_db();
[$month, $start_at, $end_at] = month_range($_GET['month'] ?? date('Y-m'));
$report_generated_at = date('Y-m-d H:i:s');
$report_scope = '온습도 임계 경보, 장비 오프라인, SMS';
// ── 월간 요약: SMS 태그별 건수 + 실패 건수 ───────────────────────────────────
$stmt = $db->prepare("
SELECT
" . sms_summary_select_sql() . "
FROM leak_sms_log
WHERE sent_at >= :start_at AND sent_at < :end_at
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
$row = $stmt->fetch() ?: [];
$summary = [
'high_temp' => (int)($row['high_temp'] ?? 0),
'low_temp' => (int)($row['low_temp'] ?? 0),
'high_humidity' => (int)($row['high_humidity'] ?? 0),
'low_humidity' => (int)($row['low_humidity'] ?? 0),
'recovery' => (int)($row['recovery'] ?? 0),
'device_offline' => (int)($row['device_offline'] ?? 0),
'device_online' => (int)($row['device_online'] ?? 0),
'sms_fail' => (int)($row['sms_fail'] ?? 0),
'metrics' => 0,
'avg_temp' => null,
'min_temp' => null,
'max_temp' => null,
'avg_humidity' => null,
'min_humidity' => null,
'max_humidity' => null,
];
$summary['threshold_alerts'] = $summary['high_temp'] + $summary['low_temp']
+ $summary['high_humidity'] + $summary['low_humidity'];
// ── 월간 요약: 측정 건수 + 평균/최저/최고 온도·습도 ───────────────────────────
$stmt = $db->prepare("
SELECT
COUNT(*) AS metrics,
AVG(temperature_c) AS avg_temp,
MIN(temperature_c) AS min_temp,
MAX(temperature_c) AS max_temp,
AVG(humidity_percent) AS avg_humidity,
MIN(humidity_percent) AS min_humidity,
MAX(humidity_percent) AS max_humidity
FROM leak_sensor_metric
WHERE measured_at >= :start_at AND measured_at < :end_at
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
$row = $stmt->fetch() ?: [];
$summary['metrics'] = (int)($row['metrics'] ?? 0);
$summary['avg_temp'] = round_or_null($row['avg_temp'] ?? null);
$summary['min_temp'] = round_or_null($row['min_temp'] ?? null);
$summary['max_temp'] = round_or_null($row['max_temp'] ?? null);
$summary['avg_humidity'] = round_or_null($row['avg_humidity'] ?? null);
$summary['min_humidity'] = round_or_null($row['min_humidity'] ?? null);
$summary['max_humidity'] = round_or_null($row['max_humidity'] ?? null);
// ── 센서별 SMS 태그별 건수 + 실패 건수 ───────────────────────────────────────
$stmt = $db->prepare("
SELECT sensor_id, sensor_name,
" . sms_summary_select_sql() . "
FROM leak_sms_log
WHERE sent_at >= :start_at AND sent_at < :end_at
GROUP BY sensor_id, sensor_name
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
$sms_by_sensor = [];
foreach ($stmt->fetchAll() as $r) {
$sms_by_sensor[(int)$r['sensor_id']] = $r;
}
// ── 센서별 측정 집계(온습도) ─────────────────────────────────────────────────
$stmt = $db->prepare("
SELECT sensor_id, sensor_name, device_location,
COUNT(*) AS metrics,
AVG(temperature_c) AS avg_temp,
MIN(temperature_c) AS min_temp,
MAX(temperature_c) AS max_temp,
AVG(humidity_percent) AS avg_humidity,
MIN(humidity_percent) AS min_humidity,
MAX(humidity_percent) AS max_humidity,
MAX(measured_at) AS last_measured_at
FROM leak_sensor_metric
WHERE measured_at >= :start_at AND measured_at < :end_at
GROUP BY sensor_id, sensor_name, device_location
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
$metric_by_sensor = [];
foreach ($stmt->fetchAll() as $r) {
$metric_by_sensor[(int)$r['sensor_id']] = $r;
}
// ── 센서별 행 병합(측정 + SMS) ───────────────────────────────────────────────
$sensor_rows = [];
foreach (array_unique(array_merge(array_keys($metric_by_sensor), array_keys($sms_by_sensor))) as $sid) {
$m = $metric_by_sensor[$sid] ?? [];
$s = $sms_by_sensor[$sid] ?? [];
$sensor_rows[] = [
'sensor_id' => $sid,
'sensor_name' => $m['sensor_name'] ?? $s['sensor_name'] ?? ('센서' . $sid),
'device_location' => $m['device_location'] ?? '',
'high_temp' => (int)($s['high_temp'] ?? 0),
'low_temp' => (int)($s['low_temp'] ?? 0),
'high_humidity' => (int)($s['high_humidity'] ?? 0),
'low_humidity' => (int)($s['low_humidity'] ?? 0),
'threshold_alerts' => (int)($s['high_temp'] ?? 0) + (int)($s['low_temp'] ?? 0)
+ (int)($s['high_humidity'] ?? 0) + (int)($s['low_humidity'] ?? 0),
'recovery' => (int)($s['recovery'] ?? 0),
'device_offline' => (int)($s['device_offline'] ?? 0),
'device_online' => (int)($s['device_online'] ?? 0),
'sms_fail' => (int)($s['sms_fail'] ?? 0),
'metrics' => (int)($m['metrics'] ?? 0),
'avg_temp' => round_or_null($m['avg_temp'] ?? null),
'min_temp' => round_or_null($m['min_temp'] ?? null),
'max_temp' => round_or_null($m['max_temp'] ?? null),
'avg_humidity' => round_or_null($m['avg_humidity'] ?? null),
'min_humidity' => round_or_null($m['min_humidity'] ?? null),
'max_humidity' => round_or_null($m['max_humidity'] ?? null),
'last_measured_at' => $m['last_measured_at'] ?? '',
];
}
usort($sensor_rows, function ($a, $b) {
return [$b['threshold_alerts'], $a['sensor_id']] <=> [$a['threshold_alerts'], $b['sensor_id']];
});
// ── 집계 데이터 계층(단일 출처) ──────────────────────────────────────────────
// 헬퍼(month_range/round_or_null/fmt_num/csv_cell)와 $summary/$sensor_rows 및 인쇄
// 뷰 전용 파생(일별 추이·누수 사고·verdict)을 이 include 가 산출한다. HTML 조회 뷰,
// CSV 다운로드, PDF 인쇄 뷰가 동일 소스를 공유한다(집계 중복 0 — 회귀 방지).
require_once __DIR__ . '/monthly_report_data.php';
// ── CSV 다운로드 ─────────────────────────────────────────────────────────────
if (($_GET['format'] ?? '') === 'csv') {
@ -210,6 +45,8 @@ if (($_GET['format'] ?? '') === 'csv') {
fputcsv($out, ['low_temp_alerts', $summary['low_temp']]);
fputcsv($out, ['high_humidity_alerts', $summary['high_humidity']]);
fputcsv($out, ['low_humidity_alerts', $summary['low_humidity']]);
fputcsv($out, ['leak_alert_sms', $summary['leak_alert']]);
fputcsv($out, ['leak_recovery_sms', $summary['leak_recovery']]);
fputcsv($out, ['recovery_sms', $summary['recovery']]);
fputcsv($out, ['device_offline_sms', $summary['device_offline']]);
fputcsv($out, ['device_online_sms', $summary['device_online']]);
@ -225,6 +62,7 @@ if (($_GET['format'] ?? '') === 'csv') {
fputcsv($out, [
'sensor_id', 'sensor_name', 'location',
'high_temp', 'low_temp', 'high_humidity', 'low_humidity', 'threshold_alerts',
'leak_alert', 'leak_recovery',
'recovery', 'device_offline', 'device_online', 'sms_fail',
'metrics', 'avg_temp_c', 'min_temp_c', 'max_temp_c',
'avg_humidity_percent', 'min_humidity_percent', 'max_humidity_percent',
@ -234,6 +72,7 @@ if (($_GET['format'] ?? '') === 'csv') {
fputcsv($out, [
$s['sensor_id'], csv_cell($s['sensor_name']), csv_cell($s['device_location']),
$s['high_temp'], $s['low_temp'], $s['high_humidity'], $s['low_humidity'], $s['threshold_alerts'],
$s['leak_alert'], $s['leak_recovery'],
$s['recovery'], $s['device_offline'], $s['device_online'], $s['sms_fail'],
$s['metrics'],
$s['avg_temp'] ?? '', $s['min_temp'] ?? '', $s['max_temp'] ?? '',
@ -244,6 +83,15 @@ if (($_GET['format'] ?? '') === 'csv') {
fclose($out);
exit;
}
// ── PDF 저장 전용 A4 문서 뷰 ─────────────────────────────────────────────────
// 다크 대시보드(app.css)를 그대로 인쇄하던 단순 프린트지를 대체한다. 화이트 A4 전문
// 보고서(표지·경영요약·지표·추이·지점별·누수사고·서명)를 렌더한다. 집계는 위 include 가
// 이미 산출했으므로 여기서 뷰만 그린다(CSV·HTML 조회 뷰는 불변 — 회귀 없음).
if (($_GET['format'] ?? '') === 'print') {
require __DIR__ . '/monthly_report_print.php';
exit;
}
?>
<!DOCTYPE html>
<html lang="ko">
@ -251,52 +99,37 @@ if (($_GET['format'] ?? '') === 'csv') {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>월간 운영 보고서 - <?= htmlspecialchars($month) ?></title>
<link rel="stylesheet" href="assets/app.css">
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; }
header { background: #1a56db; color: #fff; padding: 16px 24px; display:flex; justify-content:space-between; align-items:center; gap:12px; }
header h1 { margin:0; font-size:1.15rem; }
header a { color:#fff; text-decoration:none; background:rgba(255,255,255,.15); padding:7px 12px; border-radius:6px; font-size:.85rem; }
.container { max-width: 1060px; margin: 24px auto; padding: 0 16px; }
.toolbar { background:#fff; border-radius:8px; padding:14px 16px; box-shadow:0 1px 4px rgba(0,0,0,.1); display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:18px; }
input, button, .btn { border:1px solid #cbd5e1; border-radius:6px; padding:8px 10px; font-size:.9rem; }
button, .btn { background:#1a56db; color:#fff; border-color:#1a56db; text-decoration:none; cursor:pointer; }
.toolbar label { display:flex; align-items:center; gap:6px; }
.grid { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:12px; margin-bottom:18px; }
.card { background:#fff; border-radius:8px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.1); }
.card span { color:#64748b; font-size:.78rem; display:block; margin-bottom:5px; }
.card strong { font-size:1.35rem; }
table { width:100%; border-collapse:collapse; background:#fff; border-radius:8px; overflow:hidden; box-shadow:0 1px 4px rgba(0,0,0,.1); margin-bottom:22px; }
th, td { padding:9px 12px; border-bottom:1px solid #e2e8f0; text-align:left; font-size:.84rem; }
th { background:#f8fafc; color:#64748b; }
h2 { font-size:1rem; color:#475569; margin:22px 0 10px; }
.table-wrap { overflow-x:auto; -webkit-overflow-scrolling:touch; margin-bottom:22px; border-radius:8px; }
.table-wrap table { margin-bottom:0; min-width:860px; }
@media print {
body { background:#fff; }
header, .toolbar { display:none; }
.container { margin:0; max-width:none; }
.card, table { box-shadow:none; }
/* 월간 리포트 전용: 조회 툴바(공유 app.css 미포함). 값은 전부 토큰 — 생 hex 없음 */
.toolbar {
display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3);
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r);
box-shadow: var(--shadow); padding: var(--sp-3) var(--sp-4); margin-bottom: var(--sp-5);
}
@media (max-width:760px) { .grid { grid-template-columns: repeat(2, minmax(0,1fr)); } header { flex-direction:column; align-items:flex-start; } }
@media (max-width:520px) {
.container { margin:16px auto; padding:0 10px; }
header { padding:14px 16px; }
header h1 { font-size:1rem; }
.toolbar { display:grid; grid-template-columns:1fr; }
.toolbar label, .toolbar input, .toolbar button, .toolbar .btn { width:100%; }
.toolbar label { display:grid; gap:6px; }
.grid { grid-template-columns: repeat(2, minmax(0,1fr)); gap:8px; }
.card { padding:12px; }
.card strong { font-size:1.05rem; }
.toolbar label { display: flex; align-items: center; gap: var(--sp-2); }
.toolbar input[type="month"] {
padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--r-sm);
font-family: inherit; font-size: .875rem; color: var(--ink); background: var(--surface);
}
@media (max-width:360px) { .grid { grid-template-columns:1fr; } }
.toolbar button, .toolbar .tool-btn {
padding: 8px 14px; border: 1px solid var(--primary); border-radius: var(--r-sm);
font-size: .875rem; font-weight: 700; cursor: pointer; text-decoration: none;
background: var(--primary); color: var(--on-primary);
}
.toolbar button:hover, .toolbar .tool-btn:hover {
background: var(--primary-600); border-color: var(--primary-600);
}
/* 표 빈 상태 셀(가운데 정렬·뮤트) — app.css 는 블록형 .empty-note 만 제공 */
.no-data { text-align: center; color: var(--ink-3); }
/* 툴바는 페이지 전용 요소 → app.css print 규칙에 없어 중복이 아님 */
@media print { .toolbar { display: none; } }
</style>
</head>
<body>
<header>
<h1><?= htmlspecialchars($month) ?> 월간 운영 보고서</h1>
<div>
<div class="top-actions">
<a href="dashboard.php">대시보드</a>
</div>
</header>
@ -304,27 +137,29 @@ if (($_GET['format'] ?? '') === 'csv') {
<form class="toolbar" method="GET">
<label>월 <input type="month" name="month" value="<?= htmlspecialchars($month) ?>"></label>
<button type="submit">조회</button>
<a class="btn" href="?month=<?= htmlspecialchars($month) ?>&format=csv">CSV 다운로드</a>
<button type="button" onclick="window.print()">인쇄/PDF</button>
<a class="tool-btn" href="?month=<?= htmlspecialchars($month) ?>&format=print" target="_blank" rel="noopener">PDF 보고서</a>
<a class="tool-btn" href="?month=<?= htmlspecialchars($month) ?>&format=csv">CSV 다운로드</a>
</form>
<section class="card" style="margin-bottom:18px">
<span>보고서 생성</span>
<strong style="font-size:1rem"><?= htmlspecialchars($report_generated_at) ?></strong>
<p style="color:#64748b;font-size:.84rem;margin:8px 0 0">범위: <?= htmlspecialchars($report_scope) ?></p>
<section class="panel">
<p class="caption">보고서 생성</p>
<strong><?= htmlspecialchars($report_generated_at) ?></strong>
<p class="caption">범위: <?= htmlspecialchars($report_scope) ?></p>
</section>
<section class="grid">
<div class="card"><span>임계 경보(합계)</span><strong><?= $summary['threshold_alerts'] ?>건</strong></div>
<div class="card"><span>고온/저온 경보</span><strong><?= $summary['high_temp'] ?>/<?= $summary['low_temp'] ?>건</strong></div>
<div class="card"><span>고습/저습 경보</span><strong><?= $summary['high_humidity'] ?>/<?= $summary['low_humidity'] ?>건</strong></div>
<div class="card"><span>정상복귀</span><strong><?= $summary['recovery'] ?>건</strong></div>
<div class="card"><span>장비 오프라인/복구</span><strong><?= $summary['device_offline'] ?>/<?= $summary['device_online'] ?>건</strong></div>
<div class="card"><span>SMS 실패</span><strong><?= $summary['sms_fail'] ?>건</strong></div>
<div class="card"><span>측정 건수</span><strong><?= $summary['metrics'] ?>건</strong></div>
<div class="card"><span>평균 온도/습도</span><strong><?= fmt_num($summary['avg_temp'], '℃') ?> / <?= fmt_num($summary['avg_humidity'], '%') ?></strong></div>
<div class="card"><span>온도 최저/최고</span><strong><?= fmt_num($summary['min_temp'], '℃') ?> / <?= fmt_num($summary['max_temp'], '℃') ?></strong></div>
<div class="card"><span>습도 최저/최고</span><strong><?= fmt_num($summary['min_humidity'], '%') ?> / <?= fmt_num($summary['max_humidity'], '%') ?></strong></div>
<h2>월간 요약</h2>
<section class="report-grid">
<div class="report-stat"><span>임계 경보(합계)</span><strong><?= $summary['threshold_alerts'] ?>건</strong></div>
<div class="report-stat"><span>고온/저온 경보</span><strong><?= $summary['high_temp'] ?>/<?= $summary['low_temp'] ?>건</strong></div>
<div class="report-stat"><span>고습/저습 경보</span><strong><?= $summary['high_humidity'] ?>/<?= $summary['low_humidity'] ?>건</strong></div>
<div class="report-stat"><span>누수 감지/복구</span><strong class="<?= $summary['leak_alert'] > 0 ? 'err-color' : '' ?>"><?= $summary['leak_alert'] ?>/<?= $summary['leak_recovery'] ?>건</strong></div>
<div class="report-stat"><span>정상복귀</span><strong><?= $summary['recovery'] ?>건</strong></div>
<div class="report-stat"><span>장비 오프라인/복구</span><strong><?= $summary['device_offline'] ?>/<?= $summary['device_online'] ?>건</strong></div>
<div class="report-stat"><span>SMS 실패</span><strong><?= $summary['sms_fail'] ?>건</strong></div>
<div class="report-stat"><span>측정 건수</span><strong><?= $summary['metrics'] ?>건</strong></div>
<div class="report-stat"><span>평균 온도/습도</span><strong><?= fmt_num($summary['avg_temp'], '℃') ?> / <?= fmt_num($summary['avg_humidity'], '%') ?></strong></div>
<div class="report-stat"><span>온도 최저/최고</span><strong><?= fmt_num($summary['min_temp'], '℃') ?> / <?= fmt_num($summary['max_temp'], '℃') ?></strong></div>
<div class="report-stat"><span>습도 최저/최고</span><strong><?= fmt_num($summary['min_humidity'], '%') ?> / <?= fmt_num($summary['max_humidity'], '%') ?></strong></div>
</section>
<h2>센서별 요약</h2>
@ -332,7 +167,7 @@ if (($_GET['format'] ?? '') === 'csv') {
<table>
<thead><tr>
<th>센서</th><th>위치</th>
<th>임계 경보</th><th>고온/저온</th><th>고습/저습</th>
<th>임계 경보</th><th>고온/저온</th><th>고습/저습</th><th>누수 감지/복구</th>
<th>정상복귀</th><th>오프라인/복구</th><th>SMS 실패</th>
<th>측정</th><th>평균 온도/습도</th><th>온도 최저/최고</th><th>습도 최저/최고</th>
<th>최근 측정</th>
@ -345,6 +180,7 @@ if (($_GET['format'] ?? '') === 'csv') {
<td><?= $s['threshold_alerts'] ?></td>
<td><?= $s['high_temp'] ?>/<?= $s['low_temp'] ?></td>
<td><?= $s['high_humidity'] ?>/<?= $s['low_humidity'] ?></td>
<td class="<?= $s['leak_alert'] > 0 ? 'err-color' : '' ?>"><?= $s['leak_alert'] ?>/<?= $s['leak_recovery'] ?></td>
<td><?= $s['recovery'] ?></td>
<td><?= $s['device_offline'] ?>/<?= $s['device_online'] ?></td>
<td><?= $s['sms_fail'] ?></td>
@ -356,7 +192,7 @@ if (($_GET['format'] ?? '') === 'csv') {
</tr>
<?php endforeach; ?>
<?php if (empty($sensor_rows)): ?>
<tr><td colspan="13" style="text-align:center;color:#94a3b8">해당 월 측정·경보 내역이 없습니다.</td></tr>
<tr><td colspan="14" class="no-data">해당 월 측정·경보 내역이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>

396
php/monthly_report_data.php Normal file
View file

@ -0,0 +1,396 @@
<?php
// =============================================================================
// monthly_report_data.php - 월간 보고서 공유 데이터 계층 (단일 출처)
//
// monthly_report.php(HTML 조회 + CSV 다운로드)와 monthly_report_print.php(PDF
// 저장용 A4 문서 뷰)가 함께 require 한다. 집계 로직을 이 한 곳에만 두어 두 뷰가
// 동일한 값을 쓰게 하고(중복 = 회귀 위험), 인쇄 뷰 전용의 일별 추이·누수 사고·
// 판정(verdict) 파생을 여기서 함께 산출한다.
//
// 산출 변수(require 후 호출부에서 사용):
// $db, $month, $start_at, $end_at, $report_generated_at, $report_scope,
// $summary, $sensor_rows,
// $cal_days, $daily_temp, $daily_hum, $daily_metric_alerts, $daily_leak_alerts,
// $incidents, $has_incident_table, $unrecovered_leak,
// $verdict, $verdict_label, $verdict_reason
//
// 제공 헬퍼: month_range(), round_or_null(), fmt_num(), csv_cell(),
// report_table_exists()
// =============================================================================
// include 전용 — 웹 직접 접근 차단(.htaccess 와 이중 방어). 호출부(monthly_report.php)
// 가 세션 인증 후 require 한다. 익명 요청 컨텍스트에서 단독 실행되지 않게 막는다.
if (PHP_SAPI !== 'cli'
&& basename((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === basename(__FILE__)) {
http_response_code(404);
exit;
}
require_once __DIR__ . '/config.php';
// ─── 공용 헬퍼 (기존 monthly_report.php 에서 이관 — 로직 무변경) ────────────────
if (!function_exists('month_range')) {
function month_range(string $month): array {
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
$month = date('Y-m');
}
$start = DateTime::createFromFormat('Y-m-d H:i:s', $month . '-01 00:00:00');
if (!$start) {
$start = new DateTime('first day of this month 00:00:00');
}
$end = clone $start;
$end->modify('first day of next month');
return [$month, $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s')];
}
}
if (!function_exists('round_or_null')) {
function round_or_null($value, int $precision = 1): ?float {
return $value !== null ? round((float)$value, $precision) : null;
}
}
if (!function_exists('fmt_num')) {
function fmt_num(?float $value, string $unit = ''): string {
if ($value === null) {
return '-';
}
return htmlspecialchars(rtrim(rtrim(number_format($value, 1), '0'), '.')) . $unit;
}
}
// CSV 수식 주입(formula injection) 방지: 자유 텍스트 셀이 =,+,-,@,탭,CR 로 시작하면
// 선행 작은따옴표로 무력화한다(Excel/Sheets 가 수식으로 실행하지 않도록).
// 숫자 셀(음수 온도 등)은 호출하지 않는다 — 값이 깨지지 않도록.
if (!function_exists('csv_cell')) {
function csv_cell($value): string {
$s = (string)$value;
if ($s !== '' && in_array($s[0], ['=', '+', '-', '@', "\t", "\r"], true)) {
return "'" . $s;
}
return $s;
}
}
// 테이블 존재 확인(정보스키마) — dashboard_data.php 의 table_exists() 와 동일 취지지만
// 그 파일은 include 시 부작용(전역 쿼리) 위험이 있어 결합을 피한다. 부재 시 graceful
// degrade(누수 사고 섹션·판정 폴백). 요청 내 static 캐시로 재조회 방지.
if (!function_exists('report_table_exists')) {
function report_table_exists(PDO $db, string $table): bool {
static $cache = [];
if (array_key_exists($table, $cache)) {
return $cache[$table];
}
try {
$stmt = $db->prepare("
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table
");
$stmt->execute([':table' => $table]);
return $cache[$table] = (int)$stmt->fetchColumn() > 0;
} catch (Throwable $e) {
return $cache[$table] = false;
}
}
}
$db = get_db();
[$month, $start_at, $end_at] = month_range($_GET['month'] ?? date('Y-m'));
$report_generated_at = date('Y-m-d H:i:s');
$report_scope = '온습도 임계 경보, 누수 감지, 장비 오프라인, SMS';
// ── 월간 요약: SMS 태그별 건수 + 실패 건수 + 총 발송 건수 ─────────────────────
$stmt = $db->prepare("
SELECT
" . sms_summary_select_sql() . ",
COUNT(*) AS total_sms
FROM leak_sms_log
WHERE sent_at >= :start_at AND sent_at < :end_at
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
$row = $stmt->fetch() ?: [];
$summary = [
'high_temp' => (int)($row['high_temp'] ?? 0),
'low_temp' => (int)($row['low_temp'] ?? 0),
'high_humidity' => (int)($row['high_humidity'] ?? 0),
'low_humidity' => (int)($row['low_humidity'] ?? 0),
'recovery' => (int)($row['recovery'] ?? 0),
'device_offline' => (int)($row['device_offline'] ?? 0),
'device_online' => (int)($row['device_online'] ?? 0),
'leak_alert' => (int)($row['leak_alert'] ?? 0),
'leak_recovery' => (int)($row['leak_recovery'] ?? 0),
'sms_fail' => (int)($row['sms_fail'] ?? 0),
'total_sms' => (int)($row['total_sms'] ?? 0),
'metrics' => 0,
'avg_temp' => null,
'min_temp' => null,
'max_temp' => null,
'avg_humidity' => null,
'min_humidity' => null,
'max_humidity' => null,
];
$summary['threshold_alerts'] = $summary['high_temp'] + $summary['low_temp']
+ $summary['high_humidity'] + $summary['low_humidity'];
// ── 월간 요약: 측정 건수 + 평균/최저/최고 온도·습도 ───────────────────────────
$stmt = $db->prepare("
SELECT
COUNT(*) AS metrics,
AVG(temperature_c) AS avg_temp,
MIN(temperature_c) AS min_temp,
MAX(temperature_c) AS max_temp,
AVG(humidity_percent) AS avg_humidity,
MIN(humidity_percent) AS min_humidity,
MAX(humidity_percent) AS max_humidity
FROM leak_sensor_metric
WHERE measured_at >= :start_at AND measured_at < :end_at
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
$row = $stmt->fetch() ?: [];
$summary['metrics'] = (int)($row['metrics'] ?? 0);
$summary['avg_temp'] = round_or_null($row['avg_temp'] ?? null);
$summary['min_temp'] = round_or_null($row['min_temp'] ?? null);
$summary['max_temp'] = round_or_null($row['max_temp'] ?? null);
$summary['avg_humidity'] = round_or_null($row['avg_humidity'] ?? null);
$summary['min_humidity'] = round_or_null($row['min_humidity'] ?? null);
$summary['max_humidity'] = round_or_null($row['max_humidity'] ?? null);
// SMS 신뢰성(성공률, %) — 총 발송 대비 성공 비율. 발송이 없으면 100%로 본다.
$summary['sms_reliability_pct'] = $summary['total_sms'] > 0
? (int)round(($summary['total_sms'] - $summary['sms_fail']) / $summary['total_sms'] * 100)
: 100;
// ── 센서별 SMS 태그별 건수 + 실패 건수 ───────────────────────────────────────
// sensor_id 로만 묶는다(보고명 sensor_name 은 시기별로 다를 수 있어 GROUP 키에서 제외 —
// 한 센서가 여러 행으로 쪼개지는 것 방지). 표시명은 아래에서 sensor_label() 로 결정.
$stmt = $db->prepare("
SELECT sensor_id, MAX(sensor_name) AS sensor_name,
" . sms_summary_select_sql() . "
FROM leak_sms_log
WHERE sent_at >= :start_at AND sent_at < :end_at
GROUP BY sensor_id
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
$sms_by_sensor = [];
foreach ($stmt->fetchAll() as $r) {
$sms_by_sensor[(int)$r['sensor_id']] = $r;
}
// ── 센서별 측정 집계(온습도) ─────────────────────────────────────────────────
// 마찬가지로 sensor_id 로만 묶고, 보고명/위치는 최신 대표값(MAX)만 폴백용으로 가져온다.
$stmt = $db->prepare("
SELECT sensor_id,
MAX(sensor_name) AS sensor_name,
MAX(device_location) AS device_location,
COUNT(*) AS metrics,
AVG(temperature_c) AS avg_temp,
MIN(temperature_c) AS min_temp,
MAX(temperature_c) AS max_temp,
AVG(humidity_percent) AS avg_humidity,
MIN(humidity_percent) AS min_humidity,
MAX(humidity_percent) AS max_humidity,
MAX(measured_at) AS last_measured_at
FROM leak_sensor_metric
WHERE measured_at >= :start_at AND measured_at < :end_at
GROUP BY sensor_id
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
$metric_by_sensor = [];
foreach ($stmt->fetchAll() as $r) {
$metric_by_sensor[(int)$r['sensor_id']] = $r;
}
// ── 센서별 행 병합(측정 + SMS) ───────────────────────────────────────────────
$sensor_rows = [];
foreach (array_unique(array_merge(array_keys($metric_by_sensor), array_keys($sms_by_sensor))) as $sid) {
$m = $metric_by_sensor[$sid] ?? [];
$s = $sms_by_sensor[$sid] ?? [];
// 표시명/위치: SENSOR_LABELS(현재 관리주체) 우선, 없으면 보고 대표값 폴백 — 대시보드·SMS 와 동일
[$loc, $name] = sensor_label((int)$sid, $m['device_location'] ?? null, $m['sensor_name'] ?? $s['sensor_name'] ?? null);
$threshold_alerts = (int)($s['high_temp'] ?? 0) + (int)($s['low_temp'] ?? 0)
+ (int)($s['high_humidity'] ?? 0) + (int)($s['low_humidity'] ?? 0);
$leak_alert = (int)($s['leak_alert'] ?? 0);
$sensor_rows[] = [
'sensor_id' => $sid,
'sensor_name' => $name,
'device_location' => $loc,
'high_temp' => (int)($s['high_temp'] ?? 0),
'low_temp' => (int)($s['low_temp'] ?? 0),
'high_humidity' => (int)($s['high_humidity'] ?? 0),
'low_humidity' => (int)($s['low_humidity'] ?? 0),
'threshold_alerts' => $threshold_alerts,
'leak_alert' => $leak_alert,
'leak_recovery' => (int)($s['leak_recovery'] ?? 0),
'recovery' => (int)($s['recovery'] ?? 0),
'device_offline' => (int)($s['device_offline'] ?? 0),
'device_online' => (int)($s['device_online'] ?? 0),
'sms_fail' => (int)($s['sms_fail'] ?? 0),
'metrics' => (int)($m['metrics'] ?? 0),
'avg_temp' => round_or_null($m['avg_temp'] ?? null),
'min_temp' => round_or_null($m['min_temp'] ?? null),
'max_temp' => round_or_null($m['max_temp'] ?? null),
'avg_humidity' => round_or_null($m['avg_humidity'] ?? null),
'min_humidity' => round_or_null($m['min_humidity'] ?? null),
'max_humidity' => round_or_null($m['max_humidity'] ?? null),
'last_measured_at' => $m['last_measured_at'] ?? '',
'alert_sort' => $threshold_alerts + $leak_alert, // 정렬용(온습도 경보 + 누수 감지)
];
}
usort($sensor_rows, function ($a, $b) {
return [$b['alert_sort'], $a['sensor_id']] <=> [$a['alert_sort'], $b['sensor_id']];
});
// ─── 인쇄 뷰 전용 파생: 당월 일자 축(cal_days) ────────────────────────────────
// 일별 차트가 28~31 가변 말일을 정확히 다루도록 1일~말일 전체 축을 만든다. 결측일은
// null 로 남겨(추이 라인 끊김) 왜곡을 막는다.
$cal_days = [];
try {
$cursor = new DateTime($start_at);
$stop = new DateTime($end_at);
while ($cursor < $stop) {
$cal_days[] = $cursor->format('Y-m-d');
$cursor->modify('+1 day');
}
} catch (Throwable $e) {
$cal_days = [];
}
// ─── 쿼리A: 일별 평균 온·습도 (strip-chart 용) ────────────────────────────────
$daily_temp = array_fill_keys($cal_days, null);
$daily_hum = array_fill_keys($cal_days, null);
try {
$stmt = $db->prepare("
SELECT DATE(measured_at) AS d,
AVG(temperature_c) AS avg_t,
AVG(humidity_percent) AS avg_h,
COUNT(*) AS c
FROM leak_sensor_metric
WHERE measured_at >= :start_at AND measured_at < :end_at
GROUP BY DATE(measured_at)
ORDER BY d
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
foreach ($stmt->fetchAll() as $r) {
$d = (string)$r['d'];
if (!array_key_exists($d, $daily_temp)) { continue; }
$daily_temp[$d] = $r['avg_t'] !== null ? round((float)$r['avg_t'], 1) : null;
$daily_hum[$d] = $r['avg_h'] !== null ? round((float)$r['avg_h'], 1) : null;
}
} catch (Throwable $e) {
// 스키마 편차(컬럼 부재 등) → 빈 추이로 graceful degrade
$daily_temp = array_fill_keys($cal_days, null);
$daily_hum = array_fill_keys($cal_days, null);
}
// ─── 쿼리B: 일별 경보 발생(온습도 임계 + 누수 감지) — 스택 막대용 ─────────────
$daily_metric_alerts = array_fill_keys($cal_days, 0);
$daily_leak_alerts = array_fill_keys($cal_days, 0);
try {
$stmt = $db->prepare("
SELECT DATE(measured_at) AS d, COUNT(*) AS c
FROM leak_sensor_metric
WHERE measured_at >= :start_at AND measured_at < :end_at
AND metric_status <> 'normal'
GROUP BY DATE(measured_at)
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
foreach ($stmt->fetchAll() as $r) {
$d = (string)$r['d'];
if (array_key_exists($d, $daily_metric_alerts)) {
$daily_metric_alerts[$d] = (int)$r['c'];
}
}
} catch (Throwable $e) {
$daily_metric_alerts = array_fill_keys($cal_days, 0);
}
try {
$stmt = $db->prepare("
SELECT DATE(sensor_time) AS d, COUNT(*) AS c
FROM leak_sensor_log
WHERE sensor_time >= :start_at AND sensor_time < :end_at
AND event_type = 'leak_alert'
GROUP BY DATE(sensor_time)
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
foreach ($stmt->fetchAll() as $r) {
$d = (string)$r['d'];
if (array_key_exists($d, $daily_leak_alerts)) {
$daily_leak_alerts[$d] = (int)$r['c'];
}
}
} catch (Throwable $e) {
$daily_leak_alerts = array_fill_keys($cal_days, 0);
}
// ─── 쿼리C: 월중 누수 사고 로그(leak_incident, 존재 시) ───────────────────────
$has_incident_table = report_table_exists($db, 'leak_incident');
$incidents = [];
if ($has_incident_table) {
try {
$stmt = $db->prepare("
SELECT id, sensor_id, sensor_name, device_location, started_at, recovered_at
FROM leak_incident
WHERE started_at >= :start_at AND started_at < :end_at
ORDER BY started_at ASC
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
foreach ($stmt->fetchAll() as $r) {
[$loc, $name] = sensor_label((int)$r['sensor_id'], $r['device_location'] ?? null, $r['sensor_name'] ?? null);
$started = $r['started_at'] ?? null;
$recovered = $r['recovered_at'] ?? null;
// 지속시간(분) — 미복구(null)는 진행중
$duration_min = null;
if ($started !== null && $recovered !== null) {
$ds = strtotime($started); $de = strtotime($recovered);
if ($ds !== false && $de !== false && $de >= $ds) {
$duration_min = (int)round(($de - $ds) / 60);
}
}
$incidents[] = [
'id' => (int)$r['id'],
'sensor_id' => (int)$r['sensor_id'],
'sensor_name' => $name,
'device_location' => $loc,
'started_at' => $started,
'recovered_at' => $recovered,
'duration_min' => $duration_min,
'ongoing' => $recovered === null,
];
}
} catch (Throwable $e) {
$incidents = [];
}
}
// ─── 판정(verdict) 파생 — 씰·경영 요약이 공유하는 결정론적 규칙 ───────────────
// 미복구 누수: leak_incident 가 있으면 당월 사고 중 recovered_at IS NULL 개수,
// 없으면 [누수감지] SMS − [누수복구] SMS 차이로 폴백(일관성).
if ($has_incident_table) {
$unrecovered_leak = 0;
foreach ($incidents as $inc) {
if ($inc['ongoing']) { $unrecovered_leak++; }
}
} else {
$unrecovered_leak = max(0, $summary['leak_alert'] - $summary['leak_recovery']);
}
if ($unrecovered_leak > 0 || $summary['sms_fail'] > 0) {
$verdict = 'alert';
$verdict_label = '경보';
$bits = [];
if ($unrecovered_leak > 0) { $bits[] = "미복구 누수 {$unrecovered_leak}건"; }
if ($summary['sms_fail'] > 0) { $bits[] = "SMS 실패 {$summary['sms_fail']}건"; }
$verdict_reason = implode(' · ', $bits);
} elseif ($summary['threshold_alerts'] > 0 || $summary['device_offline'] > 0) {
$verdict = 'warn';
$verdict_label = '주의';
$bits = [];
if ($summary['threshold_alerts'] > 0) { $bits[] = "임계 경보 {$summary['threshold_alerts']}건"; }
if ($summary['device_offline'] > 0) { $bits[] = "오프라인 {$summary['device_offline']}건"; }
$verdict_reason = implode(' · ', $bits);
} else {
$verdict = 'ok';
$verdict_label = '안정';
$verdict_reason = '미복구 누수·SMS 실패·임계 경보·오프라인 없음';
}

View file

@ -0,0 +1,340 @@
<?php
// =============================================================================
// monthly_report_print.php - 월간 운영 보고서 · PDF 저장 전용 A4 문서 뷰
//
// monthly_report.php 의 ?format=print 분기에서 require 된다(단독 진입점 아님).
// 집계 변수는 monthly_report_data.php 가 이미 산출해 이 스코프에 있다:
// $month, $start_at, $end_at, $report_generated_at, $report_scope, $summary,
// $sensor_rows, $cal_days, $daily_temp, $daily_hum, $daily_metric_alerts,
// $daily_leak_alerts, $incidents, $has_incident_table, $unrecovered_leak,
// $verdict, $verdict_label, $verdict_reason
//
// app.css(다크 셸)는 링크하지 않는다 — assets/report-print.css 만 링크해 다크 토큰과
// 격리한다. 차트는 report_charts.php 의 인라인 SVG 서버렌더(폐쇄망: 외부 참조 0).
// 모든 동적 텍스트는 htmlspecialchars, 숫자는 fmt_num() 으로 출력한다.
// =============================================================================
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단(monthly_report.php 경유 전용)
require_once __DIR__ . '/report_charts.php';
// ── 표시용 파생값 ─────────────────────────────────────────────────────────────
$h = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
$month_dot = str_replace('-', '.', $month); // 2026-06 → 2026.06
$year_n = (int)substr($month, 0, 4);
$month_n = (int)substr($month, 5, 2);
$doc_no = 'ENV-' . $month; // 문서번호
try {
$dt_last = new DateTime($end_at);
$dt_last->modify('-1 day');
$period_end = $dt_last->format('Y-m-d');
} catch (Throwable $e) {
$period_end = substr($end_at, 0, 10);
}
$period_start = substr($start_at, 0, 10);
// 관제 대상(설정 기준)
$metric_ids = defined('METRIC_SENSOR_IDS') ? METRIC_SENSOR_IDS : [];
$leak_ids = defined('LEAK_SENSOR_IDS') ? LEAK_SENSOR_IDS : [];
$scope_total = count(array_unique(array_merge($metric_ids, $leak_ids)));
$total_alerts = $summary['threshold_alerts'] + $summary['leak_alert'];
$incident_count = $has_incident_table ? count($incidents) : $summary['leak_alert'];
// verdict 색 클래스(요약 카드/문장)
$verdict_cls = 'is-' . ($verdict === 'alert' ? 'alert' : ($verdict === 'warn' ? 'warn' : 'ok'));
$verdict_color = rc_verdict_color($verdict);
// 자동 총평 한 줄
$auto_summary = sprintf(
'당월 온습도 측정 %s건 · 임계 경보 %d건 · 누수 감지 %d건이 집계되었으며, SMS 발송 신뢰성은 %d%%(%d건 중 실패 %d건)입니다.',
number_format($summary['metrics']),
$summary['threshold_alerts'],
$summary['leak_alert'],
$summary['sms_reliability_pct'],
$summary['total_sms'],
$summary['sms_fail']
);
// 센서(지점)별 판정 — 데이터 층 verdict 규칙과 동일(미복구 누수는 월 단위라 여기선 누수감지로 근사)
$sensor_verdict = function (array $s): array {
if ($s['leak_alert'] > 0 || $s['sms_fail'] > 0) { return ['alert', '경보']; }
if ($s['threshold_alerts'] > 0 || $s['device_offline'] > 0) { return ['warn', '주의']; }
return ['ok', '안정'];
};
// 임계 기준 문자열
$rh_low_txt = (METRIC_RH_LOW >= 0) ? (rc_fmt((float)METRIC_RH_LOW) . '%') : '비활성';
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>정보시설 관제 월간 운영 보고서 - <?= $h($month) ?></title>
<link rel="stylesheet" href="assets/report-print.css">
</head>
<body>
<div class="report-canvas">
<!-- 화면 전용 안내 + 툴바(인쇄 시 자동 숨김) -->
<div class="screen-toolbar screen-only">
<a class="ghost" href="monthly_report.php?month=<?= $h($month) ?>">← 조회 화면</a>
<button type="button" onclick="window.print()">PDF로 저장 / 인쇄</button>
<a class="ghost" href="dashboard.php">대시보드</a>
</div>
<div class="print-note screen-only">
이 화면은 <b>PDF 저장 전용 A4 보고서</b> 미리보기입니다. <b>Ctrl+P</b>(⌘P) → 대상 <b>“PDF로 저장”</b>,
용지 <b>A4</b>, 여백 <b>기본</b>, <b>“배경 그래픽”</b> 옵션을 켜 주세요. 페이지 번호가 필요하면 인쇄
대화상자의 <b>머리글/바닥글</b> 옵션을 사용하세요(폐쇄망 정책상 문서 내 자동 페이지번호는 미탑재).
</div>
<!-- 러닝 헤더(매 장 반복) -->
<div class="run-header">
<span><b>정보시설 관제 월간 운영 보고서</b> · <?= $h($month_dot) ?></span>
<span class="cls">대외비 (CONFIDENTIAL)</span>
</div>
<!-- 러닝 푸터(매 장 반복 — 문서번호·생성일시로 추적성 확보) -->
<div class="run-footer">
<span>문서번호 <?= $h($doc_no) ?> · 생성 <?= $h($report_generated_at) ?></span>
<span>서버실·문서고 온습도/누수 관제 시스템 <?= $h(APP_VERSION) ?></span>
</div>
<div class="report-paper">
<div class="report-body">
<!-- ═══════════ 1. 표지 마스트헤드 ═══════════ -->
<section class="cover">
<div class="cover-seal">
<?= render_verdict_seal($verdict, $verdict_label) ?>
<div class="seal-cap">종합 판정</div>
</div>
<div class="cover-eyebrow">정보시설 관제 · 온습도 / 누수</div>
<h1 class="cover-title">정보시설 관제<br>월간 운영 보고서</h1>
<p class="cover-sub">서버실 · 문서고 온습도 및 누수 모니터링 / SMS 경보 운영 결과</p>
<div class="cover-month"><?= $h($month_dot) ?></div>
<div class="cover-period">대상 기간 <?= $h($period_start) ?> ~ <?= $h($period_end) ?> (<?= $year_n ?>년 <?= $month_n ?>월)</div>
<div class="cover-scope">
<div class="scope-item">
<div class="k">관제 지점</div>
<div class="v"><?= (int)$scope_total ?>개소</div>
<div class="t">온습도 <?= count($metric_ids) ?> · 누수 <?= count($leak_ids) ?></div>
</div>
<div class="scope-item">
<div class="k">정보기반</div>
<div class="v">서버실(안)</div>
<div class="t">온습도 계측</div>
</div>
<div class="scope-item">
<div class="k">조직혁신 · 고객센터</div>
<div class="v">문서고 · 서버실(밖)</div>
<div class="t">누수 감시</div>
</div>
</div>
<div class="doc-control">
<div><div class="dk">문서번호</div><div class="dv"><?= $h($doc_no) ?></div></div>
<div><div class="dk">생성 일시</div><div class="dv"><?= $h($report_generated_at) ?></div></div>
<div><div class="dk">문서 분류</div><div class="dv cls">대외비</div></div>
<div><div class="dk">보고 범위</div><div class="dv"><?= $h($report_scope) ?></div></div>
<div><div class="dk">데이터 출처</div><div class="dv">leak_sms_log · leak_sensor_metric</div></div>
<div><div class="dk">시스템</div><div class="dv">관제 <?= $h(APP_VERSION) ?></div></div>
</div>
</section>
<!-- ═══════════ 2. 경영 요약 ═══════════ -->
<section class="section page-break">
<div class="section-title"><span class="no">01</span> 경영 요약 <span class="en">EXECUTIVE SUMMARY</span></div>
<div class="kpi-row">
<?php
// SMS 신뢰성 게이지·카드는 종합 판정과 독립적으로 지표 자체 기준 색을 쓴다
// (누수 등으로 판정이 경보여도 SMS 전달이 100%면 네이비로 정확히 표현).
$rel_pct = (int)$summary['sms_reliability_pct'];
$rel_fail = (int)$summary['sms_fail'];
$rel_color = rc_reliability_color($rel_pct, $rel_fail);
$rel_class = $rel_fail <= 0 ? 'is-ok' : 'is-warn';
?>
<div class="kpi-card <?= $rel_class ?>">
<div class="kpi-gauge-wrap">
<?= render_uptime_gauge($rel_pct, $rel_color, $rel_pct . '%', 'SMS 신뢰성') ?>
</div>
</div>
<div class="kpi-card <?= $total_alerts > 0 ? 'is-warn' : 'is-ok' ?>">
<div class="kpi-k">총 경보</div>
<div class="kpi-v"><?= (int)$total_alerts ?></div>
<div class="kpi-s">임계 <?= (int)$summary['threshold_alerts'] ?> · 누수 <?= (int)$summary['leak_alert'] ?></div>
</div>
<div class="kpi-card <?= $summary['leak_alert'] > 0 ? 'is-alert' : 'is-ok' ?>">
<div class="kpi-k">누수 감지</div>
<div class="kpi-v"><?= (int)$summary['leak_alert'] ?></div>
<div class="kpi-s"><?= $has_incident_table ? ('사고 ' . (int)$incident_count . '건 · 미복구 ' . (int)$unrecovered_leak) : ('복구 ' . (int)$summary['leak_recovery'] . '건') ?></div>
</div>
<div class="kpi-card is-ok">
<div class="kpi-k">측정 건수</div>
<div class="kpi-v"><?= number_format($summary['metrics']) ?></div>
<div class="kpi-s">평균 <?= fmt_num($summary['avg_temp'], '℃') ?> / <?= fmt_num($summary['avg_humidity'], '%') ?></div>
</div>
</div>
<div class="exec-summary">
<div class="verdict-line <?= $verdict_cls ?>">종합 판정: <?= $h($verdict_label) ?><?= $verdict_reason !== '' ? ' — ' . $h($verdict_reason) : '' ?></div>
<p><?= $h($auto_summary) ?></p>
</div>
</section>
<!-- ═══════════ 3. 월간 지표 상세 ═══════════ -->
<section class="section">
<div class="section-title"><span class="no">02</span> 월간 지표 상세 <span class="en">MONTHLY METRICS</span></div>
<div class="metric-grid">
<div class="metric-block">
<h3>경보 · 이벤트</h3>
<table class="def">
<tr><th>고온 경보</th><td><?= (int)$summary['high_temp'] ?>건</td></tr>
<tr><th>저온 경보</th><td><?= (int)$summary['low_temp'] ?>건</td></tr>
<tr><th>고습 경보</th><td><?= (int)$summary['high_humidity'] ?>건</td></tr>
<tr><th>저습 경보</th><td><?= (int)$summary['low_humidity'] ?>건</td></tr>
<tr><th>임계 경보 합계</th><td><?= (int)$summary['threshold_alerts'] ?>건</td></tr>
<tr><th>누수 감지 / 복구</th><td class="<?= $summary['leak_alert'] > 0 ? 'is-alert' : '' ?>"><?= (int)$summary['leak_alert'] ?> / <?= (int)$summary['leak_recovery'] ?>건</td></tr>
<tr><th>정상 복귀</th><td><?= (int)$summary['recovery'] ?>건</td></tr>
<tr><th>장비 오프라인 / 복구</th><td><?= (int)$summary['device_offline'] ?> / <?= (int)$summary['device_online'] ?>건</td></tr>
<tr><th>SMS 실패 / 총 발송</th><td class="<?= $summary['sms_fail'] > 0 ? 'is-alert' : '' ?>"><?= (int)$summary['sms_fail'] ?> / <?= (int)$summary['total_sms'] ?>건</td></tr>
</table>
</div>
<div class="metric-block">
<h3>온습도 측정</h3>
<table class="def">
<tr><th>측정 건수</th><td><?= number_format($summary['metrics']) ?>건</td></tr>
<tr><th>평균 온도</th><td><?= fmt_num($summary['avg_temp'], '℃') ?></td></tr>
<tr><th>온도 최저 / 최고</th><td><?= fmt_num($summary['min_temp'], '℃') ?> / <?= fmt_num($summary['max_temp'], '℃') ?></td></tr>
<tr><th>평균 습도</th><td><?= fmt_num($summary['avg_humidity'], '%') ?></td></tr>
<tr><th>습도 최저 / 최고</th><td><?= fmt_num($summary['min_humidity'], '%') ?> / <?= fmt_num($summary['max_humidity'], '%') ?></td></tr>
<tr><th>SMS 신뢰성</th><td><?= (int)$summary['sms_reliability_pct'] ?>%</td></tr>
<tr><th>고온 / 저온 임계</th><td><?= $h(rc_fmt((float)METRIC_TEMP_HIGH_C)) ?>℃ / <?= $h(rc_fmt((float)METRIC_TEMP_LOW_C)) ?>℃</td></tr>
<tr><th>고습 / 저습 임계</th><td><?= $h(rc_fmt((float)METRIC_RH_HIGH)) ?>% / <?= $h($rh_low_txt) ?></td></tr>
</table>
</div>
</div>
</section>
<!-- ═══════════ 4. 월중 추이(차트) ═══════════ -->
<section class="section">
<div class="section-title"><span class="no">03</span> 월중 추이 <span class="en">DAILY TRENDS</span></div>
<div class="chart-panel">
<h3>일별 평균 온·습도</h3>
<p class="chart-cap">임계 밴드(고온 적 · 안전 녹 · 저온 청)를 배경에 인쇄. 온도 실선(좌축) · 습도 파선(우축). 결측일은 선이 끊깁니다.</p>
<?= render_daily_temp_hum_line($cal_days, $daily_temp, $daily_hum) ?>
</div>
<div class="chart-panel">
<h3>일별 경보 발생</h3>
<p class="chart-cap">온습도 임계 경보(네이비)와 누수 감지(적 · 사선)를 당월 1일~말일 축에 스택으로 표시합니다.</p>
<?= render_daily_alert_bars($cal_days, $daily_metric_alerts, $daily_leak_alerts) ?>
</div>
</section>
<!-- ═══════════ 5. 관제 지점별 상세 ═══════════ -->
<section class="section">
<div class="section-title"><span class="no">04</span> 관제 지점별 상세 <span class="en">BY LOCATION</span></div>
<?php if (empty($sensor_rows)): ?>
<div class="chart-empty">해당 월 측정·경보 내역이 없습니다.</div>
<?php else: ?>
<div class="loc-cards">
<?php foreach ($sensor_rows as $s): [$sv_cls, $sv_lab] = $sensor_verdict($s); ?>
<div class="loc-card">
<div class="lc-head">
<div>
<div class="lc-name"><?= $h($s['sensor_name']) ?></div>
<div class="lc-loc"><?= $h($s['device_location']) ?></div>
</div>
<span class="pill is-<?= $sv_cls ?>"><?= $h($sv_lab) ?></span>
</div>
<dl>
<dt>임계 경보</dt><dd><?= (int)$s['threshold_alerts'] ?>건</dd>
<dt>고온/저온</dt><dd><?= (int)$s['high_temp'] ?>/<?= (int)$s['low_temp'] ?></dd>
<dt>고습/저습</dt><dd><?= (int)$s['high_humidity'] ?>/<?= (int)$s['low_humidity'] ?></dd>
<dt>누수 감지/복구</dt><dd class="<?= $s['leak_alert'] > 0 ? 'is-alert' : '' ?>"><?= (int)$s['leak_alert'] ?>/<?= (int)$s['leak_recovery'] ?></dd>
<dt>오프라인/복구</dt><dd><?= (int)$s['device_offline'] ?>/<?= (int)$s['device_online'] ?></dd>
<dt>SMS 실패</dt><dd class="<?= $s['sms_fail'] > 0 ? 'is-alert' : '' ?>"><?= (int)$s['sms_fail'] ?></dd>
<dt>측정</dt><dd><?= number_format($s['metrics']) ?>건</dd>
<dt>평균 온/습</dt><dd><?= fmt_num($s['avg_temp'], '℃') ?> / <?= fmt_num($s['avg_humidity'], '%') ?></dd>
<dt>온도 최저/최고</dt><dd><?= fmt_num($s['min_temp'], '℃') ?> / <?= fmt_num($s['max_temp'], '℃') ?></dd>
<dt>습도 최저/최고</dt><dd><?= fmt_num($s['min_humidity'], '%') ?> / <?= fmt_num($s['max_humidity'], '%') ?></dd>
<dt>최근 측정</dt><dd><?= $h($s['last_measured_at'] ?: '-') ?></dd>
</dl>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<!-- ═══════════ 6. 누수 사고 로그 ═══════════ -->
<section class="section">
<div class="section-title"><span class="no">05</span> 누수 사고 로그 <span class="en">LEAK INCIDENTS</span></div>
<?php if (!$has_incident_table): ?>
<div class="chart-empty">누수 사고 이력(leak_incident) 테이블이 도입되지 않은 배포입니다. SMS 태그 집계로 대체합니다 — 당월 누수 감지 <?= (int)$summary['leak_alert'] ?>건 / 복구 <?= (int)$summary['leak_recovery'] ?>건.</div>
<?php elseif (empty($incidents)): ?>
<div class="incident-none">✓ 당월 누수 사고 없음 (0건) — 감시 구간 내 누수 미발생.</div>
<?php else: ?>
<table class="grid">
<thead><tr><th>발생 일시</th><th>지점</th><th>위치</th><th>지속</th><th>상태</th></tr></thead>
<tbody>
<?php foreach ($incidents as $inc): ?>
<tr>
<td class="l"><?= $h($inc['started_at']) ?></td>
<td class="l"><?= $h($inc['sensor_name']) ?></td>
<td class="l"><?= $h($inc['device_location']) ?></td>
<td><?php
if ($inc['ongoing']) { echo '진행중'; }
elseif ($inc['duration_min'] !== null) {
$mm = (int)$inc['duration_min'];
echo $mm >= 60 ? intdiv($mm, 60) . '시간 ' . ($mm % 60) . '분' : $mm . '분';
} else { echo '-'; }
?></td>
<td class="<?= $inc['ongoing'] ? 'is-alert' : '' ?>"><?= $inc['ongoing'] ? '미복구' : '복구' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>
<!-- ═══════════ 7. 기준 · 주석 및 서명 ═══════════ -->
<section class="section">
<div class="section-title"><span class="no">06</span> 기준 · 주석 및 서명 <span class="en">BASIS &amp; SIGN-OFF</span></div>
<div class="basis-grid">
<div class="basis-block">
<h3>판정 · 임계 기준</h3>
<ul>
<li><b>임계</b> 고온 <?= $h(rc_fmt((float)METRIC_TEMP_HIGH_C)) ?>℃ · 저온 <?= $h(rc_fmt((float)METRIC_TEMP_LOW_C)) ?>℃ · 고습 <?= $h(rc_fmt((float)METRIC_RH_HIGH)) ?>% · 저습 <?= $h($rh_low_txt) ?></li>
<li><b>판정 규칙</b> 미복구 누수 또는 SMS 실패 &gt; 0 → 경보 · 임계 경보 또는 오프라인 발생 → 주의 · 그 외 → 안정</li>
<li><b>오프라인 판정</b> <?= (int)(HEARTBEAT_TIMEOUT_SEC / 60) ?>분 무수신 · <b>경보 쿨다운</b> 온습도 <?= (int)(METRIC_ALERT_COOLDOWN_SEC / 60) ?>분 / 누수 <?= (int)(LEAK_ALERT_COOLDOWN_SEC / 60) ?>분</li>
</ul>
</div>
<div class="basis-block">
<h3>데이터 · 주석</h3>
<ul>
<li><b>출처</b> leak_sms_log(태그 접두 집계) · leak_sensor_metric(측정) · leak_sensor_log/leak_incident(누수)</li>
<li><b>집계 범위</b> <?= $h($period_start) ?> 00:00 ~ <?= $h($period_end) ?> 24:00 (센서는 sensor_id 기준, 표시명은 관리주체 라벨)</li>
<li><b>개인정보</b> 수신번호는 표시 단계에서 마스킹(mask_phone) · 본 문서 분류: <span style="color:#b11f1f;font-weight:700">대외비</span></li>
</ul>
</div>
</div>
<div class="sign-row">
<div class="sign-box"><div class="sk">작성</div><div class="sv"></div><div class="sn">직/성명 · 일자</div></div>
<div class="sign-box"><div class="sk">검토</div><div class="sv"></div><div class="sn">직/성명 · 일자</div></div>
<div class="sign-box"><div class="sk">승인</div><div class="sv"></div><div class="sn">직/성명 · 일자</div></div>
</div>
</section>
</div><!-- /report-body -->
</div><!-- /report-paper -->
</div><!-- /report-canvas -->
</body>
</html>

View file

@ -94,7 +94,7 @@ function collect_ops_checks(): array {
}
$default_recipients = count(SMS_RECIPIENTS) === 1 && preg_replace('/\D+/', '', SMS_RECIPIENTS[0]) === '01000000000';
$checks[] = ops_check('설정', 'API 키', API_KEY !== 'change-this-api-key', 'API_KEY 운영값 설정 여부', 'config.local.php와 /etc/sht30-sensor.env(또는 펌웨어 secrets.h)의 API 키를 동일하게 설정', 'critical', 'config.local.php 원문 미공개 설정 확인');
$checks[] = ops_check('설정', 'API 키', API_KEY !== 'change-this-api-key', 'API_KEY 운영값 설정 여부', 'config.local.php와 라즈베리파이 config_local.py(또는 STM32 펌웨어 secrets.h)의 API 키를 동일하게 설정', 'critical', 'config.local.php 원문 미공개 설정 확인');
$checks[] = ops_check('설정', '관리자 비밀번호', ADMIN_PASSWORD_HASH !== '', 'ADMIN_PASSWORD_HASH 설정 여부', 'php setup_hash.php "new-password" 실행 후 config.local.php에 반영', 'critical', '관리자 해시 설정 확인');
$checks[] = ops_check('관리자 인증', '관리자 MFA', admin_mfa_configured(), 'ADMIN_TOTP_SECRET 설정 여부', '인증 앱용 Base32 비밀키를 config.local.php에 설정', 'critical', 'MFA 등록 확인표');
$checks[] = ops_check('관리자 인증', 'MFA 설정 토큰 정리', MFA_SETUP_TOKEN === '', MFA_SETUP_TOKEN === '' ? '정리됨(비어 있음)' : '설정됨 — 등록 후 제거 필요', 'MFA 등록을 마쳤으면 config.local.php의 MFA_SETUP_TOKEN을 빈 값으로 비우세요. 남아 있으면 토큰 보유자가 setup_mfa.php로 새 TOTP 비밀키를 발급·검증할 수 있습니다.', 'warn', 'config.local.php MFA_SETUP_TOKEN 미설정 확인');

348
php/report_charts.php Normal file
View file

@ -0,0 +1,348 @@
<?php
// =============================================================================
// report_charts.php - 월간 보고서 인라인 SVG 차트 렌더 함수 (폐쇄망: 외부 0)
//
// monthly_report_print.php 가 include 해 호출한다. 전부 서버렌더 인라인 SVG —
// 차트 라이브러리·외부 폰트·CDN 없음. 색은 인쇄 안전 팔레트(app.css @media print
// 토큰과 값 정합)를 hex 로 직접 박아 print-color-adjust:exact 로 유실을 막는다.
// trend.php 의 좌표/스케일(x_at·y_temp·y_hum·build_segments·threshold) 로직을 이식하고
// 임계 밴드 배경을 더했다.
//
// 흑백 프린터 대비: 색 외 채널을 병행한다 — 온도 실선 vs 습도 파선, 누수 막대 사선
// 해치, 판정 씰 텍스트 라벨. (색·도형·텍스트 3중 인코딩)
// =============================================================================
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단(include 전용)
// ─── 인쇄 안전 팔레트(단일 출처) ──────────────────────────────────────────────
const RC_NAVY = '#14315e'; // 기준/온습도 경보 막대
const RC_INK = '#1a1f2b';
const RC_SUB = '#5b6472';
const RC_HAIR = '#c9d0da'; // 그리드/괘선
const RC_OK = '#0a6b39'; // 안정(녹)
const RC_WARN = '#8a5a00'; // 주의(황갈)
const RC_ALERT = '#b11f1f'; // 경보/누수(적)
const RC_TEMP = '#b34a1f'; // 온도 실선(cat-hot)
const RC_HUM = '#0f5e74'; // 습도 파선(cat-cold)
const RC_BAND_SAFE = '#e7f0ea'; // 안전 밴드(녹)
const RC_BAND_HOT = '#f6e3dc'; // 고온 밴드(적)
const RC_BAND_COLD = '#dfeef2'; // 저온 밴드(청)
// verdict → 색 매핑(씰·게이지 공유)
function rc_verdict_color(string $verdict): string {
return match ($verdict) {
'alert' => RC_ALERT,
'warn' => RC_WARN,
default => RC_NAVY, // ok(안정)은 신뢰감 있는 네이비
};
}
// '높을수록 좋은' 지표(SMS 신뢰성 등) 자체 기준 색 — 종합 판정과 독립.
// 실패 0 = 완전(네이비) / 경미한 실패 = 주의(황) / 저하 = 경보(적).
// 종합 판정이 경보여도 SMS 전달이 100%면 이 게이지는 네이비로 정확히 표현한다.
function rc_reliability_color(int $pct, int $fail_count): string {
if ($fail_count <= 0) { return RC_NAVY; } // 실패 0 → 완전 신뢰
if ($pct >= 95) { return RC_WARN; } // 경미
return RC_ALERT; // 저하
}
// 짧은 수치 포맷(소수 1자리, 불필요한 0 제거)
function rc_fmt(float $v): string {
return rtrim(rtrim(number_format($v, 1, '.', ''), '0'), '.');
}
// =============================================================================
// 1) 판정 씰 — 결재 도장형 스탬프. verdict 색·라벨 자동. 표지/경영요약 공유.
// =============================================================================
function render_verdict_seal(string $verdict, string $label): string {
$c = rc_verdict_color($verdict);
$lab = htmlspecialchars($label);
ob_start(); ?>
<svg viewBox="0 0 120 120" width="112" height="112" role="img"
aria-label="관제 판정: <?= $lab ?>" class="verdict-seal"
style="print-color-adjust:exact;-webkit-print-color-adjust:exact">
<g transform="rotate(-8 60 60)" fill="none" stroke="<?= $c ?>">
<circle cx="60" cy="60" r="56" stroke-width="2.4"></circle>
<circle cx="60" cy="60" r="48" stroke-width="1"></circle>
</g>
<g transform="rotate(-8 60 60)" fill="<?= $c ?>" stroke="none" text-anchor="middle">
<text x="60" y="34" font-size="10.5" font-weight="700" letter-spacing="1.5">관제 판정</text>
<text x="60" y="74" font-size="30" font-weight="800" letter-spacing="2"><?= $lab ?></text>
<text x="60" y="94" font-size="7.5" font-weight="700" letter-spacing="3">MONTHLY REVIEW</text>
</g>
</svg>
<?php
return (string)ob_get_clean();
}
// =============================================================================
// 2) 원형 게이지 — 값 %(0~100). 경영 요약 KPI(예: SMS 신뢰성). trend.php 게이지 이식.
// =============================================================================
function render_uptime_gauge(int $pct, string $color, string $center_big, string $center_small): string {
$pct = max(0, min(100, $pct));
ob_start(); ?>
<svg viewBox="0 0 120 120" width="100%" height="120" role="img"
aria-label="<?= htmlspecialchars($center_small) ?> <?= $pct ?>%" class="kpi-gauge"
style="print-color-adjust:exact;-webkit-print-color-adjust:exact">
<g transform="rotate(-90 60 60)" fill="none" stroke-linecap="round">
<circle cx="60" cy="60" r="50" pathLength="100" stroke="<?= RC_HAIR ?>" stroke-width="10"></circle>
<circle cx="60" cy="60" r="50" pathLength="100" stroke="<?= htmlspecialchars($color) ?>"
stroke-width="10" stroke-dasharray="<?= $pct ?> 100"></circle>
</g>
<text x="60" y="58" text-anchor="middle" font-size="26" font-weight="800"
fill="<?= RC_INK ?>"><?= htmlspecialchars($center_big) ?></text>
<text x="60" y="76" text-anchor="middle" font-size="9" font-weight="600"
fill="<?= RC_SUB ?>"><?= htmlspecialchars($center_small) ?></text>
</svg>
<?php
return (string)ob_get_clean();
}
// =============================================================================
// 3) 일별 평균 온·습도 strip-chart — 임계 밴드 배경 + 온도 실선 + 습도 파선(이중축).
// $cal_days: ['Y-m-d',...] $daily_temp/$daily_hum: 동일 순서, 결측 null.
// =============================================================================
function render_daily_temp_hum_line(array $cal_days, array $daily_temp, array $daily_hum): string {
$n = count($cal_days);
$temps = array_values($daily_temp); // cal_days 순서 유지(값 배열)
$hums = array_values($daily_hum);
$has_temp = count(array_filter($temps, fn($v) => $v !== null)) > 0;
$has_hum = count(array_filter($hums, fn($v) => $v !== null)) > 0;
if ($n === 0 || (!$has_temp && !$has_hum)) {
return '<div class="chart-empty">당월 측정 데이터가 없습니다.</div>';
}
$W = 920; $H = 300;
$pad_l = 46; $pad_r = 48; $pad_t = 22; $pad_b = 40;
$plot_w = $W - $pad_l - $pad_r;
$plot_h = $H - $pad_t - $pad_b;
// 임계 상수(밴드/스케일에 포함해 항상 보이게)
$t_high = (float)METRIC_TEMP_HIGH_C;
$t_low = (float)METRIC_TEMP_LOW_C;
// 온도 축: 데이터 + 임계 포함해 범위 산정
$t_vals = array_filter($temps, fn($v) => $v !== null);
$t_pool = array_merge($t_vals, [$t_high, $t_low]);
$t_min = min($t_pool); $t_max = max($t_pool);
if ($t_min === $t_max) { $t_min -= 1; $t_max += 1; }
$t_span = ($t_max - $t_min) * 0.12;
$t_min -= $t_span; $t_max += $t_span;
// 습도 축: 데이터 기준
$h_vals = array_filter($hums, fn($v) => $v !== null);
if ($h_vals) {
$h_min = min($h_vals); $h_max = max($h_vals);
if ($h_min === $h_max) { $h_min -= 1; $h_max += 1; }
$h_span = ($h_max - $h_min) * 0.12;
$h_min -= $h_span; $h_max += $h_span;
} else {
$h_min = 0; $h_max = 100;
}
$x_at = function (int $i) use ($pad_l, $plot_w, $n): float {
if ($n <= 1) { return $pad_l + $plot_w / 2; }
return $pad_l + ($plot_w * $i / ($n - 1));
};
$y_temp = function (?float $v) use ($pad_t, $plot_h, $t_min, $t_max): ?float {
if ($v === null) { return null; }
$r = ($t_max - $t_min) ?: 1;
return $pad_t + $plot_h * (1 - (($v - $t_min) / $r));
};
$y_hum = function (?float $v) use ($pad_t, $plot_h, $h_min, $h_max): ?float {
if ($v === null) { return null; }
$r = ($h_max - $h_min) ?: 1;
return $pad_t + $plot_h * (1 - (($v - $h_min) / $r));
};
$clampy = fn(float $y) => max($pad_t, min($pad_t + $plot_h, $y));
// 결측(null)은 구간을 끊어 polyline 을 만든다
$build_segments = function (array $vals, callable $y_fn) use ($x_at): array {
$segments = []; $current = [];
foreach ($vals as $i => $v) {
if ($v === null) {
if (count($current) >= 2) { $segments[] = $current; }
$current = [];
continue;
}
$y = $y_fn((float)$v);
$current[] = sprintf('%.1f,%.1f', $x_at($i), $y);
}
if (count($current) >= 2) { $segments[] = $current; }
return $segments;
};
$temp_segments = $build_segments($temps, $y_temp);
$hum_segments = $build_segments($hums, $y_hum);
// 임계 밴드 경계(온도축)
$y_high = $clampy($y_temp($t_high));
$y_low = $clampy($y_temp($t_low));
$band_top = $pad_t; $band_bottom = $pad_t + $plot_h;
ob_start(); ?>
<svg viewBox="0 0 <?= $W ?> <?= $H ?>" width="100%" height="<?= $H ?>"
preserveAspectRatio="xMidYMid meet" role="img"
aria-label="당월 일별 평균 온도·습도 추이(임계 밴드 배경)" class="strip-chart"
style="print-color-adjust:exact;-webkit-print-color-adjust:exact">
<!-- 임계 밴드: 고온(적)·안전(녹)·저온(청) -->
<rect x="<?= $pad_l ?>" y="<?= sprintf('%.1f', $band_top) ?>" width="<?= $plot_w ?>"
height="<?= sprintf('%.1f', max(0, $y_high - $band_top)) ?>" fill="<?= RC_BAND_HOT ?>"></rect>
<rect x="<?= $pad_l ?>" y="<?= sprintf('%.1f', $y_high) ?>" width="<?= $plot_w ?>"
height="<?= sprintf('%.1f', max(0, $y_low - $y_high)) ?>" fill="<?= RC_BAND_SAFE ?>"></rect>
<rect x="<?= $pad_l ?>" y="<?= sprintf('%.1f', $y_low) ?>" width="<?= $plot_w ?>"
height="<?= sprintf('%.1f', max(0, $band_bottom - $y_low)) ?>" fill="<?= RC_BAND_COLD ?>"></rect>
<rect x="<?= $pad_l ?>" y="<?= $pad_t ?>" width="<?= $plot_w ?>" height="<?= $plot_h ?>"
fill="none" stroke="<?= RC_HAIR ?>" stroke-width="1"></rect>
<!-- 가로 그리드 + 좌(온도)/우(습도) 눈금 -->
<?php for ($g = 0; $g <= 4; $g++):
$gy = $pad_t + $plot_h * $g / 4;
$tv = $t_max - ($t_max - $t_min) * $g / 4;
$hv = $h_max - ($h_max - $h_min) * $g / 4; ?>
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $gy) ?>" x2="<?= $pad_l + $plot_w ?>"
y2="<?= sprintf('%.1f', $gy) ?>" stroke="<?= RC_HAIR ?>" stroke-width="0.6" stroke-dasharray="3,3"></line>
<text x="<?= $pad_l - 7 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="end"
font-size="10" fill="<?= RC_TEMP ?>"><?= htmlspecialchars(rc_fmt($tv)) ?></text>
<text x="<?= $pad_l + $plot_w + 7 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="start"
font-size="10" fill="<?= RC_HUM ?>"><?= htmlspecialchars(rc_fmt($hv)) ?></text>
<?php endfor; ?>
<!-- 임계선(온도 고온/저온) 라벨 -->
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $y_high) ?>" x2="<?= $pad_l + $plot_w ?>"
y2="<?= sprintf('%.1f', $y_high) ?>" stroke="<?= RC_ALERT ?>" stroke-width="1" stroke-dasharray="2,3" opacity="0.7"></line>
<text x="<?= $pad_l + 4 ?>" y="<?= sprintf('%.1f', $y_high - 3) ?>" font-size="9"
fill="<?= RC_ALERT ?>">고온 <?= htmlspecialchars(rc_fmt($t_high)) ?>℃</text>
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $y_low) ?>" x2="<?= $pad_l + $plot_w ?>"
y2="<?= sprintf('%.1f', $y_low) ?>" stroke="<?= RC_HUM ?>" stroke-width="1" stroke-dasharray="2,3" opacity="0.7"></line>
<text x="<?= $pad_l + 4 ?>" y="<?= sprintf('%.1f', $y_low + 11) ?>" font-size="9"
fill="<?= RC_HUM ?>">저온 <?= htmlspecialchars(rc_fmt($t_low)) ?>℃</text>
<!-- X축 날짜(일자) 라벨: 1/5/10.../말일 -->
<?php
for ($i = 0; $i < $n; $i++):
$day = (int)substr($cal_days[$i], 8, 2);
$is_tick = ($day === 1 || $day % 5 === 0 || $i === $n - 1);
if (!$is_tick) { continue; }
$lx = $x_at($i);
$anchor = $i === 0 ? 'start' : ($i === $n - 1 ? 'end' : 'middle'); ?>
<line x1="<?= sprintf('%.1f', $lx) ?>" y1="<?= $pad_t + $plot_h ?>" x2="<?= sprintf('%.1f', $lx) ?>"
y2="<?= $pad_t + $plot_h + 4 ?>" stroke="<?= RC_HAIR ?>" stroke-width="0.8"></line>
<text x="<?= sprintf('%.1f', $lx) ?>" y="<?= $H - 22 ?>" text-anchor="<?= $anchor ?>"
font-size="9.5" fill="<?= RC_SUB ?>"><?= $day ?></text>
<?php endfor; ?>
<text x="<?= $pad_l + $plot_w / 2 ?>" y="<?= $H - 6 ?>" text-anchor="middle"
font-size="9" fill="<?= RC_SUB ?>">일(당월)</text>
<!-- 습도 파선 -->
<?php foreach ($hum_segments as $seg): ?>
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>" fill="none" stroke="<?= RC_HUM ?>"
stroke-width="1.8" stroke-dasharray="6,3" stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endforeach; ?>
<!-- 온도 실선 -->
<?php foreach ($temp_segments as $seg): ?>
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>" fill="none" stroke="<?= RC_TEMP ?>"
stroke-width="2" stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endforeach; ?>
</svg>
<div class="chart-legend-row">
<span><span class="lg-line" style="background:<?= RC_TEMP ?>"></span> 평균 온도(℃, 좌축·실선)</span>
<span><span class="lg-line dash" style="background:<?= RC_HUM ?>"></span> 평균 습도(%, 우축·파선)</span>
<span><span class="lg-band" style="background:<?= RC_BAND_SAFE ?>"></span> 안전대</span>
<span><span class="lg-band" style="background:<?= RC_BAND_HOT ?>"></span> 고온대</span>
<span><span class="lg-band" style="background:<?= RC_BAND_COLD ?>"></span> 저온대</span>
</div>
<?php
return (string)ob_get_clean();
}
// =============================================================================
// 4) 일별 경보 발생 스택 막대 — 온습도 임계(네이비) + 누수 감지(적·사선 해치).
// =============================================================================
function render_daily_alert_bars(array $cal_days, array $daily_metric, array $daily_leak): string {
$n = count($cal_days);
$metric = array_values($daily_metric);
$leak = array_values($daily_leak);
if ($n === 0) {
return '<div class="chart-empty">당월 일자 축이 없습니다.</div>';
}
$totals = [];
for ($i = 0; $i < $n; $i++) {
$totals[$i] = (int)($metric[$i] ?? 0) + (int)($leak[$i] ?? 0);
}
$peak = max(1, max($totals));
// Y 눈금 상한을 보기 좋은 정수로 올림(최대 4단계)
$step = (int)max(1, ceil($peak / 4));
$y_top = $step * 4;
$W = 920; $H = 240;
$pad_l = 40; $pad_r = 16; $pad_t = 20; $pad_b = 40;
$plot_w = $W - $pad_l - $pad_r;
$plot_h = $H - $pad_t - $pad_b;
$slot = $plot_w / $n;
$bar_w = min(22, $slot * 0.6);
ob_start(); ?>
<svg viewBox="0 0 <?= $W ?> <?= $H ?>" width="100%" height="<?= $H ?>"
preserveAspectRatio="xMidYMid meet" role="img"
aria-label="당월 일별 경보 발생(온습도 임계 + 누수 감지) 스택 막대"
style="print-color-adjust:exact;-webkit-print-color-adjust:exact">
<defs>
<pattern id="leakHatch" patternUnits="userSpaceOnUse" width="5" height="5" patternTransform="rotate(45)">
<rect width="5" height="5" fill="<?= RC_ALERT ?>"></rect>
<line x1="0" y1="0" x2="0" y2="5" stroke="#fff" stroke-width="1.4"></line>
</pattern>
</defs>
<rect x="<?= $pad_l ?>" y="<?= $pad_t ?>" width="<?= $plot_w ?>" height="<?= $plot_h ?>"
fill="none" stroke="<?= RC_HAIR ?>" stroke-width="1"></rect>
<!-- Y 그리드 + 눈금 -->
<?php for ($g = 0; $g <= 4; $g++):
$gv = $y_top - $step * $g;
$gy = $pad_t + $plot_h * $g / 4; ?>
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $gy) ?>" x2="<?= $pad_l + $plot_w ?>"
y2="<?= sprintf('%.1f', $gy) ?>" stroke="<?= RC_HAIR ?>" stroke-width="0.6" stroke-dasharray="3,3"></line>
<text x="<?= $pad_l - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="end"
font-size="10" fill="<?= RC_SUB ?>"><?= $gv ?></text>
<?php endfor; ?>
<!-- 스택 막대 -->
<?php for ($i = 0; $i < $n; $i++):
$mv = (int)($metric[$i] ?? 0);
$lv = (int)($leak[$i] ?? 0);
$cx = $pad_l + $slot * ($i + 0.5);
$bx = $cx - $bar_w / 2;
$base = $pad_t + $plot_h;
// 온습도(네이비) 아래, 누수(해치) 위
$m_h = $mv > 0 ? ($plot_h * $mv / $y_top) : 0;
$l_h = $lv > 0 ? ($plot_h * $lv / $y_top) : 0;
$m_y = $base - $m_h;
$l_y = $m_y - $l_h;
?>
<?php if ($mv > 0): ?>
<rect x="<?= sprintf('%.1f', $bx) ?>" y="<?= sprintf('%.1f', $m_y) ?>" width="<?= sprintf('%.1f', $bar_w) ?>"
height="<?= sprintf('%.1f', $m_h) ?>" fill="<?= RC_NAVY ?>"></rect>
<?php endif; ?>
<?php if ($lv > 0): ?>
<rect x="<?= sprintf('%.1f', $bx) ?>" y="<?= sprintf('%.1f', $l_y) ?>" width="<?= sprintf('%.1f', $bar_w) ?>"
height="<?= sprintf('%.1f', $l_h) ?>" fill="url(#leakHatch)" stroke="<?= RC_ALERT ?>" stroke-width="0.5"></rect>
<?php endif; ?>
<?php
$day = (int)substr($cal_days[$i], 8, 2);
if ($day === 1 || $day % 5 === 0 || $i === $n - 1): ?>
<text x="<?= sprintf('%.1f', $cx) ?>" y="<?= $H - 22 ?>" text-anchor="middle"
font-size="9.5" fill="<?= RC_SUB ?>"><?= $day ?></text>
<?php endif; ?>
<?php endfor; ?>
<text x="<?= $pad_l + $plot_w / 2 ?>" y="<?= $H - 6 ?>" text-anchor="middle"
font-size="9" fill="<?= RC_SUB ?>">일(당월) · 발생 건수</text>
</svg>
<div class="chart-legend-row">
<span><span class="lg-sw" style="background:<?= RC_NAVY ?>"></span> 온습도 임계 경보</span>
<span><span class="lg-sw hatch"></span> 누수 감지</span>
</div>
<?php
return (string)ob_get_clean();
}

View file

@ -27,7 +27,7 @@ $control_rows = [
['사업 범위', '서버실 온습도(SHT30) 모니터링 및 임계치 초과 SMS 경보, 장비 오프라인 알림', '보안대책서 사업개요', 'SECURITY_PLAN_PASS_READINESS.md'],
['전송구간', 'HTTPS/TLS 사용, API 서명 검증', 'api/sensor_data.php, config.php', 'SOURCE_SECURITY_EVIDENCE.md'],
['API 인증', '공유 API 키 기반 요청 서명 검증', 'config.php', '403 테스트 결과'],
['비밀값', 'config.local.php와 /etc/sht30-sensor.env로 분리', 'config.php', '설정 파일 권한 화면'],
['비밀값', 'config.local.php(서버)와 config_local.py(라즈베리파이)로 분리', 'config.php', '설정 파일 권한 화면'],
['관리자 인증', '비밀번호 해시, CSRF, 세션 보호, 실패 제한, 관리자 MFA', 'login.php, admin_security.php', '로그인 화면, MFA 등록 확인표'],
['감사로그', '로그인 성공/실패, 로그아웃, 관리자 활동 접속기록 저장', 'admin_security.php, php/var/admin_audit.log', '감사로그 파일'],
['로그관리', '센서 측정 이력(leak_sensor_metric)·SMS 발송 이력(leak_sms_log)·관리자 감사로그 저장 및 보관기간 정리', 'MySQL, retention_cleanup.php', '보관기간 정리 dry-run 결과'],
@ -68,56 +68,43 @@ if (($_GET['format'] ?? '') === 'md') {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>보안 증적 보고서 - 온습도 모니터링 <?= htmlspecialchars(APP_VERSION) ?></title>
<link rel="stylesheet" href="assets/app.css">
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; }
header { background: #1a56db; color: #fff; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
header h1 { font-size: 1.15rem; margin: 0; }
header a, .btn { color: #fff; text-decoration: none; background: rgba(255,255,255,.15); padding: 7px 12px; border-radius: 6px; font-size: .85rem; border: 0; cursor: pointer; }
.container { max-width: 1120px; margin: 24px auto; padding: 0 16px; }
.summary { background: #fff; border-radius: 8px; padding: 18px; box-shadow: 0 1px 4px rgba(0,0,0,.1); margin-bottom: 18px; }
.summary strong { font-size: 1.2rem; color: <?= $failed_count ? '#b91c1c' : '#166534' ?>; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.actions a, .actions button { background: #1a56db; color: #fff; border: 0; border-radius: 6px; padding: 8px 12px; text-decoration: none; font-weight: 700; cursor: pointer; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.08); margin-bottom: 24px; }
th, td { border-bottom: 1px solid #e2e8f0; padding: 9px 10px; text-align: left; font-size: .84rem; vertical-align: top; }
th { background: #f8fafc; color: #475569; }
h2 { font-size: 1rem; color: #334155; margin: 24px 0 10px; }
.ok { color: #166534; font-weight: 700; }
.fail { color: #b91c1c; font-weight: 700; }
.muted { color: #64748b; font-size: .84rem; }
@media print {
body { background: #fff; }
header, .actions { display: none; }
.container { margin: 0; max-width: none; }
table, .summary { box-shadow: none; }
}
@media (max-width: 760px) {
header { align-items: flex-start; flex-direction: column; }
table { display: block; overflow-x: auto; }
/* 보안 증적 전용 잔여 스타일 — 값은 app.css 토큰(var)만 사용 */
.evi-summary { margin-bottom: var(--sp-5); }
.evi-summary strong { font-size: 1.25rem; }
.evi-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; margin-top: var(--sp-4); }
.evi-actions a, .evi-actions button {
display: inline-block; background: var(--primary); color: var(--on-primary);
border: 0; border-radius: var(--r-sm); padding: 8px 14px;
font-size: .8125rem; font-weight: 700; font-family: var(--font);
text-decoration: none; cursor: pointer;
}
.evi-actions a:hover, .evi-actions button:hover { background: var(--primary-600); }
@media print { .evi-actions { display: none; } }
</style>
</head>
<body>
<header>
<h1>보안 증적 보고서 <?= htmlspecialchars(APP_VERSION) ?></h1>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div class="top-actions">
<a href="dashboard.php">대시보드</a>
<a href="setup_wizard.php">설치 점검</a>
</div>
</header>
<main class="container">
<section class="summary">
<strong><?= $failed_count === 0 ? '전체 점검 통과' : "확인 필요 {$failed_count}건" ?></strong>
<p class="muted">생성시각: <?= htmlspecialchars($generated_at) ?> · 버전: <?= htmlspecialchars(APP_VERSION) ?></p>
<p class="muted">이 보고서는 보안대책서 첨부용 운영 점검 결과와 보안통제 매트릭스를 제공합니다. 비밀번호, API 키, SMS 인증키 원문은 표시하지 않습니다.</p>
<div class="actions">
<section class="panel evi-summary">
<strong class="<?= $failed_count === 0 ? 'check-ok' : 'check-fail' ?>"><?= $failed_count === 0 ? '전체 점검 통과' : "확인 필요 {$failed_count}건" ?></strong>
<p class="caption">생성시각: <?= htmlspecialchars($generated_at) ?> · 버전: <?= htmlspecialchars(APP_VERSION) ?></p>
<p class="caption">이 보고서는 보안대책서 첨부용 운영 점검 결과와 보안통제 매트릭스를 제공합니다. 비밀번호, API 키, SMS 인증키 원문은 표시하지 않습니다.</p>
<div class="evi-actions">
<a href="?format=md">Markdown 다운로드</a>
<button type="button" onclick="window.print()">인쇄/PDF</button>
</div>
</section>
<h2>운영 점검 결과</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>분류</th><th>항목</th><th>결과</th><th>상세</th><th>조치</th><th>증적</th></tr>
@ -127,7 +114,7 @@ if (($_GET['format'] ?? '') === 'md') {
<tr>
<td><?= htmlspecialchars($check['category']) ?></td>
<td><?= htmlspecialchars($check['label']) ?></td>
<td class="<?= $check['ok'] ? 'ok' : 'fail' ?>"><?= $check['ok'] ? '정상' : '확인 필요' ?></td>
<td class="<?= $check['ok'] ? 'check-ok' : 'check-fail' ?>"><?= $check['ok'] ? '정상' : '확인 필요' ?></td>
<td><?= htmlspecialchars($check['detail']) ?></td>
<td><?= htmlspecialchars($check['fix'] ?: '-') ?></td>
<td><?= htmlspecialchars($check['evidence'] ?: '-') ?></td>
@ -135,8 +122,10 @@ if (($_GET['format'] ?? '') === 'md') {
<?php endforeach; ?>
</tbody>
</table>
</div>
<h2>보안통제 매트릭스</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>영역</th><th>보안대책</th><th>구현 위치</th><th>확인 증적</th></tr>
@ -151,6 +140,7 @@ if (($_GET['format'] ?? '') === 'md') {
<?php endforeach; ?>
</tbody>
</table>
</div>
</main>
</body>
</html>

View file

@ -70,36 +70,25 @@ $token = $_GET['token'] ?? $_POST['token'] ?? '';
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>관리자 MFA 등록</title>
<link rel="stylesheet" href="assets/app.css">
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; }
main { max-width: 760px; margin: 32px auto; padding: 0 16px; }
.panel { background: #fff; border-radius: 8px; padding: 22px; box-shadow: 0 1px 5px rgba(15,23,42,.12); margin-bottom: 16px; }
h1 { font-size: 1.3rem; margin: 0 0 8px; color: #1a56db; }
h2 { font-size: 1rem; margin: 20px 0 8px; }
p, li { line-height: 1.55; font-size: .92rem; }
code, textarea { font-family: Consolas, Monaco, monospace; }
textarea, input[type=text] { width: 100%; border: 1px solid #cbd5e1; border-radius: 6px; padding: 10px; font-size: .9rem; }
textarea { min-height: 82px; resize: vertical; }
label { display: block; font-weight: 700; margin: 12px 0 6px; }
.secret { font-size: 1.05rem; letter-spacing: .08em; word-break: break-all; background: #f8fafc; padding: 12px; border-radius: 6px; border: 1px solid #e2e8f0; }
.btn { background: #1a56db; color: #fff; border: 0; border-radius: 6px; padding: 10px 14px; font-weight: 700; cursor: pointer; }
.error { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; border-radius: 6px; padding: 10px; }
.ok { background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534; border-radius: 6px; padding: 10px; }
.muted { color: #64748b; }
/* 페이지 전용: 문서형 좁은 단 (app.css .container는 1320px로 넓음). 값은 토큰만. */
.mfa-doc { max-width: 760px; margin: var(--sp-6) auto; padding: 0 var(--sp-4); }
.mfa-doc .panel { margin-bottom: var(--sp-4); }
.mfa-doc textarea { min-height: 82px; resize: vertical; }
</style>
</head>
<body>
<main>
<main class="mfa-doc">
<section class="panel">
<h1>Google Authenticator MFA 등록</h1>
<p class="muted">이 화면은 Google Authenticator, Microsoft Authenticator, Authy 등 TOTP 인증 앱에 관리자 계정을 등록하기 위한 도구입니다. 외부 Google API로 비밀키를 보내지 않습니다.</p>
<p class="caption">이 화면은 Google Authenticator, Microsoft Authenticator, Authy 등 TOTP 인증 앱에 관리자 계정을 등록하기 위한 도구입니다. 외부 Google API로 비밀키를 보내지 않습니다.</p>
<?php if ($error): ?>
<div class="error"><?= htmlspecialchars($error) ?></div>
<div class="error" role="alert"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<?php if ($verified): ?>
<div class="ok">인증 앱 코드가 확인되었습니다. 아래 설정 줄을 `config.local.php`에 반영하세요.</div>
<div class="banner-ok" role="status">인증 앱 코드가 확인되었습니다. 아래 설정 줄을 `config.local.php`에 반영하세요.</div>
<?php endif; ?>
<h2>1. 인증 앱에 수동 등록</h2>
@ -111,7 +100,7 @@ $token = $_GET['token'] ?? $_POST['token'] ?? '';
<div class="secret"><?= htmlspecialchars($secret) ?></div>
<h2>2. 앱 링크 등록</h2>
<p class="muted">모바일 브라우저에서 열면 인증 앱이 이 URI를 인식할 수 있습니다. 데스크톱에서는 수동 등록을 사용하세요.</p>
<p class="caption">모바일 브라우저에서 열면 인증 앱이 이 URI를 인식할 수 있습니다. 데스크톱에서는 수동 등록을 사용하세요.</p>
<textarea readonly><?= htmlspecialchars($uri) ?></textarea>
<p><a class="btn" href="<?= htmlspecialchars($uri) ?>">인증 앱으로 열기</a></p>
@ -130,7 +119,7 @@ $token = $_GET['token'] ?? $_POST['token'] ?? '';
<?php if ($verified): ?>
<h2>4. config.local.php 반영</h2>
<textarea readonly>'ADMIN_TOTP_SECRET' => '<?= htmlspecialchars($secret) ?>',</textarea>
<p class="muted">반영 후 `MFA_SETUP_TOKEN`은 빈 값으로 바꾸거나 삭제하세요.</p>
<p class="caption">반영 후 `MFA_SETUP_TOKEN`은 빈 값으로 바꾸거나 삭제하세요.</p>
<?php endif; ?>
</section>
</main>

View file

@ -33,9 +33,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'send_
exit;
}
$message = "[온습도 모니터링 테스트]\n"
. "설치/운영 점검 문자입니다.\n"
. "시각: " . date('Y-m-d H:i:s');
$message = "[모니터링 테스트]\n"
. "설치/운영 점검 문자\n"
. date('m-d H:i');
$results = [];
foreach (SMS_RECIPIENTS as $receiver) {
$digits = preg_replace('/\D+/', '', $receiver);
@ -52,66 +52,64 @@ $failed_count = ops_failed_count($checks);
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>설치 점검 - 온습도 모니터링 <?= htmlspecialchars(APP_VERSION) ?></title>
<link rel="stylesheet" href="assets/app.css">
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; }
header { background: #1a56db; color: #fff; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; }
header h1 { font-size: 1.15rem; margin: 0; }
header a { color: #fff; text-decoration: none; background: rgba(255,255,255,.15); padding: 7px 12px; border-radius: 6px; font-size: .85rem; }
.container { max-width: 980px; margin: 24px auto; padding: 0 16px; }
.summary { background: #fff; border-radius: 8px; padding: 18px; box-shadow: 0 1px 4px rgba(0,0,0,.1); margin-bottom: 18px; }
.summary strong { font-size: 1.2rem; color: <?= $failed_count ? '#b91c1c' : '#166534' ?>; }
.check-list { display: grid; gap: 10px; }
.check { background: #fff; border-radius: 8px; padding: 14px 16px; box-shadow: 0 1px 4px rgba(0,0,0,.08); border-left: 6px solid #22c55e; }
.check.fail { border-left-color: #ef4444; }
.check h2 { margin: 0 0 6px; font-size: .98rem; }
.check p { margin: 0; color: #64748b; font-size: .84rem; }
.fix { margin-top: 7px; color: #92400e; font-size: .8rem; }
.actions { margin: 18px 0; display: flex; gap: 10px; flex-wrap: wrap; }
button { border: 0; background: #1a56db; color: #fff; padding: 10px 14px; border-radius: 7px; font-weight: 700; cursor: pointer; }
.result { background: #fff; border-radius: 8px; padding: 14px 16px; box-shadow: 0 1px 4px rgba(0,0,0,.08); margin-top: 18px; }
@media (max-width: 520px) { header { align-items: flex-start; flex-direction: column; gap: 10px; } }
/* 설치 점검 전용 잔여 스타일 — 값은 app.css 토큰(var)만 사용 */
.wiz-summary { margin-bottom: var(--sp-5); }
.wiz-summary strong { font-size: 1.25rem; }
.check-list { display: grid; gap: var(--sp-3); margin-bottom: var(--sp-5); }
/* 점검 카드 = .panel + 좌측레일(도형). 상태 텍스트는 .check-ok/.check-fail(글리프+색) */
.check-list .panel { border-left: 6px solid var(--normal); }
.check-list .panel.is-fail { border-left-color: var(--leak); background: var(--leak-bg); }
.wiz-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; margin: var(--sp-5) 0; }
.wiz-actions button {
display: inline-block; background: var(--primary); color: var(--on-primary);
border: 0; border-radius: var(--r-sm); padding: 10px 16px;
font-size: .8125rem; font-weight: 700; font-family: var(--font); cursor: pointer;
}
.wiz-actions button:hover { background: var(--primary-600); }
@media print { .wiz-actions { display: none; } }
</style>
</head>
<body>
<header>
<h1>설치/운영 점검 <?= htmlspecialchars(APP_VERSION) ?></h1>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div class="top-actions">
<a href="dashboard.php">대시보드</a>
<a href="security_evidence.php">보안 증적</a>
</div>
</header>
<main class="container">
<section class="summary">
<strong><?= $failed_count === 0 ? '전체 점검 통과' : "확인 필요 {$failed_count}건" ?></strong>
<div style="color:#64748b;font-size:.86rem;margin-top:6px">운영 반영 전 이 화면에서 설정, DB, 권한, SMS를 확인합니다.</div>
<section class="panel wiz-summary">
<strong class="<?= $failed_count === 0 ? 'check-ok' : 'check-fail' ?>"><?= $failed_count === 0 ? '전체 점검 통과' : "확인 필요 {$failed_count}건" ?></strong>
<p class="caption">운영 반영 전 이 화면에서 설정, DB, 권한, SMS를 확인합니다.</p>
</section>
<div class="check-list">
<?php foreach ($checks as $check): ?>
<section class="check <?= $check['ok'] ? '' : 'fail' ?>">
<h2><?= htmlspecialchars($check['category']) ?> · <?= htmlspecialchars($check['label']) ?> · <?= $check['ok'] ? '정상' : '확인 필요' ?></h2>
<p><?= htmlspecialchars($check['detail']) ?></p>
<section class="panel<?= $check['ok'] ? '' : ' is-fail' ?>">
<h2><?= htmlspecialchars($check['category']) ?> · <?= htmlspecialchars($check['label']) ?> · <span class="<?= $check['ok'] ? 'check-ok' : 'check-fail' ?>"><?= $check['ok'] ? '정상' : '확인 필요' ?></span></h2>
<p class="caption"><?= htmlspecialchars($check['detail']) ?></p>
<?php if (!$check['ok'] && $check['fix'] !== ''): ?>
<div class="fix">조치: <?= htmlspecialchars($check['fix']) ?></div>
<p class="caption">조치: <?= htmlspecialchars($check['fix']) ?></p>
<?php endif; ?>
<?php if (($check['evidence'] ?? '') !== ''): ?>
<div class="fix">증적: <?= htmlspecialchars($check['evidence']) ?></div>
<p class="caption">증적: <?= htmlspecialchars($check['evidence']) ?></p>
<?php endif; ?>
</section>
<?php endforeach; ?>
</div>
<form method="POST" class="actions">
<form method="POST" class="wiz-actions">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<button type="submit" name="action" value="send_test_sms">테스트 SMS 발송</button>
</form>
<?php if ($sms_test_result !== null): ?>
<section class="result">
<strong>테스트 SMS 결과</strong>
<section class="panel">
<h2>테스트 SMS 결과</h2>
<?php foreach ($sms_test_result as $row): ?>
<p><?= htmlspecialchars(mask_phone($row['receiver'])) ?> · <?= $row['result']['success'] ? '성공' : '실패' ?> · <?= htmlspecialchars($row['result']['message']) ?></p>
<p class="caption"><?= htmlspecialchars(mask_phone($row['receiver'])) ?> · <span class="<?= $row['result']['success'] ? 'check-ok' : 'check-fail' ?>"><?= $row['result']['success'] ? '성공' : '실패' ?></span> · <?= htmlspecialchars($row['result']['message']) ?></p>
<?php endforeach; ?>
</section>
<?php endif; ?>

View file

@ -12,6 +12,10 @@
// =============================================================================
require_once __DIR__ . '/config.php';
// dew_point_c() 는 forecast.php 의 정규(nullable-safe) 정의를 단일 출처로 사용한다.
// (과거 이 파일에도 중복 정의가 있어 forecast.php 와 함께 로드되는 dashboard 에서
// Cannot redeclare 치명오류가 났다 — 단일 정의로 통합.)
require_once __DIR__ . '/forecast.php';
/**
* 발신번호를 3분할 (sphone1, sphone2, sphone3)
@ -178,10 +182,12 @@ function send_sms_cafe24(string $receiver, string $message): array {
* @return bool 전원 발송 성공 여부
*/
function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message, string $logLabel, ?array $recipients = null): bool {
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
[, $name] = sensor_label((int)($sensor_data['sensor_id'] ?? 0), null, $sensor_data['sensor_name'] ?? null);
$allSuccess = true;
$lastRemaining = null; // 게이트웨이가 알려준 최신 잔여 건수(잔액 사전경보용)
$targets = $recipients ?? SMS_RECIPIENTS; // 기본 1차, 에스컬레이션은 TIER2 전달
// 명시 전달($recipients: 에스컬레이션 TIER2)이 우선. 없으면 센서별 수신자 그룹
// (SENSOR_RECIPIENTS[sid]) → 지정 없으면 전역 SMS_RECIPIENTS 로 폴백.
$targets = $recipients ?? recipients_for_sensor((int)($sensor_data['sensor_id'] ?? 0));
foreach ($targets as $receiver) {
$receiverDigits = preg_replace('/\D+/', '', $receiver);
@ -246,9 +252,8 @@ function maybe_warn_sms_balance(PDO $db, ?int $remaining): void {
}
$message = SMS_TAG_BALANCE_LOW . "\n"
. "SMS 잔여 {$remaining}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건 이하)\n"
. "충전하지 않으면 경보 발송이 중단됩니다.\n"
. "시각: " . date('Y-m-d H:i:s');
. "잔여 {$remaining}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건)\n"
. "충전 필요 · " . date('m-d H:i');
foreach (SMS_RECIPIENTS as $receiver) {
$receiverDigits = preg_replace('/\D+/', '', $receiver);
@ -283,19 +288,7 @@ function metric_value_str(float $value): string {
return $s;
}
/**
* 이슬점(℃) — Magnus 근사(물, 약 -45~60℃, 계수 a=17.62 b=243.12).
* 상대습도 0% 이하면 이슬점 정의 불가로 null(결로 위험 없음으로 취급).
*/
function dew_point_c(float $temp_c, float $rh): ?float {
if ($rh <= 0.0) {
return null;
}
$a = 17.62;
$b = 243.12;
$gamma = ($a * $temp_c) / ($b + $temp_c) + log($rh / 100.0);
return ($b * $gamma) / ($a - $gamma);
}
// dew_point_c() 정의는 forecast.php 로 이동(단일 출처). 여기서는 require 로 로드해 사용.
/**
* 결로마진(℃) = 현재온도 − 이슬점. 좁을수록 결로 위험. 측정 결측/산출불가면 null.
@ -327,11 +320,21 @@ function condensation_is_clear(?float $temp_c, ?float $rh): bool {
}
/**
* 온습도 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다.
* 온습도·미세먼지 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다.
*
* pm25/pm10 은 PMS7003(µg/m³ 대기환산 정수)용 선택 인자다(온습도 전용 호출은 종전대로
* 두 인자만 넘기면 된다 — 후방호환). PMS 는 누수 노드(sensor_id 1·3)에 온습도와 같은
* sensor_id 로 통합되며 metric_type='pms7003' 로 구분되므로, 한 보고에는 온습도 또는
* 미세먼지 중 한쪽만 실린다.
*
* @param ?float $temp_c 온도(℃) — 없으면 null
* @param ?float $rh 습도(%) — 없으면 null
* @param ?float $pm25 PM2.5(µg/m³) — 없으면 null
* @param ?float $pm10 PM10(µg/m³) — 없으면 null
* @return array{breaches: string[], status: string}
* breaches: 발생한 경보 종류 목록, status: leak_sensor_metric 저장용 대표 상태
*/
function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array {
function evaluate_metric_thresholds(?float $temp_c, ?float $rh, ?float $pm25 = null, ?float $pm10 = null): array {
$breaches = [];
if ($temp_c !== null) {
if ($temp_c >= METRIC_TEMP_HIGH_C) {
@ -355,6 +358,13 @@ function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array {
$breaches[] = 'condensation_risk';
}
}
// 미세먼지(PMS7003): PM2.5·PM10 각각 고농도 임계 초과 시 발화. 온습도와 독립.
if ($pm25 !== null && $pm25 > METRIC_PM25_HIGH) {
$breaches[] = 'high_pm25';
}
if ($pm10 !== null && $pm10 > METRIC_PM10_HIGH) {
$breaches[] = 'high_pm10';
}
return [
'breaches' => $breaches,
'status' => empty($breaches) ? 'normal' : $breaches[0],
@ -412,6 +422,82 @@ function metric_was_alerting(PDO $db, int $sensor_id): bool {
return !str_starts_with($msg, SMS_TAG_RECOVERY);
}
/**
* 에피소드 인지 쿨다운 판정(순수 함수 — DB/네트워크 없음, 테스트 용이).
*
* $latestMessage/$latestSentAt 은 "이 경보 태그" 또는 "복구 태그" 로 시작하는 SMS 중
* 가장 최근 1건(없으면 둘 다 null). 최신이 경보 태그로 시작하고(=아직 미복구) 발송
* 시각이 쿨다운 이내이면 true(생략). 최신이 복구면 에피소드 종료 → false(발송).
*
* 이렇게 하면 "같은 경보 지속 중"의 중복 폭주는 계속 억제하되, 복구 후 재발한 경보는
* 별개 에피소드로 보아 즉시 발송한다(쿨다운이 복구 직후의 새 사고를 삼키던 버그 수정).
*
* @param ?string $latestMessage 최신 경보/복구 SMS 본문(없으면 null)
* @param ?string $latestSentAt 그 sent_at(파싱 가능한 일시 문자열, 없으면 null)
* @param string $alertTag 이 경보의 태그(prefix). 최신이 이걸로 시작해야 억제 후보.
* @param int $cooldownSec 쿨다운(초)
* @param int $now 현재 Unix 시각(테스트 주입용)
*/
function alert_cooldown_suppresses(
?string $latestMessage,
?string $latestSentAt,
string $alertTag,
int $cooldownSec,
int $now
): bool {
if ($latestMessage === null || $latestSentAt === null) {
return false; // 이력 없음 → 발송
}
if (!str_starts_with($latestMessage, $alertTag)) {
return false; // 최신이 복구(또는 다른 상태) → 새 에피소드 → 발송
}
return ($now - strtotime($latestSentAt)) < $cooldownSec;
}
/**
* 누수 감지/복구 디바운스 판정(순수 함수 — DB/네트워크 없음, 테스트 용이).
*
* raw(현재 실제) 상태가 확정 상태와 어긋나면 확인창 동안 지속돼야 확정한다. 단일 타이머로
* 감지·복구를 대칭 처리한다. raw 가 확정과 같아지면(짧은 블립이 원상 복귀) pending 을
* 즉시 해제해, 감지↔복구 채터링(플래핑)이 SMS 로 번지는 것을 원천 차단한다.
*
* @param int $raw 0/1 현재 raw 누수 상태
* @param int $confirmed 0/1 현재 확정 누수 상태(SMS/사고추적 기준)
* @param ?int $pendingSinceTs 미확정 전이 시작 Unix (안정이면 null)
* @param int $alertConfirmSec raw=1 확정에 필요한 지속(초)
* @param int $recoveryConfirmSec raw=0 확정에 필요한 지속(초)
* @param int $now 현재 Unix (테스트 주입)
* @return array{confirmed:int, pending_since:?int, sms:?string}
* sms: 확정 시 'leak'|'recovery', 아니면 null.
*/
function leak_debounce_decide(
int $raw,
int $confirmed,
?int $pendingSinceTs,
int $alertConfirmSec,
int $recoveryConfirmSec,
int $now
): array {
// raw 가 확정과 같음 → 안정. 진행 중이던 반대 전이(pending)를 해제한다(블립 필터).
if ($raw === $confirmed) {
return ['confirmed' => $confirmed, 'pending_since' => null, 'sms' => null];
}
// raw != confirmed → 전이 대기.
if ($pendingSinceTs === null) {
return ['confirmed' => $confirmed, 'pending_since' => $now, 'sms' => null]; // 타이머 시작
}
$threshold = ($raw === 1) ? $alertConfirmSec : $recoveryConfirmSec;
if (($now - $pendingSinceTs) >= $threshold) {
// 확정: 확정 상태를 raw 로 갱신하고 SMS 1회.
return [
'confirmed' => $raw,
'pending_since' => null,
'sms' => ($raw === 1) ? 'leak' : 'recovery',
];
}
return ['confirmed' => $confirmed, 'pending_since' => $pendingSinceTs, 'sms' => null]; // 대기 유지
}
/**
* 온습도 임계 경보 SMS 발송 (종류별 쿨다운 포함).
*
@ -419,8 +505,8 @@ function metric_was_alerting(PDO $db, int $sensor_id): bool {
* 이내면 발송을 생략한다(중복 폭주 방지).
*
* @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location']
* @param string $kind 'high_temp'|'low_temp'|'high_humidity'|'low_humidity'
* @param float $value 측정값 (온도℃ 또는 습도%)
* @param string $kind 'high_temp'|'low_temp'|'high_humidity'|'low_humidity'|'high_pm25'|'high_pm10'
* @param float $value 측정값 (온도℃ / 습도% / 미세먼지 µg/m³)
* @return bool 발송 시도 여부 (쿨다운/오류로 생략 시 false)
*/
function send_metric_alert_sms(array $sensor_data, string $kind, float $value): bool {
@ -428,12 +514,16 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
$sensor_id = (int)$sensor_data['sensor_id'];
// [태그, 지표명, 단위, 임계값, 방향문구] (태그는 config.php 단일 출처 상수)
// 화재의심(high_pm25/high_pm10)은 PM2.5·PM10 이 같은 태그([화재의심])를 쓰되 지표명·
// 임계값으로 구분한다. 쿨다운은 태그 기준이므로 두 종류가 같은 쿨다운 창을 공유한다.
$labels = [
'high_temp' => [SMS_TAG_HIGH_TEMP, '온도', '℃', METRIC_TEMP_HIGH_C, '이상'],
'low_temp' => [SMS_TAG_LOW_TEMP, '온도', '℃', METRIC_TEMP_LOW_C, '이하'],
'high_humidity' => [SMS_TAG_HIGH_HUMIDITY, '습도', '%', METRIC_RH_HIGH, '이상'],
'low_humidity' => [SMS_TAG_LOW_HUMIDITY, '습도', '%', METRIC_RH_LOW, '이하'],
'condensation_risk' => [SMS_TAG_CONDENSATION, '결로마진', '℃', METRIC_DEWPOINT_MARGIN_C, '이하'],
'high_pm25' => [SMS_TAG_HIGH_PM, 'PM2.5', '㎍/㎥', METRIC_PM25_HIGH, '초과'],
'high_pm10' => [SMS_TAG_HIGH_PM, 'PM10', '㎍/㎥', METRIC_PM10_HIGH, '초과'],
];
if (!isset($labels[$kind])) {
error_log("[SMS] 알 수 없는 임계 경보 종류: {$kind}");
@ -466,35 +556,44 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
}
try {
// ── 종류별 쿨다운 (leak_sms_log 메시지 prefix 기준) ──────────────
// ── 종류별 쿨다운 (에피소드 인지) ────────────────────────────────
// 이 종류 경보([고온경보] 등)와 정상복귀([정상복귀]) 중 최신 1건을 본다. 최신이
// 이 종류 경보이고(=아직 미복귀) 쿨다운 이내일 때만 생략한다. 최신이 정상복귀면
// 에피소드가 끝난 것이라 재발한 경보는 별개 에피소드 → 즉시 발송한다.
// (과거 버그: 종류 태그만 봐서, 복귀 직후 재발한 경보가 이전 경보의 쿨다운에
// 걸려 통째로 누락됐다.)
$recent = $db->prepare("
SELECT sent_at FROM leak_sms_log
SELECT message, sent_at FROM leak_sms_log
WHERE sensor_id = :sensor_id
AND status = 'success'
AND message LIKE :tag
ORDER BY sent_at DESC
AND (message LIKE :alert OR message LIKE :recovery)
ORDER BY sent_at DESC, id DESC
LIMIT 1
");
$recent->execute([':sensor_id' => $sensor_id, ':tag' => $tag . '%']);
$last_at = $recent->fetchColumn();
if ($last_at !== false && (time() - strtotime($last_at)) < METRIC_ALERT_COOLDOWN_SEC) {
$elapsed = time() - strtotime($last_at);
error_log("[SMS] {$tag} 쿨다운 생략 - 센서{$sensor_id} ({$elapsed}s/" . METRIC_ALERT_COOLDOWN_SEC . "s)");
$recent->execute([
':sensor_id' => $sensor_id,
':alert' => $tag . '%',
':recovery' => SMS_TAG_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, $tag, METRIC_ALERT_COOLDOWN_SEC, time())) {
$elapsed = time() - strtotime((string)$lastAt);
error_log("[SMS] {$tag} 쿨다운 생략(지속중) - 센서{$sensor_id} ({$elapsed}s/" . METRIC_ALERT_COOLDOWN_SEC . "s)");
return false;
}
// ── 메시지 구성 ──────────────────────────────────────────
$dt = date('Y-m-d H:i:s');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}";
$loc = $sensor_data['device_location'] ?? '알 수 없음';
// ── 메시지 구성 (단문 90B EUC-KR 이내) ─────────────────────
$dt = date('m-d H:i');
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$valStr = metric_value_str($value);
$thrStr = metric_value_str((float)$threshold);
$op = ($dir === '이하') ? '>' : '<'; // 정상 범위 방향(임계 미만/초과)
$message = "{$tag}\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "{$metricName}: {$valStr}{$unit} (임계 {$thrStr}{$unit} {$dir})\n"
. "시각: {$dt}";
$message = "{$tag} {$loc}\n"
. "{$metricName} {$valStr}{$unit} 정상{$op}{$thrStr}\n"
. "{$dt} 즉시확인";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "임계경보 {$kind}");
} finally {
@ -519,16 +618,12 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
function send_metric_recovery_sms(array $sensor_data, float $temp_c, float $rh): bool {
$db = get_db();
$dt = date('Y-m-d H:i:s');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$dt = date('m-d H:i');
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$message = SMS_TAG_RECOVERY . "\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "온도 " . metric_value_str($temp_c) . "℃ / 습도 " . metric_value_str($rh) . "%\n"
. "시각: {$dt}\n"
. "온습도가 정상 범위로 복귀했습니다.";
$message = SMS_TAG_RECOVERY . " {$loc}\n"
. "온도 " . metric_value_str($temp_c) . "℃ 습도 " . metric_value_str($rh) . "%\n"
. "{$dt} 정상복귀";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "정상복귀");
}
@ -567,33 +662,41 @@ function send_leak_alert_sms(array $sensor_data): bool {
}
try {
// ── 쿨다운 (leak_sms_log 메시지 prefix 기준) ──
// ── 쿨다운 (에피소드 인지) ──
// [누수감지]와 [누수복구] 중 최신 1건을 본다. 최신이 [누수감지](=아직 미복구)이고
// 쿨다운 이내일 때만 생략(지속 누수 중 주기보고 폭주 방지). 최신이 [누수복구]면
// 사고가 끝난 것이므로 재발한 누수는 별개 에피소드 → 즉시 발송한다.
// (과거 버그: [누수감지] 태그만 봐서, 복구 직후 재발한 누수가 이전 감지의 쿨다운에
// 걸려 통째로 누락됐다.)
$recent = $db->prepare("
SELECT sent_at FROM leak_sms_log
SELECT message, sent_at FROM leak_sms_log
WHERE sensor_id = :sensor_id
AND status = 'success'
AND message LIKE :tag
ORDER BY sent_at DESC
AND (message LIKE :alert OR message LIKE :recovery)
ORDER BY sent_at DESC, id DESC
LIMIT 1
");
$recent->execute([':sensor_id' => $sensor_id, ':tag' => SMS_TAG_LEAK_ALERT . '%']);
$last_at = $recent->fetchColumn();
if ($last_at !== false && (time() - strtotime($last_at)) < LEAK_ALERT_COOLDOWN_SEC) {
$elapsed = time() - strtotime($last_at);
error_log("[SMS] " . SMS_TAG_LEAK_ALERT . " 쿨다운 생략 - 센서{$sensor_id} ({$elapsed}s/" . LEAK_ALERT_COOLDOWN_SEC . "s)");
$recent->execute([
':sensor_id' => $sensor_id,
':alert' => SMS_TAG_LEAK_ALERT . '%',
':recovery' => SMS_TAG_LEAK_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_LEAK_ALERT, LEAK_ALERT_COOLDOWN_SEC, time())) {
$elapsed = time() - strtotime((string)$lastAt);
error_log("[SMS] " . SMS_TAG_LEAK_ALERT . " 쿨다운 생략(지속중) - 센서{$sensor_id} ({$elapsed}s/" . LEAK_ALERT_COOLDOWN_SEC . "s)");
return false;
}
// ── 메시지 구성 ──
$dt = date('Y-m-d H:i:s');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}";
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$dt = date('m-d H:i');
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$message = SMS_TAG_LEAK_ALERT . "\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "누수가 감지되었습니다. 즉시 확인하세요!\n"
. "시각: {$dt}";
$message = SMS_TAG_LEAK_ALERT . " {$loc}\n"
. "누수 감지! 즉시 확인\n"
. "{$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수감지");
} finally {
@ -638,15 +741,12 @@ function send_leak_recovery_sms(array $sensor_data): bool {
return false; // 직전 누수 경보 없음(또는 이미 복구 통지함) → 발송 안 함
}
$dt = date('Y-m-d H:i:s');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}";
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$dt = date('m-d H:i');
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$message = SMS_TAG_LEAK_RECOVERY . "\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "누수가 해소(복구)되었습니다.\n"
. "시각: {$dt}";
$message = SMS_TAG_LEAK_RECOVERY . " {$loc}\n"
. "누수 해소(복구)\n"
. "{$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수복구");
}
@ -660,17 +760,11 @@ function send_leak_recovery_sms(array $sensor_data): bool {
function send_device_offline_sms(array $sensor_data): bool {
$db = get_db();
$dt = date('Y-m-d H:i:s');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$last = $sensor_data['last_seen'] ?? '알 수 없음';
$message = SMS_TAG_DEVICE_OFFLINE . "\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "마지막 통신: {$last}\n"
. "감지 시각: {$dt}\n"
. "장비 상태를 확인하세요!";
$dt = date('m-d H:i');
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$message = SMS_TAG_DEVICE_OFFLINE . " {$loc}\n"
. "통신 두절 · 장비 확인\n"
. "{$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "오프라인");
}
@ -684,15 +778,12 @@ function send_device_offline_sms(array $sensor_data): bool {
function send_device_online_sms(array $sensor_data): bool {
$db = get_db();
$dt = date('Y-m-d H:i:s');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$dt = date('m-d H:i');
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$message = SMS_TAG_DEVICE_ONLINE . "\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "복구 시각: {$dt}\n"
. "통신이 정상 복구되었습니다.";
$message = SMS_TAG_DEVICE_ONLINE . " {$loc}\n"
. "통신 정상 복구\n"
. "{$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "장비복구");
}
@ -706,7 +797,7 @@ function build_health_summary(PDO $db): string {
$total = (int)$db->query("SELECT COUNT(*) FROM leak_sensor_status")->fetchColumn();
$lastSeen = $db->query("SELECT TIMESTAMPDIFF(MINUTE, MAX(last_seen), NOW()) FROM leak_sensor_status")->fetchColumn();
$recvStr = ($lastSeen === null) ? '수신없음' : ((int)$lastSeen . '분전');
$recvStr = ($lastSeen === null) ? '수신없음' : ((int)$lastSeen . '분');
$alerts = (int)$db->query("
SELECT COUNT(*) FROM leak_sms_log
@ -717,10 +808,10 @@ function build_health_summary(PDO $db): string {
OR message LIKE '" . SMS_TAG_CONDENSATION . "%' OR message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%')
")->fetchColumn();
$summary = "센서 {$online}/{$total} 정상, 최근수신 {$recvStr}, 24h경보 {$alerts}건";
$summary = "센서 {$online}/{$total} 최근{$recvStr} 24h경보{$alerts}건";
$bal = latest_sms_balance($db);
if ($bal !== null) {
$summary .= ", 잔여 {$bal}건";
$summary .= " 잔여{$bal}";
}
return $summary;
}
@ -874,13 +965,11 @@ function maybe_escalate_alerts(PDO $db): void {
continue;
}
$firstLine = strtok($msg, "\n");
$name = $s['sensor_name'] ?: "센서{$sid}";
$loc = $s['device_location'] ?: '알 수 없음';
[$loc, $name] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null);
$mins = (int)floor((time() - strtotime((string)$row['sent_at'])) / 60);
$message = SMS_TAG_ESCALATION . "\n"
. "위치: {$loc}\n센서: {$name}\n"
. "{$firstLine} 미복귀 {$mins}분 경과\n"
. "1차 미대응 — 확인 바랍니다.\n시각: " . date('Y-m-d H:i:s');
. "{$firstLine} 미복귀 {$mins}분\n"
. date('m-d H:i') . " 즉시확인";
$info = ['sensor_id' => $sid, 'sensor_name' => $name, 'device_id' => $s['device_id'], 'device_location' => $loc];
sms_dispatch_to_recipients($db, $info, $message, "에스컬레이션", SMS_RECIPIENTS_TIER2);
error_log("[SMS] 에스컬레이션 발송 - 센서{$sid} ({$mins}분 미복귀)");
@ -923,11 +1012,10 @@ function maybe_detect_stuck_sensors(PDO $db): void {
if (recent_tag_within($db, $sid, SMS_TAG_SENSOR_STUCK, STUCK_ALERT_COOLDOWN_SEC)) {
continue; // 쿨다운
}
$name = $s['sensor_name'] ?: "센서{$sid}";
$loc = $s['device_location'] ?: '알 수 없음';
[$loc, $name] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null);
$tval = number_format((float)$rows[0]['temperature_c'], 2);
$hval = number_format((float)$rows[0]['humidity_percent'], 2);
$message = SMS_TAG_SENSOR_STUCK . "\n위치: {$loc}\n센서: {$name}\n"
$message = SMS_TAG_SENSOR_STUCK . "\n위치: {$loc}\n관리주체: {$name}\n"
. "최근 {$n}회 측정값 고착(온도 {$tval}℃ / 습도 {$hval}%)\n"
. "센서/배선 점검 필요\n시각: " . date('Y-m-d H:i:s');
$info = ['sensor_id' => $sid, 'sensor_name' => $name, 'device_id' => $s['device_id'], 'device_location' => $loc];
@ -935,3 +1023,213 @@ function maybe_detect_stuck_sensors(PDO $db): void {
error_log("[SMS] 센서고착 의심 - 센서{$sid} (최근 {$n}회 평탄)");
}
}
// ─── 누수 디바운스 (감지/복구 확정 상태머신) ──────────────────────────────────
/**
* leak_sensor_status 에 디바운스 컬럼(leak_confirmed/leak_pending_since)이 있는지.
* 없으면(구배포) 디바운스를 비활성하고 레거시 즉시 발송 경로로 graceful degrade 한다.
* 1회 조회 후 캐시.
*/
function leak_status_has_debounce_cols(PDO $db): bool {
static $cached = null;
if ($cached !== null) {
return $cached;
}
try {
$stmt = $db->prepare("
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'leak_sensor_status'
AND COLUMN_NAME IN ('leak_confirmed', 'leak_pending_since')
");
$stmt->execute();
$cached = ((int)$stmt->fetchColumn() === 2);
} catch (Throwable $e) {
error_log("[Leak] 디바운스 컬럼 확인 실패: " . $e->getMessage());
$cached = false;
}
return $cached;
}
/**
* 확정 누수 사고(leak_incident)를 연다(미복구 사고 없을 때만). started_at=확정 시각(NOW),
* log_id_alert=해당 센서의 최근 leak_alert 로그. 사고 테이블 없으면 조용히 스킵.
*/
function leak_incident_open(PDO $db, int $sensor_id, array $info): void {
try {
$open = $db->prepare("
SELECT id FROM leak_incident
WHERE sensor_id = :sid AND recovered_at IS NULL
ORDER BY started_at DESC, id DESC LIMIT 1
");
$open->execute([':sid' => $sensor_id]);
if ($open->fetchColumn() !== false) {
return; // 이미 미복구 사고 있음
}
$lg = $db->prepare("
SELECT id FROM leak_sensor_log
WHERE sensor_id = :sid AND event_type = 'leak_alert'
ORDER BY id DESC LIMIT 1
");
$lg->execute([':sid' => $sensor_id]);
$logId = $lg->fetchColumn();
[$loc, $name] = sensor_label($sensor_id, $info['device_location'] ?? null, $info['sensor_name'] ?? null);
$ins = $db->prepare("
INSERT INTO leak_incident
(sensor_id, device_id, sensor_name, device_location, started_at, log_id_alert, created_at)
VALUES
(:sid, :dev, :name, :loc, NOW(), :log, NOW())
");
$ins->execute([
':sid' => $sensor_id,
':dev' => $info['device_id'] ?? '',
':name' => $name,
':loc' => $loc,
':log' => $logId !== false ? (int)$logId : null,
]);
} catch (PDOException $e) {
if ($e->getCode() !== '42S02') { // 테이블 없음이면 조용히
error_log("[Leak] incident open 오류: " . $e->getMessage());
}
}
}
/**
* 해당 센서의 가장 최근 미복구 사고를 닫는다(recovered_at=NOW, log_id_recovery 링크).
*/
function leak_incident_close(PDO $db, int $sensor_id): void {
try {
$lg = $db->prepare("
SELECT id FROM leak_sensor_log
WHERE sensor_id = :sid AND event_type = 'leak_recovery'
ORDER BY id DESC LIMIT 1
");
$lg->execute([':sid' => $sensor_id]);
$logId = $lg->fetchColumn();
$close = $db->prepare("
UPDATE leak_incident
SET recovered_at = NOW(), log_id_recovery = :log
WHERE sensor_id = :sid AND recovered_at IS NULL
ORDER BY started_at DESC, id DESC LIMIT 1
");
$close->execute([
':log' => $logId !== false ? (int)$logId : null,
':sid' => $sensor_id,
]);
} catch (PDOException $e) {
if ($e->getCode() !== '42S02') {
error_log("[Leak] incident close 오류: " . $e->getMessage());
}
}
}
/**
* 누수 디바운스 상태머신 1스텝. raw(현재 실제 누수) 상태를 확정 상태와 비교해 확정 엣지에서만
* SMS 를 1회 보낸다(감지→[누수감지], 복구→[누수복구]). 확정 시 leak_incident 도 함께 갱신.
* (센서) 네임드 락으로 read-decide-update 를 직렬화해 동시 보고 경쟁을 막는다.
*
* 디바운스 비활성/컬럼 부재/상태행 부재면 false 를 반환하고, 호출자가 레거시 경로로 처리한다.
*
* @return bool 이번 호출에서 확정 SMS 를 발송했는지
*/
function apply_leak_debounce(PDO $db, int $sensor_id, int $raw, array $sensor_info): bool {
if (!LEAK_DEBOUNCE_ENABLED || !leak_status_has_debounce_cols($db)) {
return false;
}
// ── 센서 단위 직렬화 ──
$lockName = substr("leak_debounce_{$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';
}
if ($lockState === 'busy') {
return false; // 다른 프로세스가 이 센서를 처리 중
}
try {
$sel = $db->prepare("
SELECT leak_confirmed, UNIX_TIMESTAMP(leak_pending_since) AS pend
FROM leak_sensor_status WHERE sensor_id = :sid
");
$sel->execute([':sid' => $sensor_id]);
$row = $sel->fetch();
if ($row === false) {
return false; // 상태행 없음(업서트 전) — 다음 보고에서 처리
}
$confirmed = (int)$row['leak_confirmed'];
$pend = ($row['pend'] !== null) ? (int)$row['pend'] : null;
$d = leak_debounce_decide(
$raw, $confirmed, $pend,
LEAK_ALERT_CONFIRM_SEC, LEAK_RECOVERY_CONFIRM_SEC, time()
);
// 상태 저장(변경 없어도 pending 해제/유지 반영 위해 항상 갱신).
$upd = $db->prepare("
UPDATE leak_sensor_status
SET leak_confirmed = :c, leak_pending_since = :p
WHERE sensor_id = :sid
");
$upd->execute([
':c' => $d['confirmed'],
':p' => $d['pending_since'] !== null ? date('Y-m-d H:i:s', $d['pending_since']) : null,
':sid' => $sensor_id,
]);
if ($d['sms'] === 'leak') {
leak_incident_open($db, $sensor_id, $sensor_info);
return function_exists('send_leak_alert_sms') ? send_leak_alert_sms($sensor_info) : false;
}
if ($d['sms'] === 'recovery') {
leak_incident_close($db, $sensor_id);
return function_exists('send_leak_recovery_sms') ? send_leak_recovery_sms($sensor_info) : false;
}
return false;
} finally {
if ($lockState === 'acquired') {
try {
$db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => $lockName]);
} catch (Throwable $e) {
/* 해제 실패 무시 */
}
}
}
}
/**
* 보고가 끊겨도(예: 복구 대기 중 장기 주기, 장비 무응답) 미확정 전이를 확정으로 해소한다.
* leak_pending_since 가 세팅된 센서를 raw=is_leak 로 재평가한다. cron_heartbeat 가 주기 호출.
*/
function sweep_pending_leak_transitions(PDO $db): void {
if (!LEAK_DEBOUNCE_ENABLED || !leak_status_has_debounce_cols($db)) {
return;
}
try {
$rows = $db->query("
SELECT sensor_id, sensor_name, device_id, device_location, is_leak
FROM leak_sensor_status WHERE leak_pending_since IS NOT NULL
")->fetchAll();
} catch (Throwable $e) {
error_log("[Leak] pending sweep 조회 실패: " . $e->getMessage());
return;
}
foreach ($rows as $r) {
$sid = (int)$r['sensor_id'];
$info = [
'sensor_id' => $sid,
'sensor_name' => $r['sensor_name'],
'device_id' => $r['device_id'],
'device_location' => $r['device_location'],
];
apply_leak_debounce($db, $sid, (int)$r['is_leak'], $info);
}
}

9
php/tabs/.htaccess Normal file
View file

@ -0,0 +1,9 @@
# tabs/ 는 dashboard.php 가 include 하는 뷰 partial 전용 — 웹 직접 접근 전면 차단.
# (각 partial 의 APP_VERSION 가드 + 루트 .htaccess FilesMatch 와 삼중 방어)
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>

273
php/tabs/history.php Normal file
View file

@ -0,0 +1,273 @@
<?php
// tabs/history.php — [이력 조회] 탭. dashboard.php 셸에서 include 된다.
// 장애 원인 추적용: 누수 사고·측정·이벤트·SMS 이력을 한 탭에서 대조한다.
// 사용 변수: load_tab_history() 결과(extract) — $leak_incidents, $leak_photos_by_log,
// $recent_metrics, $logs, $sms_logs, $sms_stats.
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
?>
<div class="zone" id="tab-history">
<div class="zone-label"><b>운영 이력</b> <span class="en">History</span></div>
<!-- 누수 사고 이력 + 사진 -->
<h2 class="table-title">누수 사고 이력 <span class="h2-sub">최근 20건</span></h2>
<details class="mobile-detail" id="d-incidents" data-mobile-collapsed open>
<summary>누수 사고 이력 <span>최근 20건</span></summary>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-hide-xs">#</th><th>센서</th><th class="col-hide-xs">위치</th>
<th>감지 시각</th><th>복구 시각</th><th>사진</th>
</tr>
</thead>
<tbody>
<?php foreach ($leak_incidents as $inc):
$is_open = ($inc['recovered_at'] === null);
$lid = $inc['log_id_alert'] !== null ? (int)$inc['log_id_alert'] : 0;
$photos = $leak_photos_by_log[$lid] ?? [];
?>
<tr class="<?= $is_open ? 'alert-row' : '' ?>">
<td class="col-hide-xs"><?= (int)$inc['id'] ?></td>
<td><?= htmlspecialchars($inc['sensor_name']) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars($inc['device_location']) ?></td>
<td><?= htmlspecialchars($inc['started_at']) ?></td>
<td>
<?php if ($is_open): ?>
<span class="pill leak">미복구</span>
<?php else: ?>
<?= htmlspecialchars($inc['recovered_at']) ?>
<?php endif; ?>
</td>
<td>
<?php if (!empty($photos)): ?>
<div class="leak-thumbs">
<?php foreach ($photos as $ph): ?>
<a href="photo.php?id=<?= (int)$ph['id'] ?>" target="_blank" rel="noopener">
<img src="photo.php?id=<?= (int)$ph['id'] ?>"
alt="누수 사진 <?= htmlspecialchars($ph['captured_at']) ?>"
loading="lazy">
</a>
<?php endforeach; ?>
</div>
<?php else: ?>
<span class="caption">-</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($leak_incidents)): ?>
<tr><td colspan="6" class="empty-cell">아직 누수 사고 이력이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
<!-- 최근 온습도 측정 이력 -->
<h2 id="metrics-section" class="table-title">최근 온습도 측정 <span class="h2-sub">30건 기준</span></h2>
<details class="mobile-detail" id="d-metrics" data-mobile-collapsed open>
<summary>최근 온습도 측정 <span>30건 기준</span></summary>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>측정시각</th><th>센서</th><th class="col-hide-xs">위치</th>
<th>온도</th><th>습도</th><th>상태</th>
</tr>
</thead>
<tbody>
<?php foreach ($recent_metrics as $row):
$status_code = $row['metric_status'] ?? 'normal';
$is_alert = $status_code !== 'normal';
?>
<tr class="<?= $is_alert ? 'alert-row' : '' ?>">
<td><?= htmlspecialchars($row['measured_at']) ?></td>
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></td>
<td><?= htmlspecialchars(metric_display($row['temperature_c'], '°C')) ?></td>
<td><?= htmlspecialchars(metric_display($row['humidity_percent'], '%')) ?></td>
<td><span class="pill <?= htmlspecialchars($status_code) ?>"><?= htmlspecialchars(metric_status_label($status_code)) ?></span></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recent_metrics)): ?>
<tr><td colspan="6" class="empty-cell">아직 온습도 측정 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
<!-- 최근 미세먼지 측정 이력 (PMS7003 — 온습도와 별도 계통) -->
<h2 id="pm-section" class="table-title">최근 화재감시(PM) 측정 <span class="h2-sub">30건 기준</span></h2>
<details class="mobile-detail" id="d-pm" data-mobile-collapsed open>
<summary>최근 화재감시(PM) 측정 <span>30건 기준</span></summary>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>측정시각</th><th>센서</th><th class="col-hide-xs">위치</th>
<th class="col-hide-xs">PM1.0</th><th>PM2.5</th><th>PM10</th><th>상태</th>
</tr>
</thead>
<tbody>
<?php foreach (($recent_pm ?? []) as $row):
$status_code = $row['metric_status'] ?? 'normal';
$is_alert = $status_code !== 'normal';
?>
<tr class="<?= $is_alert ? 'alert-row' : '' ?>">
<td><?= htmlspecialchars($row['measured_at']) ?></td>
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></td>
<td class="col-hide-xs"><?= $row['pm1_0'] !== null ? (int)$row['pm1_0'] . 'µg/m³' : '-' ?></td>
<td><?= $row['pm2_5'] !== null ? (int)$row['pm2_5'] . 'µg/m³' : '-' ?></td>
<td><?= $row['pm10'] !== null ? (int)$row['pm10'] . 'µg/m³' : '-' ?></td>
<td><span class="pill <?= htmlspecialchars($status_code) ?>"><?= htmlspecialchars(metric_status_label($status_code)) ?></span></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recent_pm)): ?>
<tr><td colspan="7" class="empty-cell">아직 화재감시(PM) 측정 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
<!-- 센서 이벤트 로그 (startup/periodic/leak_alert/leak_recovery) -->
<h2 id="logs-section" class="table-title">센서 이벤트 로그 <span class="h2-sub">30건 기준</span></h2>
<details class="mobile-detail" id="d-logs" data-mobile-collapsed open>
<summary>센서 이벤트 로그 <span>30건 기준</span></summary>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-hide-xs">#</th><th>시각</th><th>센서</th>
<th class="col-hide-xs">위치</th><th>이벤트</th>
</tr>
</thead>
<tbody>
<?php foreach ($logs as $row):
$event_labels = [
'startup' => '장비시작',
'periodic' => '정기보고',
'leak_alert' => '누수감지',
'leak_recovery' => '누수복구',
];
$evt = $row['event_type'];
?>
<tr class="<?= $evt === 'leak_alert' ? 'alert-row' : '' ?>">
<td class="col-hide-xs"><?= (int)$row['id'] ?></td>
<td><?= htmlspecialchars($row['sensor_time']) ?></td>
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></td>
<td>
<?php if ($evt === 'leak_alert' || $evt === 'leak_recovery'): ?>
<span class="pill <?= htmlspecialchars($evt) ?>"><?= htmlspecialchars($event_labels[$evt]) ?></span>
<?php else: ?>
<?= htmlspecialchars($event_labels[$evt] ?? $evt) ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($logs)): ?>
<tr><td colspan="5" class="empty-cell">아직 센서 이벤트가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
<!-- SMS 발송 로그 -->
<h2 id="sms-section" class="table-title">SMS 발송 이력 <span class="h2-sub">최근 20건</span></h2>
<details class="mobile-detail" id="d-sms" data-mobile-collapsed open>
<summary>SMS 발송 이력 <span>최근 20건</span></summary>
<div class="table-wrap">
<table>
<thead>
<tr><th class="col-hide-xs">#</th><th>발송시각</th><th>센서</th><th>수신자</th><th>상태</th><th class="col-hide-xs">결과</th></tr>
</thead>
<tbody>
<?php foreach ($sms_logs as $row): ?>
<tr>
<td class="col-hide-xs"><?= (int)$row['id'] ?></td>
<td><?= htmlspecialchars($row['sent_at']) ?></td>
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
<td><?= htmlspecialchars(mask_phone($row['receivers'] ?? '')) ?></td>
<td>
<?php if ($row['status'] === 'success'): ?>
<span class="ok-color">성공</span>
<?php else: ?>
<span class="err-color">실패</span>
<?php endif; ?>
</td>
<td class="col-hide-xs caption"><?= htmlspecialchars($row['result_message']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($sms_logs)): ?>
<tr><td colspan="6" class="empty-cell">아직 SMS 발송 이력이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
<!-- 30일 평균 온습도 (센서별 — 전 센서 혼합 평균은 위치별 환경 차이를 뭉개므로 분리) -->
<?php if (!empty($metric_avgs)): ?>
<section class="panel">
<h2>30일 평균 온습도 <span class="h2-sub">· 센서별</span></h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>센서</th><th class="col-hide-xs">위치</th><th>평균 온도</th><th>평균 습도</th><th class="col-hide-xs">측정 수</th></tr>
</thead>
<tbody>
<?php foreach ($metric_avgs as $ma): ?>
<tr>
<td><?= htmlspecialchars($ma['name']) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars($ma['location']) ?></td>
<td><?= $ma['avg_temp'] !== null ? htmlspecialchars(number_format((float)$ma['avg_temp'], 1)) . '°C' : '-' ?></td>
<td><?= $ma['avg_hum'] !== null ? htmlspecialchars(number_format((float)$ma['avg_hum'], 1)) . '%' : '-' ?></td>
<td class="col-hide-xs"><?= (int)$ma['n'] ?>건</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<?php endif; ?>
<!-- 30일 SMS 발송 통계 (발송 건수 기준 — 측정 기준 경보 통계는 추이·통계 탭) -->
<section class="panel" id="summary-section">
<h2>30일 SMS 발송 통계</h2>
<div class="report-grid">
<div class="report-stat">
<span>총 발송</span>
<strong><?= (int)$sms_stats['total'] ?>건</strong>
</div>
<div class="report-stat">
<span>온습도 경보 SMS</span>
<strong class="<?= $sms_stats['alert_total'] > 0 ? 'warn-color' : '' ?>"><?= (int)$sms_stats['alert_total'] ?>건</strong>
</div>
<div class="report-stat">
<span>화재의심 SMS</span>
<strong class="<?= ($sms_stats['high_pm'] ?? 0) > 0 ? 'warn-color' : '' ?>"><?= (int)($sms_stats['high_pm'] ?? 0) ?>건</strong>
</div>
<div class="report-stat">
<span>누수감지 SMS</span>
<strong class="<?= $sms_stats['leak_alert'] > 0 ? 'err-color' : '' ?>"><?= (int)$sms_stats['leak_alert'] ?>건</strong>
</div>
<div class="report-stat">
<span>복구 SMS</span>
<strong><?= (int)$sms_stats['recovery'] ?>건</strong>
</div>
<div class="report-stat">
<span>오프라인 SMS</span>
<strong><?= (int)$sms_stats['offline'] ?>건</strong>
</div>
<div class="report-stat">
<span>발송 실패</span>
<strong class="<?= $sms_stats['fail'] > 0 ? 'err-color' : '' ?>"><?= (int)$sms_stats['fail'] ?>건</strong>
</div>
</div>
<p class="panel-note">종류별 상세 집계와 월별 분석은 <a href="monthly_report.php">월간 보고</a>에서 확인하세요. 측정 기준 경보 통계(도넛·추이)는 <a href="<?= htmlspecialchars(dash_url(['tab' => 'trend'])) ?>">추이·통계 탭</a>에 있습니다.</p>
</section>
</div>

308
php/tabs/live.php Normal file
View file

@ -0,0 +1,308 @@
<?php
// tabs/live.php — [실시간 현황] 탭. dashboard.php 셸에서 include 된다.
// 사용 변수: load_global_data() 결과 — $metric_statuses, $leak_statuses,
// $unrecovered_incidents, $forecasts, $sms_*, $today_* 등
// + load_tab_live() 결과 — $live_sparks, $live_leak_meta.
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
// ── 센서 id 별 결합 카드 구성 ────────────────────────────────────────────────
// 1·3번(누수+온습도 결합 노드)은 한 장의 카드에 두 계통을 함께 표시한다.
// (구버전은 metric 카드가 leak 카드를 가려 누수 상태가 실시간 화면에서 빠졌음)
$sensor_cards = [];
foreach ($metric_statuses as $sid => $s) {
$sensor_cards[(int)$sid] = ['metric' => true, 'leak' => false, 's' => $s];
}
foreach ($leak_statuses as $sid => $s) {
$sid = (int)$sid;
if (isset($sensor_cards[$sid])) {
$sensor_cards[$sid]['leak'] = true;
if ($sensor_cards[$sid]['s'] === null) { $sensor_cards[$sid]['s'] = $s; }
} else {
$sensor_cards[$sid] = ['metric' => false, 'leak' => true, 's' => $s];
}
}
ksort($sensor_cards);
?>
<div class="zone" id="tab-live">
<div class="zone-label"><b>실시간 상태</b> <span class="en">Live</span></div>
<h2 id="sensors-section">센서 현황 <span class="h2-sub">· <?= count($sensor_cards) ?>개소</span></h2>
<div class="sensor-grid" id="leak-section">
<?php foreach ($sensor_cards as $sid => $card):
$is_metric = $card['metric'];
$is_leak_node = $card['leak'];
$s = $card['s'];
// 보고 주기 표기 — 누수 노드(결합)는 10분, 온습도 단독은 1시간
// (config.php heartbeat 주석 기준: raspberry_pi4 REPORT_INTERVAL=600 / sht30=3600)
$cycle_label = $is_leak_node ? '10분 주기' : '1시간 주기';
// 미세먼지 노드 여부(PM_SENSOR_IDS) — 온습도·누수와 독립적으로 카드에 PM 계통 표시.
$is_pm_node = in_array((int)$sid, array_map('intval', PM_SENSOR_IDS), true);
$type_labels = [];
if ($is_leak_node) { $type_labels[] = ['leak', '누수']; }
if ($is_pm_node) { $type_labels[] = ['pm', '화재감시']; }
if ($is_metric) { $type_labels[] = ['metric', '온습도']; }
// ── 미수신 placeholder ──
if ($s === null): ?>
<div class="sensor-card offline">
<div class="sc-head">
<strong><?= (int)$sid ?>번 센서</strong>
<span class="sc-types">
<?php foreach ($type_labels as [$t_cls, $t_txt]): ?>
<span class="sc-type <?= $t_cls ?>"><?= $t_txt ?></span>
<?php endforeach; ?>
</span>
</div>
<div class="status-line off"><span class="sdot"></span>데이터 없음</div>
<div class="metric-time">아직 수신된 데이터가 없습니다.</div>
<div class="sc-foot"><span>수신 대기</span><span><?= $cycle_label ?></span></div>
</div>
<?php continue; endif;
// ── 상태 판정: 오프라인 > 누수 확정 > 확인중(디바운스) > 미세먼지 경보 > 온습도 경보 > 정상 ──
$metric_code = $s['metric_status'] ?? 'normal';
$pm_code = $s['pm_status'] ?? 'normal';
$has_m_alert = $is_metric && !empty($s['is_alerting']);
$has_pm_alert = $is_pm_node && !empty($s['is_pm_alerting']);
$is_leaking = $is_leak_node && (int)($s['leak_confirmed'] ?? $s['is_leak'] ?? 0) === 1;
$is_pending = $is_leak_node && !empty($s['leak_pending']);
if ($s['is_offline']) {
$card_class = 'offline'; $line_class = 'off';
$line_text = '오프라인';
if ($is_leaking) { $line_text .= ' (누수 중 끊김)'; }
elseif ($has_pm_alert) { $line_text .= ' (' . metric_status_label($pm_code) . ' 중 끊김)'; }
elseif ($has_m_alert) { $line_text .= ' (' . metric_status_label($metric_code) . ' 중 끊김)'; }
} elseif ($is_leaking) {
$card_class = 'leak'; $line_class = 'leakst';
$line_text = '누수 감지';
} elseif ($is_pending) {
$card_class = 'pending'; $line_class = 'pend';
$line_text = '확인중(디바운스)';
} elseif ($has_pm_alert) {
$card_class = 'pm'; $line_class = 'pmst';
$line_text = metric_status_label($pm_code) . ' 경보';
} elseif ($has_m_alert) {
$card_class = 'alert'; $line_class = 'err';
$line_text = metric_status_label($metric_code) . ' 경보';
} else {
$card_class = ''; $line_class = 'ok';
$line_text = $is_leak_node ? '정상 (건조)' : '정상';
}
?>
<div class="sensor-card <?= $card_class ?>">
<div class="sc-head">
<strong><?= htmlspecialchars($s['sensor_name']) ?></strong>
<span class="sc-types">
<?php foreach ($type_labels as [$t_cls, $t_txt]): ?>
<span class="sc-type <?= $t_cls ?>"><?= $t_txt ?></span>
<?php endforeach; ?>
</span>
</div>
<small><?= htmlspecialchars($s['device_location']) ?> / <?= htmlspecialchars($s['device_id']) ?></small>
<div class="status-line <?= $line_class ?>"><span class="sdot"></span><?= htmlspecialchars($line_text) ?></div>
<?php
// ── 누수 행: 확정 누수 시 경과·SMS·사진 (결합/단독 공통) ──
if ($is_leaking):
$lm = $live_leak_meta[$sid] ?? null; ?>
<div class="leak-row">
<?php if ($lm): ?>
<span class="leak-fact">경과 <b><?= (int)$lm['elapsed_min'] ?>분</b></span>
<span class="leak-fact">SMS <b><?= (int)$lm['sms_count'] ?>건</b></span>
<?php if (!empty($lm['photos'])): ?>
<div class="leak-thumbs">
<?php foreach ($lm['photos'] as $lp): ?>
<a href="photo.php?id=<?= (int)$lp['id'] ?>" target="_blank" rel="noopener">
<img src="photo.php?id=<?= (int)$lp['id'] ?>"
alt="누수 사진 <?= htmlspecialchars($lp['captured_at']) ?>" loading="lazy">
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php else: ?>
<span class="leak-fact">감지 확정 — 현장 확인 필요</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php
// ── 온습도 블록: 큰 수치 2열 + 24h 스파크라인 ──
if ($is_metric):
$temp_cls = ''; $hum_cls = '';
if ($metric_code === 'high_temp') { $temp_cls = 'hot'; }
elseif ($metric_code === 'low_temp') { $temp_cls = 'cold'; }
elseif ($metric_code === 'high_humidity') { $hum_cls = 'humid'; }
elseif ($metric_code === 'low_humidity') { $hum_cls = 'dry'; }
if (!empty($s['latest_metric'])):
$metric = $s['latest_metric']; ?>
<div class="vals">
<div class="v temp">
<div class="n <?= $temp_cls ?>"><?= htmlspecialchars(metric_display($metric['temperature_c'], '')) ?><small>°C</small></div>
<div class="l">온도</div>
</div>
<div class="v hum">
<div class="n <?= $hum_cls ?>"><?= htmlspecialchars(metric_display($metric['humidity_percent'], '')) ?><small>%</small></div>
<div class="l">습도</div>
</div>
</div>
<?php
// 24h 스파크라인 (2pt 이상일 때만 — 온도 실선/습도 파선, viewBox 300×34)
$sp = $live_sparks[$sid] ?? [];
$sp_n = count($sp);
if ($sp_n >= 2):
$sp_series = [];
foreach (['temp' => 'sp-t', 'hum' => 'sp-h'] as $sp_key => $sp_cls) {
$sp_vals = array_map(fn($p) => $p[$sp_key], $sp);
$sp_nums = array_filter($sp_vals, fn($v) => $v !== null);
if (count($sp_nums) < 2) { continue; }
$sp_min = min($sp_nums); $sp_max = max($sp_nums);
$sp_rng = ($sp_max - $sp_min) ?: 1;
$sp_pts = [];
foreach ($sp_vals as $sp_i => $sp_v) {
if ($sp_v === null) { continue; }
$sp_x = $sp_n > 1 ? $sp_i * (300 / ($sp_n - 1)) : 150;
$sp_y = 3 + (1 - ($sp_v - $sp_min) / $sp_rng) * 28;
$sp_pts[] = sprintf('%.1f,%.1f', $sp_x, $sp_y);
}
if (count($sp_pts) >= 2) { $sp_series[$sp_cls] = implode(' ', $sp_pts); }
}
if (!empty($sp_series)): ?>
<svg class="spark24" viewBox="0 0 300 34" preserveAspectRatio="none" role="img" aria-label="최근 24시간 온습도 추이">
<?php foreach ($sp_series as $sp_cls => $sp_points): ?>
<polyline class="<?= $sp_cls ?>" points="<?= htmlspecialchars($sp_points) ?>"></polyline>
<?php endforeach; ?>
</svg>
<?php endif; endif; ?>
<?php else: ?>
<div class="metric-time">측정값 없음</div>
<?php endif; ?>
<?php endif; ?>
<?php
// ── 미세먼지 블록: PM2.5/PM10 (µg/m³) — 온습도와 별도 계통, 위반 시 hot 강조 ──
if ($is_pm_node):
$pm25_cls = ($pm_code === 'high_pm25') ? 'hot' : '';
$pm10_cls = ($pm_code === 'high_pm10') ? 'hot' : '';
if (!empty($s['latest_pm'])):
$pmr = $s['latest_pm']; ?>
<div class="vals pm-vals">
<div class="v pm25">
<div class="n <?= $pm25_cls ?>"><?= $pmr['pm2_5'] !== null ? (int)$pmr['pm2_5'] : '-' ?><small>µg/m³</small></div>
<div class="l">PM2.5</div>
</div>
<div class="v pm10">
<div class="n <?= $pm10_cls ?>"><?= $pmr['pm10'] !== null ? (int)$pmr['pm10'] : '-' ?><small>µg/m³</small></div>
<div class="l">PM10</div>
</div>
</div>
<?php else: ?>
<div class="metric-time">화재감시(PM) 측정값 없음</div>
<?php endif; endif; ?>
<?php
// ── 예보 칩: 온라인 + 예보 있으면 임계 여유 상시 표시(over>near>safe) ──
if ($is_metric):
$fc = (!$s['is_offline'] && isset($forecasts[$sid])) ? $forecasts[$sid] : null;
if ($fc):
$chips = [];
foreach ($fc['headroom'] as $h) {
$chips[] = ['state' => $h['state'], 'text' => $h['label'] . ' 여유 ' . $h['margin'] . $h['unit']];
}
foreach (['temp', 'hum'] as $vk) {
$e = $fc['trend'][$vk]['eta'] ?? null;
if ($e) { $chips[] = ['state' => 'near', 'text' => '↗ ~' . (int)$e['min'] . '분 뒤 ' . metric_status_label($e['key'])]; }
}
foreach ($fc['rate'] as $r) {
$chips[] = ['state' => 'near', 'text' => ($r['spike'] === 'rise' ? '급상승 ' : '급락 ') . ($r['per10'] > 0 ? '+' : '') . $r['per10'] . '/10분'];
}
if (!empty($fc['dew']['risk'])) {
$chips[] = ['state' => 'near', 'text' => '결로주의 (마진 ' . $fc['dew']['margin'] . '℃)'];
}
$chip_rank = ['over' => 0, 'near' => 1, 'safe' => 2];
usort($chips, fn($a, $b) => ($chip_rank[$a['state']] ?? 3) <=> ($chip_rank[$b['state']] ?? 3));
?>
<div class="forecast">
<?php if ($chips): foreach ($chips as $c): ?>
<span class="forecast-chip <?= htmlspecialchars($c['state']) ?>"><?= htmlspecialchars($c['text']) ?></span>
<?php endforeach; else: ?>
<span class="forecast-note">예보: 임계 여유 충분</span>
<?php endif; ?>
</div>
<?php endif; endif; ?>
<div class="sc-foot">
<span class="<?= $s['is_offline'] ? 'stale' : '' ?>">
최종확인 <?= htmlspecialchars($s['last_seen']) ?><?php if ($s['is_offline']): ?> (<?= $s['offline_elapsed_min'] ?>분 전)<?php endif; ?>
</span>
<span><?= $cycle_label ?></span>
</div>
<?php if ($is_leaking): ?>
<svg class="leak-wave" viewBox="0 0 300 42" preserveAspectRatio="none" aria-hidden="true">
<path d="M0 26 Q 25 16 50 26 T 100 26 T 150 26 T 200 26 T 250 26 T 300 26 V42 H0 Z"/>
</svg>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php if (empty($sensor_cards)): ?>
<p class="empty-note">아직 수신된 센서 데이터가 없습니다.</p>
<?php endif; ?>
</div>
<?php if (!empty($unrecovered_incidents)): ?>
<!-- 미복구 누수 사고 (전체 이력·사진은 '이력 조회' 탭) -->
<h2>미복구 누수 사고 <span class="h2-sub">· <?= count($unrecovered_incidents) ?>건</span></h2>
<div class="table-wrap">
<table>
<thead>
<tr><th class="col-hide-xs">#</th><th>센서</th><th>위치</th><th>감지 시각</th><th>상태</th></tr>
</thead>
<tbody>
<?php foreach ($unrecovered_incidents as $inc): ?>
<tr class="alert-row">
<td class="col-hide-xs"><?= (int)$inc['id'] ?></td>
<td><?= htmlspecialchars($inc['sensor_name']) ?></td>
<td><?= htmlspecialchars($inc['device_location']) ?></td>
<td><?= htmlspecialchars($inc['started_at']) ?></td>
<td><span class="pill leak">미복구</span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<p class="panel-note">사진·복구 시각 등 전체 사고 이력은 <a href="<?= htmlspecialchars(dash_url(['tab' => 'history'])) ?>">이력 조회 탭</a>에서 확인하세요.</p>
<?php endif; ?>
<!-- 채널·오늘 요약 -->
<h2>채널·오늘 요약</h2>
<div class="mini-panels">
<section class="panel mini-panel" aria-label="SMS 채널 상태">
<h3>SMS 채널 상태</h3>
<dl class="mini-rows">
<div><dt>잔여 건수</dt>
<dd class="<?= ($sms_balance !== null && $sms_balance <= SMS_BALANCE_WARN_THRESHOLD) ? 'err-color' : '' ?>">
<?= $sms_balance !== null ? (int)$sms_balance . '건' : '확인 전' ?>
</dd></div>
<div><dt>마지막 성공 발송</dt>
<dd><?= $sms_last_success_at !== null ? htmlspecialchars($sms_last_success_at) : '기록 없음' ?></dd></div>
<div><dt>오늘 발송 / 실패</dt>
<dd><?= (int)$sms_today_total ?>건 / <span class="<?= $sms_today_fail > 0 ? 'err-color' : '' ?>"><?= (int)$sms_today_fail ?>건</span></dd></div>
</dl>
<p class="panel-note">SMS는 유일한 경보 채널입니다. 잔여 건수가 <?= (int)SMS_BALANCE_WARN_THRESHOLD ?>건 이하면 충전하세요.</p>
</section>
<section class="panel mini-panel" aria-label="오늘 경보 요약">
<h3>오늘 경보 요약</h3>
<dl class="mini-rows">
<div><dt>오늘 경보 횟수</dt>
<dd class="<?= $today_alert_count > 0 ? 'warn-color' : 'ok-color' ?>"><?= (int)$today_alert_count ?>건</dd></div>
<div><dt>오늘 최근 경보</dt>
<dd><?= $today_last_alert_at !== null ? htmlspecialchars($today_last_alert_at) : '오늘 없음' ?></dd></div>
<div><dt>가동률</dt>
<dd><?= (int)$uptime_pct ?>% (<?= (int)$online_count ?>/<?= (int)$sensor_total ?> 온라인)</dd></div>
</dl>
<p class="panel-note">7일·30일 추이는 <a href="<?= htmlspecialchars(dash_url(['tab' => 'trend'])) ?>">추이·통계 탭</a>에서 확인하세요.</p>
</section>
</div>
</div>

182
php/tabs/system.php Normal file
View file

@ -0,0 +1,182 @@
<?php
// tabs/system.php — [시스템 점검] 탭. dashboard.php 셸에서 include 된다.
// 관제(무슨 일이 나는가)와 분리된 관리 정보(시스템이 올바로 설정·동작하는가):
// 자가진단 · 수집 거부 관측성 · SMS 채널 점검 · 대응 플레이북 사본 · 임계 설정 요약.
// 사용 변수: load_tab_system() 결과(extract) — $ops_issues, $ops_ok, $reject_events,
// $sms_fail_30d, $config_rows + 전역 $ops_*, $ingest_rejections, $sms_* 등.
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
$ops_ok_count = count($ops_ok);
$ops_pill = $ops_critical_count > 0 ? 'crit' : ($ops_warn_count > 0 ? 'warn' : 'ok');
$ops_pill_text = $ops_failed > 0 ? '확인 필요' : '정상';
// 수집 거부 사유 한국어 라벨(관측성 — api/sensor_data.php 의 거부 사유 키와 대응)
$reason_ko = [
'signature' => '서명불일치(API키/펌웨어 점검)',
'timestamp' => '시각오차(SNTP/RTC 점검)',
'key_default' => '기본 API키(미설정)',
'body_too_large' => '본문과대',
'invalid_json' => 'JSON오류',
'missing_field' => '필드누락',
'sensor_id_range' => 'sensor_id 범위',
];
?>
<div class="zone" id="tab-system">
<div class="zone-label"><b>시스템 점검</b> <span class="en">System</span></div>
<div class="system-grid">
<!-- 운영 자가진단 (전체) -->
<section class="panel" id="diagnostics-section">
<div class="diag-head">
<h2>운영 자가진단</h2>
<span class="diag-pill <?= $ops_pill ?>"><?= htmlspecialchars($ops_pill_text) ?></span>
</div>
<div class="diag-counts">
<span class="dc ok">정상<b><?= $ops_ok_count ?></b></span>
<span class="dc warn">주의<b><?= $ops_warn_count ?></b></span>
<span class="dc crit">중요<b><?= $ops_critical_count ?></b></span>
</div>
<?php if (!empty($ops_issues)): ?>
<ul class="diag-issues">
<?php foreach ($ops_issues as $check):
// 색 외 채널: 심각도를 텍스트로도 구분(critical=중요, warn=주의)
$sev_text = $check['severity'] === 'critical' ? '확인 필요 · 중요' : '확인 필요 · 주의';
$issue_detail = $check['detail']
. ($check['fix'] !== '' ? ' — ' . $check['fix'] : '');
?>
<li class="diag-issue <?= htmlspecialchars($check['severity']) ?>">
<i class="diag-dot"></i>
<div>
<strong><?= htmlspecialchars($check['label']) ?> · <?= htmlspecialchars($sev_text) ?></strong>
<span><?= htmlspecialchars($issue_detail) ?></span>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if (!empty($ops_ok)): ?>
<details class="diag-ok" id="d-diag-ok"<?= !empty($render_all) ? ' open' : '' ?>>
<summary>정상 항목 <?= $ops_ok_count ?>건 보기</summary>
<ul class="diag-ok-grid">
<?php foreach ($ops_ok as $check): ?>
<li><i></i><span><?= htmlspecialchars($check['label']) ?></span></li>
<?php endforeach; ?>
</ul>
</details>
<?php endif; ?>
<p class="panel-note">진단 결과는 최대 5분 캐시됩니다. 보안대책서 첨부용 점검표는 <a href="security_evidence.php">보안 증적</a>에서 내려받을 수 있습니다.</p>
</section>
<!-- 수집 거부 관측성 (왜 데이터가 안 들어오는가 / 무단 접근 출처) -->
<section class="panel" aria-label="수집 거부 관측성">
<h2>수집 거부 <span class="h2-sub">· 최근 24시간 <?= (int)$rejection_total ?>건</span></h2>
<?php if ($rejection_total > 0): ?>
<p class="diag-summary"><?php
$parts = [];
foreach ($ingest_rejections as $r => $c) {
$parts[] = htmlspecialchars($reason_ko[$r] ?? $r) . ' ' . (int)$c;
}
echo implode(' · ', $parts);
?></p>
<?php else: ?>
<p class="diag-summary">최근 24시간 수집 거부가 없습니다.</p>
<?php endif; ?>
<?php if (!empty($reject_events)): ?>
<h3 class="sub-h3">최근 거부 출처 <?= count($reject_events) ?>건 <span class="h2-sub">(우리 노드 오설정 vs 외부 스캐너 판별)</span></h3>
<div class="table-wrap">
<table>
<thead><tr><th>시각</th><th>출처 IP</th><th>사유</th><th class="col-hide-xs">모드</th><th class="col-hide-xs">device_id</th></tr></thead>
<tbody>
<?php foreach ($reject_events as $ev): ?>
<tr>
<td><?= htmlspecialchars((string)($ev['t'] ?? '')) ?></td>
<td><?= htmlspecialchars((string)($ev['ip'] ?? '?')) ?><?php if (!empty($ev['fwd'])): ?> <small>(xff <?= htmlspecialchars((string)$ev['fwd']) ?>)</small><?php endif; ?></td>
<td><?= htmlspecialchars($reason_ko[$ev['reason'] ?? ''] ?? (string)($ev['reason'] ?? '')) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars((string)($ev['mode'] ?? '')) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars((string)($ev['dev'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<!-- SMS 채널 점검 -->
<section class="panel" aria-label="SMS 채널 점검">
<h2>SMS 채널 점검</h2>
<dl class="mini-rows">
<div><dt>잔여 건수</dt>
<dd class="<?= ($sms_balance !== null && $sms_balance <= SMS_BALANCE_WARN_THRESHOLD) ? 'err-color' : '' ?>">
<?= $sms_balance !== null ? (int)$sms_balance . '건 (경고 임계 ' . (int)SMS_BALANCE_WARN_THRESHOLD . '건)' : '확인 전' ?>
</dd></div>
<div><dt>마지막 성공 발송</dt>
<dd><?= $sms_last_success_at !== null ? htmlspecialchars($sms_last_success_at) : '기록 없음' ?></dd></div>
<div><dt>최근 1시간 경보 SMS 실패</dt>
<dd class="<?= $recent_alert_fail > 0 ? 'err-color' : 'ok-color' ?>"><?= (int)$recent_alert_fail ?>건</dd></div>
<div><dt>최근 30일 발송 실패</dt>
<dd class="<?= $sms_fail_30d > 0 ? 'warn-color' : 'ok-color' ?>"><?= (int)$sms_fail_30d ?>건</dd></div>
</dl>
<p class="panel-note">실패 지속 시 Cafe24 SMS 잔액·계정·발신번호를 확인하세요. 발송 이력은 <a href="<?= htmlspecialchars(dash_url(['tab' => 'history'], 'sms-section')) ?>">이력 조회 탭</a>에 있습니다.</p>
</section>
<!-- 대응 플레이북·담당자 (평시 열람 사본 — 경보 시에는 상단 '대응 필요'에 자동 표시) -->
<section class="panel" aria-label="대응 플레이북">
<h2>대응 플레이북 <span class="h2-sub">· 평시 열람용</span></h2>
<?php foreach (RESPONSE_PLAYBOOKS as $pb_key => $pb): if (empty($pb['steps'])) { continue; } ?>
<details class="playbook-item" id="pb-<?= htmlspecialchars((string)$pb_key) ?>"<?= !empty($render_all) ? ' open' : '' ?>>
<summary><?= htmlspecialchars($pb['title'] ?? '대응 절차') ?></summary>
<ol class="playbook-steps">
<?php foreach ($pb['steps'] as $step): ?><li><?= htmlspecialchars($step) ?></li><?php endforeach; ?>
</ol>
</details>
<?php endforeach; ?>
<?php if (!empty(RESPONSE_CONTACTS)): ?>
<div class="response-contacts">
<span class="contacts-label">담당자</span>
<?php foreach (RESPONSE_CONTACTS as $ct):
$ph = preg_replace('/[^0-9+]/', '', (string)($ct['phone'] ?? '')); ?>
<span class="contact">
<?= htmlspecialchars(trim(($ct['role'] ?? '') . ' ' . ($ct['name'] ?? ''))) ?><?php if ($ph !== ''): ?> <a href="tel:<?= htmlspecialchars($ph) ?>"><?= htmlspecialchars((string)$ct['phone']) ?></a><?php endif; ?>
</span>
<?php endforeach; ?>
</div>
<?php else: ?>
<p class="panel-note">담당자 연락처는 config.local.php 의 RESPONSE_CONTACTS 로 등록합니다.</p>
<?php endif; ?>
</section>
<!-- 임계·설정 요약 (읽기 전용) -->
<section class="panel" aria-label="임계 및 설정 요약">
<h2>임계·설정 요약 <span class="h2-sub">· 읽기 전용</span></h2>
<div class="table-wrap">
<table>
<thead><tr><th>항목</th><th>현재값</th></tr></thead>
<tbody>
<?php foreach ($config_rows as [$label, $value]): ?>
<tr><td><?= htmlspecialchars($label) ?></td><td><?= htmlspecialchars((string)$value) ?></td></tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<p class="panel-note">값 변경은 config.local.php 오버라이드로 합니다(폐쇄망에서 펌웨어 재배포 없이 조정 가능).</p>
</section>
<!-- 관리 화면 바로가기 -->
<section class="panel" aria-label="관리 화면 바로가기">
<h2>관리 화면 바로가기</h2>
<ul class="link-list">
<li><a href="setup_wizard.php">설치 점검</a> — DB·테이블·수신 상태 점검</li>
<li><a href="monthly_report.php">월간 보고</a> — 월별 경보·SMS 통계 보고서</li>
<li><a href="security_evidence.php">보안 증적</a> — 보안대책서 첨부용 점검표</li>
<li><a href="<?= htmlspecialchars(dash_url(['tab' => 'all'])) ?>">인쇄용 전체 보기</a> — 전 탭 한 페이지 렌더(자동 새로고침 없음)</li>
</ul>
</section>
</div>
</div>

471
php/tabs/trend.php Normal file
View file

@ -0,0 +1,471 @@
<?php
// tabs/trend.php — [추이·통계] 탭. dashboard.php 셸에서 include 된다.
// 사용 변수: load_tab_trend() 결과(extract) — $chart_*, $trend_*, $donut_*, $board_*,
// $spark_alerts, $ov_badge* + 전역 $uptime_pct, $online_count, $sensor_total.
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
?>
<div class="zone" id="tab-trend">
<div class="zone-label"><b>추세 분석</b> <span class="en">Trend</span></div>
<!-- 온습도 추이 차트 (인라인 SVG, 외부 라이브러리 없음) -->
<h2 id="chart-section">온습도 추이
<?php if ($chart_sensor_id > 0): ?>
<span class="h2-sub">
· <?= htmlspecialchars($chart_sensor_name !== '' ? $chart_sensor_name : ('센서 ' . $chart_sensor_id)) ?> 최근 <?= count($chart_points) ?>개
</span>
<?php endif; ?>
</h2>
<div class="chart-panel">
<div class="chart-head">
<div class="chart-legend" style="margin:0">
<span><i class="legend-swatch temp"></i> 온도(°C)</span>
<span><i class="legend-swatch hum"></i> 습도(%)</span>
</div>
<?php if (count($chart_sensor_options ?? []) > 1): ?>
<div class="range-toggle" role="group" aria-label="추이 센서 선택">
<?php foreach ($chart_sensor_options as $opt): ?>
<a href="<?= htmlspecialchars(dash_url(['sensor' => $opt['id']], 'chart-section')) ?>"<?= $chart_sensor_id === $opt['id'] ? ' aria-current="true"' : '' ?>><?= htmlspecialchars($opt['name'] !== '' ? $opt['name'] : ('센서 ' . $opt['id'])) ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="range-toggle" role="group" aria-label="추이 기간 선택">
<?php foreach (['today' => '오늘', '3d' => '3일', '7d' => '7일'] as $rk => $rlabel): ?>
<a href="<?= htmlspecialchars(dash_url(['range' => $rk], 'chart-section')) ?>"<?= $chart_range === $rk ? ' aria-current="true"' : '' ?>><?= htmlspecialchars($rlabel) ?></a>
<?php endforeach; ?>
</div>
</div>
<?php if (count($chart_points) >= 2): ?>
<div class="chart-svg-wrap">
<?php
// ── 인라인 SVG 라인 차트 (온도/습도 2계열) ──────────────
$chart_w = 920; $chart_h = 280;
$pad_l = 44; $pad_r = 44; $pad_t = 16; $pad_b = 34;
$plot_w = $chart_w - $pad_l - $pad_r;
$plot_h = $chart_h - $pad_t - $pad_b;
$n = count($chart_points);
$temps = array_values(array_filter(array_map(fn($p) => $p['temp'], $chart_points), fn($v) => $v !== null));
$hums = array_values(array_filter(array_map(fn($p) => $p['hum'], $chart_points), fn($v) => $v !== null));
// 온도 축 범위 (여유 패딩)
$t_min = !empty($temps) ? min($temps) : 0;
$t_max = !empty($temps) ? max($temps) : 1;
if ($t_min === $t_max) { $t_min -= 1; $t_max += 1; }
$t_pad = ($t_max - $t_min) * 0.1;
$t_min -= $t_pad; $t_max += $t_pad;
// 습도 축 범위
$h_min = !empty($hums) ? min($hums) : 0;
$h_max = !empty($hums) ? max($hums) : 1;
if ($h_min === $h_max) { $h_min -= 1; $h_max += 1; }
$h_pad = ($h_max - $h_min) * 0.1;
$h_min -= $h_pad; $h_max += $h_pad;
$x_at = function (int $i) use ($pad_l, $plot_w, $n): float {
if ($n <= 1) { return $pad_l; }
return $pad_l + ($plot_w * $i / ($n - 1));
};
$y_temp = function (?float $v) use ($pad_t, $plot_h, $t_min, $t_max): ?float {
if ($v === null) { return null; }
$r = ($t_max - $t_min) ?: 1;
return $pad_t + $plot_h * (1 - (($v - $t_min) / $r));
};
$y_hum = function (?float $v) use ($pad_t, $plot_h, $h_min, $h_max): ?float {
if ($v === null) { return null; }
$r = ($h_max - $h_min) ?: 1;
return $pad_t + $plot_h * (1 - (($v - $h_min) / $r));
};
// 결측(null)은 구간을 끊어 polyline 들을 만든다
$build_segments = function (string $key, callable $y_fn) use ($chart_points, $x_at): array {
$segments = [];
$current = [];
foreach ($chart_points as $i => $p) {
$val = $p[$key];
if ($val === null) {
if (count($current) >= 2) { $segments[] = $current; }
$current = [];
continue;
}
$current[] = sprintf('%.1f,%.1f', $x_at($i), $y_fn((float)$val));
}
if (count($current) >= 2) { $segments[] = $current; }
return $segments;
};
$temp_segments = $build_segments('temp', $y_temp);
$hum_segments = $build_segments('hum', $y_hum);
// 각 계열의 마지막 유효 지점(끝점 마커 + 최신값 라벨용)
$last_temp_i = null; $last_temp_v = null;
$last_hum_i = null; $last_hum_v = null;
foreach ($chart_points as $i => $p) {
if ($p['temp'] !== null) { $last_temp_i = $i; $last_temp_v = (float)$p['temp']; }
if ($p['hum'] !== null) { $last_hum_i = $i; $last_hum_v = (float)$p['hum']; }
}
$fmt = fn($v) => rtrim(rtrim(number_format($v, 1, '.', ''), '0'), '.');
?>
<svg viewBox="0 0 <?= $chart_w ?> <?= $chart_h ?>" width="100%" height="<?= $chart_h ?>"
preserveAspectRatio="xMidYMid meet" role="img"
aria-label="온습도 추이 라인 차트" class="chart-svg">
<!-- 플롯 배경/축 -->
<rect x="<?= $pad_l ?>" y="<?= $pad_t ?>" width="<?= $plot_w ?>" height="<?= $plot_h ?>"
class="plot-bg"></rect>
<?php
// 가로 그리드 + 좌(온도)/우(습도) 눈금 라벨 (5단계)
for ($g = 0; $g <= 4; $g++):
$gy = $pad_t + $plot_h * $g / 4;
$tv = $t_max - ($t_max - $t_min) * $g / 4;
$hv = $h_max - ($h_max - $h_min) * $g / 4;
?>
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $gy) ?>"
x2="<?= $pad_l + $plot_w ?>" y2="<?= sprintf('%.1f', $gy) ?>"
class="grid-line" stroke-dasharray="3,3"></line>
<text x="<?= $pad_l - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>"
text-anchor="end" font-size="10" class="axis-temp"><?= htmlspecialchars($fmt($tv)) ?></text>
<text x="<?= $pad_l + $plot_w + 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>"
text-anchor="start" font-size="10" class="axis-hum"><?= htmlspecialchars($fmt($hv)) ?></text>
<?php endfor; ?>
<!-- X축 시간 라벨 (시작/중간/끝) -->
<?php
$label_idx = $n > 1 ? [0, intdiv($n - 1, 2), $n - 1] : [0];
foreach ($label_idx as $li):
$lx = $x_at($li);
$anchor = $li === 0 ? 'start' : ($li === $n - 1 ? 'end' : 'middle');
$tlabel = substr($chart_points[$li]['t'], 5, 11); // MM-DD HH:MM
?>
<text x="<?= sprintf('%.1f', $lx) ?>" y="<?= $chart_h - 12 ?>"
text-anchor="<?= $anchor ?>" font-size="10" class="axis-x"><?= htmlspecialchars($tlabel) ?></text>
<?php endforeach; ?>
<!-- 임계선 오버레이 (폴리라인과 동일한 y 스케일; -1/미정의는 생략, 스케일 밖도 생략) -->
<?php
// [임계값, y 계산 클로저, 축(temp|hum), 라벨, 단위]
$threshold_defs = [
[METRIC_TEMP_HIGH_C, $y_temp, 'temp', '고온', '℃'],
[METRIC_TEMP_LOW_C, $y_temp, 'temp', '저온', '℃'],
[METRIC_RH_HIGH, $y_hum, 'hum', '고습', '%'],
[METRIC_RH_LOW, $y_hum, 'hum', '저습', '%'],
];
foreach ($threshold_defs as [$cval, $yfn, $axis, $tlabel, $unit]):
if ($cval === null || (float)$cval === -1.0) { continue; } // 비활성
$ty = $yfn((float)$cval);
if ($ty === null || $ty < $pad_t || $ty > $pad_t + $plot_h) { continue; } // 스케일 밖
$thr_line_cls = $axis === 'temp' ? 'line-temp' : 'line-hum';
$thr_label_cls = $axis === 'temp' ? 'axis-temp' : 'axis-hum';
$thr_lx = $axis === 'temp' ? ($pad_l + 4) : ($pad_l + $plot_w - 4);
$thr_anchor = $axis === 'temp' ? 'start' : 'end';
?>
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $ty) ?>"
x2="<?= $pad_l + $plot_w ?>" y2="<?= sprintf('%.1f', $ty) ?>"
class="<?= $thr_line_cls ?>" stroke-width="1" stroke-dasharray="2,4" opacity="0.85"></line>
<text x="<?= sprintf('%.1f', $thr_lx) ?>" y="<?= sprintf('%.1f', $ty - 3) ?>"
text-anchor="<?= $thr_anchor ?>" font-size="9"
class="<?= $thr_label_cls ?>"><?= htmlspecialchars($tlabel . ' ' . $fmt((float)$cval) . $unit) ?></text>
<?php endforeach; ?>
<!-- 습도 라인 (파선 — 흑백 인쇄 시 온도선과 형태로 구분) -->
<?php foreach ($hum_segments as $seg): ?>
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
class="line-hum" fill="none" stroke-width="1.8" stroke-dasharray="6,3"
stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endforeach; ?>
<!-- 온도 라인 -->
<?php foreach ($temp_segments as $seg): ?>
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
class="line-temp" fill="none" stroke-width="1.8"
stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endforeach; ?>
<!-- 각 계열 마지막 점: 채운 원 + 최신값 라벨(폴리라인과 동일 스케일) -->
<?php if ($last_hum_i !== null):
$hx = $x_at($last_hum_i); $hy = $y_hum($last_hum_v);
?>
<circle cx="<?= sprintf('%.1f', $hx) ?>" cy="<?= sprintf('%.1f', $hy) ?>" r="3" class="axis-hum"></circle>
<text x="<?= sprintf('%.1f', $hx - 5) ?>" y="<?= sprintf('%.1f', $hy - 5) ?>"
text-anchor="end" font-size="10" class="axis-hum"><?= htmlspecialchars($fmt($last_hum_v) . '%') ?></text>
<?php endif; ?>
<?php if ($last_temp_i !== null):
$tx = $x_at($last_temp_i); $tyv = $y_temp($last_temp_v);
?>
<circle cx="<?= sprintf('%.1f', $tx) ?>" cy="<?= sprintf('%.1f', $tyv) ?>" r="3" class="axis-temp"></circle>
<text x="<?= sprintf('%.1f', $tx - 5) ?>" y="<?= sprintf('%.1f', $tyv - 5) ?>"
text-anchor="end" font-size="10" class="axis-temp"><?= htmlspecialchars($fmt($last_temp_v) . '℃') ?></text>
<?php endif; ?>
<!-- 피크(기간 내 최고 온도) 마커 + 플래그 라벨 -->
<?php if ($peak_i !== null):
$pk_x = $x_at($peak_i); $pk_y = $y_temp($peak_v);
$pk_fw = 66;
$pk_fx = min(max($pk_x - $pk_fw / 2, $pad_l), $pad_l + $plot_w - $pk_fw);
?>
<line x1="<?= sprintf('%.1f', $pk_x) ?>" y1="<?= sprintf('%.1f', $pk_y) ?>"
x2="<?= sprintf('%.1f', $pk_x) ?>" y2="<?= $pad_t ?>" class="grid-line"></line>
<rect x="<?= sprintf('%.1f', $pk_fx) ?>" y="<?= $pad_t - 15 ?>" width="<?= $pk_fw ?>" height="14" rx="4" class="peak-flag"></rect>
<text x="<?= sprintf('%.1f', $pk_fx + $pk_fw / 2) ?>" y="<?= $pad_t - 4 ?>"
text-anchor="middle" font-size="10" class="peak-label"><?= htmlspecialchars('피크 ' . $fmt($peak_v) . '℃') ?></text>
<circle cx="<?= sprintf('%.1f', $pk_x) ?>" cy="<?= sprintf('%.1f', $pk_y) ?>" r="4" class="peak-marker"></circle>
<?php endif; ?>
</svg>
</div>
<?php else: ?>
<div class="chart-empty">추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).</div>
<?php endif; ?>
</div>
<!-- 미세먼지 추이 차트 (PM2.5/PM10 µg/m³ — 온습도와 별도 스케일; PM 센서에서만 표시) -->
<?php if (!empty($chart_pm_points)): ?>
<h2 id="pm-chart-section">화재감시 추이 (PM)
<span class="h2-sub">· <?= htmlspecialchars($chart_sensor_name !== '' ? $chart_sensor_name : ('센서 ' . $chart_sensor_id)) ?> 최근 <?= count($chart_pm_points) ?>개</span>
</h2>
<div class="chart-panel">
<div class="chart-head">
<div class="chart-legend" style="margin:0">
<span><i class="legend-swatch pm25"></i> PM2.5(µg/m³)</span>
<span><i class="legend-swatch pm10"></i> PM10(µg/m³)</span>
</div>
</div>
<?php if (count($chart_pm_points) >= 2): ?>
<div class="chart-svg-wrap">
<?php
// ── 인라인 SVG (PM2.5/PM10 2계열, 공통 µg/m³ 스케일) ──────────
$pmc_w = 920; $pmc_h = 240;
$pmp_l = 44; $pmp_r = 20; $pmp_t = 16; $pmp_b = 34;
$pmplot_w = $pmc_w - $pmp_l - $pmp_r;
$pmplot_h = $pmc_h - $pmp_t - $pmp_b;
$pmn = count($chart_pm_points);
// 스케일: 0..(측정 최댓값·임계값 중 큰 값 + 여유). 임계선이 항상 보이게 임계값 포함.
$pm_all = [(float)METRIC_PM25_HIGH, (float)METRIC_PM10_HIGH];
foreach ($chart_pm_points as $p) {
if ($p['pm25'] !== null) { $pm_all[] = (float)$p['pm25']; }
if ($p['pm10'] !== null) { $pm_all[] = (float)$p['pm10']; }
}
$pm_min = 0.0;
$pm_max = max($pm_all) * 1.1;
if ($pm_max <= 0) { $pm_max = 1.0; }
$pmx = function (int $i) use ($pmp_l, $pmplot_w, $pmn): float {
if ($pmn <= 1) { return $pmp_l; }
return $pmp_l + ($pmplot_w * $i / ($pmn - 1));
};
$pmy = function (?float $v) use ($pmp_t, $pmplot_h, $pm_min, $pm_max): ?float {
if ($v === null) { return null; }
$r = ($pm_max - $pm_min) ?: 1;
return $pmp_t + $pmplot_h * (1 - (($v - $pm_min) / $r));
};
$pm_build = function (string $key) use ($chart_pm_points, $pmx, $pmy): array {
$segments = []; $cur = [];
foreach ($chart_pm_points as $i => $p) {
$val = $p[$key];
if ($val === null) { if (count($cur) >= 2) { $segments[] = $cur; } $cur = []; continue; }
$cur[] = sprintf('%.1f,%.1f', $pmx($i), $pmy((float)$val));
}
if (count($cur) >= 2) { $segments[] = $cur; }
return $segments;
};
$pm25_segs = $pm_build('pm25');
$pm10_segs = $pm_build('pm10');
$pm_fmt = fn($v) => rtrim(rtrim(number_format($v, 1, '.', ''), '0'), '.');
?>
<svg viewBox="0 0 <?= $pmc_w ?> <?= $pmc_h ?>" width="100%" height="<?= $pmc_h ?>"
preserveAspectRatio="xMidYMid meet" role="img"
aria-label="화재감시 추이 라인 차트" class="chart-svg">
<rect x="<?= $pmp_l ?>" y="<?= $pmp_t ?>" width="<?= $pmplot_w ?>" height="<?= $pmplot_h ?>" class="plot-bg"></rect>
<?php for ($g = 0; $g <= 4; $g++):
$gy = $pmp_t + $pmplot_h * $g / 4;
$gv = $pm_max - ($pm_max - $pm_min) * $g / 4; ?>
<line x1="<?= $pmp_l ?>" y1="<?= sprintf('%.1f', $gy) ?>"
x2="<?= $pmp_l + $pmplot_w ?>" y2="<?= sprintf('%.1f', $gy) ?>"
class="grid-line" stroke-dasharray="3,3"></line>
<text x="<?= $pmp_l - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>"
text-anchor="end" font-size="10" class="axis-pm"><?= htmlspecialchars($pm_fmt($gv)) ?></text>
<?php endfor; ?>
<!-- X축 시간 라벨 (시작/중간/끝) -->
<?php
$pm_lbl_idx = $pmn > 1 ? [0, intdiv($pmn - 1, 2), $pmn - 1] : [0];
foreach ($pm_lbl_idx as $li):
$anchor = $li === 0 ? 'start' : ($li === $pmn - 1 ? 'end' : 'middle');
$tlabel = substr($chart_pm_points[$li]['t'], 5, 11); ?>
<text x="<?= sprintf('%.1f', $pmx($li)) ?>" y="<?= $pmc_h - 12 ?>"
text-anchor="<?= $anchor ?>" font-size="10" class="axis-x"><?= htmlspecialchars($tlabel) ?></text>
<?php endforeach; ?>
<!-- 임계선 오버레이 (PM2.5/PM10) -->
<?php
$pm_thr = [
[(float)METRIC_PM25_HIGH, 'PM2.5', 'line-pm25'],
[(float)METRIC_PM10_HIGH, 'PM10', 'line-pm10'],
];
foreach ($pm_thr as [$cval, $lbl, $cls]):
$ty = $pmy($cval);
if ($ty === null || $ty < $pmp_t || $ty > $pmp_t + $pmplot_h) { continue; } ?>
<line x1="<?= $pmp_l ?>" y1="<?= sprintf('%.1f', $ty) ?>"
x2="<?= $pmp_l + $pmplot_w ?>" y2="<?= sprintf('%.1f', $ty) ?>"
class="<?= $cls ?>" stroke-width="1" stroke-dasharray="2,4" opacity="0.85"></line>
<text x="<?= $pmp_l + 4 ?>" y="<?= sprintf('%.1f', $ty - 3) ?>"
text-anchor="start" font-size="9" class="axis-pm"><?= htmlspecialchars($lbl . ' ' . $pm_fmt($cval) . 'µg/m³') ?></text>
<?php endforeach; ?>
<!-- PM10 라인 (파선) -->
<?php foreach ($pm10_segs as $seg): ?>
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
class="line-pm10" fill="none" stroke-width="1.8" stroke-dasharray="6,3"
stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endforeach; ?>
<!-- PM2.5 라인 -->
<?php foreach ($pm25_segs as $seg): ?>
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
class="line-pm25" fill="none" stroke-width="1.8"
stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endforeach; ?>
</svg>
</div>
<p class="caption donut-note">임계 — PM2.5 <?= htmlspecialchars($pm_fmt((float)METRIC_PM25_HIGH)) ?> · PM10 <?= htmlspecialchars($pm_fmt((float)METRIC_PM10_HIGH)) ?> µg/m³ 초과 시 경보</p>
<?php else: ?>
<div class="chart-empty">화재감시(PM) 추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).</div>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- 통계 위젯 (7일/30일 — 시간축 라벨을 각 타일 부제에 명기) -->
<h2>경보·가동 통계</h2>
<div class="overview trend-widgets">
<!-- 경보 발생 추이 (7일 · 온습도 경보 측정 vs 누수 감지 이벤트) -->
<div class="ov-tile ov-trend">
<h3>경보 발생 추이 <span class="sub">최근 7일 · 유형별</span></h3>
<div class="mini-legend">
<span><i style="background:var(--warn)"></i>온습도 경보</span>
<span><i style="background:var(--pm)"></i>화재의심</span>
<span><i style="background:var(--leak)"></i>누수 감지</span>
</div>
<?php
$tw = 640; $th = 200; $tpl = 40; $tpr = 16; $tpt = 16; $tpb = 34;
$tplot_w = $tw - $tpl - $tpr; $tplot_h = $th - $tpt - $tpb; $tn = count($trend_days);
$t_x = function ($i) use ($tpl, $tplot_w, $tn) { return $tn > 1 ? $tpl + $tplot_w * $i / ($tn - 1) : $tpl; };
$t_y = function ($v) use ($tpt, $tplot_h, $trend_max) { return $tpt + $tplot_h * (1 - $v / $trend_max); };
$t_line = function (array $vals) use ($t_x, $t_y) {
$pts = [];
foreach ($vals as $i => $v) { $pts[] = sprintf('%.1f,%.1f', $t_x($i), $t_y($v)); }
return implode(' ', $pts);
};
$t_peak_i = 0;
foreach ($trend_metric as $i => $v) { if ($v > $trend_metric[$t_peak_i]) { $t_peak_i = $i; } }
?>
<svg class="trend-svg" viewBox="0 0 <?= $tw ?> <?= $th ?>" preserveAspectRatio="xMidYMid meet" role="img" aria-label="온습도 경보 vs 누수 감지 7일 추이">
<rect x="<?= $tpl ?>" y="<?= $tpt ?>" width="<?= $tplot_w ?>" height="<?= $tplot_h ?>" class="plot-bg"></rect>
<?php for ($g = 0; $g <= 2; $g++):
$gy = $tpt + $tplot_h * $g / 2; $gv = $trend_max - $trend_max * $g / 2; ?>
<line x1="<?= $tpl ?>" y1="<?= sprintf('%.1f', $gy) ?>" x2="<?= $tpl + $tplot_w ?>" y2="<?= sprintf('%.1f', $gy) ?>" class="grid-line" stroke-dasharray="3,3"></line>
<text x="<?= $tpl - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="end" font-size="10" class="ax"><?= htmlspecialchars(rtrim(rtrim(number_format($gv, 1, '.', ''), '0'), '.')) ?></text>
<?php endfor; ?>
<polyline points="<?= htmlspecialchars($t_line($trend_leak)) ?>" class="line-alert" fill="none" stroke-width="2" stroke-dasharray="6,3" stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php if (!empty($trend_pm) && max($trend_pm) > 0): ?>
<polyline points="<?= htmlspecialchars($t_line($trend_pm)) ?>" class="line-pm" fill="none" stroke-width="2" stroke-dasharray="2,3" stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endif; ?>
<polyline points="<?= htmlspecialchars($t_line($trend_metric)) ?>" class="line-metric" fill="none" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php if ($trend_metric[$t_peak_i] > 0): ?>
<circle cx="<?= sprintf('%.1f', $t_x($t_peak_i)) ?>" cy="<?= sprintf('%.1f', $t_y($trend_metric[$t_peak_i])) ?>" r="3.2" class="dot-metric"></circle>
<?php endif; ?>
<?php
$t_lbl_idx = $tn > 1 ? [0, intdiv($tn - 1, 2), $tn - 1] : [0];
foreach ($t_lbl_idx as $li):
$anchor = $li === 0 ? 'start' : ($li === $tn - 1 ? 'end' : 'middle'); ?>
<text x="<?= sprintf('%.1f', $t_x($li)) ?>" y="<?= $th - 12 ?>" text-anchor="<?= $anchor ?>" font-size="9" class="ax"><?= htmlspecialchars(substr($trend_days[$li], 5)) ?></text>
<?php endforeach; ?>
</svg>
</div>
<!-- 경보 종류 도넛 (30일) — 단위가 다른 두 카운트를 명기해 오독 방지 -->
<div class="ov-tile ov-donut">
<h3>경보 종류 <span class="sub">최근 30일</span></h3>
<?php if ($donut_total > 0): ?>
<div class="donut-body">
<div class="donut-ring">
<svg viewBox="0 0 120 120" role="img" aria-label="경보 종류 구성 총 <?= (int)$donut_total ?>건">
<circle class="d-track" cx="60" cy="60" r="47" pathLength="100"></circle>
<g transform="rotate(-90 60 60)">
<?php $d_acc = 0; foreach ($donut_segs as $seg):
if ((int)$seg['val'] <= 0) { continue; }
$d_pct = $seg['val'] / $donut_total * 100; ?>
<circle class="seg cat-<?= htmlspecialchars($seg['cat']) ?>" cx="60" cy="60" r="47" pathLength="100"
stroke-dasharray="<?= sprintf('%.2f', $d_pct) ?> 100"
stroke-dashoffset="<?= sprintf('%.2f', -$d_acc) ?>"></circle>
<?php $d_acc += $d_pct; endforeach; ?>
</g>
</svg>
<div class="donut-num"><b><?= (int)$donut_total ?></b><small>건</small></div>
</div>
<div class="donut-legend">
<?php foreach ($donut_segs as $seg): if ((int)$seg['val'] <= 0) { continue; } ?>
<span class="dl"><i class="cat-<?= htmlspecialchars($seg['cat']) ?>"></i><?= htmlspecialchars($seg['label']) ?><span class="dl-c"><?= (int)$seg['val'] ?></span></span>
<?php endforeach; ?>
</div>
</div>
<p class="caption donut-note">단위 — 온습도·화재의심: 비정상 측정 건수 · 누수: 감지 이벤트 건수</p>
<?php else: ?>
<div class="donut-empty">최근 30일 경보 없음</div>
<?php endif; ?>
</div>
<!-- 가동률 게이지 + 7일 경보 스파크라인 -->
<div class="ov-tile ov-gauge">
<h3>가동률 <span class="sub">온라인 센서</span></h3>
<div class="gauge-body">
<div class="gauge-ring <?= htmlspecialchars($ov_badge) ?>">
<svg viewBox="0 0 120 120" role="img" aria-label="가동률 <?= (int)$uptime_pct ?>%">
<g transform="rotate(-90 60 60)">
<circle class="g-track" cx="60" cy="60" r="52" pathLength="100"></circle>
<circle class="g-val" cx="60" cy="60" r="52" pathLength="100" stroke-dasharray="<?= (int)$uptime_pct ?> 100"></circle>
</g>
</svg>
<div class="gauge-num"><b><?= (int)$uptime_pct ?>%</b><small><?= (int)$online_count ?> / <?= (int)$sensor_total ?> 온라인</small></div>
</div>
<div class="gauge-foot">
<span class="lbl">7일 경보</span>
<?php
$sp_n = count($spark_alerts);
$sp_max = max(1, max($spark_alerts));
$sp_pts = [];
foreach ($spark_alerts as $spi => $spv) {
$spx = $sp_n > 1 ? $spi * (84 / ($sp_n - 1)) : 42;
$spy = 4 + (1 - $spv / $sp_max) * 22;
$sp_pts[] = sprintf('%.1f,%.1f', $spx, $spy);
}
$sp_last = explode(',', end($sp_pts));
?>
<svg class="spark" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true">
<polyline points="<?= htmlspecialchars(implode(' ', $sp_pts)) ?>"></polyline>
<circle class="sp-dot" cx="<?= htmlspecialchars($sp_last[0]) ?>" cy="<?= htmlspecialchars($sp_last[1]) ?>" r="2"></circle>
</svg>
<span class="g-badge <?= htmlspecialchars($ov_badge) ?>"><?= htmlspecialchars($ov_badge_txt) ?></span>
</div>
</div>
</div>
<?php if (!empty($board_rows)): ?>
<!-- 경보 최다 센서 리더보드 (30일 · 온습도 센서 4개 이상 규모에서만 렌더) -->
<div class="ov-tile ov-board">
<h3>경보 최다 센서 <span class="sub">30일 · TOP <?= count($board_rows) ?></span></h3>
<div class="board">
<?php foreach ($board_rows as $b_rank => $br):
$b_w = $board_max > 0 ? (int)round((int)$br['c'] / $board_max * 100) : 0; ?>
<div class="board-row">
<span class="board-rank"><?= $b_rank + 1 ?></span>
<div class="board-main">
<div class="board-name"><?= htmlspecialchars($br['sensor_name']) ?></div>
<div class="board-bar"><span style="width:<?= $b_w ?>%"></span></div>
</div>
<span class="board-cnt"><?= (int)$br['c'] ?><small>건</small></span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>

View file

@ -1,14 +1,14 @@
# 운영·비밀값 (절대 커밋 금지)
# Operational secrets (never commit)
config_local.py
# Python venv / 캐시
# Python venv / cache
venv/
__pycache__/
*.pyc
# 오프라인 설치 번들 (vendor.sh 산출물 — 용량이 큼, 전송은 별도)
# Offline install bundle (vendor.sh output - large; transferred separately)
wheelhouse/
debs/
# 런타임 로그
# Runtime logs
*.log

View file

@ -1,127 +1,135 @@
# 누수감지 센서 모듈 — Raspberry Pi 4 Model B 전용
# Leak Detection Sensor Module — Raspberry Pi 4 Model B Only
e-con **5C-08 저항형 누수감지센서**를 Raspberry Pi 4 Model B 의 GPIO 로 읽어,
누수 발생 시 즉시 PHP API 서버에 보고(+SMS 발송 트리거)하고 **Pi Camera 로 현장 사진을
촬영해 업로드**하는 모니터링 모듈입니다.
A monitoring module that reads the e-con **5C-08 resistive leak detection sensor** via the
GPIO of a Raspberry Pi 4 Model B. On a leak it immediately reports to the PHP API server
(+ triggers an SMS), and **captures an on-site photo with the Pi Camera and uploads it**.
> 원래 **Raspberry Pi Zero 2 W** 용으로 작성된 모듈을 **Pi 4 Model B 전용**으로 이식한
> 폴더입니다. GPIO(BCM) 번호 체계와 `rpicam`/`libcamera` 카메라 스택은 두 보드가 동일해
> 센서·촬영 로직은 그대로이며, 차이는 **배선/전원/이더넷/카메라 커넥터**와 설치 환경뿐입니다.
> This folder is a port of a module originally written for the **Raspberry Pi Zero 2 W**,
> adapted to be **Pi 4 Model B only**. The GPIO (BCM) numbering scheme and the
> `rpicam`/`libcamera` camera stack are identical on both boards, so the sensor/capture
> logic is unchanged; the differences are only in the **wiring/power/Ethernet/camera
> connector** and the install environment.
---
## 1. 구성 파일
## 1. Files
| 파일 | 역할 |
| File | Role |
|---|---|
| `leak_sensor.py` | 메인 — GPIO 인터럽트 누수 감지, 주기 보고, 누수 시 촬영+업로드 |
| `config.py` | 비밀이 아닌 기본 설정(센서/임계/간격/카메라). `config_local` 에서 비밀값 로드 |
| `config_local.example.py` | 운영·비밀값 템플릿 → `config_local.py` 로 복사해 사용(미커밋) |
| `requirements.txt` | Python 의존성(핀 고정) |
| `install.sh` | Pi 4 설치 스크립트(폐쇄망 오프라인 + 온라인 폴백) |
| `vendor.sh` | 폐쇄망용 의존성 사전 다운로드(빌드 머신에서 실행) |
| `test_connection.py` | 서버 연결/서명 점검 |
| `systemd/leak-sensor.service` | 부팅 자동 실행 서비스 유닛 |
| `leak_sensor.py` | Main — GPIO interrupt leak detection, periodic reporting, capture+upload on leak |
| `config.py` | Non-secret default settings (sensor/threshold/interval/camera). Loads secrets from `config_local` |
| `config_local.example.py` | Operational/secret value template → copy to `config_local.py` before use (not committed) |
| `requirements.txt` | Python dependencies (pinned) |
| `install.sh` | Pi 4 install script (air-gapped offline + online fallback) |
| `vendor.sh` | Pre-download dependencies for air-gapped use (run on build machine) |
| `test_connection.py` | Server connection/signature check |
| `systemd/leak-sensor.service` | Auto-start-on-boot service unit |
---
## 2. 하드웨어 (Pi 4 Model B 기준)
## 2. Hardware (based on Pi 4 Model B)
- **Raspberry Pi 4 Model B** (BCM2711) + Raspberry Pi OS **Bookworm 64-bit**
- e-con **5C-08** 누수감지센서(저항형, 고임피던스 ~1.5MΩ)
- **외부 4.7MΩ 풀다운 저항** (필수 — 내부 풀저항은 끔)
- (선택) **Pi Camera** — Pi 4 = **15핀(1.0mm 피치) 표준 CSI**, Zero 2 W = **22핀(0.5mm 피치) CSI**
→ 리본/카메라 보드 호환 안 됨 (Pi 4용 15핀 리본 또는 15↔22 변환 어댑터 필요)
- 전원: **USB-C 5V/3A** (카메라 사용 시 여유 전원 권장)
- 네트워크: Pi 4 **Gigabit Ethernet**(유선) 또는 WiFi
- e-con **5C-08** leak detection sensor (resistive, high-impedance ~1.5MΩ)
- **External 10MΩ pull-down resistor** (required — internal pull resistor is off)
- (Optional) **Pi Camera** — Pi 4 = **15-pin (1.0mm pitch) standard CSI**, Zero 2 W = **22-pin (0.5mm pitch) CSI**
→ Ribbon/camera boards are not compatible (a Pi 4 15-pin ribbon or a 15↔22 adapter is required)
- Power: **USB-C 5V/3A** (a power margin is recommended when using the camera)
- Network: Pi 4 **Gigabit Ethernet** (wired) or WiFi
### 2.1 핀 배치 (기본 센서 1개)
### 2.1 Pinout (default single sensor)
| 신호 | BCM | 40핀 헤더 물리 핀 |
| Signal | BCM | 40-pin header physical pin |
|---|---|---|
| 센서 입력 | GPIO17 | 11 |
| Sensor input | GPIO17 | 11 |
| 3.3V | — | 1 |
| GND | — | 6 (또는 9/14/20/25/30/34/39) |
| GND | — | 6 (or 9/14/20/25/30/34/39) |
> 센서를 추가하려면 `config.py` 의 `SENSORS` 에 BCM 번호로 항목을 추가합니다.
> (예: GPIO27=물리 13번, GPIO22=물리 15번)
> To add a sensor, add an entry by BCM number to `SENSORS` in `config.py`.
> (e.g. GPIO27=physical pin 13, GPIO22=physical pin 15)
### 2.2 전기 설계 (중요)
### 2.2 Electrical design (important)
- `ACTIVE_LOW = False`: 건조 시 GPIO=LOW(0), 누수 시 GPIO=HIGH(1)
- `USE_INTERNAL_PULL = False`(**PUD_OFF 유지**): 내부 50kΩ 풀다운을 켜면 1.5MΩ 센서가
GPIO 를 HIGH 로 끌어올리지 못해 **감지 실패**합니다. 외부 4.7MΩ 풀다운만 사용하세요.
- `ACTIVE_LOW = False`: when dry GPIO=LOW(0), on leak GPIO=HIGH(1)
- `USE_INTERNAL_PULL = False` (**keep PUD_OFF**): if you turn on the internal 50kΩ pull-down,
the 1.5MΩ sensor cannot pull GPIO up to HIGH, causing **detection failure**. Use only the
external 10MΩ pull-down.
---
## 3. 서버 호환성 (반드시 확인)
## 3. Server compatibility (must verify)
이 모듈은 `leak_alert` / `leak_recovery` 이벤트와 `photo_upload.php` 를 **수신하는 서버**에
보고합니다.
This module reports to a **server that receives** the `leak_alert` / `leak_recovery` events
and `photo_upload.php`.
> ✅ **이 repo 의 `php/` 서버가 바로 그 서버입니다(단일 서버 통합 완료).**
> 1번(누수+카메라)·2번(SHT30 온습도) 두 모듈 모두 **동일한 cafe24 `php/` 서버**에
> raw-body 서명 `X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))`
> 로 보고하며, 누수 상태·사진·온습도가 하나의 대시보드(`dashboard.php`)에 함께 표시됩니다.
> ✅ **The `php/` server in this repo is exactly that server (single-server integration complete).**
> Both module 1 (leak + camera) and module 2 (SHT30 temperature/humidity) report to the
> **same cafe24 `php/` server** with the raw-body signature
> `X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))`,
> and leak status, photos, and temperature/humidity are shown together on a single
> dashboard (`dashboard.php`).
>
> - **신규 설치:** `sql/schema_sht30.sql` 하나만 실행 — 누수 요소
> (`leak_sensor_log.is_leak` / `leak_sensor_status.is_leak` 컬럼, `leak_incident`, `leak_photo`,
> `leak_ingest_nonce` 테이블)가 이미 포함되어 있습니다.
> - **New install:** run only `sql/schema_sht30.sql` — the leak elements
> (`leak_sensor_log.is_leak` / `leak_sensor_status.is_leak` columns, `leak_incident`, `leak_photo`,
> `leak_ingest_nonce` tables) are already included.
>
> `config_local.py` 의 `API_URL` / `PHOTO_UPLOAD_URL` 을 이 통합 서버로 설정하고,
> `API_KEY` 는 서버 `config.local.php` 의 `API_KEY` 와 **바이트 단위로 동일**하게 맞춥니다.
> Set `API_URL` / `PHOTO_UPLOAD_URL` in `config_local.py` to this integrated server, and make
> `API_KEY` **byte-for-byte identical** to the `API_KEY` in the server's `config.local.php`.
---
## 4. 설치
## 4. Installation
### 4.1 설정값 준비
### 4.1 Prepare configuration values
```bash
cp config_local.example.py config_local.py
# config_local.py 를 열어 API_URL / PHOTO_UPLOAD_URL / API_KEY / DEVICE_ID 채움
# Open config_local.py and fill in API_URL / PHOTO_UPLOAD_URL / API_KEY / DEVICE_ID
```
`config_local.py` 가 없으면 실행 시 **명확한 오류로 즉시 중단**됩니다(fail closed).
`config_local.py` 와 `API_KEY` 는 **절대 커밋하지 마세요**(`.gitignore` 처리됨).
If `config_local.py` is missing, execution **stops immediately with a clear error** (fail closed).
**Never commit** `config_local.py` or `API_KEY` (they are handled by `.gitignore`).
### 4.2 온라인 설치 (인터넷 가능 환경)
### 4.2 Online install (environment with internet)
```bash
chmod +x install.sh
./install.sh
```
### 4.3 폐쇄망(air-gapped) 오프라인 설치
### 4.3 Air-gapped offline install
인터넷이 되는 **동일 아키텍처(aarch64, 가급적 동일 Pi 4 + Bookworm 64-bit)** 머신에서
의존성을 먼저 받아 번들을 만든 뒤, 타깃 Pi 4 로 옮겨 설치합니다.
On a machine with internet of the **same architecture (aarch64, ideally the same Pi 4 +
Bookworm 64-bit)**, first fetch the dependencies to build a bundle, then move it to the
target Pi 4 to install.
```bash
# (빌드 머신, 인터넷 O) wheelhouse/ + debs/ 생성
# (build machine, internet YES) create wheelhouse/ + debs/
./vendor.sh
# wheelhouse/ 와 debs/ 를 타깃 Pi 4 의 이 폴더로 복사한 뒤:
# (타깃 Pi 4, 인터넷 X)
./install.sh # debs/ · wheelhouse/ 가 있으면 자동으로 오프라인 설치
# After copying wheelhouse/ and debs/ into this folder on the target Pi 4:
# (target Pi 4, internet NO)
./install.sh # if debs/ · wheelhouse/ exist, installs offline automatically
```
> `install.sh` 는 `debs/` 가 있으면 `dpkg` 오프라인, `wheelhouse/` 가 있으면
> `pip --no-index` 오프라인으로 설치합니다. 둘 다 없을 때만 인터넷 apt/pip 로 폴백합니다.
> wheel/deb 는 아키텍처 종속이므로 x86_64 PC 에서 받은 것은 Pi 4 에서 동작하지 않습니다.
> If `debs/` exists, `install.sh` installs offline with `dpkg`; if `wheelhouse/` exists, it
> installs offline with `pip --no-index`. It falls back to internet apt/pip only when neither
> is present. wheel/deb are architecture-dependent, so ones fetched on an x86_64 PC will not
> work on the Pi 4.
---
## 5. 실행
## 5. Running
```bash
source venv/bin/activate
python3 leak_sensor.py
```
`RPi.GPIO` 가 없는 환경(개발 PC 등)에서는 자동으로 **시뮬레이션 모드**로 동작합니다.
In an environment without `RPi.GPIO` (a development PC, etc.), it automatically runs in
**simulation mode**.
### 연결 점검
### Connection check
```bash
source venv/bin/activate
@ -130,10 +138,10 @@ python3 test_connection.py
---
## 6. 부팅 자동 실행 (systemd)
## 6. Auto-start on boot (systemd)
```bash
# 폴더를 /home/pi/raspberry_pi4 에 배치했다고 가정 (다르면 .service 의 경로 수정)
# Assumes the folder is placed at /home/pi/raspberry_pi4 (if different, edit the path in .service)
sudo cp systemd/leak-sensor.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable leak-sensor
@ -142,19 +150,19 @@ sudo systemctl status leak-sensor
journalctl -u leak-sensor -f
```
`SupplementaryGroups=video` 가 카메라 접근에 필요합니다.
`SupplementaryGroups=video` is required for camera access.
---
## 7. 보안 메모
## 7. Security notes
- 비밀값(`API_KEY`)·서버 URL·기기 정보는 `config_local.py`(미커밋)에만 둡니다.
커밋되는 파일에는 placeholder 만 존재합니다.
- 보고 본문은 **raw-body HMAC 서명**으로 위변조를 방지합니다:
`X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))`
(`leak_sensor.sign_raw` ↔ 서버 `config.php::verify_signature_raw`, 2번 모듈과 동일 스킴).
- 사진 업로드 서명의 canonical 문자열은 `device_id|sensor_id|log_id|timestamp`
(공백 없음)입니다 (`config_local.example.py` 참고).
- 구버전의 `sha256(API_KEY + 정렬 JSON)` body-field 서명은 **폐기**되었고, 서버 기본
설정이 거부합니다(`ALLOW_LEGACY_BODY_SIGNATURE=false`, `php/config.php`).
- `config_local.py` 노출 시 즉시 `API_KEY` 를 교체(서버 동시 변경)하세요.
- Secret values (`API_KEY`), the server URL, and device info live only in `config_local.py`
(not committed). Committed files contain only placeholders.
- The report body is protected against tampering with a **raw-body HMAC signature**:
`X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))`
(`leak_sensor.sign_raw` ↔ server `config.php::verify_signature_raw`, same scheme as module 2).
- The canonical string for the photo upload signature is `device_id|sensor_id|log_id|timestamp`
(no spaces) (see `config_local.example.py`).
- The old `sha256(API_KEY + sorted JSON)` body-field signature is **deprecated**, and the
server default rejects it (`ALLOW_LEGACY_BODY_SIGNATURE=false`, `php/config.php`).
- If `config_local.py` is exposed, immediately rotate `API_KEY` (change it on the server at the same time).

View file

@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="ko">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>누수 감지 노드 · 배선 &amp; 핀맵 (Raspberry Pi 4)</title>
<title>Leak Detection Node · Wiring &amp; Pinmap (Raspberry Pi 4)</title>
<style>
:root{
--bg:#eef1f5; --card:#ffffff; --ink:#212529; --muted:#6b7280; --line:#e5e8ec;
@ -124,26 +124,26 @@
<!-- 1) HEADER -->
<header class="hero">
<p class="eyebrow">Field Wiring &amp; Pinmap · 누수 감지 노드</p>
<h1>누수 감지 노드 · 배선 &amp; 핀맵</h1>
<p>e-con 5C-08 저항형 누수감지센서를 <b>GPIO17(물리 핀 11)</b>에 연결하는 표준 배선도입니다.
Pi-A(sensor_id=1 · 1번 지점 · 서버실)와 Pi-C(sensor_id=3 · 3번 지점 · 신규)는 배선이 동일한 <b>공통 노드</b>입니다.</p>
<p class="eyebrow">Field Wiring &amp; Pinmap · Leak Detection Node</p>
<h1>Leak Detection Node · Wiring &amp; Pinmap</h1>
<p>Standard wiring diagram for connecting the e-con 5C-08 resistive leak sensor to <b>GPIO17 (physical pin 11)</b>.
Pi-A (sensor_id=1 · Site 1 · Server Room) and Pi-C (sensor_id=3 · Site 3 · New) are <b>common nodes</b> with identical wiring.</p>
<div class="chips">
<span class="chip">Raspberry Pi 4 Model B</span>
<span class="chip mono">BCM2711</span>
<span class="chip">Raspberry Pi OS Bookworm 64-bit</span>
<span class="chip">e-con 5C-08 · 저항형 ~1.5MΩ</span>
<span class="chip mono">GPIO17 / 핀11</span>
<span class="chip">외부 4.7MΩ 풀다운 필수</span>
<span class="chip">e-con 5C-08 · Resistive ~1.5MΩ</span>
<span class="chip mono">GPIO17 / Pin 11</span>
<span class="chip">External 10MΩ pull-down required</span>
</div>
</header>
<!-- 2) 40핀 헤더 핀맵 도해 -->
<!-- 2) 40-pin header pinmap diagram -->
<section>
<h2><span class="num">2</span>40핀 헤더 핀맵 도해</h2>
<h2><span class="num">2</span>40-Pin Header Pinmap Diagram</h2>
<div class="pinwrap">
<div class="diagram">
<svg viewBox="0 0 600 681" role="img" aria-label="Raspberry Pi 4 40핀 GPIO 헤더 핀맵" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;max-width:600px;">
<svg viewBox="0 0 600 681" role="img" aria-label="Raspberry Pi 4 40-pin GPIO header pinmap" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;max-width:600px;">
<rect x="70" y="69" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="131" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="193" width="460" height="31" fill="#f6f8fa"/>
@ -156,8 +156,8 @@
<rect x="70" y="627" width="460" height="31" fill="#f6f8fa"/>
<rect x="234" y="30" width="112" height="633" rx="10" fill="#f1f3f5" stroke="#adb5bd" stroke-width="1.5"/>
<rect x="236" y="36" width="32" height="32" rx="6" fill="none" stroke="#e03131" stroke-width="1.5" stroke-dasharray="3 2"/>
<text x="252" y="22" text-anchor="middle" font-size="10" fill="#868e96">홀수 핀</text>
<text x="330" y="22" text-anchor="middle" font-size="10" fill="#868e96">짝수 핀</text>
<text x="252" y="22" text-anchor="middle" font-size="10" fill="#868e96">Odd pins</text>
<text x="330" y="22" text-anchor="middle" font-size="10" fill="#868e96">Even pins</text>
<text x="220" y="56" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="700" fill="#7d1616">3V3</text>
<text x="360" y="56" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">5V</text>
<circle cx="252" cy="52" r="12" fill="#e03131" stroke="#c92a2a" stroke-width="1.5"/><text x="252" y="55.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#ffffff">1</text>
@ -241,174 +241,381 @@
</svg>
</div>
<div class="legend">
<div class="lg"><span class="sw sig"></span><b>신호 (GPIO17 · 핀 11)<span>센서 신호선 입력</span></b></div>
<div class="lg"><span class="sw pwr"></span><b>전원 3V3 (핀 1)<span>센서 전원 공급</span></b></div>
<div class="lg"><span class="sw gnd"></span><b>접지 GND (핀 6 · 사용)<span>센서 GND 기본 연결</span></b></div>
<div class="lg"><span class="sw alt"></span><b>접지 대체 가능<span>핀 9 / 14 / 20 / 25 / 30 / 34 / 39</span></b></div>
<div class="lg"><span class="sw exp"></span><b>확장 예시 (선택)<span>GPIO27·핀 13, GPIO22·핀 15</span></b></div>
<div class="lg"><span class="sw off"></span><b>미사용 핀<span>이 노드에서 연결하지 않음</span></b></div>
<div class="hint">붉은 점선 사각형이 <b>핀 1</b> 위치입니다. 헤더 방향(핀 1 기준)을 반드시 확인하고 배선하세요. 실제 사용 핀은 <b>1 · 6 · 11</b> 세 개뿐입니다.</div>
<div class="lg"><span class="sw sig"></span><b>Signal (GPIO17 · Pin 11)<span>Sensor signal line input</span></b></div>
<div class="lg"><span class="sw pwr"></span><b>Power 3V3 (Pin 1)<span>Sensor power supply</span></b></div>
<div class="lg"><span class="sw gnd"></span><b>GND (Pin 6) · 풀다운용<span>10MΩ 풀다운 리턴 · 센서 선 아님</span></b></div>
<div class="lg"><span class="sw alt"></span><b>Ground alternatives<span>Pin 9 / 14 / 20 / 25 / 30 / 34 / 39</span></b></div>
<div class="lg"><span class="sw exp"></span><b>Expansion example (optional)<span>GPIO27·Pin 13, GPIO22·Pin 15</span></b></div>
<div class="lg"><span class="sw off"></span><b>Unused pins<span>Not connected on this node</span></b></div>
<div class="hint">The red dashed square marks the <b>Pin 1</b> position. Always verify the header orientation (relative to Pin 1) before wiring. Only three pins are actually used: <b>1 · 6 · 11</b>.</div>
</div>
</div>
</section>
<!-- 3) 핀맵 표 -->
<!-- 3) Pinmap table -->
<section>
<h2><span class="num">3</span>핀맵 표</h2>
<h2><span class="num">3</span>Pinmap Table</h2>
<table>
<thead>
<tr><th>신호</th><th class="mono">BCM</th><th class="mono">물리 핀</th><th>비고</th></tr>
<tr><th>Signal</th><th class="mono">BCM</th><th class="mono">Physical Pin</th><th>Notes</th></tr>
</thead>
<tbody>
<tr>
<td><span class="tag sig">신호</span> 센서 신호선</td>
<td><span class="tag sig">Signal</span> Sensor signal line</td>
<td class="mono">GPIO17</td>
<td class="mono">핀 11</td>
<td>센서 신호 입력. 외부 4.7MΩ 풀다운을 <b>핀 11 ↔ GND</b> 사이에 연결.</td>
<td class="mono">Pin 11</td>
<td>Sensor signal input. Connect an external 10MΩ pull-down between <b>Pin 11 ↔ GND</b>.</td>
</tr>
<tr>
<td><span class="tag pwr">전원</span> 센서 전원 3.3V</td>
<td><span class="tag pwr">Power</span> Sensor power 3.3V</td>
<td class="mono">3V3</td>
<td class="mono">핀 1</td>
<td>센서 VCC 공급. (예비 3V3 = 핀 17)</td>
<td class="mono">Pin 1</td>
<td>Sensor VCC supply. (Spare 3V3 = Pin 17)</td>
</tr>
<tr>
<td><span class="tag gnd">접지</span> 센서 GND</td>
<td><span class="tag gnd">Ground</span> 풀다운 GND <span style="color:#868e96">(센서 선 아님)</span></td>
<td class="mono">GND</td>
<td class="mono">핀 6</td>
<td>대체 가능: 핀 9 / 14 / 20 / 25 / 30 / 34 / 39 중 아무 GND.</td>
<td class="mono">Pin 6</td>
<td>10MΩ 풀다운(GPIO17↔GND) 리턴용. Alternatives: any GND among Pin 9 / 14 / 20 / 25 / 30 / 34 / 39.</td>
</tr>
<tr class="exprow">
<td><span class="tag exp">확장</span> 추가 센서 예시</td>
<td><span class="tag exp">Expansion</span> Additional sensor example</td>
<td class="mono">GPIO27</td>
<td class="mono">핀 13</td>
<td>선택. <span class="inline-code">config.py</span>의 SENSORS에 BCM 번호로 추가.</td>
<td class="mono">Pin 13</td>
<td>Optional. Add by BCM number to SENSORS in <span class="inline-code">config.py</span>.</td>
</tr>
<tr class="exprow">
<td><span class="tag exp">확장</span> 추가 센서 예시</td>
<td><span class="tag exp">Expansion</span> Additional sensor example</td>
<td class="mono">GPIO22</td>
<td class="mono">핀 15</td>
<td>선택. 위와 동일한 방법으로 추가(각 채널마다 외부 풀다운 필요).</td>
<td class="mono">Pin 15</td>
<td>Optional. Add the same way as above (each channel needs its own external pull-down).</td>
</tr>
</tbody>
<caption>사용 핀은 3개(핀 1 · 6 · 11). 확장 행은 다중 센서 구성 시 참고용 예시입니다. 기기별 sensor_id / location은 <span class="inline-code">config_local.py</span>에서 설정.</caption>
<caption>Three pins used (Pin 1 · 6 · 11). The expansion rows are reference examples for multi-sensor setups. Per-device sensor_id / location is set in <span class="inline-code">config_local.py</span>.</caption>
</table>
</section>
<!-- 4) 배선 스키매틱 -->
<!-- 4) Wiring schematic -->
<section>
<h2><span class="num">4</span>배선 스키매틱</h2>
<h2><span class="num">4</span>Wiring Schematic</h2>
<div class="diagram">
<svg viewBox="0 0 640 400" role="img" aria-label="누수센서 배선 스키매틱 — 3V3·GPIO17·GND 및 외부 4.7MΩ 풀다운" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto">
<svg viewBox="0 0 640 400" role="img" aria-label="누수 저항식 2전극 프로브 배선 — 전극A→3V3, 전극B→GPIO17. 10MΩ 풀다운은 GPIO17↔GND 보드 부품(센서 선 아님)" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto">
<!-- Pi header block -->
<rect x="36" y="70" width="150" height="250" rx="10" fill="#f1f3f5" stroke="#adb5bd" stroke-width="1.5"/>
<text x="111" y="60" text-anchor="middle" font-size="13" font-weight="700" fill="#212529">Raspberry Pi 4 · 40핀 헤더</text>
<text x="111" y="60" text-anchor="middle" font-size="13" font-weight="700" fill="#212529">Raspberry Pi 4 · 40-pin header</text>
<!-- header pin rows -->
<circle cx="60" cy="120" r="5" fill="#e03131"/>
<text x="74" y="124" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">핀1 · 3V3</text>
<text x="74" y="124" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">Pin 1 · 3V3</text>
<rect x="182" y="114" width="10" height="12" rx="2" fill="#e03131"/>
<circle cx="60" cy="195" r="5" fill="#2f9e44"/>
<text x="74" y="199" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">핀11 · GPIO17</text>
<text x="74" y="199" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">Pin 11 · GPIO17</text>
<rect x="182" y="189" width="10" height="12" rx="2" fill="#2f9e44"/>
<circle cx="60" cy="270" r="5" fill="#1971c2"/>
<text x="74" y="274" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">핀6 · GND</text>
<text x="74" y="274" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">Pin 6 · GND</text>
<rect x="182" y="264" width="10" height="12" rx="2" fill="#1971c2"/>
<!-- sensor block -->
<rect x="470" y="95" width="150" height="190" rx="10" fill="#ffffff" stroke="#495057" stroke-width="1.5"/>
<text x="545" y="84" text-anchor="middle" font-size="13" font-weight="700" fill="#212529">e-con 5C-08 누수센서</text>
<rect x="470" y="92" width="164" height="150" rx="10" fill="#ffffff" stroke="#495057" stroke-width="1.5"/>
<text x="552" y="82" text-anchor="middle" font-size="13" font-weight="700" fill="#212529">e-con 5C-08 리크 프로브</text>
<text x="552" y="106" text-anchor="middle" font-size="11" font-weight="700" fill="#2b8a3e">2선(2전극)</text>
<rect x="462" y="114" width="10" height="12" rx="2" fill="#e03131"/>
<text x="484" y="124" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">VCC 3.3V</text>
<text x="484" y="124" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">전극 A</text>
<rect x="462" y="189" width="10" height="12" rx="2" fill="#2f9e44"/>
<text x="484" y="199" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">SIG 신호</text>
<rect x="462" y="264" width="10" height="12" rx="2" fill="#1971c2"/>
<text x="484" y="274" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">GND</text>
<text x="484" y="199" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">전극 B</text>
<rect x="556" y="150" width="9" height="58" rx="2" fill="#e03131"/>
<rect x="580" y="150" width="9" height="58" rx="2" fill="#2f9e44"/>
<path d="M560,208 q13,9 25,0" fill="none" stroke="#4dabf7" stroke-width="2.5"/>
<text x="572" y="228" text-anchor="middle" font-size="10.5" fill="#1971c2">물 닿으면 도통</text>
<!-- wires -->
<path d="M192,120 H462" stroke="#e03131" stroke-width="2.5" fill="none"/>
<path d="M192,195 H462" stroke="#2f9e44" stroke-width="2.5" fill="none"/>
<path d="M192,270 H462" stroke="#1971c2" stroke-width="2.5" fill="none"/>
<text x="327" y="111" text-anchor="middle" font-size="11" fill="#c92a2a">3.3V 전원 · 핀1</text>
<text x="250" y="186" text-anchor="middle" font-size="11" fill="#2b8a3e">신호 · GPIO17 · 핀11</text>
<text x="250" y="261" text-anchor="middle" font-size="11" fill="#1864ab">GND · 핀6</text>
<!-- external pulldown between SIG wire and GND wire -->
<path d="M192,270 H340" stroke="#1971c2" stroke-width="2.5" fill="none"/>
<text x="322" y="111" text-anchor="middle" font-size="11" fill="#c92a2a">① 전극 A → 3V3 · Pin 1</text>
<text x="248" y="186" text-anchor="middle" font-size="11" fill="#2b8a3e">② 전극 B → GPIO17 · Pin 11</text>
<text x="228" y="288" text-anchor="middle" font-size="11" fill="#1864ab">GND · Pin 6 (풀다운용)</text>
<!-- external pulldown between GPIO17 wire and GND wire (board part, not a sensor lead) -->
<circle cx="340" cy="195" r="4" fill="#2f9e44"/>
<circle cx="340" cy="270" r="4" fill="#1971c2"/>
<polyline points="340,199 340,206 330,210 350,218 330,226 350,234 330,242 340,246 340,266" fill="none" stroke="#343a40" stroke-width="2"/>
<text x="360" y="226" text-anchor="start" font-size="13" font-weight="700" fill="#343a40">4.7MΩ</text>
<text x="360" y="242" text-anchor="start" font-size="11" fill="#495057">외부 풀다운(필수)</text>
<text x="356" y="222" text-anchor="start" font-size="12" font-weight="700" fill="#343a40">10MΩ 풀다운</text>
<text x="356" y="238" text-anchor="start" font-size="10" fill="#c92a2a">(보드 부품·센서 선 아님)</text>
<!-- internal pull off note -->
<rect x="60" y="300" width="330" height="26" rx="6" fill="#fff5f5" stroke="#ffa8a8" stroke-width="1.5" stroke-dasharray="4 3"/>
<text x="72" y="317" text-anchor="start" font-size="11" font-weight="700" fill="#c92a2a">내부 풀 OFF · USE_INTERNAL_PULL=False (PUD_OFF)</text>
<!-- logic box -->
<rect x="60" y="350" width="520" height="30" rx="6" fill="#f1f3f5" stroke="#ced4da"/>
<text x="320" y="369" text-anchor="middle" font-size="11.5" fill="#343a40">건조 → GPIO=LOW(0) · 누수 → GPIO=HIGH(1) · ACTIVE_LOW=False</text>
<rect x="60" y="350" width="540" height="30" rx="6" fill="#f1f3f5" stroke="#ced4da"/>
<text x="330" y="369" text-anchor="middle" font-size="11.5" fill="#343a40">건조 → GPIO17=LOW(0) · 누수(물 도통) → GPIO17=HIGH(1) · ACTIVE_LOW=False</text>
</svg>
</div>
<div class="hint">동작 원리: 건조 시 센서는 초고임피던스(거의 개방) 상태라 외부 4.7MΩ 풀다운이 GPIO17을 <b>LOW</b>로 유지합니다. 누수로 물이 전극을 연결하면 3.3V가 ~1.5MΩ을 통해 신호선을 끌어올려 GPIO17이 <b>HIGH</b>로 전환됩니다.</div>
<div class="hint"><b>센서는 2선(2전극).</b> 전극 A는 3V3(Pin 1), 전극 B는 GPIO17(Pin 11)에 연결한다. Pin 6 GND는 <b>센서 선이 아니라</b> GPIO17↔GND 사이 10MΩ 풀다운 저항용이다. 건조 시 프로브가 초고임피던스(거의 개방)라 풀다운이 GPIO17을 <b>LOW</b>로 잡고, 물이 두 전극을 이으면 3.3V가 ~1.5MΩ를 통해 전극 B 선을 끌어올려 GPIO17이 <b>HIGH</b>가 된다.</div>
</section>
<!-- 5) 전기 설계 · 주의 -->
<!-- 5) Electrical design · cautions -->
<section>
<h2><span class="num">5</span>전기 설계 · 주의</h2>
<h2><span class="num">5</span>Electrical Design · Cautions</h2>
<div class="crit">
<span class="badge">★ 절대 주의 · CRITICAL</span>
<h4>내부 풀다운을 켜지 마세요</h4>
<p>내부 <b>50kΩ</b> 풀다운을 활성화하면, ~1.5MΩ 고임피던스 센서가 GPIO를 HIGH로 끌어올리지 못해
<b>누수를 감지하지 못합니다(감지 실패)</b>. 반드시 내부 풀은 <span class="inline-code">PUD_OFF</span>로 끄고,
<b>외부 4.7MΩ 풀다운만</b> 사용하세요. (<span class="inline-code">USE_INTERNAL_PULL = False</span>)</p>
<span class="badge">★ Critical Caution · CRITICAL</span>
<h4>Do not enable the internal pull-down</h4>
<p>If you enable the internal <b>50kΩ</b> pull-down, the ~1.5MΩ high-impedance sensor cannot pull the GPIO HIGH,
so <b>leaks will not be detected (detection failure)</b>. Always turn the internal pull off with <span class="inline-code">PUD_OFF</span>
and use <b>only the external 10MΩ pull-down</b>. (<span class="inline-code">USE_INTERNAL_PULL = False</span>)</p>
</div>
<div class="notes" style="margin-top:14px">
<div class="note">
<h4>극성 · 로직 레벨</h4>
<p><span class="inline-code">ACTIVE_LOW = False</span> → 건조 시 <b>GPIO=LOW(0)</b>, 누수 시 <b>GPIO=HIGH(1)</b>.</p>
<h4>Polarity · Logic Level</h4>
<p><span class="inline-code">ACTIVE_LOW = False</span> → when dry <b>GPIO=LOW(0)</b>, on leak <b>GPIO=HIGH(1)</b>.</p>
</div>
<div class="note">
<h4>외부 풀다운 (필수)</h4>
<p>4.7MΩ 저항을 <b>GPIO17(핀 11) ↔ GND</b> 사이에 연결. 고임피던스 센서용으로 반드시 필요.</p>
<h4>External Pull-down (required)</h4>
<p>Connect a 10MΩ resistor between <b>GPIO17 (Pin 11) ↔ GND</b>. Essential for the high-impedance sensor.</p>
</div>
<div class="note">
<h4>내부 풀 설정</h4>
<p><span class="inline-code">USE_INTERNAL_PULL = False</span> — 내부 풀은 <span class="inline-code">PUD_OFF</span> 유지.</p>
<h4>Internal Pull Setting</h4>
<p><span class="inline-code">USE_INTERNAL_PULL = False</span> — keep the internal pull at <span class="inline-code">PUD_OFF</span>.</p>
</div>
<div class="note">
<h4>디바운스</h4>
<p><span class="inline-code">DEBOUNCE_MS = 500</span> — 고임피던스 특성상 넉넉하게 설정.</p>
<h4>Debounce</h4>
<p><span class="inline-code">DEBOUNCE_MS = 500</span> — set generously given the high-impedance behavior.</p>
</div>
<div class="note" style="grid-column:1 / -1">
<h4>설정 파일</h4>
<p>전기 파라미터는 <span class="inline-code">raspberry_pi4/config.py</span> (SENSORS · ACTIVE_LOW · USE_INTERNAL_PULL · DEBOUNCE_MS).
기기별 <b>sensor_id / location</b>은 <span class="inline-code">config_local.py</span>
(Pi-A = id 1 · 1번 지점 · 서버실 / Pi-C = id 3 · 3번 지점 · 신규).</p>
<h4>Configuration Files</h4>
<p>Electrical parameters live in <span class="inline-code">raspberry_pi4/config.py</span> (SENSORS · ACTIVE_LOW · USE_INTERNAL_PULL · DEBOUNCE_MS).
Per-device <b>sensor_id / location</b> is in <span class="inline-code">config_local.py</span>
(Pi-A = id 1 · Site 1 · Server Room / Pi-C = id 3 · Site 3 · New).</p>
</div>
</div>
<div class="cam">
<h4>카메라 (선택 · CAMERA_ENABLED=True)</h4>
<p>Raspberry Pi 4는 <b>15핀(1.0mm 피치) 표준 CSI 커넥터</b>입니다. Zero 2 W는 22핀(0.5mm)이라
리본·카메라보드가 <b>비호환</b> → Pi 4용 <b>15핀 리본</b> 또는 <b>15↔22 변환 어댑터</b>가 필요합니다.
촬영은 <span class="inline-code">rpicam-still</span>(Bookworm) / <span class="inline-code">libcamera-still</span>(Bullseye), <b>640×480 JPEG</b>.</p>
<h4>Camera (optional · CAMERA_ENABLED=True)</h4>
<p>The Raspberry Pi 4 uses a <b>15-pin (1.0mm pitch) standard CSI connector</b>. The Zero 2 W uses 22-pin (0.5mm),
so its ribbon and camera board are <b>incompatible</b> → you need a <b>15-pin ribbon</b> for the Pi 4 or a <b>15↔22 conversion adapter</b>.
Capture with <span class="inline-code">rpicam-still</span> (Bookworm) / <span class="inline-code">libcamera-still</span> (Bullseye), <b>640×480 JPEG</b>.</p>
</div>
</section>
<!-- 6) 설치 체크리스트 -->
<!-- 6) Installation checklist -->
<section>
<h2><span class="num">6</span>설치 체크리스트</h2>
<h2><span class="num">6</span>Installation Checklist</h2>
<ol class="check">
<li>Raspberry Pi 전원을 <b>완전히 차단</b>한 상태에서 배선을 시작한다.</li>
<li>헤더 방향(핀 1 위치)을 확인한다. 붉은 점선 표시 = 핀 1.</li>
<li>센서 신호선을 <b>핀 11(GPIO17)</b>에 연결한다.</li>
<li>센서 전원(3.3V)을 <b>핀 1(3V3)</b>, 센서 GND를 <b>핀 6(GND)</b>에 연결한다.</li>
<li class="key">외부 <b>4.7MΩ 풀다운</b>을 <b>핀 11 ↔ GND</b> 사이에 연결한다. <b>(필수 · 생략 시 감지 실패)</b></li>
<li class="key">내부 풀이 꺼져 있는지 확인한다 — <span class="inline-code">USE_INTERNAL_PULL=False</span> (PUD_OFF). <b>내부 풀다운 절대 금지.</b></li>
<li><span class="inline-code">config.py</span> 확인: <span class="inline-code">ACTIVE_LOW=False</span>, <span class="inline-code">DEBOUNCE_MS=500</span>, SENSORS에 GPIO17 등록.</li>
<li><span class="inline-code">config_local.py</span>에서 기기별 <b>sensor_id / location</b> 설정 (Pi-A=1/서버실, Pi-C=3/신규).</li>
<li>전원 인가 후, <b>건조 상태</b>에서 GPIO17 = <b>LOW(0)</b>인지 로그로 확인한다.</li>
<li>물(또는 젖은 천)로 센서를 적셔 GPIO17이 <b>HIGH(1)</b>로 전환되고 누수 이벤트가 기록되는지 확인한다.</li>
<li>(선택) 카메라 사용 시 리본 방향·커넥터 규격(15핀 / 어댑터)을 확인하고 640×480 촬영 테스트를 수행한다.</li>
<li>Start wiring with the Raspberry Pi power <b>fully disconnected</b>.</li>
<li>Verify the header orientation (Pin 1 position). Red dashed marker = Pin 1.</li>
<li>Connect the sensor signal line to <b>Pin 11 (GPIO17)</b>.</li>
<li>Connect sensor power (3.3V) to <b>Pin 1 (3V3)</b> and sensor GND to <b>Pin 6 (GND)</b>.</li>
<li class="key">Connect an external <b>10MΩ pull-down</b> between <b>Pin 11 ↔ GND</b>. <b>(Required · omitting it causes detection failure)</b></li>
<li class="key">Confirm the internal pull is off — <span class="inline-code">USE_INTERNAL_PULL=False</span> (PUD_OFF). <b>Never use the internal pull-down.</b></li>
<li>Check <span class="inline-code">config.py</span>: <span class="inline-code">ACTIVE_LOW=False</span>, <span class="inline-code">DEBOUNCE_MS=500</span>, GPIO17 registered in SENSORS.</li>
<li>Set the per-device <b>sensor_id / location</b> in <span class="inline-code">config_local.py</span> (Pi-A=1/Server Room, Pi-C=3/New).</li>
<li>After powering on, confirm via the log that GPIO17 = <b>LOW(0)</b> in the <b>dry state</b>.</li>
<li>Wet the sensor with water (or a damp cloth) and confirm GPIO17 switches to <b>HIGH(1)</b> and a leak event is logged.</li>
<li>(Optional) When using the camera, verify the ribbon orientation and connector spec (15-pin / adapter) and run a 640×480 capture test.</li>
</ol>
</section>
<!-- 7) Camera lighting (always-on) -->
<section>
<h2><span class="num">7</span>Camera Lighting (Always-On)</h2>
<div class="cam">
<h4>White LED module — always on, no switching, no code</h4>
<p>Power a small <b>white LED module</b> directly from the Pi so the leak-check camera has light.
<b>VCC → 5V (Pin 2)</b>, <b>GND → GND (Pin 6)</b>. It stays <b>lit continuously</b> — there is
<b>no GPIO control, no switching, and no software</b>. The module is low-power, so continuous
operation produces <b>negligible heat</b> (harmless to leave on).</p>
</div>
<div class="notes" style="margin-top:14px">
<div class="note">
<h4>Wiring</h4>
<p>LED module <span class="inline-code">VCC → 5V (Pin 2)</span>, <span class="inline-code">GND → GND (Pin 6)</span>.
No signal pin — it is a bare power connection, always on.</p>
</div>
<div class="note">
<h4>No conflict with sensors</h4>
<p>Uses only 5V/GND power rails — it does not touch GPIO17 (leak), the I2C pins (SHT30),
or the UART pins (PMS7003 on GPIO15). All can coexist.</p>
</div>
<div class="note" style="grid-column:1 / -1">
<h4>NoIR camera + white light → pink tint</h4>
<p>A <b>NoIR</b> camera (no IR-cut filter) under white LED light renders the image with a
<b>pink/magenta tint</b>. This does <b>not</b> hinder leak identification (a wet patch is still
clearly visible). If you need <b>natural color</b>, use a <b>standard camera with a built-in
IR-cut filter</b> instead of the NoIR module.</p>
</div>
</div>
</section>
<!-- 8) 통합 노드 배선 (combined node master reference) -->
<section>
<h2><span class="num">8</span>통합 노드 배선 (누수 + 온습도 + 미세먼지 + 카메라 + 조명)</h2>
<p style="margin:0 0 16px;font-size:14px;color:#374151">
Pi-A (id 1) / Pi-C (id 3) are <b>combined nodes</b> carrying <b>all five loads on one Pi</b>:
leak sensor + SHT30 (온습도) + PMS7003 (미세먼지) + camera + always-on light. The three standalone
sensor sheets each independently claim <b>Pin 1 / Pin 2 / Pin 6</b>, which <b>physically collide</b>
on a shared board. <b>This single table is the de-conflicted master; wire the combined node from
here, not from the per-sensor sections above.</b>
</p>
<div class="crit">
<span class="badge">★ De-conflict · KEY FIX</span>
<h4>GND is common, but you cannot stack 4 wires on Pin 6</h4>
<p>Every device shares the same ground, but the Pi 4 exposes <b>8 separate GND pins
(6 · 9 · 14 · 20 · 25 · 30 · 34 · 39)</b>. Spread the four grounds across them, one per device.
Likewise there are <b>two 3V3 pins (1, 17)</b> and <b>two 5V pins (2, 4)</b>: give the leak sensor
Pin 1 and the SHT30 the <b>second</b> 3V3 (Pin 17); give the PMS7003 Pin 2 and the LED the
<b>second</b> 5V (Pin 4). Never double up two wires on a single rail pin.</p>
</div>
<table style="margin-top:16px">
<thead>
<tr><th>Device</th><th>Line</th><th class="mono">BCM / Rail</th><th class="mono">Physical Pin</th><th>Notes</th></tr>
</thead>
<tbody>
<!-- Leak -->
<tr>
<td rowspan="3"><b>누수 Leak</b><br><span style="color:var(--muted);font-size:12.5px">e-con 5C-08</span></td>
<td><span class="tag sig">Signal</span> Sensor signal</td>
<td class="mono">GPIO17</td>
<td class="mono">Pin 11</td>
<td>External <b>10MΩ pull-down</b> Pin 11 ↔ GND (required). Internal pull <b>OFF</b> (PUD_OFF).</td>
</tr>
<tr>
<td><span class="tag pwr">Power</span> VCC 3.3V</td>
<td class="mono">3V3</td>
<td class="mono">Pin 1</td>
<td>First 3V3 rail.</td>
</tr>
<tr>
<td><span class="tag gnd">Ground</span> GND</td>
<td class="mono">GND</td>
<td class="mono">Pin 20</td>
<td>10MΩ 풀다운 리턴 GND(<b>센서 선 아님</b>). 고임피던스 라인이라 짧고 견고하게. (alt: Pin 25)</td>
</tr>
<!-- SHT30 -->
<tr>
<td rowspan="4"><b>온습도 SHT30</b><br><span style="color:var(--muted);font-size:12.5px">I2C · 3.3V ONLY</span></td>
<td><span class="tag sig">I2C</span> SDA</td>
<td class="mono">GPIO2</td>
<td class="mono">Pin 3</td>
<td>I2C data (SDA1).</td>
</tr>
<tr>
<td><span class="tag sig">I2C</span> SCL</td>
<td class="mono">GPIO3</td>
<td class="mono">Pin 5</td>
<td>I2C clock (SCL1).</td>
</tr>
<tr>
<td><span class="tag pwr">Power</span> VCC 3.3V</td>
<td class="mono">3V3</td>
<td class="mono">Pin 17</td>
<td><b>SECOND 3V3, NOT Pin 1.</b> <b>3.3V only</b>, never 5V (5V damages the SHT30).</td>
</tr>
<tr>
<td><span class="tag gnd">Ground</span> GND</td>
<td class="mono">GND</td>
<td class="mono">Pin 9</td>
<td>I2C ground (adjacent to Pin 3 / 5).</td>
</tr>
<!-- PMS7003 -->
<tr>
<td rowspan="4"><b>미세먼지 PMS7003</b><br><span style="color:var(--muted);font-size:12.5px">UART</span></td>
<td><span class="tag sig">UART</span> PMS TXD → Pi RXD</td>
<td class="mono">GPIO15</td>
<td class="mono">Pin 10</td>
<td>PMS transmit into the Pi; the data line that must be present.</td>
</tr>
<tr>
<td><span class="tag sig">UART</span> Pi TXD → PMS RXD <em>(opt)</em></td>
<td class="mono">GPIO14</td>
<td class="mono">Pin 8</td>
<td>Optional: only for sleep/wake commands. Passive read does not need it.</td>
</tr>
<tr>
<td><span class="tag pwr">Power</span> VCC 5V</td>
<td class="mono">5V</td>
<td class="mono">Pin 2</td>
<td>PMS7003 runs on <b>5V</b> (its UART logic is 3.3V-safe for the Pi).</td>
</tr>
<tr>
<td><span class="tag gnd">Ground</span> GND</td>
<td class="mono">GND</td>
<td class="mono">Pin 6</td>
<td>UART ground.</td>
</tr>
<!-- Camera -->
<tr>
<td><b>카메라 Camera</b></td>
<td><span class="tag exp">CSI</span> Ribbon</td>
<td class="mono">n/a</td>
<td class="mono">CSI (separate)</td>
<td><b>Not on the 40-pin header.</b> 15-pin CSI ribbon into the camera connector (see §5).</td>
</tr>
<!-- LED -->
<tr>
<td rowspan="2"><b>조명 LED</b><br><span style="color:var(--muted);font-size:12.5px">always-on</span></td>
<td><span class="tag pwr">Power</span> VCC 5V</td>
<td class="mono">5V</td>
<td class="mono">Pin 4</td>
<td><b>SECOND 5V, NOT Pin 2.</b> Must be an LED <b>module with an integrated resistor</b>, or add
a <b>~150–330Ω series resistor</b> (a bare LED on 5V burns out).</td>
</tr>
<tr>
<td><span class="tag gnd">Ground</span> GND</td>
<td class="mono">GND</td>
<td class="mono">Pin 14</td>
<td>Always lit: no GPIO, no switching, no code.</td>
</tr>
</tbody>
<caption>Combined-node master pinout. Header pins used: 1 · 2 · 3 · 4 · 5 · 6 · 8 · 9 · 10 · 11 · 14 · 17 · 20 (+ CSI ribbon). Four grounds spread over Pin 6 / 9 / 14 / 20, never stacked on one pin.</caption>
</table>
<h3>Boot configuration (Bookworm · idempotent via install.sh)</h3>
<div class="notes">
<div class="note">
<h4>I2C · SHT30</h4>
<p><span class="inline-code">dtparam=i2c_arm=on</span> in
<span class="inline-code">/boot/firmware/config.txt</span>. Verify with
<span class="inline-code">i2cdetect -y 1</span> (SHT30 at 0x44 / 0x45).</p>
</div>
<div class="note">
<h4>UART · PMS7003</h4>
<p><span class="inline-code">enable_uart=1</span> in
<span class="inline-code">config.txt</span>, and <b>remove</b>
<span class="inline-code">console=serial0,115200</span> from
<span class="inline-code">/boot/firmware/cmdline.txt</span> so the login console does not fight the sensor.</p>
</div>
<div class="note">
<h4>Camera</h4>
<p><span class="inline-code">camera_auto_detect=1</span> in
<span class="inline-code">config.txt</span>. Verify with
<span class="inline-code">rpicam-hello --list-cameras</span>.</p>
</div>
<div class="note">
<h4>Leak GPIO17 · unaffected</h4>
<p>Enabling UART / disabling the serial console does <b>not</b> affect the GPIO17 leak read or
the camera. GPIO17 is a plain input, independent of the serial peripheral.</p>
</div>
<div class="note" style="grid-column:1 / -1">
<h4>One reboot applies everything</h4>
<p>All three <span class="inline-code">install.sh</span> scripts set these <b>idempotently</b>
(I2C · UART + cmdline · camera). Run them, then a <b>single reboot</b> applies I2C, UART, and the
camera together (no per-sensor reboot needed).</p>
</div>
</div>
<h3>Power supply</h3>
<div class="note" style="border-left-color:var(--pwr)">
<h4>Use a genuine 5.1V / 3A USB-C PSU</h4>
<p>Combined draw of all five loads is <b>~1.4–1.8A typical, ~2.2A worst-case</b>, so the official
<b>5.1V / 3A</b> supply has ample headroom. <b>Avoid phone chargers and unpowered USB hubs</b>:
sag below ~4.8V triggers a brown-out → CPU throttle and <b>SD-card corruption</b>. Check health with
<span class="inline-code">vcgencmd get_throttled</span> (expect <span class="inline-code">0x0</span>;
any non-zero indicates under-voltage).</p>
</div>
</section>
</div>
</body>
</html>

View file

@ -1,75 +1,83 @@
# =============================================================================
# config.py - 누수감지 시스템 v2 (Pi 4 Model B) 설정 — 비밀이 아닌 기본값
# config.py - Leak Detection System v2 (Pi 4 Model B) settings — non-secret defaults
#
# 서버 URL / API_KEY / 기기 식별자 등 운영·비밀값은 config_local.py 에서 로드한다.
# config_local.py 는 .gitignore 로 커밋 제외 대상이다.
# 설정 방법: cp config_local.example.py config_local.py → 실제 값으로 채움
# Operational/secret values such as server URL / API_KEY / device identifier are loaded from config_local.py.
# config_local.py is excluded from commits via .gitignore.
# Setup: cp config_local.example.py config_local.py → fill in with real values
# =============================================================================
# ─────────────────────────────────────────────
# 센서 GPIO 설정 (BCM 번호 기준 — Pi 4 40핀 헤더와 동일)
# GPIO17 = 40핀 헤더 물리 11번 핀
# Sensor GPIO settings (BCM numbering — same as Pi 4 40-pin header)
# GPIO17 = physical pin 11 on the 40-pin header
# ─────────────────────────────────────────────
SENSORS = [
{"id": 1, "gpio": 17, "name": "1번 센서 (서버실)"},
# 추가 센서가 있으면 아래에 추가 (BCM 번호 기준)
# {"id": 2, "gpio": 27, "name": "2번 센서 (기계실)"}, # 물리 13번
# {"id": 3, "gpio": 22, "name": "3번 센서 (화장실)"}, # 물리 15번
{"id": 1, "gpio": 17, "name": "Sensor 1 (Server Room)"},
# Add more sensors below if present (BCM numbering)
# {"id": 2, "gpio": 27, "name": "Sensor 2 (Machine Room)"}, # physical pin 13
# {"id": 3, "gpio": 22, "name": "Sensor 3 (Restroom)"}, # physical pin 15
]
# 센서 활성 레벨 설정
# e-con 5C-08 저항형 센서 + 4.7MΩ 풀다운 사용 시:
# 건조: GPIO = LOW(0) → ACTIVE_LOW = False
# 누수: GPIO = HIGH(1)
# Sensor active-level setting
# When using the e-con 5C-08 resistive sensor + 10MΩ pull-down:
# Dry: GPIO = LOW(0) → ACTIVE_LOW = False
# Leak: GPIO = HIGH(1)
ACTIVE_LOW = False
# GPIO 내부 풀저항 설정
# 반드시 False (PUD_OFF) 유지!
# 내부 풀다운(50kΩ)을 켜면 누수 시에도 HIGH가 되지 않아 감지 실패함.
# → 외부 4.7MΩ 풀다운 저항만 사용
# GPIO internal pull-resistor setting
# Must stay False (PUD_OFF)!
# Enabling the internal pull-down (50kΩ) keeps the line from going HIGH even on a leak, causing detection failure.
# → Use only the external 10MΩ pull-down resistor
USE_INTERNAL_PULL = False # True = PUD_DOWN, False = PUD_OFF
# ─────────────────────────────────────────────
# 주기 설정
# Interval settings
# ─────────────────────────────────────────────
REPORT_INTERVAL = 600 # 정상 상태 주기 보고 간격 (초) = 10분
# 서버 HEARTBEAT_TIMEOUT_SEC(기본 1200초)보다 짧아야
# 오프라인 오탐이 없다 (600초 = 2회 누락 시 오프라인 판정).
LEAK_REPORT_INTERVAL = 10 # 누수 감지 시 재보고 간격 (초)
DEBOUNCE_MS = 500 # 바운싱 방지 시간 (ms) — 고임피던스라 넉넉히 설정
REPORT_INTERVAL = 600 # Normal-state periodic report interval (sec) = 10 min
# Must be shorter than the server's HEARTBEAT_TIMEOUT_SEC (default 1200 sec)
# to avoid offline false positives (600 sec = offline after 2 missed reports).
LEAK_REPORT_INTERVAL = 10 # Re-report interval while a leak is detected (sec)
DEBOUNCE_MS = 500 # Debounce time (ms) — set generously due to high impedance
# Confirm-read debounce: an edge alone never changes state. After an edge the pin is
# re-read CONFIRM_READS times, CONFIRM_INTERVAL_SEC apart; ALL reads must agree with the
# new state or the event is discarded as noise. Real water persists; sub-second EMI
# blips on the 10MΩ high-impedance line (server-room noise) do not.
# Total confirmation latency ≈ CONFIRM_READS × CONFIRM_INTERVAL_SEC ≈ 0.9 sec.
CONFIRM_READS = 3 # Consecutive identical reads required to accept a change
CONFIRM_INTERVAL_SEC = 0.3 # Delay between confirmation reads (sec)
# ─────────────────────────────────────────────
# 로그 설정
# Log settings
# ─────────────────────────────────────────────
LOG_FILE = "/var/log/leak_sensor.log"
LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB
LOG_BACKUP_COUNT = 3
# ─────────────────────────────────────────────
# 재시도 설정
# Retry settings
# ─────────────────────────────────────────────
HTTP_TIMEOUT = 10 # HTTP 요청 타임아웃 (초)
HTTP_RETRY_COUNT = 3 # 재시도 횟수
HTTP_RETRY_DELAY = 5 # 재시도 대기 (초)
HTTP_TIMEOUT = 10 # HTTP request timeout (sec)
HTTP_RETRY_COUNT = 3 # Retry count
HTTP_RETRY_DELAY = 5 # Retry delay (sec)
# ─────────────────────────────────────────────
# 카메라 설정
# Pi 4 = 15핀(1.0mm 피치) 표준 CSI, Zero 2 W = 22핀(0.5mm 피치) CSI →
# 리본/카메라 보드 호환 안 됨 (Pi 4용 15핀 리본 또는 15↔22 변환 어댑터 필요).
# 촬영은 rpicam-still(Bookworm) / libcamera-still(Bullseye) 사용.
# Camera settings
# Pi 4 = 15-pin (1.0mm pitch) standard CSI, Zero 2 W = 22-pin (0.5mm pitch) CSI →
# ribbon/camera board not compatible (needs a Pi 4 15-pin ribbon or a 15↔22 adapter).
# Capture uses rpicam-still (Bookworm) / libcamera-still (Bullseye).
# ─────────────────────────────────────────────
CAMERA_ENABLED = True # False로 설정하면 촬영/업로드 건너뜀
PHOTO_WIDTH = 640 # 촬영 해상도 (가로)
PHOTO_HEIGHT = 480 # 촬영 해상도 (세로)
PHOTO_QUALITY = 60 # JPEG 품질 (1-100, 60 ≈ 50-80KB)
CAMERA_ENABLED = True # Set to False to skip capture/upload
PHOTO_WIDTH = 640 # Capture resolution (width)
PHOTO_HEIGHT = 480 # Capture resolution (height)
PHOTO_QUALITY = 60 # JPEG quality (1-100, 60 ≈ 50-80KB)
# ─────────────────────────────────────────────
# 운영·비밀값 로드 (fail closed)
# config_local.py 가 없으면 placeholder/하드코딩 비밀로 잘못 기동하는 대신
# 명확한 오류로 즉시 중단한다.
# Load operational/secret values (fail closed)
# If config_local.py is missing, stop immediately with a clear error
# instead of wrongly starting with placeholder/hardcoded secrets.
# ─────────────────────────────────────────────
try:
from config_local import ( # noqa: F401 (leak_sensor.py 가 config 에서 재수입)
from config_local import ( # noqa: F401 (leak_sensor.py re-imports from config)
API_URL,
API_KEY,
PHOTO_UPLOAD_URL,
@ -78,25 +86,25 @@ try:
)
except ImportError as e:
raise RuntimeError(
"config_local.py 를 찾을 수 없습니다. "
"`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / "
"기기 정보를 채우세요. (비밀값은 절대 커밋하지 마세요.)"
"config_local.py not found. "
"Run `cp config_local.example.py config_local.py`, then fill in the real server URL / API_KEY / "
"device info. (Never commit secret values.)"
) from e
# ─────────────────────────────────────────────
# 선택 오버라이드 — 기기별 값을 config_local.py 에서 덮어쓸 수 있다.
# 추적 파일(config.py)을 기기마다 수정하면 배포 드리프트가 생기므로,
# 신규 지점 노드(예: sensor_id 3)는 config_local.py 에 SENSORS 를 정의한다.
# Optional override — per-device values can be overridden in config_local.py.
# Editing the tracked file (config.py) per device causes deployment drift, so
# define SENSORS in config_local.py for a new site node (e.g. sensor_id 3).
# ─────────────────────────────────────────────
try:
from config_local import SENSORS # noqa: F811 — 기기별 센서 목록 오버라이드
from config_local import SENSORS # noqa: F811 — per-device sensor list override
except ImportError:
pass # config_local 에 SENSORS 가 없으면 위 기본값 사용
pass # use the default above if config_local has no SENSORS
# SENSORS 형식 검증 (fail closed) — 오버라이드 오타/형식 오류로 잘못된 sensor_id
# (예: 기본값 1)로 조용히 기동해 다른 지점 상태를 덮어쓰는 사고를 막는다.
# SENSORS format validation (fail closed) — prevents an override typo/format error from
# silently starting with a wrong sensor_id (e.g. default 1) and overwriting another site's state.
if not isinstance(SENSORS, list) or not SENSORS:
raise RuntimeError("SENSORS 는 비어있지 않은 list 여야 합니다 (config_local.py 확인).")
raise RuntimeError("SENSORS must be a non-empty list (check config_local.py).")
_ids, _gpios = set(), set()
for _s in SENSORS:
if not (isinstance(_s, dict)
@ -104,10 +112,10 @@ for _s in SENSORS:
and isinstance(_s.get("gpio"), int)
and isinstance(_s.get("name"), str) and _s["name"]):
raise RuntimeError(
f"SENSORS 항목 형식 오류: {_s!r} — id(int 1..255)/gpio(int)/name(str) 필수."
f"SENSORS entry format error: {_s!r} — id(int 1..255)/gpio(int)/name(str) required."
)
if _s["id"] in _ids or _s["gpio"] in _gpios:
raise RuntimeError(f"SENSORS id/gpio 중복: {_s!r}")
raise RuntimeError(f"SENSORS duplicate id/gpio: {_s!r}")
_ids.add(_s["id"])
_gpios.add(_s["gpio"])
del _ids, _gpios

View file

@ -1,49 +1,49 @@
# =============================================================================
# config_local.example.py - 운영·비밀값 템플릿 (Pi 4 누수감지 모듈)
# config_local.example.py - Operational/secret value template (Pi 4 leak detection module)
#
# 사용법: cp config_local.example.py config_local.py
# → 실제 값으로 채운 뒤 저장. config_local.py 는 절대 커밋하지 말 것
# (.gitignore 로 제외되어 있음).
# Usage: cp config_local.example.py config_local.py
# → Fill in with real values, then save. Never commit config_local.py
# (excluded via .gitignore).
#
# ⚠️ 서버 호환성
# 이 모듈(1번)은 2번 모듈(SHT30 온습도)과 "동일한" cafe24 서버 php/ 로 보고한다(통합).
# 누수 이벤트(leak_alert / leak_recovery)와 사진 업로드는 서버가 raw-body 서명을 요구한다:
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))
# 사진 업로드 canonical = device_id|sensor_id|log_id|timestamp (공백 없음)
# → API_KEY 는 서버 config.local.php 의 API_KEY 와 "정확히 동일"해야 한다.
# 서버 DB 는 sql/schema_sht30.sql 하나로 설치되며(누수 요소 포함),
# 누수 상태·사진·온습도가 한 대시보드(dashboard.php)에 함께 표시된다.
# (구버전의 sha256(API_KEY + 정렬 JSON) body-field 서명은 폐기됨 — 서버가 거부한다.)
# ⚠️ Server compatibility
# This module (Module 1) reports to the "same" cafe24 server php/ as Module 2 (SHT30 temp/humidity) — integrated.
# For leak events (leak_alert / leak_recovery) and photo uploads, the server requires a raw-body signature:
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))
# photo upload canonical = device_id|sensor_id|log_id|timestamp (no spaces)
# → API_KEY must be "exactly identical" to the API_KEY in the server's config.local.php.
# The server DB is installed from the single sql/schema_sht30.sql (includes leak components),
# and leak status, photos, and temp/humidity are shown together on one dashboard (dashboard.php).
# (The legacy sha256(API_KEY + sorted JSON) body-field signature is deprecated — the server rejects it.)
# =============================================================================
# ─────────────────────────────────────────────
# 서버 API 엔드포인트 (통합 서버 = cafe24 php/, 2번 모듈과 동일 서버)
# INSTALL_PI_SERVER.md 기준 웹 루트: public_html/raspi_leck_detecter/
# Server API endpoints (integrated server = cafe24 php/, same server as Module 2)
# Web root per INSTALL_PI_SERVER.md: public_html/raspi_leck_detecter/
# ─────────────────────────────────────────────
API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php"
PHOTO_UPLOAD_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/photo_upload.php"
# ─────────────────────────────────────────────
# 공유 비밀키 — 서버 측 API_KEY 와 정확히 동일해야 함. 절대 커밋 금지.
# Shared secret key — must be exactly identical to the server-side API_KEY. Never commit.
# ─────────────────────────────────────────────
API_KEY = "CHANGE_ME"
# ─────────────────────────────────────────────
# 기기 식별 (여러 대 운영 시 구분)
# Device identification (distinguishes multiple units)
# ─────────────────────────────────────────────
DEVICE_ID = "rpi4-leak-01"
DEVICE_LOCATION = "서버실"
DEVICE_LOCATION = "Server Room"
# ─────────────────────────────────────────────
# (선택) 센서 목록 오버라이드 — 신규 지점 노드용
# 미정의 시 config.py 기본값(SENSORS = [{"id": 1, ...}]) 사용.
# 신규 지점(예: Pi-C, sensor_id 3) 배포 시 이 파일에서만 정의하면 되고,
# 추적 파일(config.py)은 수정할 필요 없다. 서버 대시보드 카드 표시는
# 서버 config.local.php 의 'LEAK_SENSOR_IDS' => [1, 3] 도 함께 갱신할 것.
# ⚠️ sensor_id 는 지점마다 고유해야 함 — 1/2 재사용 시 기존 지점 상태를
# 조용히 덮어쓴다 (leak_sensor_status 는 sensor_id 단독 키).
# (Optional) Sensor list override — for new site nodes
# If undefined, uses the config.py default (SENSORS = [{"id": 1, ...}]).
# When deploying a new site (e.g. Pi-C, sensor_id 3), define it only in this file;
# the tracked file (config.py) does not need to be modified. For server dashboard card
# display, also update 'LEAK_SENSOR_IDS' => [1, 3] in the server's config.local.php.
# ⚠️ sensor_id must be unique per site — reusing 1/2 will silently overwrite the
# existing site's status (leak_sensor_status is keyed on sensor_id alone).
# ─────────────────────────────────────────────
# SENSORS = [
# {"id": 3, "gpio": 17, "name": "3번 센서 (신규 지점)"},
# {"id": 3, "gpio": 17, "name": "Sensor 3 (New Site)"},
# ]
# (위를 켤 때 DEVICE_ID 예: "rpi4-leak-02", DEVICE_LOCATION 도 지점명으로 변경)
# (When enabling the above, DEVICE_ID e.g. "rpi4-leak-02", and change DEVICE_LOCATION to the site name too)

View file

@ -1,15 +1,16 @@
#!/usr/bin/env bash
# =============================================================================
# 누수감지 센서 v2 (Raspberry Pi 4 Model B) 설치 스크립트
# 대상 OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
# Leak Detection Sensor v2 (Raspberry Pi 4 Model B) install script
# Target OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
#
# 폐쇄망(air-gapped) 지원:
# - debs/ 또는 wheelhouse/ 가 있으면 자동으로 "오프라인 모드"로 전환.
# - 오프라인 모드에서는 절대 네트워크에 접근하지 않는다. 번들이 일부만 있으면
# 온라인으로 몰래 빠지지 않고 명확한 오류로 중단한다(폐쇄망 규칙: 네트워크가
# 필요하면 반드시 FLAG). 사전 다운로드는 vendor.sh 로 빌드 머신에서 수행.
# - 환경변수 OFFLINE=1/0 으로 강제 지정 가능.
# - 로그 파일 소유자: SERVICE_USER (기본 pi).
# Air-gapped support:
# - If debs/ or wheelhouse/ is present, automatically switch to "offline mode".
# - In offline mode, never access the network. If the bundle is only partial,
# do not silently fall back to online; abort with a clear error (air-gap rule:
# if the network is needed, always FLAG it). Pre-download is done on the build
# machine via vendor.sh.
# - Can be forced with the OFFLINE=1/0 environment variable.
# - Log file owner: SERVICE_USER (default pi).
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")"
@ -17,101 +18,101 @@ cd "$(dirname "$0")"
SERVICE_USER="${SERVICE_USER:-pi}"
echo "=========================================="
echo " 누수감지 센서 v2 (Pi 4) 설치"
echo " Leak Detection Sensor v2 (Pi 4) install"
echo "=========================================="
# 0) 보드 모델 안내 (강제 아님)
# 0) Board model notice (not enforced)
if [ -r /proc/device-tree/model ]; then
MODEL="$(tr -d '\0' < /proc/device-tree/model)"
echo "[INFO] 보드: ${MODEL}"
echo "[INFO] Board: ${MODEL}"
case "${MODEL}" in
*"Raspberry Pi 4"*) : ;;
*) echo "[WARN] 이 폴더는 Raspberry Pi 4 Model B 전용입니다 (계속 진행은 가능)." ;;
*) echo "[WARN] This folder is intended for the Raspberry Pi 4 Model B only (continuing is possible)." ;;
esac
fi
# 오프라인 모드 판정
# Determine offline mode
OFFLINE="${OFFLINE:-}"
if [ -z "${OFFLINE}" ]; then
if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then
OFFLINE=1
echo "[INFO] 오프라인 번들 감지 → 폐쇄망 오프라인 설치 모드 (네트워크 미사용)"
echo "[INFO] Offline bundle detected -> air-gapped offline install mode (no network)"
else
OFFLINE=0
echo "[INFO] 오프라인 번들 없음 → 온라인 설치 모드 (인터넷 필요)"
echo "[INFO] No offline bundle -> online install mode (internet required)"
fi
fi
# 1) 시스템 패키지: RPi.GPIO 대체(lgpio) + requests + venv + 카메라 앱
echo "[1/3] 시스템 패키지 설치..."
# 1) System packages: RPi.GPIO replacement (lgpio) + requests + venv + camera app
echo "[1/3] Installing system packages..."
if [ "${OFFLINE}" = "1" ]; then
if ! compgen -G "debs/*.deb" >/dev/null; then
echo "[ERROR] 오프라인 모드인데 debs/ 가 비어있습니다. vendor.sh 산출물(debs/)을 이 폴더에 두고 재실행하세요." >&2
echo "[ERROR] Offline mode but debs/ is empty. Place the vendor.sh output (debs/) in this folder and re-run." >&2
exit 1
fi
echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)"
# --no-install-recommends: vendor.sh 가 Recommends 를 안 담으므로(--no-recommends) 맞춘다.
echo " Installing offline .deb (apt-get --no-download - automatic dependency ordering/resolution, no network)"
# --no-install-recommends: vendor.sh does not include Recommends (--no-recommends), so match that.
sudo apt-get install -y --no-install-recommends --no-download ./debs/*.deb
else
echo " 온라인 apt 설치 (인터넷 필요)"
echo " Online apt install (internet required)"
sudo apt-get update
if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; fi
sudo apt-get install -y python3-rpi-lgpio python3-requests python3-venv "${CAM}"
fi
# 2) Python venv + requests
echo "[2/3] Python 가상환경 설정..."
echo "[2/3] Setting up Python virtual environment..."
if [ ! -d venv ]; then
python3 -m venv --system-site-packages venv
echo " venv 생성 완료"
echo " venv created"
else
echo " venv 이미 존재 — 건너뜀"
echo " venv already exists - skipping"
fi
# shellcheck disable=SC1091
source venv/bin/activate
if [ "${OFFLINE}" = "1" ]; then
if [ ! -d wheelhouse ]; then
echo "[ERROR] 오프라인 모드인데 wheelhouse/ 가 없습니다. vendor.sh 산출물(wheelhouse/)을 두고 재실행하세요." >&2
echo "[ERROR] Offline mode but wheelhouse/ is missing. Place the vendor.sh output (wheelhouse/) and re-run." >&2
exit 1
fi
echo " 오프라인 wheel 설치 (--no-index — 네트워크 미사용)"
echo " Installing offline wheels (--no-index - no network)"
pip install --no-index --find-links=wheelhouse -r requirements.txt
else
echo " 온라인 pip 설치 (인터넷 필요)"
echo " Online pip install (internet required)"
pip install --upgrade pip
pip install -r requirements.txt
fi
echo " 패키지 설치 완료"
echo " Package installation complete"
# 3) 로그 파일 (최소 권한: 소유자만 쓰기 — leak_sensor 가 단독 기록)
echo "[3/3] 로그 파일 생성..."
# 3) Log file (least privilege: owner write only - leak_sensor is the sole writer)
echo "[3/3] Creating log file..."
sudo mkdir -p /var/log
sudo touch /var/log/leak_sensor.log
if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/leak_sensor.log; then
echo "[WARN] chown 실패 — SERVICE_USER=${SERVICE_USER} 존재 여부 확인 (서비스 사용자와 일치해야 파일 로깅 가능)"
echo "[WARN] chown failed - check whether SERVICE_USER=${SERVICE_USER} exists (must match the service user for file logging to work)"
fi
sudo chmod 640 /var/log/leak_sensor.log
# 3.5) config_local.py 즉시 잠금 (API_KEY 포함 — harden 전까지 world-readable 방지)
# 하드닝(harden_pi.sh)이 나중에 다시 조이지만, 그 사이 창(window)에도 노출되지
# 않도록 설치 시점에 바로 600 으로 잠근다. harden 여부와 무관하게 보호된다.
# 3.5) Lock config_local.py immediately (contains API_KEY - prevent world-readable until harden)
# Hardening (harden_pi.sh) tightens this again later, but lock it to 600 right at install
# time so it is not exposed even in the window between. Protected regardless of harden.
if [ -f config_local.py ]; then
chmod 600 config_local.py
sudo chown "${SERVICE_USER}:${SERVICE_USER}" config_local.py 2>/dev/null || true
echo " config_local.py 권한 600 (소유자 ${SERVICE_USER}) 설정"
echo " config_local.py permissions set to 600 (owner ${SERVICE_USER})"
fi
# 4) config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨)
# 4) config_local notice (fail closed - if missing, execution aborts immediately)
if [ ! -f config_local.py ]; then
echo ""
echo "[!] config_local.py 가 없습니다. 다음을 실행 후 실제 값으로 채우세요:"
echo "[!] config_local.py is missing. Run the following, then fill in the real values:"
echo " cp config_local.example.py config_local.py"
fi
echo ""
echo "=========================================="
echo " 설치 완료! (OFFLINE=${OFFLINE})"
echo " Installation complete! (OFFLINE=${OFFLINE})"
echo "=========================================="
echo " 실행: source venv/bin/activate && python3 leak_sensor.py"
echo " 서비스: README.md 의 systemd 섹션 참고"
echo " Run: source venv/bin/activate && python3 leak_sensor.py"
echo " Service: see the systemd section in README.md"
echo ""

View file

@ -1,26 +1,27 @@
#!/usr/bin/env python3
# =============================================================================
# leak_sensor.py - e-con 5C-08 누수감지센서 모니터링 메인 스크립트 (v2, Pi 4 전용)
# leak_sensor.py - e-con 5C-08 leak detection sensor monitoring main script (v2, Pi 4 only)
#
# [대상 하드웨어] Raspberry Pi 4 Model B (BCM2711, 40핀 GPIO 헤더)
# - 원래 Raspberry Pi Zero 2 W 용이던 모듈을 Pi 4 Model B 전용으로 이식.
# - GPIO BCM 번호 체계·rpicam/libcamera 카메라 스택은 두 보드가 동일하므로
# 센서/촬영 로직은 그대로 유지. 차이는 배선/전원/이더넷/카메라 커넥터 문서뿐.
# (Pi 4 = 15핀 1.0mm CSI / Zero 2 W = 22핀 0.5mm CSI → 리본 비호환,
# Gigabit Ethernet, USB-C 5V/3A) → README.md 참고.
# [Target hardware] Raspberry Pi 4 Model B (BCM2711, 40-pin GPIO header)
# - Module originally for Raspberry Pi Zero 2 W, ported to Pi 4 Model B only.
# - GPIO BCM numbering and the rpicam/libcamera camera stack are identical on
# both boards, so the sensor/capture logic is unchanged. Only the wiring/
# power/Ethernet/camera-connector docs differ.
# (Pi 4 = 15-pin 1.0mm CSI / Zero 2 W = 22-pin 0.5mm CSI -> ribbon incompatible,
# Gigabit Ethernet, USB-C 5V/3A) -> see README.md.
#
# [동작 흐름]
# 1. GPIO 초기화 → 센서 핀 입력 설정
# 2. 주기적(REPORT_INTERVAL)으로 현재 상태를 PHP API에 POST
# 3. 누수 감지 인터럽트 발생 시 즉시 알림 POST
# 4. 누수 지속 중엔 LEAK_REPORT_INTERVAL 마다 재보고
# 5. 복구(정상) 감지 시 복구 알림 POST
# 6. 누수 감지 시 Pi Camera로 촬영 → photo_upload.php에 업로드
# (촬영/업로드 실패해도 알림+SMS는 이미 전송된 상태)
# [Operation flow]
# 1. GPIO init -> configure sensor pins as input
# 2. POST current state to the PHP API periodically (REPORT_INTERVAL)
# 3. On leak-detection interrupt, POST an alert immediately
# 4. While the leak persists, re-report every LEAK_REPORT_INTERVAL
# 5. On recovery (normal) detection, POST a recovery alert
# 6. On leak detection, capture with the Pi Camera -> upload to photo_upload.php
# (even if capture/upload fails, the alert+SMS has already been sent)
#
# [설정·비밀값] config.py 는 비밀이 아닌 기본값만 보유하고, 서버 URL/API_KEY/
# 기기 식별자는 config_local.py(미커밋)에서 불러온다. config_local.py 가 없으면
# 기동 시 명확한 오류로 즉시 중단(fail closed)된다.
# [Config/secrets] config.py holds only non-secret defaults; the server URL/API_KEY/
# device identifiers are loaded from config_local.py (not committed). If config_local.py
# is missing, startup aborts immediately with a clear error (fail closed).
# =============================================================================
import time
@ -40,14 +41,15 @@ try:
import RPi.GPIO as GPIO
SIMULATION = False
except (ImportError, RuntimeError):
# 개발/테스트 환경에서 RPi.GPIO 없을 때 시뮬레이션 모드
# Simulation mode when RPi.GPIO is unavailable (dev/test environment)
SIMULATION = True
print("[WARNING] RPi.GPIO 없음 → 시뮬레이션 모드로 실행됩니다.")
print("[WARNING] RPi.GPIO not found -> running in simulation mode.")
from config import (
SENSORS, ACTIVE_LOW, USE_INTERNAL_PULL,
API_URL, API_KEY,
REPORT_INTERVAL, LEAK_REPORT_INTERVAL, DEBOUNCE_MS,
CONFIRM_READS, CONFIRM_INTERVAL_SEC,
DEVICE_ID, DEVICE_LOCATION,
LOG_FILE, LOG_MAX_BYTES, LOG_BACKUP_COUNT,
HTTP_TIMEOUT, HTTP_RETRY_COUNT, HTTP_RETRY_DELAY,
@ -56,7 +58,7 @@ from config import (
)
# ─────────────────────────────────────────────
# 로거 설정
# Logger setup
# ─────────────────────────────────────────────
def setup_logger():
logger = logging.getLogger("leak_sensor")
@ -67,7 +69,7 @@ def setup_logger():
datefmt="%Y-%m-%d %H:%M:%S"
)
# 파일 핸들러 (로테이션)
# File handler (rotating)
try:
fh = logging.handlers.RotatingFileHandler(
LOG_FILE, maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUP_COUNT,
@ -76,9 +78,9 @@ def setup_logger():
fh.setFormatter(fmt)
logger.addHandler(fh)
except PermissionError:
pass # 권한 없으면 콘솔만 사용
pass # No permission -> console only
# 콘솔 핸들러
# Console handler
ch = logging.StreamHandler(sys.stdout)
ch.setFormatter(fmt)
logger.addHandler(ch)
@ -89,7 +91,7 @@ log = setup_logger()
# ─────────────────────────────────────────────
# 시뮬레이션 GPIO (테스트용)
# Simulation GPIO (for testing)
# ─────────────────────────────────────────────
class SimGPIO:
BCM = "BCM"
@ -118,12 +120,12 @@ if SIMULATION:
# ─────────────────────────────────────────────
# 누수 상태 관리
# Leak state management
# ─────────────────────────────────────────────
class SensorState:
def __init__(self):
self.lock = threading.Lock()
# 센서별 상태 딕셔너리 {sensor_id: bool}
# Per-sensor state dictionary {sensor_id: bool}
self.leak_detected = {s["id"]: False for s in SENSORS}
self.last_report_time = {s["id"]: 0 for s in SENSORS}
@ -146,18 +148,19 @@ state = SensorState()
# ─────────────────────────────────────────────
# HTTP POST 유틸
# HTTP POST utilities
# ─────────────────────────────────────────────
# [보안 메모 — raw-body HMAC 서명 (2번 모듈과 동일 스킴)]
# 서버(php/api/sensor_data.php + config.php::verify_signature_raw)는 "받은 본문
# 바이트 그대로"에 대해 X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY,
# msg=raw_body)) 를 검증한다. 따라서 "서명한 바이트"와 "실제 전송한 바이트"가
# 100% 동일해야 한다. requests 의 json= 직렬화에 맡기면 공백/순서/부동소수 포맷이
# 달라져 서명이 깨질 수 있으므로, 본문을 직접 문자열로 만들고 그 바이트에 서명한 뒤
# 그 바이트를 그대로 POST(data=body_bytes) 한다.
# (구버전의 sha256(API_KEY + 정렬 JSON) body-field 서명은 폐기 — 서버가 거부한다.)
# [Security note — raw-body HMAC signature (same scheme as sensor module 2)]
# The server (php/api/sensor_data.php + config.php::verify_signature_raw) verifies
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)) over the
# received body bytes exactly as received. So the "signed bytes" and the "actually
# sent bytes" must be 100% identical. Letting requests' json= serialization handle it
# could change whitespace/ordering/float formatting and break the signature, so we
# build the body as a string ourselves, sign those bytes, and POST those same bytes
# as-is (data=body_bytes).
# (The old sha256(API_KEY + sorted JSON) body-field signature is retired — the server rejects it.)
def _esc(s: str) -> str:
r"""JSON 문자열 값 이스케이프 ('"' / '\\' / 제어문자<0x20 만). 멀티바이트는 그대로."""
r"""Escape a JSON string value (only '"' / '\\' / control chars <0x20). Multibyte left as-is."""
out = []
for ch in s:
o = ord(ch)
@ -173,9 +176,10 @@ def _esc(s: str) -> str:
def leak_event_body(event_type: str, sensor: dict, is_leak: bool, timestamp: int) -> str:
"""누수 이벤트 본문 JSON 문자열을 고정 필드 순서로 생성(서명 호환).
"""Build the leak-event body JSON string with a fixed field order (signature-compatible).
필드 순서(서버 파싱은 순서 무관이나, 서명은 바이트 동일성을 요구하므로 고정):
Field order (server parsing is order-agnostic, but the signature requires byte
identity, so it is fixed):
device_id, device_location, sensor_id, sensor_name, event_type, is_leak, timestamp
"""
return (
@ -192,16 +196,16 @@ def leak_event_body(event_type: str, sensor: dict, is_leak: bool, timestamp: int
def sign_raw(body: str) -> str:
"""raw-body 서명: lowercase_hex(HMAC-SHA256(key=API_KEY, msg=body 바이트))."""
"""raw-body signature: lowercase_hex(HMAC-SHA256(key=API_KEY, msg=body bytes))."""
return hmac.new(API_KEY.encode("utf-8"), body.encode("utf-8"),
hashlib.sha256).hexdigest()
def _post_event(event_type: str, sensor: dict, is_leak: bool, want_response: bool):
"""누수 이벤트를 raw-body HMAC 서명으로 POST.
"""POST a leak event with a raw-body HMAC signature.
body 문자열을 만들고 그 바이트에 서명한 뒤 data=body_bytes 로 그대로 전송한다.
want_response=False 면 성공 여부(bool), True 면 응답 JSON(dict) 또는 None 을 반환.
Builds the body string, signs those bytes, and sends them as-is via data=body_bytes.
If want_response=False, returns success (bool); if True, returns the response JSON (dict) or None.
"""
body = leak_event_body(event_type, sensor, is_leak, int(time.time()))
body_bytes = body.encode("utf-8")
@ -222,30 +226,30 @@ def _post_event(event_type: str, sensor: dict, is_leak: bool, want_response: boo
headers=headers,
)
if resp.status_code == 200:
log.info(f"[POST OK] 센서{sensor['id']} event={event_type} leak={is_leak} → {resp.text[:80]}")
log.info(f"[POST OK] sensor{sensor['id']} event={event_type} leak={is_leak} -> {resp.text[:80]}")
if not want_response:
return True
try:
return resp.json()
except ValueError:
log.warning("[POST] 응답 JSON 파싱 실패")
log.warning("[POST] failed to parse response JSON")
return None
else:
log.warning(f"[POST {resp.status_code}] 시도 {attempt}/{HTTP_RETRY_COUNT}: {resp.text[:120]}")
log.warning(f"[POST {resp.status_code}] attempt {attempt}/{HTTP_RETRY_COUNT}: {resp.text[:120]}")
except requests.exceptions.Timeout:
log.warning(f"[POST TIMEOUT] 시도 {attempt}/{HTTP_RETRY_COUNT}")
log.warning(f"[POST TIMEOUT] attempt {attempt}/{HTTP_RETRY_COUNT}")
except requests.exceptions.ConnectionError as e:
log.warning(f"[POST CONN ERR] 시도 {attempt}/{HTTP_RETRY_COUNT}: {e}")
log.warning(f"[POST CONN ERR] attempt {attempt}/{HTTP_RETRY_COUNT}: {e}")
if attempt < HTTP_RETRY_COUNT:
time.sleep(HTTP_RETRY_DELAY)
log.error(f"[POST FAIL] {HTTP_RETRY_COUNT}회 모두 실패. 센서{sensor['id']}")
log.error(f"[POST FAIL] all {HTTP_RETRY_COUNT} attempts failed. sensor{sensor['id']}")
return False if not want_response else None
def post_to_server(event_type: str, sensor: dict, is_leak: bool):
"""PHP API 서버에 센서 데이터 POST (성공 여부 bool 반환).
"""POST sensor data to the PHP API server (returns success as bool).
event_type: "periodic" | "startup" | "leak_alert" | "leak_recovery"
"""
@ -253,17 +257,17 @@ def post_to_server(event_type: str, sensor: dict, is_leak: bool):
def post_to_server_with_response(event_type: str, sensor: dict, is_leak: bool):
"""post_to_server 와 동일하나 응답 JSON(dict) 또는 None 을 반환."""
"""Same as post_to_server, but returns the response JSON (dict) or None."""
return _post_event(event_type, sensor, is_leak, want_response=True)
# ─────────────────────────────────────────────
# 카메라 촬영 / 사진 업로드
# Camera capture / photo upload
# ─────────────────────────────────────────────
def capture_photo(output_path: str) -> bool:
"""
rpicam-still (Bookworm+) 우선, 없으면 libcamera-still (Bullseye)로 사진 촬영.
성공 시 True, 실패 시 False.
Capture a photo, preferring rpicam-still (Bookworm+), falling back to libcamera-still (Bullseye).
Returns True on success, False on failure.
"""
for cmd in ["rpicam-still", "libcamera-still"]:
try:
@ -281,34 +285,34 @@ def capture_photo(output_path: str) -> bool:
)
if result.returncode == 0 and os.path.exists(output_path):
file_size = os.path.getsize(output_path)
log.info(f"[CAMERA] 촬영 성공: {output_path} ({file_size} bytes, cmd={cmd})")
log.info(f"[CAMERA] capture succeeded: {output_path} ({file_size} bytes, cmd={cmd})")
return True
else:
log.warning(f"[CAMERA] {cmd} 실패 (rc={result.returncode}): {result.stderr[:200]}")
log.warning(f"[CAMERA] {cmd} failed (rc={result.returncode}): {result.stderr[:200]}")
except FileNotFoundError:
continue # 이 명령어가 없으면 다음 것 시도
continue # This command is missing -> try the next one
except subprocess.TimeoutExpired:
log.warning(f"[CAMERA] {cmd} 타임아웃")
log.warning(f"[CAMERA] {cmd} timeout")
except Exception as e:
log.warning(f"[CAMERA] {cmd} 예외: {e}")
log.warning(f"[CAMERA] {cmd} exception: {e}")
log.error("[CAMERA] 촬영 실패: rpicam-still, libcamera-still 모두 사용 불가")
log.error("[CAMERA] capture failed: neither rpicam-still nor libcamera-still available")
return False
def upload_photo(photo_path: str, log_id: int, sensor: dict) -> bool:
"""
촬영된 사진을 photo_upload.php에 multipart POST.
성공 시 True, 실패 시 False.
multipart POST the captured photo to photo_upload.php.
Returns True on success, False on failure.
[인증] X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=canonical))
canonical = f"{DEVICE_ID}|{sensor_id}|{log_id}|{timestamp}" (공백 없음)
서버 php/api/photo_upload.php 의 canonical 과 바이트 동일해야 한다.
[Auth] X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=canonical))
canonical = f"{DEVICE_ID}|{sensor_id}|{log_id}|{timestamp}" (no whitespace)
Must be byte-identical to the canonical in the server's php/api/photo_upload.php.
"""
timestamp = int(time.time())
sensor_id = int(sensor["id"])
# 서버와 정확히 동일한 문자열: device_id|sensor_id|log_id|timestamp (공백 없음).
# 폼 필드도 동일한 문자열 표현으로 보내야 서버 재구성 canonical 과 일치한다.
# Exact same string as the server: device_id|sensor_id|log_id|timestamp (no whitespace).
# The form fields must be sent in the same string representation to match the server's reconstructed canonical.
sensor_id_str = str(sensor_id)
log_id_str = str(log_id)
timestamp_str = str(timestamp)
@ -339,7 +343,7 @@ def upload_photo(photo_path: str, log_id: int, sensor: dict) -> bool:
)
if resp.status_code == 200:
log.info(f"[PHOTO UPLOAD OK] log_id={log_id} → {resp.text[:80]}")
log.info(f"[PHOTO UPLOAD OK] log_id={log_id} -> {resp.text[:80]}")
return True
else:
log.warning(f"[PHOTO UPLOAD {resp.status_code}] {resp.text[:120]}")
@ -352,43 +356,43 @@ def upload_photo(photo_path: str, log_id: int, sensor: dict) -> bool:
def _handle_leak_with_photo(sensor: dict):
"""
누수 감지 시 처리 순서:
1. sensor_data.php에 POST → log_id 수신 (+ SMS 발송)
2. 카메라 촬영 (CAMERA_ENABLED 시)
3. photo_upload.php에 업로드
4. 임시파일 삭제
촬영/업로드 실패해도 1단계(알림+SMS)는 이미 완료.
Processing order on leak detection:
1. POST to sensor_data.php -> receive log_id (+ send SMS)
2. Capture with camera (when CAMERA_ENABLED)
3. Upload to photo_upload.php
4. Delete temp file
Even if capture/upload fails, step 1 (alert+SMS) is already done.
"""
# 1) leak_alert POST → log_id 수신
# 1) leak_alert POST -> receive log_id
resp_data = post_to_server_with_response("leak_alert", sensor, True)
if resp_data is None:
log.error(f"[PHOTO] 센서{sensor['id']} leak_alert POST 실패 → 촬영 건너뜀")
log.error(f"[PHOTO] sensor{sensor['id']} leak_alert POST failed -> skipping capture")
return
log_id = resp_data.get("log_id")
if not log_id:
log.warning(f"[PHOTO] 센서{sensor['id']} 응답에 log_id 없음 → 촬영 건너뜀")
log.warning(f"[PHOTO] sensor{sensor['id']} response has no log_id -> skipping capture")
return
# 2) 카메라 촬영
# 2) Camera capture
if not CAMERA_ENABLED:
log.info(f"[PHOTO] 카메라 비활성화 → 촬영 건너뜀 (log_id={log_id})")
log.info(f"[PHOTO] camera disabled -> skipping capture (log_id={log_id})")
return
# /tmp에 임시 JPEG 파일 생성
# Create a temp JPEG file in /tmp
fd, photo_path = tempfile.mkstemp(suffix=".jpg", prefix="leak_")
os.close(fd)
try:
if not capture_photo(photo_path):
log.warning(f"[PHOTO] 촬영 실패 → 업로드 건너뜀 (log_id={log_id})")
log.warning(f"[PHOTO] capture failed -> skipping upload (log_id={log_id})")
return
# 3) 업로드
# 3) Upload
upload_photo(photo_path, log_id, sensor)
finally:
# 4) 임시파일 정리
# 4) Clean up temp file
try:
if os.path.exists(photo_path):
os.unlink(photo_path)
@ -397,50 +401,89 @@ def _handle_leak_with_photo(sensor: dict):
# ─────────────────────────────────────────────
# GPIO 인터럽트 콜백
# GPIO interrupt callback (+ confirm-read debounce)
# ─────────────────────────────────────────────
def _read_is_leak(gpio_pin: int) -> bool:
"""Read the pin and map to a leak boolean per ACTIVE_LOW polarity."""
gpio_val = GPIO.input(gpio_pin)
# ACTIVE_LOW: GPIO=0 means leak / ACTIVE_HIGH (transistor-inverted): GPIO=1 means leak
return (gpio_val == 0) if ACTIVE_LOW else (gpio_val == 1)
# Sensors with a confirmation in progress — an edge storm must not stack threads.
_confirm_pending = set()
_confirm_lock = threading.Lock()
def _confirm_state_change(sensor: dict, candidate: bool):
"""Re-read the pin CONFIRM_READS times; apply the change only if every read agrees.
Real water persists across ~1s of reads; sub-second EMI blips on the 10MΩ
high-impedance line do not (server-room noise produced alert→recovery pairs
0-1s apart in production logs)."""
try:
for _ in range(CONFIRM_READS):
time.sleep(CONFIRM_INTERVAL_SEC)
if _read_is_leak(sensor["gpio"]) != candidate:
log.info(f"[NOISE REJECTED] sensor{sensor['id']} ({sensor['name']}) "
f"transient {'wet' if candidate else 'dry'} blip discarded")
return
changed = state.set_leak(sensor["id"], candidate)
if not changed:
return
if candidate:
log.warning(f"[LEAK DETECTED] sensor{sensor['id']} ({sensor['name']}) !!!")
# Run leak_alert + photo capture/upload sequentially in one thread
threading.Thread(
target=_handle_leak_with_photo,
args=(sensor,),
daemon=True
).start()
else:
log.info(f"[LEAK RECOVERED] sensor{sensor['id']} ({sensor['name']}) recovered to normal")
threading.Thread(
target=post_to_server,
args=("leak_recovery", sensor, False),
daemon=True
).start()
finally:
with _confirm_lock:
_confirm_pending.discard(sensor["id"])
def make_callback(sensor: dict):
def callback(channel):
gpio_val = GPIO.input(sensor["gpio"])
candidate = _read_is_leak(sensor["gpio"])
# ACTIVE_LOW: GPIO=0 이면 누수, GPIO=1 이면 정상
# ACTIVE_HIGH(트랜지스터 반전): GPIO=1 이면 누수
if ACTIVE_LOW:
is_leak = (gpio_val == 0)
else:
is_leak = (gpio_val == 1)
# No actual change vs confirmed state -> nothing to verify
if candidate == state.is_leaking(sensor["id"]):
return
changed = state.set_leak(sensor["id"], is_leak)
# One confirmation at a time per sensor; the confirm thread reads the live pin,
# so edges arriving meanwhile are naturally folded into its reads.
with _confirm_lock:
if sensor["id"] in _confirm_pending:
return
_confirm_pending.add(sensor["id"])
if changed:
if is_leak:
log.warning(f"[LEAK DETECTED] 센서{sensor['id']} ({sensor['name']}) !!!")
# leak_alert + 사진 촬영/업로드를 한 스레드에서 순차 실행
threading.Thread(
target=_handle_leak_with_photo,
args=(sensor,),
daemon=True
).start()
else:
log.info(f"[LEAK RECOVERED] 센서{sensor['id']} ({sensor['name']}) 정상 복구")
threading.Thread(
target=post_to_server,
args=("leak_recovery", sensor, False),
daemon=True
).start()
threading.Thread(
target=_confirm_state_change,
args=(sensor, candidate),
daemon=True
).start()
return callback
# ─────────────────────────────────────────────
# 주기 보고 스레드
# Periodic reporting thread
# ─────────────────────────────────────────────
def periodic_reporter():
"""
정상 상태: REPORT_INTERVAL 주기로 보고
누수 상태: LEAK_REPORT_INTERVAL 주기로 반복 보고 (SMS 재발송 방지는 PHP에서 처리)
Normal state: report every REPORT_INTERVAL
Leak state: repeat-report every LEAK_REPORT_INTERVAL (SMS resend suppression handled in PHP)
"""
log.info("[REPORTER] 주기 보고 스레드 시작")
log.info("[REPORTER] periodic reporting thread started")
last_sent = {s["id"]: 0 for s in SENSORS}
while True:
@ -458,26 +501,26 @@ def periodic_reporter():
# ─────────────────────────────────────────────
# GPIO 초기화
# GPIO initialization
# ─────────────────────────────────────────────
def setup_gpio():
GPIO.setmode(GPIO.BCM)
# 외부 4.7MΩ 풀다운만 사용 → 내부 풀저항은 PUD_OFF 필수
# (내부 50kΩ 풀다운을 켜면 1.5MΩ 센서가 GPIO를 HIGH로 올리지 못함)
# Use only the external 10MΩ pull-down -> internal pull resistor must be PUD_OFF
# (Enabling the internal 50kΩ pull-down prevents the 1.5MΩ sensor from pulling GPIO HIGH)
pull = GPIO.PUD_DOWN if USE_INTERNAL_PULL else GPIO.PUD_OFF
for sensor in SENSORS:
gpio = sensor["gpio"]
GPIO.setup(gpio, GPIO.IN, pull_up_down=pull)
# 초기 상태 읽기
# Read initial state
gpio_val = GPIO.input(gpio)
is_leak = (gpio_val == 0) if ACTIVE_LOW else (gpio_val == 1)
state.set_leak(sensor["id"], is_leak)
log.info(f"[INIT] 센서{sensor['id']} GPIO{gpio} → {'누수!' if is_leak else '정상'}")
log.info(f"[INIT] sensor{sensor['id']} GPIO{gpio} -> {'LEAK!' if is_leak else 'normal'}")
# 엣지 감지 인터럽트 등록
# Register the edge-detection interrupt
GPIO.add_event_detect(
gpio,
GPIO.BOTH,
@ -485,46 +528,46 @@ def setup_gpio():
bouncetime=DEBOUNCE_MS
)
log.info(f"[INIT] GPIO 설정 완료. 총 {len(SENSORS)}개 센서")
log.info(f"[INIT] GPIO setup complete. {len(SENSORS)} sensors total")
# ─────────────────────────────────────────────
# 메인
# Main
# ─────────────────────────────────────────────
def main():
log.info("=" * 60)
log.info(f"누수감지 시스템 v2 (Pi 4) 시작 | 기기={DEVICE_ID} | 위치={DEVICE_LOCATION}")
log.info(f"플랫폼={platform.machine()} | API={API_URL}")
log.info(f"카메라={'활성' if CAMERA_ENABLED else '비활성'}")
log.info(f"센서={[s['name'] for s in SENSORS]}")
log.info(f"모드={'시뮬레이션' if SIMULATION else '실제 GPIO'}")
log.info(f"Leak detection system v2 (Pi 4) starting | device={DEVICE_ID} | location={DEVICE_LOCATION}")
log.info(f"platform={platform.machine()} | API={API_URL}")
log.info(f"camera={'enabled' if CAMERA_ENABLED else 'disabled'}")
log.info(f"sensors={[s['name'] for s in SENSORS]}")
log.info(f"mode={'simulation' if SIMULATION else 'real GPIO'}")
log.info("=" * 60)
try:
setup_gpio()
# 시작 시 현재 상태 즉시 보고
# Report current state immediately at startup
for sensor in SENSORS:
post_to_server("startup", sensor, state.is_leaking(sensor["id"]))
# 주기 보고 스레드 시작
# Start the periodic reporting thread
reporter_thread = threading.Thread(target=periodic_reporter, daemon=True)
reporter_thread.start()
log.info("[MAIN] 모니터링 시작. Ctrl+C로 종료.")
log.info("[MAIN] monitoring started. Press Ctrl+C to exit.")
# 메인 스레드는 살아있어야 인터럽트 작동
# The main thread must stay alive for interrupts to work
while True:
time.sleep(1)
except KeyboardInterrupt:
log.info("[MAIN] 사용자에 의해 종료됨.")
log.info("[MAIN] terminated by user.")
except Exception as e:
log.exception(f"[MAIN] 예외 발생: {e}")
log.exception(f"[MAIN] exception occurred: {e}")
raise
finally:
GPIO.cleanup()
log.info("[MAIN] GPIO 정리 완료.")
log.info("[MAIN] GPIO cleanup complete.")
if __name__ == "__main__":

View file

@ -1,4 +1,4 @@
# Pi 4 누수감지 모듈 Python 의존성 (폐쇄망 대비 정확 핀 고정)
# 오프라인 설치: vendor.sh 로 wheelhouse/ 에 미리 받아두고
# Pi 4 leak detection module Python dependencies (exact version pins for air-gapped deployment)
# Offline install: pre-download into wheelhouse/ with vendor.sh, then
# pip install --no-index --find-links=wheelhouse -r requirements.txt
requests==2.32.3

View file

@ -1,16 +1,16 @@
# =============================================================================
# /etc/systemd/system/leak-sensor.service
# Raspberry Pi 4 부팅 시 누수감지 모니터를 자동 실행하는 서비스
# Service that auto-runs the leak-detection monitor at Raspberry Pi 4 boot
#
# 설치 방법:
# Installation:
# sudo cp systemd/leak-sensor.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable leak-sensor
# sudo systemctl start leak-sensor
# sudo systemctl status leak-sensor
#
# ※ WorkingDirectory/ExecStart 경로는 실제 배치 경로에 맞게 수정.
# 아래는 /home/pi/raspberry_pi4 에 배치한 경우 기준.
# NOTE: Adjust the WorkingDirectory/ExecStart paths to match the actual deployment path.
# The values below assume deployment under /home/pi/raspberry_pi4.
# =============================================================================
[Unit]
@ -22,7 +22,7 @@ Wants=network-online.target
Type=simple
User=pi
Group=pi
# rpi-lgpio 는 /dev/gpiochip0(root:gpio 660) 접근에 gpio 그룹, 카메라는 video 그룹 필요.
# rpi-lgpio needs the gpio group to access /dev/gpiochip0 (root:gpio 660); the camera needs the video group.
SupplementaryGroups=video gpio
WorkingDirectory=/home/pi/raspberry_pi4
ExecStart=/home/pi/raspberry_pi4/venv/bin/python leak_sensor.py
@ -32,27 +32,27 @@ StandardOutput=journal
StandardError=journal
SyslogIdentifier=leak-sensor
# 환경 변수
# Environment variables
Environment=PYTHONUNBUFFERED=1
# lgpio(rpi-lgpio 백엔드)는 add_event_detect 시 작업디렉터리에 .lgd-nfy 알림 파이프를
# 만든다. 아래 ProtectHome=read-only 로 /home 이 읽기전용이 되므로, PrivateTmp 의
# 쓰기가능 /tmp 로 lgpio 작업디렉터리를 옮긴다. (없으면 누수 인터럽트 등록이 실패해
# 데몬이 크래시루프 → 누수 감지 불능.) LG_WD 는 lg 라이브러리가 읽는 환경변수.
# ※ 이 하드닝은 chardev 백엔드(python3-rpi-lgpio) 를 가정한다. sysfs 기반
# python3-rpi.gpio 로 되돌리면 ProtectKernelTunables/ProtectHome 와 충돌하므로
# 백엔드를 바꾸지 말 것(vendor.sh/install.sh 의 python3-rpi-lgpio 핀 유지).
# lgpio (rpi-lgpio backend) creates a .lgd-nfy notification pipe in the working
# directory on add_event_detect. Since ProtectHome=read-only below makes /home
# read-only, the lgpio working directory is moved to PrivateTmp's writable /tmp.
# (Otherwise leak interrupt registration fails -> daemon crash-loops -> no leak detection.) LG_WD is the env var the lg library reads.
# NOTE: This hardening assumes the chardev backend (python3-rpi-lgpio). Reverting to
# the sysfs-based python3-rpi.gpio conflicts with ProtectKernelTunables/ProtectHome,
# so do not change the backend (keep the python3-rpi-lgpio pin in vendor.sh/install.sh).
Environment=LG_WD=/tmp
# 누수 촬영 임시파일을 프로세스 전용 /tmp 로 격리 (공용 /tmp 노출 방지)
# Isolate leak-capture temp files to the process-private /tmp (prevents exposure via shared /tmp)
PrivateTmp=true
# ── 샌드박싱(하드닝) — 데몬 침해 시 root 승격·파일시스템 오염을 차단 ──────────
# User=pi 는 NOPASSWD sudo 계정이므로 NoNewPrivileges 로 setuid(sudo) 승격 경로를 막는다.
# -- Sandboxing (hardening) -- blocks root escalation / filesystem tampering if the daemon is compromised --------
# User=pi is a NOPASSWD sudo account, so NoNewPrivileges blocks the setuid(sudo) escalation path.
NoNewPrivileges=true
# 루트파일시스템 읽기전용 + /var/log 만 쓰기 허용(로그 로테이션 포함). /tmp 는 PrivateTmp.
# Root filesystem read-only + only /var/log writable (including log rotation). /tmp is PrivateTmp.
ProtectSystem=strict
ReadWritePaths=/var/log
# /home 는 읽기전용(자기 코드/config_local.py 읽기만; 쓰기·타 계정 홈 접근 차단).
# /home is read-only (reads its own code/config_local.py only; blocks writes and access to other accounts' homes).
ProtectHome=read-only
ProtectKernelModules=true
ProtectKernelTunables=true
@ -60,13 +60,13 @@ ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictRealtime=true
# HTTPS(POST)+로컬소켓(DNS/journal)만. 카메라/GPIO 는 /dev char (소켓 아님)라 무관.
# AF_NETLINK 필수 — glibc getaddrinfo/NSS 가 로컬주소 열거에 netlink 를 쓴다
# (빼면 DNS 해석이 간헐 실패 → 서버 POST 불가). AF_INET/6=HTTPS, AF_UNIX=DNS·journal.
# HTTPS(POST) + local sockets (DNS/journal) only. Camera/GPIO are /dev char (not sockets), so unaffected.
# AF_NETLINK required -- glibc getaddrinfo/NSS uses netlink to enumerate local addresses
# (omitting it makes DNS resolution fail intermittently -> server POST impossible). AF_INET/6=HTTPS, AF_UNIX=DNS/journal.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
# ※ PrivateDevices 는 쓰지 않는다 — /dev/gpiochip*, 카메라 노드가 숨겨져 동작 불능.
# NOTE: PrivateDevices is not used -- it would hide /dev/gpiochip* and the camera node, breaking operation.
# 재시작 상한 (10분 내 5번 실패 시 중단)
# Restart cap (stop after 5 failures within 10 minutes)
StartLimitBurst=5
StartLimitInterval=600s

View file

@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""서버 연결 테스트 스크립트 — Pi 4 에서 실행.
비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다 (config 경유)."""
"""Server connection test script — run on the Pi 4.
Secrets (API_URL/API_KEY) are loaded from config_local.py (via config)."""
import hashlib
import hmac
@ -10,23 +10,23 @@ import subprocess
from config import API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION, SENSORS
signed_ok = False # 서명 POST 200 여부 — setup.sh 가 이 exit 코드로 하드닝을 게이트한다
signed_ok = False # whether the signed POST returned 200 — setup.sh gates hardening on this exit code
# 이 기기에 설정된 첫 센서로 테스트한다 — sensor_id 를 하드코딩하면(예: 1)
# 다른 지점 노드에서 테스트할 때 그 지점의 leak_sensor_status 를 오염시킨다.
# Test with the first sensor configured on this device — hardcoding sensor_id (e.g. 1)
# would pollute another site node's leak_sensor_status when testing from that site.
TEST_SENSOR_ID = SENSORS[0]["id"]
TEST_SENSOR_NAME = SENSORS[0]["name"]
# API_URL 에서 베이스 경로 추출 (.../api/sensor_data.php → .../api)
# Extract the base path from API_URL (.../api/sensor_data.php → .../api)
API_BASE = API_URL.rsplit("/", 1)[0]
print("=" * 50)
print(" 서버 연결 테스트 (Pi 4)")
print(" Server connection test (Pi 4)")
print("=" * 50)
print(f" API_BASE = {API_BASE}")
# ── 1단계: curl로 test.php 호출 ──
print("\n[1/3] curl로 test.php 연결 확인...")
# ── Step 1: call test.php with curl ──
print("\n[1/3] Checking test.php connectivity with curl...")
try:
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
@ -35,19 +35,19 @@ try:
capture_output=True, text=True, timeout=10
)
code = result.stdout.strip()
print(f" HTTP 응답코드: {code}")
print(f" HTTP response code: {code}")
if code == "200":
print(" → 성공! 서버 연결 정상")
print(" → Success! Server connection OK")
elif code == "403":
print(" → 403 Forbidden: 서버/호스팅 자체가 차단 중")
print(" → 호스팅 설정 또는 상위 .htaccess 확인 필요")
print(" → 403 Forbidden: the server/host itself is blocking")
print(" → Check the hosting settings or the upstream .htaccess")
else:
print(f" → 예상 외 응답: {code}")
print(f" → Unexpected response: {code}")
except Exception as e:
print(f" → curl 실패: {e}")
print(f" → curl failed: {e}")
# ── 2단계: curl로 sensor_data.php 호출 ──
print("\n[2/3] curl로 sensor_data.php 연결 확인...")
# ── Step 2: call sensor_data.php with curl ──
print("\n[2/3] Checking sensor_data.php connectivity with curl...")
try:
result = subprocess.run(
["curl", "-s", "-w", "\n%{http_code}",
@ -60,25 +60,26 @@ try:
lines = result.stdout.strip().split("\n")
code = lines[-1] if lines else "?"
body = "\n".join(lines[:-1])
print(f" HTTP 응답코드: {code}")
print(f" 응답 내용: {body[:200]}")
print(f" HTTP response code: {code}")
print(f" Response body: {body[:200]}")
except Exception as e:
print(f" → curl 실패: {e}")
print(f" → curl failed: {e}")
# ── 3단계: Python requests로 서명 포함 호출 ──
print("\n[3/3] Python requests로 sensor_data.php 호출...")
# ── Step 3: signed call via Python requests ──
print("\n[3/3] Calling sensor_data.php with Python requests...")
try:
import requests
# raw-body X-Signature HMAC (서버 verify_signature_raw 와 동일 — leak_sensor.leak_event_body
# 와 같은 고정 필드 순서로 본문 문자열을 만들고 그 바이트에 서명한 뒤 그대로 POST 한다).
# raw-body X-Signature HMAC (same as the server's verify_signature_raw — build the body
# string with the same fixed field order as leak_sensor.leak_event_body, sign those bytes,
# then POST them verbatim).
ts = int(time.time())
body = (
'{'
f'"device_id":"{DEVICE_ID}",'
f'"device_location":"{DEVICE_LOCATION}",'
f'"sensor_id":{TEST_SENSOR_ID},'
f'"sensor_name":"{TEST_SENSOR_NAME} (연결테스트)",'
f'"sensor_name":"{TEST_SENSOR_NAME} (connection test)",'
f'"event_type":"startup",'
f'"is_leak":0,'
f'"timestamp":{ts}'
@ -99,24 +100,24 @@ try:
}
)
print(f" HTTP 응답코드: {resp.status_code}")
print(f" 응답 내용: {resp.text[:200]}")
print(f" HTTP response code: {resp.status_code}")
print(f" Response body: {resp.text[:200]}")
if resp.status_code == 200:
signed_ok = True
print(" → 성공! 모든 연결 정상")
print(" → Success! All connections OK")
elif resp.status_code == 403:
print(" → 403: requests 라이브러리가 차단됨 또는 서명 불일치")
print(" → 403: the requests library is blocked or the signature does not match")
print(f" → User-Agent: {resp.request.headers.get('User-Agent', '?')}")
else:
print(f" → 응답코드: {resp.status_code}")
print(f" → Response code: {resp.status_code}")
except ImportError:
print(" → requests 미설치. install.sh 실행 또는 sudo apt install python3-requests")
print(" → requests not installed. Run install.sh or sudo apt install python3-requests")
except Exception as e:
print(f" → 실패: {e}")
print(f" → Failed: {e}")
print("\n" + "=" * 50)
print(" 테스트 완료" + ("" if signed_ok else " — 서명 POST 실패(위 로그 확인)"))
print(" Test complete" + ("" if signed_ok else " — signed POST failed (check the log above)"))
print("=" * 50)
sys.exit(0 if signed_ok else 1)

View file

@ -1,14 +1,14 @@
#!/usr/bin/env bash
# =============================================================================
# vendor.sh - 폐쇄망 설치용 의존성 사전 다운로드
# 인터넷에 연결된 빌드/스테이징 머신에서 실행한다.
# 산출물: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → 타깃 Pi 4 로 전송.
# vendor.sh - Pre-download dependencies for air-gapped installation
# Run on a build/staging machine that has internet access.
# Output: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → transfer to target Pi 4.
#
# wheel/deb 는 아키텍처에 종속된다. 기본값은 타깃(Raspberry Pi OS Bookworm 64-bit,
# aarch64, Python 3.11)에 맞춰 명시 핀하므로 x86_64 빌드 머신에서 받아도 올바른
# aarch64 wheel 을 확보한다(빌드호스트 arch 와 무관). 그래도 .deb 는 타깃 OS/arch
# 에서 받는 것이 가장 안전하다. 필요 시 환경변수로 타깃 조정:
# TARGET_PLATFORM (기본 manylinux2014_aarch64), TARGET_PYVER (기본 311), CAM
# wheel/deb are architecture-dependent. The defaults are explicitly pinned to the target
# (Raspberry Pi OS Bookworm 64-bit, aarch64, Python 3.11), so even downloading on an x86_64
# build machine yields the correct aarch64 wheel (independent of the build host arch). Still, .deb
# files are safest downloaded on the target OS/arch. Adjust the target via env vars if needed:
# TARGET_PLATFORM (default manylinux2014_aarch64), TARGET_PYVER (default 311), CAM
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")"
@ -25,12 +25,12 @@ python3 -m pip download -r requirements.txt -d wheelhouse \
--python-version "${TARGET_PYVER}" \
--implementation cp \
--abi "${TARGET_ABI}" \
|| { echo "[ERROR] 타깃 wheel 다운로드 실패 (TARGET_PLATFORM/TARGET_PYVER 확인)"; exit 1; }
|| { echo "[ERROR] Target wheel download failed (check TARGET_PLATFORM/TARGET_PYVER)"; exit 1; }
python3 -m pip download -r requirements.txt -d wheelhouse --no-binary=:all: \
|| echo "[WARN] sdist 폴백 일부 실패 (바이너리 wheel 이 모두 있으면 무시 가능)"
|| echo "[WARN] sdist fallback partially failed (safe to ignore if all binary wheels are present)"
WHEEL_COUNT="$(ls -A wheelhouse 2>/dev/null | wc -l)"
echo " wheelhouse 파일 수: ${WHEEL_COUNT}"
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse 비어있음"; exit 1; }
echo " wheelhouse file count: ${WHEEL_COUNT}"
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse is empty"; exit 1; }
echo "[vendor] (2/2) apt .deb → debs/"
mkdir -p debs
@ -38,41 +38,41 @@ CAM="${CAM:-}"
if [ -z "${CAM}" ]; then
if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; fi
fi
# nftables·unattended-upgrades 도 포함 → harden_pi.sh 를 폐쇄망에서 오프라인 실행 가능.
# Include nftables and unattended-upgrades too → harden_pi.sh can run offline in an air-gapped network.
BASE_PKGS="python3-rpi-lgpio python3-requests python3-venv nftables unattended-upgrades ${CAM}"
DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
| awk '/^\w/{print $1}' | sort -u)"
( cd debs && apt-get download ${BASE_PKGS} ${DEPS} ) 2>/dev/null \
|| echo "[WARN] 일부 .deb 다운로드 실패 (가상패키지/전이 의존성일 수 있음 — 필수 패키지는 아래에서 검증)"
|| echo "[WARN] Some .deb downloads failed (may be virtual packages / transitive deps — required packages are verified below)"
MISS=0
for p in ${BASE_PKGS}; do
if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then
echo "[ERROR] 필수 패키지 .deb 누락: ${p}"
echo "[ERROR] Missing required package .deb: ${p}"
MISS=1
fi
done
[ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; }
echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)"
[ "${MISS}" = 0 ] || { echo "[ERROR] Missing required .deb — you must run vendor.sh on the target arch (aarch64 Bookworm)."; exit 1; }
echo " debs file count: $(ls debs/*.deb 2>/dev/null | wc -l) (all required packages confirmed)"
# 아키텍처 실검사 — 파일명이 아니라 .deb 내부 Architecture 필드로 타깃(arm64/all) 검증.
# (호스트가 amd64면 apt-get download 가 amd64 .deb 를 받아, 타깃 Pi 에서 dpkg 가
# "wrong architecture" 로 깨진다. 파일명만 보는 검사는 이를 못 잡는다.)
# Real architecture check — validate the target (arm64/all) via the .deb's internal Architecture field, not the filename.
# (If the host is amd64, apt-get download fetches amd64 .debs, and on the target Pi dpkg
# breaks with "wrong architecture". A filename-only check cannot catch this.)
BAD_ARCH=0
for deb in debs/*.deb; do
[ -e "$deb" ] || continue
arch="$(dpkg-deb --field "$deb" Architecture 2>/dev/null || echo '?')"
case "$arch" in
arm64|all) : ;;
*) echo "[ERROR] arch 불일치: $(basename "$deb") = ${arch} (arm64/all 아님)"; BAD_ARCH=1 ;;
*) echo "[ERROR] arch mismatch: $(basename "$deb") = ${arch} (not arm64/all)"; BAD_ARCH=1 ;;
esac
done
if [ "${BAD_ARCH}" != 0 ]; then
echo "[ERROR] 타깃(aarch64/arm64) 이 아닌 .deb 가 있습니다. 호스트 arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2
echo " 반드시 arm64 Pi(또는 arm64 chroot/qemu)에서 vendor.sh 를 실행하세요." >&2
echo "[ERROR] There are .deb files that are not the target (aarch64/arm64). Host arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2
echo " You must run vendor.sh on an arm64 Pi (or arm64 chroot/qemu)." >&2
exit 1
fi
echo " arch 검사 통과 (모든 .deb = arm64/all)"
echo " arch check passed (all .deb = arm64/all)"
echo "[vendor] 완료. wheelhouse/ 와 debs/ 를 타깃 Pi 4 로 전송 후 install.sh 실행."
echo "[vendor] Done. Transfer wheelhouse/ and debs/ to the target Pi 4, then run install.sh."

14
raspberry_pi4_pms7003/.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
# Operational secrets (never commit)
config_local.py
# Python venv / cache
venv/
__pycache__/
*.pyc
# Offline install bundle (vendor.sh output - large; transferred separately)
wheelhouse/
debs/
# Runtime logs
*.log

View file

@ -0,0 +1,165 @@
# Particulate-Matter (PMS7003) Sensor Module — Raspberry Pi 4 Model B only
A module that reads a **Plantower PMS7003** laser particulate-matter sensor over the Raspberry Pi 4
Model B **UART** and periodically reports **PM1.0 / PM2.5 / PM10** (µg/m³, atmospheric concentration)
to the cafe24 server (`php/api/sensor_data.php`). The server stores the measurements and performs
**high-PM2.5 / high-PM10 threshold evaluation, SMS, and dashboard display**.
> This is a **Pi-only** metric — there is **no STM32 firmware** for the PMS7003. The module is a
> faithful mirror of the SHT30 module (`../raspberry_pi4_sht30/`): same measurement loop,
> `startup→periodic` events, raw-body HMAC signing, and fail-closed secret handling. PM is a **new
> metric** on the **existing metric pipeline** — distinguished only by `metric_type="pms7003"`.
>
> The PMS7003 **co-locates on an existing leak node** and reports under the **same `sensor_id`** as
> that node's leak sensor (Pi-C = 3, the other node = 1), exactly like the SHT30 co-location.
---
## 1. Files
| File | Role |
|---|---|
| `pms7003_sensor.py` | Main — periodic measurement → body build/sign → POST (with retry), startup→periodic |
| `pms7003_driver.py` | PMS7003 UART 32-byte frame read + checksum verification + PM decode (pyserial, simulation fallback) |
| `wire.py` | Body JSON + raw-body HMAC signature (`fixed2`/`_esc`/`sign_raw` copied verbatim from the SHT30 module) |
| `config.py` | Non-secret default settings (sensor id/interval/range/UART). Loads secrets from `config_local` |
| `config_local.example.py` | Operational/secret-value template → copy to `config_local.py` for use (not committed) |
| `requirements.txt` | Python dependencies (`requests`, `pyserial`, pinned) |
| `install.sh` | Pi 4 install (air-gapped offline + online fallback, enables UART + disables the serial console) |
| `vendor.sh` | Pre-download dependencies for air-gapped use (run on the build machine) |
| `tests/test_pms_wire_and_frame.py` | Pure unit tests: body byte-string, known HMAC, frame decode + checksum rejection (no hardware) |
| `systemd/pms7003-sensor.service` | Service unit for auto-start on boot |
---
## 2. Hardware (Pi 4 Model B baseline)
- **Raspberry Pi 4 Model B** (BCM2711) + Raspberry Pi OS **Bookworm 64-bit**
- **PMS7003** laser PM sensor + its breakout/adapter cable
- Power: **USB-C 5V/3A**, network: Pi 4 **Gigabit Ethernet** (wired) or WiFi
### 2.1 Pinout (primary UART)
| PMS7003 signal | Pi 4 pin | Note |
|---|---|---|
| VCC | **5V** (physical 2) | PMS7003 needs **5V** power (fan + laser) |
| GND | GND (physical 6) | |
| TXD (sensor → Pi) | GPIO15 / **RXD** (physical 10) | 3.3V logic — safe direct to the Pi |
| RXD (Pi → sensor) | GPIO14 / TXD (physical 8) | optional (only needed for passive-mode commands) |
| SET / RESET | — | optional; leave floating (internal pull-up = enabled/run) |
> The PMS7003 **TXD idles at 3.3V logic**, so it connects **directly** to the Pi RXD — no level
> shifter needed. We only read the sensor's TXD (active streaming mode), so wiring the Pi TXD→sensor
> RXD is optional.
---
## 3. Server compatibility (must verify)
This module reports to **the current repo's `php/` server**. The server requires a **raw-body HMAC signature**.
```
X-Signature = lowercase_hex( HMAC-SHA256( key=API_KEY, msg=request body bytes ) )
```
- `API_KEY` in `config_local.py` must be **exactly identical** to `API_KEY` in the server's `config.local.php`.
- The server blocks replays via `timestamp` freshness (±600 seconds), so the Pi clock must be **NTP-synchronized** (enabled by default on Raspberry Pi OS).
- It reports with the co-located leak node's `sensor_id` (**3** by default, or **1** on the other node) and `metric_type="pms7003"`.
- Threshold alerts (high PM2.5 / PM10), SMS, and recovery evaluation are **all performed by the server**. This module only reports measurements.
- The Pi-side `metric_status` is **physical plausibility only** (`normal` / `out_of_range`); the server never trusts it for alerting.
### 3.1 Wire body (fixed order)
```json
{"device_id":"..","device_location":"..","sensor_id":3,"sensor_name":"..","event_type":"..","timestamp":0,"metric_type":"pms7003","pm1_0":8,"pm2_5":12,"pm10":18,"metric_status":"normal","app_version":".."}
```
`pm1_0` / `pm2_5` / `pm10` are **bare integers** (µg/m³, atmospheric concentration, range 0..1000).
The body is built directly and raw-POSTed so the **signed bytes == sent bytes**.
---
## 4. Installation
### 4.1 Prepare configuration values
```bash
cp config_local.example.py config_local.py
# Open config_local.py and fill in API_URL / API_KEY / DEVICE_ID / DEVICE_LOCATION
# On the id=1 node also set SENSOR_ID = 1 (Pi-C keeps the default 3).
```
If `config_local.py` is missing, execution **stops immediately with a clear error** (fail closed).
**Never commit** `config_local.py` or `API_KEY` (handled by `.gitignore`).
### 4.2 Online install (internet-capable environment)
```bash
chmod +x install.sh
./install.sh
sudo reboot # reboot once — the installer enables UART and disables the serial console
```
### 4.3 Air-gapped offline install
On an internet-connected machine of the **same architecture (aarch64, ideally the same Pi 4 + Bookworm 64-bit)**,
download the dependencies first to build a bundle, then move it to the target Pi 4 and install.
```bash
# (build machine, internet YES) generate wheelhouse/ + debs/
./vendor.sh
# after copying wheelhouse/ and debs/ into this folder on the target Pi 4:
# (target Pi 4, internet NO)
./install.sh # if debs/ · wheelhouse/ exist, it installs offline automatically
```
> `pyserial` is pure Python (arch-independent), so it can be downloaded on an x86 build machine.
> Other wheels/debs such as `requests` must be the **aarch64** builds (running `vendor.sh` on the target arch is recommended).
---
## 5. Run / check
```bash
# UART present? (should symlink to ttyS0 or ttyAMA0)
ls -l /dev/serial0
# pure wire/frame self-check (passes without hardware or config_local.py)
python3 tests/test_pms_wire_and_frame.py
# actual run (simulation mode if pyserial missing / UART unavailable)
source venv/bin/activate
python3 pms7003_sensor.py
```
In environments without `pyserial` installed or with the UART unavailable (dev PC, etc.), it automatically runs in **simulation mode**.
---
## 6. Auto-start on boot (systemd)
```bash
# assumes the folder is placed at /home/pi/raspberry_pi4_pms7003 (otherwise edit the path in the .service)
sudo cp systemd/pms7003-sensor.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable pms7003-sensor
sudo systemctl start pms7003-sensor
sudo systemctl status pms7003-sensor
journalctl -u pms7003-sensor -f
```
`SupplementaryGroups=dialout` is required for `/dev/serial0` access.
---
## 7. Security notes
- Secrets (`API_KEY`), server URL, and device info live only in `config_local.py` (not committed).
Committed files contain only placeholders.
- The report body is protected against tampering by an `HMAC-SHA256(API_KEY, raw_body)` signature (`X-Signature` header)
(immune to length-extension attacks). The legacy body-field sha256 fallback is disabled by default on the server.
- The signed bytes and the transmitted bytes are **always identical** (`wire.py` builds the body directly,
signs those bytes, then raw POSTs). `fixed2` / `_esc` / `sign_raw` are copied verbatim from the SHT30 module
so signing stays byte-for-byte identical to the shared reference / server `verify_signature_raw`.
- If `config_local.py` is exposed, immediately rotate `API_KEY` (change it on the server at the same time).

View file

@ -0,0 +1,535 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PM Node Wiring &amp; Pinmap — PMS7003 UART (sensor_id=3 / Pi-C)</title>
<style>
:root{
--ink:#1a1d21; --muted:#5c6570; --line:#dfe3e8; --bg:#eef1f4; --card:#ffffff;
--accent:#1971c2; --accent-d:#155a9c; --warn:#e03131; --warnbg:#fff5f5;
--ok:#2f9e44; --gnd:#343a40; --chip:#eef2f6; --code:#0b3d2e;
--mono:"D2Coding","Consolas","Menlo","Courier New",ui-monospace,monospace;
--sans:"Malgun Gothic","Apple SD Gothic Neo","Noto Sans KR","Segoe UI",system-ui,-apple-system,sans-serif;
}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
body{
font-family:var(--sans); color:var(--ink); background:var(--bg);
line-height:1.6; -webkit-font-smoothing:antialiased;
}
.wrap{max-width:1000px; margin:0 auto; padding:22px 18px 60px;}
/* ---------- Header ---------- */
.hero{
background:linear-gradient(135deg,#1f2933 0%,#283746 55%,#2f4a5a 100%);
color:#fff; border-radius:16px; padding:26px 28px;
box-shadow:0 8px 24px rgba(20,30,40,.18);
}
.hero .eyebrow{font-size:12.5px; letter-spacing:.14em; text-transform:uppercase;
color:#9fc3e0; font-weight:700; margin:0 0 6px;}
.hero h1{margin:0 0 8px; font-size:27px; line-height:1.25; font-weight:800;}
.hero p.sum{margin:0 0 16px; font-size:14.5px; color:#d7e2ec; max-width:72ch;}
.chips{display:flex; flex-wrap:wrap; gap:8px;}
.chip{
display:inline-flex; align-items:center; gap:6px;
background:rgba(255,255,255,.12); border:1px solid rgba(255,255,255,.22);
color:#eef5fb; font-size:12.5px; font-weight:600; padding:5px 11px; border-radius:999px;
}
.chip.mono{font-family:var(--mono); letter-spacing:.01em;}
.chip b{color:#fff;}
/* ---------- Sections ---------- */
section{
background:var(--card); border:1px solid var(--line); border-radius:14px;
padding:22px 24px; margin-top:20px; box-shadow:0 2px 10px rgba(20,30,40,.05);
}
h2{
display:flex; align-items:center; gap:11px; margin:0 0 16px;
font-size:19px; font-weight:800; color:var(--ink);
}
h2 .n{
flex:0 0 auto; width:30px; height:30px; border-radius:9px;
background:var(--accent); color:#fff; font-size:15px; font-weight:800;
display:flex; align-items:center; justify-content:center;
}
h2 small{font-weight:600; font-size:13px; color:var(--muted);}
/* ---------- Pinmap SVG ---------- */
.svg-pin{display:block; width:100%; max-width:460px; height:auto; margin:2px auto 0;}
.diagram-cap{text-align:center; color:var(--muted); font-size:12.5px; margin:12px 0 0;}
.legend{
display:flex; flex-wrap:wrap; justify-content:center; gap:12px 18px;
margin:16px auto 2px; max-width:640px;
}
.legend span{display:inline-flex; align-items:center; gap:7px; font-size:12.5px; color:var(--ink);}
.sw{width:15px; height:15px; border-radius:50%; flex:0 0 auto; border:1px solid rgba(0,0,0,.15);}
.sw.pwr{background:#2f9e44;} .sw.uart{background:#1971c2;}
.sw.gnd{background:#343a40;}
.sw.opt{background:#ced4da; border:1.5px dashed #868e96;}
.sw.off{background:#dee2e6;}
/* SVG element classes */
.p-pwr{fill:#2f9e44; stroke:#248a3b; stroke-width:1.5;}
.p-uart{fill:#1971c2; stroke:#155a9c; stroke-width:1.5;}
.p-gnd{fill:#343a40; stroke:#1f2429; stroke-width:1.5;}
.p-opt{fill:#ced4da; stroke:#868e96; stroke-width:1.5; stroke-dasharray:3 2;}
.p-off{fill:#dee2e6; stroke:#c3c9cf; stroke-width:1;}
.rail{fill:#f1f3f5; stroke:#ced4da; stroke-width:1.5;}
.pnum{font-family:var(--mono); font-size:12px; font-weight:700; text-anchor:middle; dominant-baseline:central;}
.pnum-l{fill:#ffffff;} .pnum-d{fill:#495057;}
.plbl{font-family:var(--mono); font-size:12px; dominant-baseline:central;}
.lbl-l{text-anchor:end;} .lbl-r{text-anchor:start;}
.lbl-used{fill:#1a1d21; font-weight:700;}
.lbl-alt{fill:#5c6570; font-weight:600;}
.lbl-off{fill:#adb5bd; font-weight:500;}
.cap-svg{font-family:var(--sans); font-size:12.5px; fill:#5c6570; font-weight:600; text-anchor:middle;}
/* ---------- Table ---------- */
table{width:100%; border-collapse:collapse; font-size:14px;}
thead th{
background:#f1f4f7; color:#2b3541; text-align:left; font-weight:700;
padding:10px 12px; border-bottom:2px solid #ccd4dc; font-size:13px;
}
tbody td{padding:11px 12px; border-bottom:1px solid var(--line); vertical-align:top;}
tbody tr:last-child td{border-bottom:none;}
.mono{font-family:var(--mono);}
.pinbadge{
display:inline-block; min-width:26px; text-align:center; font-family:var(--mono);
font-weight:700; font-size:13px; color:#fff; background:var(--accent);
border-radius:6px; padding:2px 7px;
}
.pinbadge.gnd{background:var(--gnd);} .pinbadge.pwr{background:var(--ok);}
.pinbadge.na{background:#adb5bd;}
td .warn-inline{color:var(--warn); font-weight:700;}
/* ---------- Schematic ---------- */
.svg-sch{display:block; width:100%; max-width:840px; height:auto; margin:0 auto;}
.box{fill:#f8f9fa; stroke:#495057; stroke-width:2; rx:8;}
.boxtitle{font-family:var(--sans); font-size:14px; font-weight:800; fill:#1a1d21; text-anchor:middle;}
.term{font-family:var(--mono); font-size:12.5px; font-weight:700; fill:#2b3541;}
.w-pwr{stroke:#e03131; stroke-width:3; fill:none; stroke-linecap:round; stroke-linejoin:round;}
.w-uart{stroke:#1971c2; stroke-width:3; fill:none; stroke-linecap:round; stroke-linejoin:round;}
.w-gnd{stroke:#343a40; stroke-width:3; fill:none; stroke-linecap:round; stroke-linejoin:round;}
.w-opt{stroke:#868e96; stroke-width:2.5; fill:none; stroke-dasharray:6 4; stroke-linecap:round;}
.wlbl{font-family:var(--sans); font-size:12px; font-weight:700; fill:#2b3541;}
.jn{fill:#212529;}
.sch-note{color:var(--muted); font-size:12.5px; text-align:center; margin:12px 0 0;}
/* ---------- Callouts ---------- */
.warn{
background:var(--warnbg); border:1.5px solid #ffc9c9; border-left:6px solid var(--warn);
border-radius:12px; padding:16px 18px; margin:0 0 16px; display:flex; gap:14px; align-items:flex-start;
}
.warn .ic{flex:0 0 auto; width:34px; height:34px;}
.warn h3{margin:0 0 4px; color:#c92a2a; font-size:16px; font-weight:800;}
.warn p{margin:0; font-size:14px; color:#5a2020;}
.warn b{color:#c92a2a;}
ul.notes{margin:6px 0 0; padding:0; list-style:none;}
ul.notes li{position:relative; padding:7px 0 7px 24px; border-bottom:1px dashed var(--line); font-size:14px;}
ul.notes li:last-child{border-bottom:none;}
ul.notes li::before{content:""; position:absolute; left:4px; top:14px; width:8px; height:8px;
background:var(--accent); border-radius:2px; transform:rotate(45deg);}
code, .kbd{font-family:var(--mono); background:#eef2f6; color:#0b3d2e; padding:1px 6px; border-radius:5px; font-size:13px;}
.cmd{
font-family:var(--mono); background:#12211b; color:#c8f7d8; border-radius:10px;
padding:12px 14px; font-size:13.5px; margin:12px 0 0; overflow-x:auto;
}
.cmd .cm{color:#7fd6a3;} .cmd .ok{color:#ffd43b;}
/* ---------- Checklist ---------- */
ol.check{margin:0; padding:0; list-style:none; counter-reset:ck;}
ol.check li{
counter-increment:ck; position:relative; display:flex; align-items:flex-start; gap:12px;
padding:11px 4px 11px 0; border-bottom:1px solid var(--line); font-size:14px;
}
ol.check li:last-child{border-bottom:none;}
ol.check li .num{
flex:0 0 auto; width:24px; height:24px; border-radius:50%; background:#e7eef5; color:#1f5f97;
font-weight:800; font-size:12.5px; display:flex; align-items:center; justify-content:center;
}
ol.check li .num::before{content:counter(ck);}
ol.check li .cbox{flex:0 0 auto; width:18px; height:18px; border:2px solid #9aa6b1; border-radius:4px; margin-top:1px;}
ol.check li .txt{flex:1;}
ol.check li .txt b{color:var(--warn);}
footer{margin-top:26px; text-align:center; color:var(--muted); font-size:12px;}
/* ---------- Print ---------- */
@media print{
body{background:#fff;}
.wrap{max-width:none; padding:0;}
.hero{background:#fff !important; color:#111; border:2px solid #111; box-shadow:none;}
.hero .eyebrow{color:#444;} .hero p.sum{color:#222;}
.chip{background:#f2f2f2; color:#111; border:1px solid #999;}
.chip b{color:#000;}
h2 .n{background:#111;}
section{box-shadow:none; border:1px solid #999; break-inside:avoid; page-break-inside:avoid;}
table, tr, td, th, .warn, .card{break-inside:avoid; page-break-inside:avoid;}
.svg-pin, .svg-sch{max-width:100%;}
.cmd{background:#f2f2f2; color:#111; border:1px solid #999;}
a{color:#000;}
}
@media (max-width:560px){
.hero h1{font-size:22px;} h2{font-size:17px;}
}
</style>
</head>
<body>
<div class="wrap">
<!-- ============ 1. HEADER ============ -->
<header class="hero">
<p class="eyebrow">Field Installation Doc · Wiring &amp; Pinmap</p>
<h1>Particulate-Matter Node — Site (Pi-C, sensor_id=3)</h1>
<p class="sum">
A measurement node connecting the Plantower PMS7003 laser PM sensor to the Raspberry Pi over the primary UART (9600 8N1).
The sensor co-locates on an existing leak node and reports PM1.0/PM2.5/PM10 (ug/m3) every <b>10 minutes</b> under the same
<code>sensor_id</code> as that node's leak sensor; high-PM2.5/PM10 threshold decisions and SMS dispatch are handled by the server.
</p>
<div class="chips">
<span class="chip">Raspberry Pi 4 Model B</span>
<span class="chip mono">BCM2711</span>
<span class="chip">Raspberry Pi OS Bookworm 64-bit</span>
<span class="chip">Plantower PMS7003</span>
<span class="chip mono">UART <b>/dev/serial0</b></span>
<span class="chip mono">9600 8N1</span>
<span class="chip mono">metric_type=<b>pms7003</b></span>
<span class="chip">Report interval 10 min</span>
</div>
</header>
<!-- ============ 2. PINMAP SVG ============ -->
<section>
<h2><span class="n">2</span>40-Pin Header Pinmap Diagram <small>Used pins highlighted · others in gray</small></h2>
<svg class="svg-pin" viewBox="0 0 520 672" role="img" aria-label="Raspberry Pi 40-pin GPIO header pinmap">
<!-- caption -->
<text class="cap-svg" x="260" y="20">40-pin GPIO header (J8) · physical pin numbers</text>
<!-- rails -->
<rect class="rail" x="186" y="46" width="28" height="600" rx="14"/>
<rect class="rail" x="306" y="46" width="28" height="600" rx="14"/>
<!-- Row 1 : pin1 (square = pin1 marker) / pin2 (5V power = used) -->
<rect class="p-off" x="188" y="48" width="24" height="24" rx="5"/>
<text class="pnum pnum-d" x="200" y="60">1</text>
<text class="plbl lbl-l lbl-off" x="176" y="60">3V3</text>
<circle class="p-pwr" cx="320" cy="60" r="12"/>
<text class="pnum pnum-l" x="320" y="60">2</text>
<text class="plbl lbl-r lbl-used" x="344" y="60">5V (VCC)</text>
<!-- Row 2 : pin3 / pin4 -->
<circle class="p-off" cx="200" cy="90" r="12"/>
<text class="pnum pnum-d" x="200" y="90">3</text>
<text class="plbl lbl-l lbl-off" x="176" y="90">GPIO2 (SDA1)</text>
<circle class="p-off" cx="320" cy="90" r="12"/>
<text class="pnum pnum-d" x="320" y="90">4</text>
<text class="plbl lbl-r lbl-off" x="344" y="90">5V</text>
<!-- Row 3 : pin5 / pin6 (GND = used) -->
<circle class="p-off" cx="200" cy="120" r="12"/>
<text class="pnum pnum-d" x="200" y="120">5</text>
<text class="plbl lbl-l lbl-off" x="176" y="120">GPIO3 (SCL1)</text>
<circle class="p-gnd" cx="320" cy="120" r="12"/>
<text class="pnum pnum-l" x="320" y="120">6</text>
<text class="plbl lbl-r lbl-used" x="344" y="120">GND</text>
<!-- Row 4 : pin7 / pin8 (GPIO14 TXD = optional) -->
<circle class="p-off" cx="200" cy="150" r="12"/>
<text class="pnum pnum-d" x="200" y="150">7</text>
<text class="plbl lbl-l lbl-off" x="176" y="150">GPIO4</text>
<circle class="p-opt" cx="320" cy="150" r="12"/>
<text class="pnum pnum-d" x="320" y="150">8</text>
<text class="plbl lbl-r lbl-alt" x="344" y="150">GPIO14 (TXD)</text>
<!-- Row 5 : pin9 / pin10 (GPIO15 RXD = used, sensor data) -->
<circle class="p-off" cx="200" cy="180" r="12"/>
<text class="pnum pnum-d" x="200" y="180">9</text>
<text class="plbl lbl-l lbl-off" x="176" y="180">GND</text>
<circle class="p-uart" cx="320" cy="180" r="12"/>
<text class="pnum pnum-l" x="320" y="180">10</text>
<text class="plbl lbl-r lbl-used" x="344" y="180">GPIO15 (RXD)</text>
<!-- Row 6 : pin11 / pin12 -->
<circle class="p-off" cx="200" cy="210" r="12"/>
<text class="pnum pnum-d" x="200" y="210">11</text>
<text class="plbl lbl-l lbl-off" x="176" y="210">GPIO17</text>
<circle class="p-off" cx="320" cy="210" r="12"/>
<text class="pnum pnum-d" x="320" y="210">12</text>
<text class="plbl lbl-r lbl-off" x="344" y="210">GPIO18</text>
<!-- Row 7 : pin13 / pin14 -->
<circle class="p-off" cx="200" cy="240" r="12"/>
<text class="pnum pnum-d" x="200" y="240">13</text>
<text class="plbl lbl-l lbl-off" x="176" y="240">GPIO27</text>
<circle class="p-off" cx="320" cy="240" r="12"/>
<text class="pnum pnum-d" x="320" y="240">14</text>
<text class="plbl lbl-r lbl-off" x="344" y="240">GND</text>
<!-- Row 8 : pin15 / pin16 -->
<circle class="p-off" cx="200" cy="270" r="12"/>
<text class="pnum pnum-d" x="200" y="270">15</text>
<text class="plbl lbl-l lbl-off" x="176" y="270">GPIO22</text>
<circle class="p-off" cx="320" cy="270" r="12"/>
<text class="pnum pnum-d" x="320" y="270">16</text>
<text class="plbl lbl-r lbl-off" x="344" y="270">GPIO23</text>
<!-- Row 9 : pin17 / pin18 -->
<circle class="p-off" cx="200" cy="300" r="12"/>
<text class="pnum pnum-d" x="200" y="300">17</text>
<text class="plbl lbl-l lbl-off" x="176" y="300">3V3</text>
<circle class="p-off" cx="320" cy="300" r="12"/>
<text class="pnum pnum-d" x="320" y="300">18</text>
<text class="plbl lbl-r lbl-off" x="344" y="300">GPIO24</text>
<!-- Row 10 : pin19 / pin20 -->
<circle class="p-off" cx="200" cy="330" r="12"/>
<text class="pnum pnum-d" x="200" y="330">19</text>
<text class="plbl lbl-l lbl-off" x="176" y="330">GPIO10 (MOSI)</text>
<circle class="p-off" cx="320" cy="330" r="12"/>
<text class="pnum pnum-d" x="320" y="330">20</text>
<text class="plbl lbl-r lbl-off" x="344" y="330">GND</text>
<!-- Row 11 : pin21 / pin22 -->
<circle class="p-off" cx="200" cy="360" r="12"/>
<text class="pnum pnum-d" x="200" y="360">21</text>
<text class="plbl lbl-l lbl-off" x="176" y="360">GPIO9 (MISO)</text>
<circle class="p-off" cx="320" cy="360" r="12"/>
<text class="pnum pnum-d" x="320" y="360">22</text>
<text class="plbl lbl-r lbl-off" x="344" y="360">GPIO25</text>
<!-- Row 12 : pin23 / pin24 -->
<circle class="p-off" cx="200" cy="390" r="12"/>
<text class="pnum pnum-d" x="200" y="390">23</text>
<text class="plbl lbl-l lbl-off" x="176" y="390">GPIO11 (SCLK)</text>
<circle class="p-off" cx="320" cy="390" r="12"/>
<text class="pnum pnum-d" x="320" y="390">24</text>
<text class="plbl lbl-r lbl-off" x="344" y="390">GPIO8 (CE0)</text>
<!-- Row 13 : pin25 / pin26 -->
<circle class="p-off" cx="200" cy="420" r="12"/>
<text class="pnum pnum-d" x="200" y="420">25</text>
<text class="plbl lbl-l lbl-off" x="176" y="420">GND</text>
<circle class="p-off" cx="320" cy="420" r="12"/>
<text class="pnum pnum-d" x="320" y="420">26</text>
<text class="plbl lbl-r lbl-off" x="344" y="420">GPIO7 (CE1)</text>
<!-- Row 14 : pin27 / pin28 -->
<circle class="p-off" cx="200" cy="450" r="12"/>
<text class="pnum pnum-d" x="200" y="450">27</text>
<text class="plbl lbl-l lbl-off" x="176" y="450">GPIO0 (ID_SD)</text>
<circle class="p-off" cx="320" cy="450" r="12"/>
<text class="pnum pnum-d" x="320" y="450">28</text>
<text class="plbl lbl-r lbl-off" x="344" y="450">GPIO1 (ID_SC)</text>
<!-- Row 15 : pin29 / pin30 -->
<circle class="p-off" cx="200" cy="480" r="12"/>
<text class="pnum pnum-d" x="200" y="480">29</text>
<text class="plbl lbl-l lbl-off" x="176" y="480">GPIO5</text>
<circle class="p-off" cx="320" cy="480" r="12"/>
<text class="pnum pnum-d" x="320" y="480">30</text>
<text class="plbl lbl-r lbl-off" x="344" y="480">GND</text>
<!-- Row 16 : pin31 / pin32 -->
<circle class="p-off" cx="200" cy="510" r="12"/>
<text class="pnum pnum-d" x="200" y="510">31</text>
<text class="plbl lbl-l lbl-off" x="176" y="510">GPIO6</text>
<circle class="p-off" cx="320" cy="510" r="12"/>
<text class="pnum pnum-d" x="320" y="510">32</text>
<text class="plbl lbl-r lbl-off" x="344" y="510">GPIO12</text>
<!-- Row 17 : pin33 / pin34 -->
<circle class="p-off" cx="200" cy="540" r="12"/>
<text class="pnum pnum-d" x="200" y="540">33</text>
<text class="plbl lbl-l lbl-off" x="176" y="540">GPIO13</text>
<circle class="p-off" cx="320" cy="540" r="12"/>
<text class="pnum pnum-d" x="320" y="540">34</text>
<text class="plbl lbl-r lbl-off" x="344" y="540">GND</text>
<!-- Row 18 : pin35 / pin36 -->
<circle class="p-off" cx="200" cy="570" r="12"/>
<text class="pnum pnum-d" x="200" y="570">35</text>
<text class="plbl lbl-l lbl-off" x="176" y="570">GPIO19</text>
<circle class="p-off" cx="320" cy="570" r="12"/>
<text class="pnum pnum-d" x="320" y="570">36</text>
<text class="plbl lbl-r lbl-off" x="344" y="570">GPIO16</text>
<!-- Row 19 : pin37 / pin38 -->
<circle class="p-off" cx="200" cy="600" r="12"/>
<text class="pnum pnum-d" x="200" y="600">37</text>
<text class="plbl lbl-l lbl-off" x="176" y="600">GPIO26</text>
<circle class="p-off" cx="320" cy="600" r="12"/>
<text class="pnum pnum-d" x="320" y="600">38</text>
<text class="plbl lbl-r lbl-off" x="344" y="600">GPIO20</text>
<!-- Row 20 : pin39 / pin40 -->
<circle class="p-off" cx="200" cy="630" r="12"/>
<text class="pnum pnum-d" x="200" y="630">39</text>
<text class="plbl lbl-l lbl-off" x="176" y="630">GND</text>
<circle class="p-off" cx="320" cy="630" r="12"/>
<text class="pnum pnum-d" x="320" y="630">40</text>
<text class="plbl lbl-r lbl-off" x="344" y="630">GPIO21</text>
<!-- pin1 hint -->
<text class="cap-svg" x="260" y="662" style="font-size:11px;">[#] square marker = pin 1 (relative to board orientation)</text>
</svg>
<div class="legend">
<span><i class="sw pwr"></i>5V power &mdash; pin 2</span>
<span><i class="sw uart"></i>UART RXD (sensor data) &mdash; pin 10</span>
<span><i class="sw gnd"></i>GND &mdash; pin 6</span>
<span><i class="sw opt"></i>UART TXD (optional) &mdash; pin 8</span>
<span><i class="sw off"></i>Unused pins</span>
</div>
<p class="diagram-cap">The pin numbers and signals in this diagram match the standard Raspberry Pi 4 Model B (BCM2711) 40-pin header layout.</p>
</section>
<!-- ============ 3. PINMAP TABLE ============ -->
<section>
<h2><span class="n">3</span>Pinmap Table <small>PMS7003 &rarr; 40-pin header</small></h2>
<table>
<thead>
<tr><th style="width:30%">PMS7003 signal</th><th style="width:24%">Direction</th><th style="width:14%">Phys pin</th><th>Notes</th></tr>
</thead>
<tbody>
<tr>
<td><b>VCC</b> (power)</td>
<td class="mono">Pi &rarr; sensor</td>
<td><span class="pinbadge pwr">2</span></td>
<td><span class="warn-inline">* 5V required</span> &mdash; the PMS7003 fan + laser need 5V (pins 2&middot;4)</td>
</tr>
<tr>
<td><b>GND</b></td>
<td class="mono">common</td>
<td><span class="pinbadge gnd">6</span></td>
<td>Any GND works: <span class="mono">6 &middot; 9 &middot; 14 &middot; 20 &middot; 25 &middot; 30 &middot; 34 &middot; 39</span></td>
</tr>
<tr>
<td><b>TXD</b> (sensor data out)</td>
<td class="mono">sensor &rarr; Pi RXD</td>
<td><span class="pinbadge">10</span></td>
<td>GPIO15 (RXD). 3.3V logic &mdash; connects <b>directly</b>, no level shifter</td>
</tr>
<tr>
<td><b>RXD</b> (command in)</td>
<td class="mono">Pi TXD &rarr; sensor</td>
<td><span class="pinbadge na">8</span></td>
<td><b>Optional</b> &mdash; only for passive-mode/sleep commands. Active streaming needs only TXD</td>
</tr>
<tr>
<td><b>SET / RESET</b></td>
<td class="mono">&mdash;</td>
<td><span class="pinbadge na">n/c</span></td>
<td>Optional. Leave floating &mdash; internal pull-up keeps the sensor enabled/running</td>
</tr>
</tbody>
</table>
</section>
<!-- ============ 4. SCHEMATIC SVG ============ -->
<section>
<h2><span class="n">4</span>Wiring Schematic <small>sensor &harr; header</small></h2>
<svg class="svg-sch" viewBox="0 0 840 380" role="img" aria-label="PMS7003 wiring schematic">
<!-- component boxes -->
<rect class="box" x="70" y="90" width="160" height="250" rx="8"/>
<rect class="box" x="640" y="90" width="150" height="250" rx="8"/>
<text class="boxtitle" x="150" y="116">PMS7003 sensor</text>
<text class="boxtitle" x="715" y="116">Pi 40-pin header</text>
<!-- PMS7003 terminal labels (right side) -->
<text class="term" x="220" y="150" text-anchor="end">VCC</text>
<text class="term" x="220" y="205" text-anchor="end">GND</text>
<text class="term" x="220" y="260" text-anchor="end">TXD</text>
<text class="term" x="220" y="315" text-anchor="end">RXD</text>
<!-- Pi terminal labels (left side) -->
<text class="term" x="650" y="150">5V (pin 2)</text>
<text class="term" x="650" y="205">GND (pin 6)</text>
<text class="term" x="650" y="260">RXD (pin 10, GPIO15)</text>
<text class="term" x="650" y="315">TXD (pin 8, GPIO14)</text>
<!-- POWER wire -->
<path class="w-pwr" d="M230 150 H640"/>
<text class="wlbl" x="330" y="140" fill="#b02a2a">Power 5V</text>
<!-- GND wire -->
<path class="w-gnd" d="M230 205 H640"/>
<text class="wlbl" x="330" y="195" fill="#2b3541">Ground GND</text>
<!-- TXD (sensor) -> RXD (Pi) : the sensor data line -->
<path class="w-uart" d="M230 260 H640"/>
<text class="wlbl" x="300" y="250" fill="#155a9c">sensor TXD &rarr; Pi RXD (GPIO15)</text>
<!-- RXD (sensor) <- TXD (Pi) : optional command line -->
<path class="w-opt" d="M230 315 H640"/>
<text class="wlbl" x="300" y="333" fill="#5c6570">Pi TXD &rarr; sensor RXD (optional)</text>
</svg>
<p class="sch-note">
Active streaming mode uses only <b>VCC &middot; GND &middot; sensor TXD &rarr; Pi RXD</b>. The dashed Pi TXD &rarr; sensor RXD
line is optional (needed only to send passive-mode / sleep commands). The PMS7003 TXD is 3.3V logic,
so it wires <b>directly</b> to the Pi &mdash; no level shifter.
</p>
</section>
<!-- ============ 5. ELECTRICAL NOTES ============ -->
<section>
<h2><span class="n">5</span>Electrical Design &middot; Cautions</h2>
<div class="warn">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2 L23 21 H1 Z" fill="#ffe3e3" stroke="#e03131" stroke-width="1.6" stroke-linejoin="round"/>
<line x1="12" y1="9" x2="12" y2="15" stroke="#e03131" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="12" cy="18" r="1.4" fill="#e03131"/>
</svg>
<div>
<h3>Critical &mdash; power VCC from 5V, but NEVER wire the sensor TXD to a Pi 5V pin</h3>
<p>The PMS7003 <b>VCC must be 5V (pin 2 or 4)</b> &mdash; its fan and laser will not run on 3.3V. However the
UART data lines are <b>3.3V logic</b>: the sensor's TXD connects <b>directly</b> to the Pi RXD (pin 10).
<b>Do not connect any 5V line to a GPIO</b> &mdash; that would damage the 3.3V-only Pi GPIO. Double-check pin numbers before power-up.</p>
</div>
</div>
<ul class="notes">
<li><b>Enable the UART:</b> add <code>enable_uart=1</code> to <code>/boot/firmware/config.txt</code> (Bookworm) or use <code>raspi-config</code> &rarr; Interface Options &rarr; Serial Port.</li>
<li><b>Disable the serial login console:</b> answer "No" to the login-shell question in <code>raspi-config</code>, or remove <code>console=serial0,115200</code> from <code>cmdline.txt</code>. Otherwise the console fights the sensor for <code>/dev/serial0</code>. <code>install.sh</code> does both automatically.</li>
<li><b>Port:</b> the module reads <code>/dev/serial0</code> (alias to the primary UART; also <code>/dev/ttyS0</code> / <code>/dev/ttyAMA0</code>). 9600 baud, 8 data bits, no parity, 1 stop bit.</li>
<li><b>dialout group:</b> the service user needs the <code>dialout</code> group to open <code>/dev/serial0</code> (<code>systemd</code> unit sets <code>SupplementaryGroups=dialout</code>).</li>
<li><b>Warm-up:</b> the PMS7003 needs a few seconds of fan/laser warm-up after power-on for stable readings; the driver simply reads until a valid checksummed frame arrives.</li>
<li><b>Role split:</b> this node only reports PM1.0/PM2.5/PM10 every 10 minutes. High-PM2.5/PM10 <b>threshold decisions and SMS dispatch are done by the server</b>.</li>
<li><b>Config file:</b> <code>raspberry_pi4_pms7003/config.py</code> (<code>UART_PORT=/dev/serial0</code>, <code>UART_BAUD=9600</code>). Per-device values (SENSOR_ID 1 vs 3) are overridden in <code>config_local.py</code>.</li>
</ul>
<div class="cmd">
<span class="cm"># Verify the UART device exists (symlink to ttyS0 / ttyAMA0)</span>
$ ls -l /dev/serial0
lrwxrwxrwx 1 root root 5 ... /dev/serial0 -&gt; <span class="ok">ttyS0</span>
</div>
</section>
<!-- ============ 6. INSTALL CHECKLIST ============ -->
<section>
<h2><span class="n">6</span>Installation Checklist</h2>
<ol class="check">
<li><span class="num"></span><span class="cbox"></span><span class="txt">Do wiring work with <b>power disconnected</b> (no hot-plugging)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Connect VCC &rarr; physical pin 2 (5V) &mdash; the sensor fan/laser need <b>5V</b></span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Connect GND &rarr; physical pin 6 (or any other GND pin)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Connect sensor <b>TXD &rarr; Pi RXD (pin 10, GPIO15)</b> &mdash; this is the data line</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">(Optional) Pi TXD (pin 8) &rarr; sensor RXD, only if sending passive-mode commands</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Leave SET/RESET floating (sensor stays enabled/running)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Re-check all wiring &mdash; confirm <b>no 5V line touches a GPIO</b> &mdash; then power up the Pi</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Enable UART (<code>enable_uart=1</code>) and <b>disable the serial console</b> (raspi-config / install.sh), then reboot</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Run <code>ls -l /dev/serial0</code> &rarr; confirm it symlinks to <code>ttyS0</code>/<code>ttyAMA0</code></span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Verify UART_PORT / UART_BAUD in config.py, and SENSOR_ID (1 or 3) in config_local.py</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Check measurement log and server reception (10-min PM reporting OK)</span></li>
</ol>
</section>
<footer>
Particulate-Matter Node &middot; PMS7003 UART wiring doc (co-located on a leak node, sensor_id 1 or 3) &mdash; for on-site printing
</footer>
</div>
</body>
</html>

View file

@ -0,0 +1,124 @@
# =============================================================================
# config.py - Particulate-matter (PMS7003) monitoring module (Pi 4 Model B) config — non-secret defaults
#
# Operational/secret values such as server URL / API_KEY / device identifiers (DEVICE_ID/LOCATION)
# are loaded from config_local.py. config_local.py is excluded from commits via .gitignore.
# Setup: cp config_local.example.py config_local.py → fill in the real values
#
# This module integrates a PMS7003 PM sensor onto an EXISTING leak node (co-located, same
# sensor_id as that node's leak sensor, distinguished by metric_type="pms7003" — exactly like
# the SHT30 co-location). It attaches to BOTH leak nodes: Pi-C (sensor_id=3, the default here)
# and the id=1 node (override SENSOR_ID=1 in config_local.py). Threshold/SMS is server-side.
# =============================================================================
# ─────────────────────────────────────────────
# Sensor identity (for server dashboard display; not secret)
# SENSOR_ID is the key in the server's leak_sensor_metric / leak_sensor_status. The PMS row
# shares the co-located leak sensor's id (1 or 3), separated by metric_type. Default = Pi-C (3).
# ─────────────────────────────────────────────
SENSOR_ID = 3
SENSOR_NAME = "Sensor 3 (PMS7003)"
# ─────────────────────────────────────────────
# UART config (Pi 4 40-pin header primary UART)
# PMS7003 TXD → Pi RXD = GPIO15 (physical pin 10). 3.3V logic, 9600 8N1.
# Port: /dev/serial0 (alias to the primary UART). Also usable: /dev/ttyS0 / /dev/ttyAMA0.
# Requires: enable UART + DISABLE the serial login console (raspi-config / enable_uart=1).
# ─────────────────────────────────────────────
UART_PORT = "/dev/serial0"
UART_BAUD = 9600
UART_TIMEOUT = 2.0 # pyserial per-read timeout (seconds)
FRAME_WAIT_TIMEOUT = 5.0 # max time to wait for a valid frame in one measurement (seconds)
# Force simulation. If True, runs with dummy measurements without hardware (for server integration checks).
# Even if False, the driver auto-falls back to simulation when pyserial is not installed / UART is unavailable.
FORCE_SIMULATION = False
# ─────────────────────────────────────────────
# Measurement/report intervals
# ─────────────────────────────────────────────
REPORT_INTERVAL = 600 # Normal measure/report interval (seconds) = 10 minutes
# Matches the co-located leak node's SHT30 cadence (600s).
# Server offline detection (METRIC_HEARTBEAT_TIMEOUT_SEC) must exceed this.
# Override per-node in config_local.py if a different cadence is needed.
MEASURE_RETRY_DELAY = 5 # Wait before retry after a failed measurement (seconds)
# ─────────────────────────────────────────────
# Physical plausibility range for PM values (µg/m³, atmospheric concentration)
# Values outside this range are reported as metric_status="out_of_range".
# * Operational threshold judgments (high PM2.5 / PM10) and SMS are all handled by the
# server (config.php METRIC_PM*). This module only faithfully reports measurements.
# ─────────────────────────────────────────────
PM_VALUE_MIN = 0
PM_VALUE_MAX = 1000
# Module identity version (server app_version field; the server only records it, does not validate)
APP_VERSION = "v2606-pms7003-rpi4"
# ─────────────────────────────────────────────
# Logging config
# ─────────────────────────────────────────────
LOG_FILE = "/var/log/pms7003_sensor.log"
LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB
LOG_BACKUP_COUNT = 3
# ─────────────────────────────────────────────
# HTTP retry config
# ─────────────────────────────────────────────
HTTP_TIMEOUT = 10 # Single request timeout (seconds)
HTTP_RETRY_COUNT = 3 # Number of retries
HTTP_RETRY_DELAY = 5 # Wait between retries (seconds)
# ─────────────────────────────────────────────
# Load operational/secret values (fail closed)
# If config_local.py is missing, abort immediately with a clear error instead of
# starting incorrectly with placeholder/hardcoded secrets.
# ─────────────────────────────────────────────
try:
from config_local import ( # noqa: F401 (pms7003_sensor.py re-imports from config)
API_URL,
API_KEY,
DEVICE_ID,
DEVICE_LOCATION,
)
except ImportError as e:
raise RuntimeError(
"config_local.py not found. "
"Run `cp config_local.example.py config_local.py`, then fill in the real server URL / API_KEY / "
"device info. (Never commit secret values.)"
) from e
# ─────────────────────────────────────────────
# Optional override — per-device values from config_local.py.
# Editing the tracked config.py per device causes deployment drift, so each leak node
# overrides SENSOR_ID / SENSOR_NAME here instead (Pi-C keeps id 3; the id=1 node sets
# SENSOR_ID=1). If config_local defines neither, the defaults above (SENSOR_ID=3) are kept.
# ─────────────────────────────────────────────
try:
from config_local import SENSOR_ID # noqa: F811 — per-device sensor_id override
except ImportError:
pass
try:
from config_local import SENSOR_NAME # noqa: F811 — per-device display name override
except ImportError:
pass
try:
# Per-device report cadence. Defaults to 600s (10 min) to match the co-located leak
# node's SHT30 cadence; override here if a node needs a different interval.
from config_local import REPORT_INTERVAL # noqa: F811 — per-device report interval override
except ImportError:
pass
try:
from config_local import UART_PORT # noqa: F811 — per-device UART device override
except ImportError:
pass
# Validate (fail closed) — a typo must not silently start with a wrong/plausible id.
if not (isinstance(SENSOR_ID, int) and 1 <= SENSOR_ID <= 255):
raise RuntimeError(f"SENSOR_ID must be int 1..255 (check config_local.py): {SENSOR_ID!r}")
if not (isinstance(SENSOR_NAME, str) and SENSOR_NAME):
raise RuntimeError("SENSOR_NAME must be a non-empty string (check config_local.py).")
if not (isinstance(REPORT_INTERVAL, int) and REPORT_INTERVAL > 0):
raise RuntimeError(f"REPORT_INTERVAL must be a positive int (seconds) (check config_local.py): {REPORT_INTERVAL!r}")
if not (isinstance(UART_PORT, str) and UART_PORT):
raise RuntimeError("UART_PORT must be a non-empty string (check config_local.py).")

View file

@ -0,0 +1,42 @@
# =============================================================================
# config_local.example.py - Operational / secret-value template (Pi 4 PMS7003 particulate-matter module)
#
# Usage: cp config_local.example.py config_local.py
# -> Fill in the real values and save. Never commit config_local.py
# (it is excluded via .gitignore).
#
# WARNING: Server compatibility
# This module reports PMS7003 particulate-matter (PM1.0/PM2.5/PM10, ug/m3) readings to the
# cafe24 server php/api/sensor_data.php. The server requires a raw-body signature:
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))
# -> API_KEY must be "exactly identical" to API_KEY in the server config.local.php.
# Threshold decisions (high PM2.5 / PM10), SMS, and the dashboard are all done by the server.
# =============================================================================
# -----------------------------------------------
# Server API endpoint (receiving server = cafe24 php/)
# Web root per INSTALL_PI_SERVER.md: public_html/raspi_leck_detecter/
# -----------------------------------------------
API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php"
# -----------------------------------------------
# Shared secret key - must be exactly identical to API_KEY in the server config.local.php. Never commit.
# -----------------------------------------------
API_KEY = "CHANGE_ME"
# -----------------------------------------------
# Device identification. The PMS7003 co-locates on an EXISTING leak node and reports under the
# SAME sensor_id as that node's leak sensor (distinguished by metric_type="pms7003").
# - Pi-C leak node: keep the config.py default (SENSOR_ID=3) - leave SENSOR_ID commented out.
# - id=1 leak node: uncomment and set SENSOR_ID=1 (and an appropriate SENSOR_NAME).
# -----------------------------------------------
DEVICE_ID = "rpi4-pms7003-01"
DEVICE_LOCATION = "Server Room"
# -----------------------------------------------
# Per-device overrides (optional). Uncomment on the id=1 node.
# -----------------------------------------------
# SENSOR_ID = 1
# SENSOR_NAME = "Sensor 1 (PMS7003)"
# REPORT_INTERVAL = 600
# UART_PORT = "/dev/serial0"

View file

@ -0,0 +1,160 @@
#!/usr/bin/env bash
# =============================================================================
# Particulate-matter (PMS7003) module v2 (Raspberry Pi 4 Model B) install script
# Target OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
#
# Air-gapped support:
# - If debs/ or wheelhouse/ is present, automatically switch to "offline mode".
# - In offline mode, never access the network. If the bundle is only partial,
# do not silently fall back to online; stop with a clear error (air-gapped rule). Pre-
# download is done on the build machine via vendor.sh.
# - Can be forced via the OFFLINE=1/0 environment variable.
# - Log file owner: SERVICE_USER (default pi).
#
# This script also enables the UART (enable_uart=1) and DISABLES the serial login console
# (so the PMS7003 has exclusive use of /dev/serial0), and adds the user to the dialout group.
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")"
SERVICE_USER="${SERVICE_USER:-pi}"
echo "=========================================="
echo " Particulate-matter (PMS7003) module v2 (Pi 4) install"
echo "=========================================="
# 0) Board model notice (not enforced)
if [ -r /proc/device-tree/model ]; then
MODEL="$(tr -d '\0' < /proc/device-tree/model)"
echo "[INFO] Board: ${MODEL}"
case "${MODEL}" in
*"Raspberry Pi 4"*) : ;;
*) echo "[WARN] This folder is for Raspberry Pi 4 Model B only (you may continue anyway)." ;;
esac
fi
# Offline mode detection
OFFLINE="${OFFLINE:-}"
if [ -z "${OFFLINE}" ]; then
if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then
OFFLINE=1
echo "[INFO] Offline bundle detected -> air-gapped offline install mode (no network)"
else
OFFLINE=0
echo "[INFO] No offline bundle -> online install mode (internet required)"
fi
fi
# 1) System packages: requests + venv
echo "[1/4] Installing system packages..."
if [ "${OFFLINE}" = "1" ]; then
if ! compgen -G "debs/*.deb" >/dev/null; then
echo "[ERROR] Offline mode but debs/ is empty. Place the vendor.sh output (debs/) in this folder and re-run." >&2
exit 1
fi
echo " Offline .deb install (apt-get --no-download - dependency ordering/resolution automatic, no network)"
# --no-install-recommends: vendor.sh does not bundle Recommends (--no-recommends), so match that.
sudo apt-get install -y --no-install-recommends --no-download ./debs/*.deb
else
echo " Online apt install (internet required)"
sudo apt-get update
sudo apt-get install -y python3-requests python3-venv
fi
# 2) Enable UART + disable the serial login console
echo "[2/4] Enabling UART (and disabling the serial console)..."
# Boot config path: Bookworm=/boot/firmware/config.txt, older=/boot/config.txt
BOOT_CFG=""
for c in /boot/firmware/config.txt /boot/config.txt; do
if [ -f "$c" ]; then BOOT_CFG="$c"; break; fi
done
if [ -n "${BOOT_CFG}" ]; then
if ! grep -q "^enable_uart=1" "${BOOT_CFG}"; then
echo " Adding enable_uart=1 to ${BOOT_CFG} (applied after reboot)"
echo "enable_uart=1" | sudo tee -a "${BOOT_CFG}" >/dev/null
NEED_REBOOT=1
else
echo " enable_uart=1 already set"
fi
else
echo "[WARN] config.txt not found - enable UART manually via raspi-config"
fi
# Disable the serial login console so /dev/serial0 is free for the sensor.
# Bookworm=/boot/firmware/cmdline.txt, older=/boot/cmdline.txt
CMDLINE=""
for c in /boot/firmware/cmdline.txt /boot/cmdline.txt; do
if [ -f "$c" ]; then CMDLINE="$c"; break; fi
done
if [ -n "${CMDLINE}" ] && grep -q "console=serial0" "${CMDLINE}"; then
echo " Removing 'console=serial0,115200' from ${CMDLINE} (frees the UART for PMS7003)"
sudo sed -i 's/console=serial0,[0-9]* //' "${CMDLINE}"
NEED_REBOOT=1
fi
# Disable the getty on the primary UART (harmless if already masked).
sudo systemctl disable --now serial-getty@ttyS0.service 2>/dev/null || true
sudo systemctl disable --now serial-getty@ttyAMA0.service 2>/dev/null || true
# Add the service user to the dialout group (/dev/serial0 access permission)
if getent group dialout >/dev/null 2>&1; then
sudo usermod -aG dialout "${SERVICE_USER}" || echo "[WARN] usermod failed - check SERVICE_USER=${SERVICE_USER}"
fi
# 3) Python venv + requests + pyserial
echo "[3/4] Setting up Python virtual environment..."
if [ ! -d venv ]; then
python3 -m venv --system-site-packages venv
echo " venv created"
else
echo " venv already exists - skipping"
fi
# shellcheck disable=SC1091
source venv/bin/activate
if [ "${OFFLINE}" = "1" ]; then
if [ ! -d wheelhouse ]; then
echo "[ERROR] Offline mode but wheelhouse/ is missing. Place the vendor.sh output (wheelhouse/) and re-run." >&2
exit 1
fi
echo " Offline wheel install (--no-index - no network)"
pip install --no-index --find-links=wheelhouse -r requirements.txt
else
echo " Online pip install (internet required)"
pip install --upgrade pip
pip install -r requirements.txt
fi
echo " Packages installed"
# 4) Log file (least privilege: owner-only write)
echo "[4/4] Creating log file..."
sudo mkdir -p /var/log
sudo touch /var/log/pms7003_sensor.log
if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/pms7003_sensor.log; then
echo "[WARN] chown failed - check whether SERVICE_USER=${SERVICE_USER} exists"
fi
sudo chmod 640 /var/log/pms7003_sensor.log
# Lock config_local.py immediately (contains API_KEY - prevent world-readable until harden)
# Hardening (harden_pi.sh) tightens it again later, but to avoid exposure even in
# that window, lock it to 600 right at install time. Protected regardless of harden.
if [ -f config_local.py ]; then
chmod 600 config_local.py
sudo chown "${SERVICE_USER}:${SERVICE_USER}" config_local.py 2>/dev/null || true
echo " config_local.py set to permission 600 (owner ${SERVICE_USER})"
fi
# config_local notice (fail closed - if missing, execution stops immediately)
if [ ! -f config_local.py ]; then
echo ""
echo "[!] config_local.py is missing. Run the following, then fill in real values:"
echo " cp config_local.example.py config_local.py"
fi
echo ""
echo "=========================================="
echo " Install complete! (OFFLINE=${OFFLINE})"
echo "=========================================="
if [ "${NEED_REBOOT:-0}" = "1" ]; then
echo " [!] UART was just enabled / serial console disabled -> reboot to apply: 'sudo reboot'."
fi
echo " Port check: ls -l /dev/serial0 (should symlink to ttyS0 or ttyAMA0)"
echo " Run: source venv/bin/activate && python3 pms7003_sensor.py"
echo " Service: see the systemd section in README.md"
echo ""

View file

@ -0,0 +1,194 @@
# =============================================================================
# pms7003_driver.py - PMS7003 UART measurement driver (Raspberry Pi 4, /dev/serial0)
#
# [Sensor] Plantower PMS7003 laser particulate-matter sensor.
# Streams a fixed 32-byte frame over UART (3.3V logic, 9600 8N1). By default the
# sensor powers up in ACTIVE mode and pushes frames continuously (~1 frame/sec in
# stable mode). We do not switch it to passive mode — we just sync on the header and
# read one complete valid frame per measurement, discarding partials/stale bytes.
#
# [Frame layout] (big-endian words; total 32 bytes)
# byte 0 : 0x42 start char 1
# byte 1 : 0x4D start char 2
# byte 2-3 : frame length = 2*13 + 2 = 28 (0x001C)
# byte 4-29 : 13 uint16 data words (big-endian)
# data[0..2] = PM1.0 / PM2.5 / PM10, CF=1 (standard particle)
# data[3] = PM1.0 atmospheric (bytes 10-11) <- reported
# data[4] = PM2.5 atmospheric (bytes 12-13) <- reported
# data[5] = PM10 atmospheric (bytes 14-15) <- reported
# data[6..11]= particle counts (>0.3/0.5/1.0/2.5/5.0/10 µm)
# data[12] = reserved (version / error code)
# byte 30-31 : checksum = uint16(sum of bytes 0..29)
#
# We report the ATMOSPHERIC concentration words (µg/m³) — the value meant for ambient
# air-quality reporting (the CF=1 words are for controlled/lab conditions).
#
# [Simulation] If pyserial is not installed or the UART device is absent (dev PC), it
# switches to SIMULATION=True and returns plausible dummy PM readings (for server
# integration/format checks) — same pattern as sht30_driver's SMBUS_AVAILABLE fallback.
# =============================================================================
from __future__ import annotations
import time
# ── Constants ────────────────────────────────────────────────────────────────
START1 = 0x42 # frame start char 1 ('B')
START2 = 0x4D # frame start char 2 ('M')
FRAME_LEN = 32 # total frame length (bytes)
BODY_LEN_FIELD = 28 # value of the length field (bytes 2-3) = 2*13 data + 2 checksum
DATA_WORD_COUNT = 13 # number of uint16 data words (bytes 4..29)
# Index of the reported ATMOSPHERIC concentration words within the 13-word data array.
IDX_PM1_0 = 3 # bytes 10-11
IDX_PM2_5 = 4 # bytes 12-13
IDX_PM10 = 5 # bytes 14-15
try:
import serial # pyserial (pure Python, arch-independent py3-none-any wheel)
SERIAL_AVAILABLE = True
except ImportError:
SERIAL_AVAILABLE = False
# ── Frame parsing (pure; importable without serial hardware) ─────────────────
def parse_frame(frame: bytes):
"""Verify + decode a 32-byte PMS7003 frame -> (pm1_0, pm2_5, pm10) ints (µg/m³).
Raises ValueError on wrong length / bad header / length-field mismatch / checksum
mismatch (caller retries/skips). Reports the atmospheric concentration words.
"""
if len(frame) != FRAME_LEN:
raise ValueError(f"frame length error: {len(frame)} (expected {FRAME_LEN})")
if frame[0] != START1 or frame[1] != START2:
raise ValueError(f"bad header: {frame[0]:#04x} {frame[1]:#04x} "
f"(expected {START1:#04x} {START2:#04x})")
length_field = (frame[2] << 8) | frame[3]
if length_field != BODY_LEN_FIELD:
raise ValueError(f"frame length field mismatch: {length_field} (expected {BODY_LEN_FIELD})")
# Checksum = sum of bytes 0..29, compared to the uint16 at bytes 30-31.
checksum = sum(frame[0:30]) & 0xFFFF
expected = (frame[30] << 8) | frame[31]
if checksum != expected:
raise ValueError(f"checksum mismatch: {checksum:#06x} (expected {expected:#06x})")
# Decode the 13 big-endian data words (bytes 4..29).
data = [
(frame[4 + 2 * i] << 8) | frame[5 + 2 * i]
for i in range(DATA_WORD_COUNT)
]
pm1_0 = data[IDX_PM1_0]
pm2_5 = data[IDX_PM2_5]
pm10 = data[IDX_PM10]
return pm1_0, pm2_5, pm10
class PMS7003:
"""PMS7003 UART measurement driver (active mode, frame-synced read).
port: serial device (Pi 4: /dev/serial0 -> primary UART; also /dev/ttyS0 / /dev/ttyAMA0)
baud: 9600 (PMS7003 fixed)
simulate: if True, returns dummy values without hardware (forced simulation)
read_timeout: pyserial per-read timeout (seconds)
frame_wait: max time to wait for a valid frame in one measure() call (seconds)
"""
def __init__(self, port: str = "/dev/serial0", baud: int = 9600,
simulate: bool = False, read_timeout: float = 2.0,
frame_wait: float = 5.0):
self.port = port
self.baud = baud
self.simulate = simulate or not SERIAL_AVAILABLE
self.read_timeout = read_timeout
self.frame_wait = frame_wait
self._ser = None
self._sim_tick = 0
def open(self):
if self.simulate:
return
# If the UART device is missing (serial console still enabled / UART not enabled),
# report a clear, actionable error.
try:
self._ser = serial.Serial(
port=self.port,
baudrate=self.baud,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=self.read_timeout,
)
except (FileNotFoundError, PermissionError, OSError) as e:
raise RuntimeError(
f"Failed to open UART {self.port}: {e}. "
f"Enable UART + disable the serial console via `sudo raspi-config` "
f"(Interface Options -> Serial Port), and verify the user's dialout group."
) from e
# Discard any stale/partial bytes buffered before we started reading.
try:
self._ser.reset_input_buffer()
except OSError:
pass
def close(self):
if self._ser is not None:
try:
self._ser.close()
finally:
self._ser = None
def measure(self):
"""Read one valid frame -> (pm1_0, pm2_5, pm10) ints. Raises on failure/timeout.
In simulation mode, returns dummy values in a plausible indoor range.
Syncs on the 0x42 0x4D header, reads the remaining 30 bytes, and validates via
parse_frame(). Bad/partial frames are discarded and retried until frame_wait elapses.
"""
if self.simulate:
self._sim_tick += 1
pm1_0 = 5 + (self._sim_tick % 6) # 5..10
pm2_5 = 10 + (self._sim_tick % 11) # 10..20
pm10 = 18 + (self._sim_tick % 13) # 18..30
return pm1_0, pm2_5, pm10
if self._ser is None:
self.open()
deadline = time.time() + self.frame_wait
last_err = None
while time.time() < deadline:
# 1) Sync on start char 1.
b = self._ser.read(1)
if not b:
last_err = "read timeout (no data on UART)"
continue
if b[0] != START1:
continue
# 2) Expect start char 2 immediately after.
b2 = self._ser.read(1)
if not b2 or b2[0] != START2:
continue
# 3) Read the remaining 30 bytes of the frame.
rest = self._ser.read(FRAME_LEN - 2)
if len(rest) != FRAME_LEN - 2:
last_err = "partial frame (short read)"
continue
frame = bytes([START1, START2]) + rest
# 4) Validate + decode (bad checksum -> keep trying within frame_wait).
try:
return parse_frame(frame)
except ValueError as e:
last_err = str(e)
continue
raise ValueError(f"no valid PMS7003 frame within {self.frame_wait}s "
f"(last: {last_err or 'unknown'})")
def __enter__(self):
self.open()
return self
def __exit__(self, *exc):
self.close()
return False

View file

@ -0,0 +1,247 @@
#!/usr/bin/env python3
# =============================================================================
# pms7003_sensor.py - PMS7003 particulate-matter monitoring main (Raspberry Pi 4 only)
#
# [Target hardware] Raspberry Pi 4 Model B (BCM2711, 40-pin GPIO header)
# - Plantower PMS7003 laser PM sensor connected to the primary UART.
# - PMS7003 TXD -> Pi RXD = GPIO15 (physical 10). VCC=5V (pin 2), GND (pin 6). 3.3V logic, 9600 8N1.
#
# [Design] PM is a NEW metric on the EXISTING metric pipeline; this module mirrors the SHT30
# module (sht30_sensor.py) exactly - measurement loop, startup->periodic, raw-body signing,
# POST + retry. There is no STM32 firmware for PMS (Pi-only).
# wire.py (build body -> sign) -> wire.pms_event_body / wire.sign_raw
# pms7003_driver.py (UART frame read / checksum / decode) -> PMS7003.measure()
#
# [Operation flow]
# 1. Open PMS7003 driver (fall back to simulation on failure/unavailability)
# 2. Measure every interval (REPORT_INTERVAL) -> POST to server
# 3. Only the "first success" measurement uses event_type="startup", afterwards "periodic"
# 4. Measurement failure (comm/checksum) does not crash; log, sleep briefly, then retry
# 5. Out of physical range -> metric_status="out_of_range" (server re-evaluates operational thresholds)
#
# [Auth] The server requires a raw-body HMAC signature (legacy body-sig disabled).
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=sent body bytes))
# -> Build the body directly and raw-POST it so the signed bytes match the sent bytes.
#
# [Config/secrets] config.py holds only non-secret defaults; the server URL/API_KEY/device
# identifiers are loaded from config_local.py (uncommitted). If missing, fail-closed and abort at startup.
# =============================================================================
import time
import math
import logging
import logging.handlers
import signal
import sys
import requests
from config import (
SENSOR_ID, SENSOR_NAME,
UART_PORT, UART_BAUD, UART_TIMEOUT, FRAME_WAIT_TIMEOUT, FORCE_SIMULATION,
API_URL, API_KEY,
DEVICE_ID, DEVICE_LOCATION,
REPORT_INTERVAL, MEASURE_RETRY_DELAY,
PM_VALUE_MIN, PM_VALUE_MAX,
APP_VERSION,
LOG_FILE, LOG_MAX_BYTES, LOG_BACKUP_COUNT,
HTTP_TIMEOUT, HTTP_RETRY_COUNT, HTTP_RETRY_DELAY,
)
import wire
from pms7003_driver import PMS7003, SERIAL_AVAILABLE
# -----------------------------------------------
# Logger setup
# -----------------------------------------------
def setup_logger():
logger = logging.getLogger("pms7003_sensor")
logger.setLevel(logging.DEBUG)
fmt = logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
try:
fh = logging.handlers.RotatingFileHandler(
LOG_FILE, maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUP_COUNT,
encoding="utf-8"
)
fh.setFormatter(fmt)
logger.addHandler(fh)
except PermissionError:
pass # No permission -> console only
ch = logging.StreamHandler(sys.stdout)
ch.setFormatter(fmt)
logger.addHandler(ch)
return logger
log = setup_logger()
# -----------------------------------------------
# Shutdown signal handling (systemd stop = SIGTERM clean shutdown)
# -----------------------------------------------
class StopFlag:
def __init__(self):
self.stop = False
def request(self, signum, frame):
self.stop = True
log.info(f"[SIGNAL] {signal.Signals(signum).name} received -> preparing to shut down")
stop_flag = StopFlag()
# -----------------------------------------------
# PM physical range classification (mirrors the SHT30 classify_metric_status)
# The server does not trust this value and re-evaluates with operational thresholds, so
# here we only check sensor physical plausibility (range / NaN / Inf).
# -----------------------------------------------
def classify_metric_status(pm1_0, pm2_5, pm10) -> str:
for v in (pm1_0, pm2_5, pm10):
if not math.isfinite(v):
return "out_of_range"
if v < PM_VALUE_MIN or v > PM_VALUE_MAX:
return "out_of_range"
return "normal"
# -----------------------------------------------
# Server reporting (raw-body + X-Signature HMAC)
# -----------------------------------------------
def post_reading(event_type: str, pm1_0: int, pm2_5: int, pm10: int, metric_status: str) -> bool:
"""POST the PMS7003 measurement to the server. Returns True on success (HTTP 200).
Build the body directly, sign those bytes, and send those exact bytes
(guarantees signed bytes == sent bytes -> matches the server's raw-body verification).
"""
timestamp = int(time.time())
body = wire.pms_event_body(
device_id=DEVICE_ID,
device_location=DEVICE_LOCATION,
sensor_id=SENSOR_ID,
sensor_name=SENSOR_NAME,
event_type=event_type,
timestamp=timestamp,
pm1_0=pm1_0,
pm2_5=pm2_5,
pm10=pm10,
metric_status=metric_status,
app_version=APP_VERSION,
)
body_bytes = body.encode("utf-8")
signature = wire.sign_raw(API_KEY, body)
headers = {
"Content-Type": "application/json",
"X-Signature": signature,
"X-Device-Id": DEVICE_ID,
"User-Agent": "PMS7003Sensor/2.0",
}
for attempt in range(1, HTTP_RETRY_COUNT + 1):
try:
# Send the raw body as-is via data=body_bytes (do not re-serialize with json=).
resp = requests.post(
API_URL, data=body_bytes, headers=headers, timeout=HTTP_TIMEOUT
)
if resp.status_code == 200:
log.info(f"[POST OK] {event_type} PM1.0={pm1_0} PM2.5={pm2_5} PM10={pm10} ug/m3 "
f"status={metric_status} -> {resp.text[:100]}")
return True
log.warning(f"[POST {resp.status_code}] attempt {attempt}/{HTTP_RETRY_COUNT}: "
f"{resp.text[:160]}")
except requests.exceptions.Timeout:
log.warning(f"[POST TIMEOUT] attempt {attempt}/{HTTP_RETRY_COUNT}")
except requests.exceptions.ConnectionError as e:
log.warning(f"[POST CONN ERR] attempt {attempt}/{HTTP_RETRY_COUNT}: {e}")
except requests.exceptions.RequestException as e:
log.warning(f"[POST ERR] attempt {attempt}/{HTTP_RETRY_COUNT}: {e}")
if attempt < HTTP_RETRY_COUNT and not stop_flag.stop:
time.sleep(HTTP_RETRY_DELAY)
log.error(f"[POST FAIL] all {HTTP_RETRY_COUNT} attempts failed (event={event_type})")
return False
# -----------------------------------------------
# Measurement loop (mirrors the SHT30 measure loop)
# -----------------------------------------------
def measure_loop(sensor: PMS7003):
# Only the first successful measurement is startup, afterwards periodic (server records startup once in leak_sensor_log)
first_success = True
log.info(f"[LOOP] measurement started (interval={REPORT_INTERVAL}s, port={UART_PORT}, baud={UART_BAUD})")
while not stop_flag.stop:
try:
pm1_0, pm2_5, pm10 = sensor.measure()
except (OSError, ValueError, RuntimeError) as e:
# Measurement failure (comm/checksum): no crash. Log, sleep briefly, then retry.
log.error(f"[MEASURE FAIL] {e} -> retry after {MEASURE_RETRY_DELAY}s")
_interruptible_sleep(MEASURE_RETRY_DELAY)
continue
metric_status = classify_metric_status(pm1_0, pm2_5, pm10)
event_type = "startup" if first_success else "periodic"
log.info(f"[MEASURE] {event_type}: PM1.0={pm1_0} PM2.5={pm2_5} PM10={pm10} ug/m3 status={metric_status}")
ok = post_reading(event_type, pm1_0, pm2_5, pm10, metric_status)
if ok and first_success:
# Switch startup->periodic only after the first successful report (first report is always startup)
first_success = False
# Wait for the normal report interval (wakes immediately on a shutdown signal)
_interruptible_sleep(REPORT_INTERVAL)
def _interruptible_sleep(seconds: float):
"""Sleep in 1-second chunks to react quickly to shutdown signals."""
end = time.time() + seconds
while not stop_flag.stop and time.time() < end:
time.sleep(min(1.0, end - time.time()))
# -----------------------------------------------
# Main
# -----------------------------------------------
def main():
signal.signal(signal.SIGTERM, stop_flag.request)
signal.signal(signal.SIGINT, stop_flag.request)
simulate = FORCE_SIMULATION or not SERIAL_AVAILABLE
log.info("=" * 60)
log.info(f"Particulate-matter (PMS7003) monitoring v2 (Pi 4) starting")
log.info(f"device={DEVICE_ID} | location={DEVICE_LOCATION} | sensor={SENSOR_NAME}(id={SENSOR_ID})")
log.info(f"API={API_URL}")
log.info(f"mode={'simulation' if simulate else f'real UART (port={UART_PORT}, baud={UART_BAUD})'}")
if simulate and not FORCE_SIMULATION:
log.warning("pyserial not installed / UART unavailable -> simulation mode. For real readings, run install.sh then restart.")
log.info("=" * 60)
sensor = PMS7003(port=UART_PORT, baud=UART_BAUD, simulate=simulate,
read_timeout=UART_TIMEOUT, frame_wait=FRAME_WAIT_TIMEOUT)
try:
sensor.open()
except RuntimeError as e:
log.error(f"[INIT] {e}")
sys.exit(1)
try:
measure_loop(sensor)
except Exception as e: # noqa: BLE001 - top-level safety net (log then exit)
log.exception(f"[MAIN] exception occurred: {e}")
raise
finally:
sensor.close()
log.info("[MAIN] shutdown complete.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,8 @@
# Pi 4 particulate-matter (PMS7003) module Python dependencies (exact version pins for air-gapped networks)
# Offline install: pre-download into wheelhouse/ with vendor.sh, then
# pip install --no-index --find-links=wheelhouse -r requirements.txt
#
# pyserial is pure Python (arch-independent py3-none-any wheel), so even if fetched on an x86 build
# machine it runs as-is on Pi 4 (aarch64). requests is version-pinned as well.
requests==2.32.3
pyserial==3.5

View file

@ -0,0 +1,66 @@
# =============================================================================
# /etc/systemd/system/pms7003-sensor.service
# Service that auto-runs the particulate-matter (PMS7003) monitor on Raspberry Pi 4 boot
#
# Installation:
# sudo cp systemd/pms7003-sensor.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable pms7003-sensor
# sudo systemctl start pms7003-sensor
# sudo systemctl status pms7003-sensor
#
# * Adjust the WorkingDirectory/ExecStart paths to match the actual deployment path.
# The example below assumes deployment under /home/pi/raspberry_pi4_pms7003.
# =============================================================================
[Unit]
Description=PMS7003 Particulate-Matter Sensor Monitor v2 (Pi 4)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
Group=pi
# dialout group required to access /dev/serial0 (the primary UART)
SupplementaryGroups=dialout
WorkingDirectory=/home/pi/raspberry_pi4_pms7003
ExecStart=/home/pi/raspberry_pi4_pms7003/venv/bin/python pms7003_sensor.py
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=pms7003-sensor
# Environment variables
Environment=PYTHONUNBUFFERED=1
# Process-private /tmp isolation
PrivateTmp=true
# -- Sandboxing (hardening) -- blocks root escalation and filesystem tampering if the daemon is compromised --------
# User=pi is a NOPASSWD sudo account, so NoNewPrivileges blocks the setuid(sudo) escalation path.
NoNewPrivileges=true
# Root filesystem read-only + only /var/log writable (including log rotation). /tmp is PrivateTmp.
ProtectSystem=strict
ReadWritePaths=/var/log
# /home is read-only (reads own code/config_local.py only; blocks writes and access to other accounts' homes).
ProtectHome=read-only
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictRealtime=true
# HTTPS(POST) + local sockets (DNS/journal) only. UART is unaffected since /dev/serial0 is a char device (not a socket).
# AF_NETLINK required -- glibc getaddrinfo/NSS uses netlink to enumerate local addresses
# (omitting it makes DNS resolution intermittently fail -> server POST impossible). AF_INET/6=HTTPS, AF_UNIX=DNS/journal.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
# * Do not use PrivateDevices -- it hides /dev/serial0 and makes the sensor inaccessible.
# Restart cap (stop after 5 failures within 10 minutes)
StartLimitBurst=5
StartLimitInterval=600s
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Server connection test — run on the Pi 4 PMS7003 (미세먼지) module.
Builds the body with wire.py, attaches a raw-body HMAC (X-Signature), and
actually sends one signed POST. Secrets (API_URL/API_KEY) are loaded from config_local.py.
The combined-node setup.sh gates hardening on this script's exit code (0 = signed 200).
"""
import sys
import time
import subprocess
import requests
signed_ok = False # whether the signed POST returned 200 — setup.sh gates hardening on this exit code
from config import (
API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION,
SENSOR_ID, SENSOR_NAME, APP_VERSION,
)
import wire
print("=" * 50)
print(" Server connection test (Pi 4 PMS7003 / 미세먼지 module)")
print("=" * 50)
print(f" API_URL = {API_URL}")
# ── Step 1: check endpoint reachability with curl (no signature → 403 auth failure is expected) ──
print("\n[1/2] Checking sensor_data.php reachability with curl (no signature → 403 auth failure is expected)...")
try:
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-A", "PMS7003Sensor/2.0", "-X", "POST", API_URL,
"-H", "Content-Type: application/json", "-d", "{}"],
capture_output=True, text=True, timeout=10
)
code = result.stdout.strip()
print(f" HTTP status code: {code}")
if code in ("400", "403"):
print(" → Endpoint reachable OK (rejected without signature/fields — expected)")
elif code == "200":
print(" → 200? Server accepted without a signature — check server configuration")
elif code == "404":
print(" → 404: check the API_URL path")
else:
print(f" → Response: {code} (check hosting/firewall/.htaccess)")
except Exception as e:
print(f" → curl failed: {e}")
# ── Step 2: real measurement POST signed with wire.py (startup, dummy PM values) ──
print("\n[2/2] Signed measurement POST (startup, dummy PM1.0=8 PM2.5=12 PM10=18)...")
try:
ts = int(time.time())
body = wire.pms_event_body(
DEVICE_ID, DEVICE_LOCATION, SENSOR_ID, SENSOR_NAME,
"startup", ts, 8, 12, 18, "normal", APP_VERSION,
)
sig = wire.sign_raw(API_KEY, body)
print(f" Body: {body}")
print(f" X-Signature: {sig[:16]}...")
resp = requests.post(
API_URL, data=body.encode("utf-8"),
headers={
"Content-Type": "application/json",
"X-Signature": sig,
"X-Device-Id": DEVICE_ID,
"User-Agent": "PMS7003Sensor/2.0",
},
timeout=10,
)
print(f" HTTP status code: {resp.status_code}")
print(f" Response body: {resp.text[:200]}")
if resp.status_code == 200:
signed_ok = True
print(f" → Success! Signature verified, measurement stored (check dashboard sensor_id={SENSOR_ID})")
elif resp.status_code == 403:
print(" → 403 auth failure: API_KEY differs from the server's config.local.php, or signature mismatch")
elif resp.status_code == 400:
print(" → 400: check timestamp freshness (±600s) — verify Pi clock (NTP) sync")
else:
print(f" → Status code: {resp.status_code}")
except ImportError:
print(" → requests not installed. Run install.sh or sudo apt install python3-requests")
except Exception as e:
print(f" → Failed: {e}")
print("\n" + "=" * 50)
print(" Test complete" + ("" if signed_ok else " — signed POST failed (see log above)"))
print("=" * 50)
sys.exit(0 if signed_ok else 1)

View file

@ -0,0 +1,152 @@
#!/usr/bin/env python3
# =============================================================================
# tests/test_pms_wire_and_frame.py - pure unit tests for the PMS7003 wire format + frame parser
#
# These tests must pass WITHOUT any serial hardware and WITHOUT config_local.py
# (they import only wire.py and pms7003_driver.py, whose pyserial import is guarded).
#
# Coverage:
# (a) wire.pms_event_body() produces the exact fixed-order contract byte string
# (b) wire.sign_raw() matches a known HMAC-SHA256 digest
# (c) pms7003_driver.parse_frame() decodes a hand-built valid 32-byte frame and
# rejects a corrupted (bad-checksum) frame
#
# Runs standalone (python tests/test_pms_wire_and_frame.py) or under pytest.
# =============================================================================
import os
import sys
# Make the module importable when run from anywhere (tests/ is a subdirectory).
_MODULE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _MODULE_DIR not in sys.path:
sys.path.insert(0, _MODULE_DIR)
import wire
from pms7003_driver import parse_frame, START1, START2, BODY_LEN_FIELD, FRAME_LEN
# ── Fixtures / helpers ───────────────────────────────────────────────────────
# Fixed known inputs (shared with the "known HMAC" expectation below).
_KNOWN_ARGS = ("dev", "loc", 3, "s3", "periodic", 1700000000, 8, 12, 18, "normal", "vX")
# The exact contract byte string these inputs must serialize to.
_EXPECTED_BODY = (
'{"device_id":"dev","device_location":"loc","sensor_id":3,'
'"sensor_name":"s3","event_type":"periodic","timestamp":1700000000,'
'"metric_type":"pms7003","pm1_0":8,"pm2_5":12,"pm10":18,'
'"metric_status":"normal","app_version":"vX"}'
)
# HMAC-SHA256(key="test", msg=_EXPECTED_BODY) — precomputed known-answer.
_KNOWN_KEY = "test"
_KNOWN_SIG = "96f3e8cdfcc6e48545806a9562554b26f18313ffa247a5b1e449c164256526dd"
def build_frame(words, checksum=None):
"""Build a 32-byte PMS7003 frame from 13 data words.
If checksum is None, compute the correct checksum (sum of bytes 0..29);
pass an explicit value to force a bad checksum.
"""
assert len(words) == 13
frame = bytearray([START1, START2, (BODY_LEN_FIELD >> 8) & 0xFF, BODY_LEN_FIELD & 0xFF])
for w in words:
frame += bytes([(w >> 8) & 0xFF, w & 0xFF])
chk = (sum(frame[0:30]) & 0xFFFF) if checksum is None else (checksum & 0xFFFF)
frame += bytes([(chk >> 8) & 0xFF, chk & 0xFF])
return bytes(frame)
# ── (a) body byte-string is exactly the contract string ──────────────────────
def test_pms_event_body_exact_string():
body = wire.pms_event_body(*_KNOWN_ARGS)
assert body == _EXPECTED_BODY, f"body mismatch:\n got={body!r}\n want={_EXPECTED_BODY!r}"
# Bare integers (no quotes, no decimals) for pm values.
assert '"pm1_0":8,' in body
assert '"pm2_5":12,' in body
assert '"pm10":18,' in body
assert '"metric_type":"pms7003"' in body
# The signed/sent bytes are the UTF-8 encoding of this exact string.
assert body.encode("utf-8") == _EXPECTED_BODY.encode("utf-8")
# ── (b) sign_raw matches a known HMAC ────────────────────────────────────────
def test_sign_raw_known_hmac():
body = wire.pms_event_body(*_KNOWN_ARGS)
sig = wire.sign_raw(_KNOWN_KEY, body)
assert sig == _KNOWN_SIG, f"signature mismatch:\n got={sig}\n want={_KNOWN_SIG}"
assert len(sig) == 64 and all(c in "0123456789abcdef" for c in sig)
# ── (c) frame parser: valid frame decodes; bad checksum rejected ─────────────
def test_parse_frame_valid():
# data[3..5] = atmospheric PM1.0/PM2.5/PM10 = 8/12/18 (the reported words).
words = [6, 10, 15, 8, 12, 18, 500, 140, 30, 5, 1, 0, 0]
frame = build_frame(words)
assert len(frame) == FRAME_LEN
assert parse_frame(frame) == (8, 12, 18)
def test_parse_frame_bad_checksum_rejected():
words = [6, 10, 15, 8, 12, 18, 500, 140, 30, 5, 1, 0, 0]
good = build_frame(words)
# Corrupt a data byte without fixing the checksum → must raise ValueError.
bad = bytearray(good)
bad[10] ^= 0xFF
try:
parse_frame(bytes(bad))
except ValueError:
pass
else:
raise AssertionError("parse_frame accepted a corrupted (bad-checksum) frame")
def test_parse_frame_bad_header_rejected():
words = [6, 10, 15, 8, 12, 18, 500, 140, 30, 5, 1, 0, 0]
good = bytearray(build_frame(words))
good[0] = 0x00 # break the 0x42 start byte
try:
parse_frame(bytes(good))
except ValueError:
pass
else:
raise AssertionError("parse_frame accepted a frame with a bad header")
def test_parse_frame_wrong_length_rejected():
try:
parse_frame(b"\x42\x4d\x00\x1c") # far too short
except ValueError:
pass
else:
raise AssertionError("parse_frame accepted a wrong-length frame")
# ── Standalone runner (no pytest required) ───────────────────────────────────
def _run():
tests = [
test_pms_event_body_exact_string,
test_sign_raw_known_hmac,
test_parse_frame_valid,
test_parse_frame_bad_checksum_rejected,
test_parse_frame_bad_header_rejected,
test_parse_frame_wrong_length_rejected,
]
failed = 0
for t in tests:
try:
t()
print(f" [OK ] {t.__name__}")
except AssertionError as e:
failed += 1
print(f" [FAIL] {t.__name__}: {e}")
print("=" * 50)
print(f" Result: {'ALL PASSED' if failed == 0 else str(failed) + ' FAILED'} ({len(tests)} tests)")
return failed
if __name__ == "__main__":
print("=" * 50)
print(" PMS7003 wire/frame self-check")
print("=" * 50)
sys.exit(1 if _run() else 0)

View file

@ -0,0 +1,75 @@
#!/usr/bin/env bash
# =============================================================================
# vendor.sh - Pre-download dependencies for air-gapped install (PMS7003 particulate-matter module)
# Run this on a build/staging machine that has internet access.
# Outputs: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) -> transfer to the target Pi 4.
#
# requests has few arch dependencies, and pyserial is pure Python (py3-none-any), so it works
# on the Pi 4 no matter which build machine downloads it. Pin wheels to the target
# (Bookworm 64-bit, aarch64, Python 3.11). The .deb files are safest downloaded on the target OS/arch.
# Env vars: TARGET_PLATFORM (default manylinux2014_aarch64), TARGET_PYVER (default 311)
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")"
TARGET_PLATFORM="${TARGET_PLATFORM:-manylinux2014_aarch64}"
TARGET_PYVER="${TARGET_PYVER:-311}"
TARGET_ABI="${TARGET_ABI:-cp${TARGET_PYVER}}"
echo "[vendor] (1/2) Python wheel -> wheelhouse/ (platform=${TARGET_PLATFORM}, py=${TARGET_PYVER})"
mkdir -p wheelhouse
# requests and other arch-dependent wheels: pin the explicit target platform.
python3 -m pip download -r requirements.txt -d wheelhouse \
--only-binary=:all: \
--platform "${TARGET_PLATFORM}" \
--python-version "${TARGET_PYVER}" \
--implementation cp \
--abi "${TARGET_ABI}" \
|| echo "[WARN] Target-platform pin failed for some wheels - pure-Python (any) fallback is downloaded below."
# pyserial (py3-none-any) and other pure-Python wheels plus sdist fallback.
python3 -m pip download -r requirements.txt -d wheelhouse \
|| echo "[WARN] Some sdist/any fallbacks failed (safe to ignore if all binary wheels are present)"
WHEEL_COUNT="$(ls -A wheelhouse 2>/dev/null | wc -l)"
echo " wheelhouse file count: ${WHEEL_COUNT}"
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse is empty"; exit 1; }
echo "[vendor] (2/2) apt .deb -> debs/"
mkdir -p debs
# nftables and unattended-upgrades are included too -> harden_pi.sh can run offline in the air-gapped env.
BASE_PKGS="python3-requests python3-venv nftables unattended-upgrades"
DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
| awk '/^\w/{print $1}' | sort -u)"
( cd debs && apt-get download ${BASE_PKGS} ${DEPS} ) 2>/dev/null \
|| echo "[WARN] Some .deb downloads failed (may be virtual/transitive deps - required packages are verified below)"
MISS=0
for p in ${BASE_PKGS}; do
if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then
echo "[ERROR] Required package .deb missing: ${p}"
MISS=1
fi
done
[ "${MISS}" = 0 ] || { echo "[ERROR] Required .deb missing - you must run vendor.sh on the target arch (aarch64 Bookworm)."; exit 1; }
echo " debs file count: $(ls debs/*.deb 2>/dev/null | wc -l) (all required packages verified)"
# Real architecture check - verify the target (arm64/all) via the .deb's internal Architecture field, not the filename.
# (If the host is amd64, apt-get download fetches amd64 .deb files, and dpkg on the target Pi
# breaks with "wrong architecture". A filename-only check can't catch this.)
BAD_ARCH=0
for deb in debs/*.deb; do
[ -e "$deb" ] || continue
arch="$(dpkg-deb --field "$deb" Architecture 2>/dev/null || echo '?')"
case "$arch" in
arm64|all) : ;;
*) echo "[ERROR] arch mismatch: $(basename "$deb") = ${arch} (not arm64/all)"; BAD_ARCH=1 ;;
esac
done
if [ "${BAD_ARCH}" != 0 ]; then
echo "[ERROR] There are .deb files that are not the target (aarch64/arm64). host arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2
echo " You must run vendor.sh on an arm64 Pi (or an arm64 chroot/qemu)." >&2
exit 1
fi
echo " arch check passed (all .deb = arm64/all)"
echo "[vendor] Done. Transfer wheelhouse/ and debs/ to the target Pi 4, then run install.sh."

View file

@ -0,0 +1,102 @@
# =============================================================================
# wire.py - wire format for server transmission (body JSON + raw-body HMAC signature)
#
# [Why a separate module]
# The server (php/api/sensor_data.php) verifies, against the received "body bytes as-is",
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)).
# Therefore the "signed bytes" and the "actually transmitted bytes" must be 100% identical.
# We build the body directly as a string, sign those bytes, then POST those same bytes as-is.
# (Leaving serialization to requests' json= can change float/whitespace/order and break the signature.)
#
# [Byte-level compatibility targets]
# PMS7003 is a Pi-only metric (no STM32 firmware), so pms_event_body() has no firmware
# counterpart. But the three helpers below are copied VERBATIM from the SHT30 module's
# wire.py so raw-body signing stays byte-identical to the shared reference:
# - firmware/common/sig.c::sig_raw_body (STM32 HMAC signature)
# - firmware/test/host/reference.py (verified Python reference)
# - php/config.php::verify_signature_raw (server verification part)
#
# fixed2() <-> jb_fixed2() / reference.fixed2() (kept for parity; PM body is integer-only)
# sign_raw() <-> sig_raw_body() / reference.sign_raw()
# pms_event_body() <- Pi-only PMS7003 body (mirrors sht30_event_body field order/escaping)
# =============================================================================
from __future__ import annotations
import hashlib
import hmac
def fixed2(v: float) -> str:
"""Fixed 2-decimal format (round-half-up, locale-independent).
Byte-identical to jb_fixed2() / reference.fixed2().
24.0 -> "24.00"
48.5 -> "48.50"
-3.245 -> "-3.25"
"""
neg = v < 0
if neg:
v = -v
scaled = int(v * 100.0 + 0.5) # round half up (C: (long long)(v*100+0.5))
ip = scaled // 100
fp = scaled % 100
sign = "-" if (neg and scaled != 0) else ""
return f"{sign}{ip}.{fp:02d}"
def _esc(s: str) -> str:
r"""Escape JSON string value (identical to jb_estr / reference._esc).
Escapes only '"', '\\', and control characters (<0x20). '/' is left as-is.
Multibyte UTF-8 (e.g. Korean) is left as-is (server verifies raw bytes).
"""
out = []
for ch in s:
o = ord(ch)
if ch == '"':
out.append('\\"')
elif ch == '\\':
out.append('\\\\')
elif o < 0x20:
out.append('\\u%04x' % o)
else:
out.append(ch)
return "".join(out)
def pms_event_body(device_id: str, device_location: str, sensor_id: int,
sensor_name: str, event_type: str, timestamp: int,
pm1_0: int, pm2_5: int, pm10: int,
metric_status: str, app_version: str) -> str:
"""Generate PMS7003 event body JSON string.
Field order is fixed (shared wire contract) and mirrors sht30_event_body() so the
server can verify the raw body generically. metric_type is always "pms7003".
PM values are serialized as BARE INTEGERS (µg/m³, atmospheric concentration).
"""
return (
'{'
f'"device_id":"{_esc(device_id)}",'
f'"device_location":"{_esc(device_location)}",'
f'"sensor_id":{int(sensor_id)},'
f'"sensor_name":"{_esc(sensor_name)}",'
f'"event_type":"{_esc(event_type)}",'
f'"timestamp":{int(timestamp)},'
f'"metric_type":"pms7003",'
f'"pm1_0":{int(pm1_0)},'
f'"pm2_5":{int(pm2_5)},'
f'"pm10":{int(pm10)},'
f'"metric_status":"{_esc(metric_status)}",'
f'"app_version":"{_esc(app_version)}"'
'}'
)
def sign_raw(api_key: str, body: str) -> str:
"""raw-body signature: lowercase_hex(HMAC-SHA256(key=api_key, msg=body)).
Identical to sig_raw_body() / reference.sign_raw() / php verify_signature_raw.
The body is encoded as UTF-8 then signed (same as transmitted bytes).
"""
return hmac.new(api_key.encode("utf-8"), body.encode("utf-8"),
hashlib.sha256).hexdigest()

View file

@ -1,14 +1,14 @@
# 운영·비밀값 (절대 커밋 금지)
# Operational secrets (never commit)
config_local.py
# Python venv / 캐시
# Python venv / cache
venv/
__pycache__/
*.pyc
# 오프라인 설치 번들 (vendor.sh 산출물 — 용량이 큼, 전송은 별도)
# Offline install bundle (vendor.sh output - large; transferred separately)
wheelhouse/
debs/
# 런타임 로그
# Runtime logs
*.log

View file

@ -1,137 +1,137 @@
# 온습도(SHT30) 센서 모듈 — Raspberry Pi 4 Model B 전용 (2번 모듈)
# Temperature/Humidity (SHT30) Sensor Module — Raspberry Pi 4 Model B only (Module 2)
**Sensirion SHT30**(한진데이터 P4422-3, I2C) 온습도 센서를 Raspberry Pi 4 Model B 의
I2C 버스로 읽어, 주기적으로 cafe24 서버(`php/api/sensor_data.php`)에 보고하는 모듈입니다.
서버가 측정값을 저장하고, **고온/저온/고습/저습 임계 판정과 SMS, 대시보드 표시**를 모두 수행합니다.
A module that reads a **Sensirion SHT30** (Hanjin Data P4422-3, I2C) temperature/humidity sensor over the
Raspberry Pi 4 Model B I2C bus and periodically reports to the cafe24 server (`php/api/sensor_data.php`).
The server stores the measurements and performs **high/low temperature and high/low humidity threshold evaluation, SMS, and dashboard display**.
> 이 폴더는 STM32F407 펌웨어(`sht30_fw`)의 동작을 **Raspberry Pi 4 + Python 으로 이식**한
> "2번 모듈"입니다. wire 포맷(본문/서명)·CRC·변환식은 펌웨어와 **바이트 단위로 동일**합니다.
> "1번 모듈"(누수+카메라)은 `../raspberry_pi4/` 폴더입니다. 전체 구성은 루트의
> [`SYSTEM_OVERVIEW.md`](../SYSTEM_OVERVIEW.md)를 보세요.
> This folder is a **port of the STM32F407 firmware (`sht30_fw`) behavior to Raspberry Pi 4 + Python**,
> the "Module 2". The wire format (body/signature), CRC, and conversion formulas are **byte-for-byte identical** to the firmware.
> "Module 1" (leak + camera) is in the `../raspberry_pi4/` folder. For the full setup, see
> [`SYSTEM_OVERVIEW.md`](../SYSTEM_OVERVIEW.md) in the root.
---
## 1. 구성 파일
## 1. Files
| 파일 | 역할 |
| File | Role |
|---|---|
| `sht30_sensor.py` | 메인 — 주기 측정 → 본문 생성/서명 → POST(재시도), startup→periodic |
| `sht30_driver.py` | SHT30 I2C 측정 + CRC-8 검증 + 물리값 변환 (smbus2, 시뮬레이션 폴백) |
| `wire.py` | 본문 JSON + raw-body HMAC 서명 (펌웨어 `jsonbody.c`/`sig.c`와 바이트 동일) |
| `config.py` | 비밀 아닌 기본 설정(센서 id/주기/범위/ I2C). `config_local` 에서 비밀값 로드 |
| `config_local.example.py` | 운영·비밀값 템플릿 → `config_local.py` 로 복사해 사용(미커밋) |
| `requirements.txt` | Python 의존성(`requests`, `smbus2`, 핀 고정) |
| `install.sh` | Pi 4 설치(폐쇄망 오프라인 + 온라인 폴백, I2C 활성화 포함) |
| `vendor.sh` | 폐쇄망용 의존성 사전 다운로드(빌드 머신에서 실행) |
| `test_connection.py` | 서버 연결/서명 POST 점검 |
| `test_sensor.py` | CRC/포맷 자가점검 + 측정 1회(하드웨어 없이도 포맷 점검 통과) |
| `systemd/sht30-sensor.service` | 부팅 자동 실행 서비스 유닛 |
| `sht30_sensor.py` | Main — periodic measurement → body build/sign → POST (with retry), startup→periodic |
| `sht30_driver.py` | SHT30 I2C measurement + CRC-8 verification + physical-value conversion (smbus2, simulation fallback) |
| `wire.py` | Body JSON + raw-body HMAC signature (byte-identical to firmware `jsonbody.c`/`sig.c`) |
| `config.py` | Non-secret default settings (sensor id/interval/range/I2C). Loads secrets from `config_local` |
| `config_local.example.py` | Operational/secret-value template → copy to `config_local.py` for use (not committed) |
| `requirements.txt` | Python dependencies (`requests`, `smbus2`, pinned) |
| `install.sh` | Pi 4 install (air-gapped offline + online fallback, includes enabling I2C) |
| `vendor.sh` | Pre-download dependencies for air-gapped use (run on the build machine) |
| `test_connection.py` | Server connectivity/signed-POST check |
| `test_sensor.py` | CRC/format self-check + one measurement (format check passes even without hardware) |
| `systemd/sht30-sensor.service` | Service unit for auto-start on boot |
---
## 2. 하드웨어 (Pi 4 Model B 기준)
## 2. Hardware (Pi 4 Model B baseline)
- **Raspberry Pi 4 Model B** (BCM2711) + Raspberry Pi OS **Bookworm 64-bit**
- **SHT30** 온습도 센서 모듈 (I2C, 한진데이터 P4422-3)
- I2C 풀업 저항 **4.7kΩ × 2** (모듈에 내장된 경우 생략)
- 전원: **USB-C 5V/3A**, 네트워크: Pi 4 **Gigabit Ethernet**(유선) 또는 WiFi
- **SHT30** temperature/humidity sensor module (I2C, Hanjin Data P4422-3)
- I2C pull-up resistors **4.7kΩ × 2** (omit if built into the module)
- Power: **USB-C 5V/3A**, network: Pi 4 **Gigabit Ethernet** (wired) or WiFi
### 2.1 핀 배치 (I2C 버스 1)
### 2.1 Pinout (I2C bus 1)
| 신호 | Pi 4 핀 | 비고 |
| Signal | Pi 4 pin | Note |
|---|---|---|
| SHT30 VCC | 3.3V (물리 1) | **5V 금지** |
| SHT30 GND | GND (물리 6/9/14/…) | |
| SHT30 SDA | GPIO2 / SDA1 (물리 3) | + 4.7kΩ 풀업 → 3.3V |
| SHT30 SCL | GPIO3 / SCL1 (물리 5) | + 4.7kΩ 풀업 → 3.3V |
| SHT30 ADDR | GND → 주소 **0x44** | VDD 면 0x45 (`config.py` `I2C_ADDR`) |
| SHT30 VCC | 3.3V (physical 1) | **No 5V** |
| SHT30 GND | GND (physical 6/9/14/…) | |
| SHT30 SDA | GPIO2 / SDA1 (physical 3) | + 4.7kΩ pull-up → 3.3V |
| SHT30 SCL | GPIO3 / SCL1 (physical 5) | + 4.7kΩ pull-up → 3.3V |
| SHT30 ADDR | GND → address **0x44** | 0x45 if tied to VDD (`config.py` `I2C_ADDR`) |
---
## 3. 서버 호환성 (반드시 확인)
## 3. Server compatibility (must verify)
이 모듈은 **현재 repo 의 `php/` 서버(=cafe24 SHT30 대시보드 서버)**로 보고합니다.
서버는 **raw-body HMAC 서명**을 요구합니다.
This module reports to **the current repo's `php/` server (= the cafe24 SHT30 dashboard server)**.
The server requires a **raw-body HMAC signature**.
```
X-Signature = lowercase_hex( HMAC-SHA256( key=API_KEY, msg=요청본문 바이트 ) )
X-Signature = lowercase_hex( HMAC-SHA256( key=API_KEY, msg=request body bytes ) )
```
- `config_local.py` 의 `API_KEY` 는 서버 `config.local.php` 의 `API_KEY` 와 **정확히 동일**해야 합니다.
- 서버는 `timestamp` 신선도(±600초)로 재전송을 막으므로 Pi 시각이 **NTP 로 동기**되어 있어야 합니다(Raspberry Pi OS 기본 활성).
- `sensor_id=2` 로 보고하며, 서버 대시보드(`dashboard.php`)는 기본 차트로 sensor 2 를 표시합니다.
- 임계 경보(고온/저온/고습/저습)·SMS·복구 판정은 **모두 서버**가 수행합니다. 이 모듈은 측정값만 보고합니다.
- `API_KEY` in `config_local.py` must be **exactly identical** to `API_KEY` in the server's `config.local.php`.
- The server blocks replays via `timestamp` freshness (±600 seconds), so the Pi clock must be **NTP-synchronized** (enabled by default on Raspberry Pi OS).
- It reports with `sensor_id=2`, and the server dashboard (`dashboard.php`) shows sensor 2 as the default chart.
- Threshold alerts (high/low temperature, high/low humidity), SMS, and recovery evaluation are **all performed by the server**. This module only reports measurements.
> 서버 측 스키마/배포는 [`../sql/schema_sht30.sql`](../sql/schema_sht30.sql),
> For the server-side schema/deployment, see [`../sql/schema_sht30.sql`](../sql/schema_sht30.sql),
> [`../docs/INSTALL_PI_SERVER.md`](../docs/INSTALL_PI_SERVER.md),
> [`../docs/SHT30_SENSOR2_SETUP.md`](../docs/SHT30_SENSOR2_SETUP.md) 참고.
> [`../docs/SHT30_SENSOR2_SETUP.md`](../docs/SHT30_SENSOR2_SETUP.md).
---
## 4. 설치
## 4. Installation
### 4.1 설정값 준비
### 4.1 Prepare configuration values
```bash
cp config_local.example.py config_local.py
# config_local.py 를 열어 API_URL / API_KEY / DEVICE_ID / DEVICE_LOCATION 채움
# Open config_local.py and fill in API_URL / API_KEY / DEVICE_ID / DEVICE_LOCATION
```
`config_local.py` 가 없으면 실행 시 **명확한 오류로 즉시 중단**됩니다(fail closed).
`config_local.py` 와 `API_KEY` 는 **절대 커밋하지 마세요**(`.gitignore` 처리됨).
If `config_local.py` is missing, execution **stops immediately with a clear error** (fail closed).
**Never commit** `config_local.py` or `API_KEY` (handled by `.gitignore`).
### 4.2 온라인 설치 (인터넷 가능 환경)
### 4.2 Online install (internet-capable environment)
```bash
chmod +x install.sh
./install.sh
sudo reboot # I2C 를 새로 켠 경우 1회 재부팅
sudo reboot # reboot once if I2C was newly enabled
```
### 4.3 폐쇄망(air-gapped) 오프라인 설치
### 4.3 Air-gapped offline install
인터넷이 되는 **동일 아키텍처(aarch64, 가급적 동일 Pi 4 + Bookworm 64-bit)** 머신에서
의존성을 먼저 받아 번들을 만든 뒤, 타깃 Pi 4 로 옮겨 설치합니다.
On an internet-connected machine of the **same architecture (aarch64, ideally the same Pi 4 + Bookworm 64-bit)**,
download the dependencies first to build a bundle, then move it to the target Pi 4 and install.
```bash
# (빌드 머신, 인터넷 O) wheelhouse/ + debs/ 생성
# (build machine, internet YES) generate wheelhouse/ + debs/
./vendor.sh
# wheelhouse/ 와 debs/ 를 타깃 Pi 4 의 이 폴더로 복사한 뒤:
# (타깃 Pi 4, 인터넷 X)
./install.sh # debs/ · wheelhouse/ 가 있으면 자동으로 오프라인 설치
# after copying wheelhouse/ and debs/ into this folder on the target Pi 4:
# (target Pi 4, internet NO)
./install.sh # if debs/ · wheelhouse/ exist, it installs offline automatically
```
> `smbus2` 는 순수 파이썬(arch 무관)이라 x86 빌드 머신에서 받아도 됩니다.
> `requests` 등 다른 wheel/deb 는 **aarch64** 용을 받아야 합니다(타깃 arch 에서 `vendor.sh` 권장).
> `smbus2` is pure Python (arch-independent), so it can be downloaded on an x86 build machine.
> Other wheels/debs such as `requests` must be the **aarch64** builds (running `vendor.sh` on the target arch is recommended).
---
## 5. 실행 / 점검
## 5. Run / check
```bash
# 센서가 보이는지 (0x44)
# is the sensor visible (0x44)
i2cdetect -y 1
# 포맷/CRC 자가점검 + 측정 1회 (하드웨어 없으면 시뮬레이션)
# format/CRC self-check + one measurement (simulation if no hardware)
source venv/bin/activate
python3 test_sensor.py
# 서버 연결/서명 점검
# server connectivity/signature check
python3 test_connection.py
# 실제 실행
# actual run
python3 sht30_sensor.py
```
`smbus2` 미설치 또는 I2C 미활성 환경(개발 PC 등)에서는 자동으로 **시뮬레이션 모드**로 동작합니다.
In environments without `smbus2` installed or with I2C disabled (dev PC, etc.), it automatically runs in **simulation mode**.
---
## 6. 부팅 자동 실행 (systemd)
## 6. Auto-start on boot (systemd)
```bash
# 폴더를 /home/pi/raspberry_pi4_sht30 에 배치했다고 가정 (다르면 .service 의 경로 수정)
# assumes the folder is placed at /home/pi/raspberry_pi4_sht30 (otherwise edit the path in the .service)
sudo cp systemd/sht30-sensor.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable sht30-sensor
@ -140,17 +140,17 @@ sudo systemctl status sht30-sensor
journalctl -u sht30-sensor -f
```
`SupplementaryGroups=i2c` 가 `/dev/i2c-1` 접근에 필요합니다.
`SupplementaryGroups=i2c` is required for `/dev/i2c-1` access.
---
## 7. 보안 메모
## 7. Security notes
- 비밀값(`API_KEY`)·서버 URL·기기 정보는 `config_local.py`(미커밋)에만 둡니다.
커밋되는 파일에는 placeholder 만 존재합니다.
- 보고 본문은 `HMAC-SHA256(API_KEY, raw_body)` 서명(`X-Signature` 헤더)으로 위변조를 방지합니다
(길이확장 공격에 면역). 레거시 body-field sha256 폴백은 서버에서 기본 비활성입니다.
- 서명하는 바이트와 전송하는 바이트는 **항상 동일**합니다(`wire.py` 가 본문을 직접 만들어
그 바이트에 서명 후 raw POST). 펌웨어 `sig.c` / `reference.py` / 서버 `verify_signature_raw`
와 바이트 단위로 일치합니다.
- `config_local.py` 노출 시 즉시 `API_KEY` 를 교체(서버 동시 변경)하세요.
- Secrets (`API_KEY`), server URL, and device info live only in `config_local.py` (not committed).
Committed files contain only placeholders.
- The report body is protected against tampering by an `HMAC-SHA256(API_KEY, raw_body)` signature (`X-Signature` header)
(immune to length-extension attacks). The legacy body-field sha256 fallback is disabled by default on the server.
- The signed bytes and the transmitted bytes are **always identical** (`wire.py` builds the body directly,
signs those bytes, then raw POSTs). This matches firmware `sig.c` / `reference.py` / server `verify_signature_raw`
byte-for-byte.
- If `config_local.py` is exposed, immediately rotate `API_KEY` (change it on the server at the same time).

View file

@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="ko">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>온습도 노드 배선·핀맵 — Pi-B / SHT30 (sensor_id=2)</title>
<title>Temp/Humidity Node Wiring & Pinmap — Pi-B / SHT30 (sensor_id=2)</title>
<style>
:root{
--ink:#1a1d21; --muted:#5c6570; --line:#dfe3e8; --bg:#eef1f4; --card:#ffffff;
@ -182,31 +182,31 @@
<!-- ============ 1. HEADER ============ -->
<header class="hero">
<p class="eyebrow">현장 설치 문서 · 배선 &amp; 핀맵</p>
<h1>온습도 노드 — 1번 지점 / 서버실</h1>
<p class="eyebrow">Field Installation Doc · Wiring &amp; Pinmap</p>
<h1>Temp/Humidity Node — Site 1 / Server Room</h1>
<p class="sum">
SHT30 온습도 센서를 라즈베리파이(Pi-B)의 I2C(버스 1, 주소 0x44)에 연결하는 측정 노드입니다.
이 노드는 측정값만 <b>5분 주기</b>로 서버에 보고하며, 고온·저온·고습·저습 임계 판정과 SMS 발송은 서버가 담당합니다.
A measurement node connecting the SHT30 temperature/humidity sensor to the Raspberry Pi (Pi-B) over I2C (bus 1, address 0x44).
This node only reports readings to the server every <b>5 minutes</b>; high/low temperature and high/low humidity threshold decisions and SMS dispatch are handled by the server.
</p>
<div class="chips">
<span class="chip">Raspberry Pi 4 Model B</span>
<span class="chip mono">BCM2711</span>
<span class="chip">Raspberry Pi OS Bookworm 64-bit</span>
<span class="chip">Sensirion SHT30 (한진데이터 P4422-3)</span>
<span class="chip">Sensirion SHT30 (Hanjin Data P4422-3)</span>
<span class="chip mono">I2C bus <b>1</b></span>
<span class="chip mono">addr <b>0x44</b></span>
<span class="chip mono">sensor_id=<b>2</b></span>
<span class="chip">보고 주기 5분</span>
<span class="chip">Report interval 5 min</span>
</div>
</header>
<!-- ============ 2. PINMAP SVG ============ -->
<section>
<h2><span class="n">2</span>40핀 헤더 핀맵 도해 <small>사용 핀만 강조 · 나머지는 회색</small></h2>
<h2><span class="n">2</span>40-Pin Header Pinmap Diagram <small>Used pins highlighted · others in gray</small></h2>
<svg class="svg-pin" viewBox="0 0 520 672" role="img" aria-label="라즈베리파이 40핀 GPIO 헤더 핀맵">
<svg class="svg-pin" viewBox="0 0 520 672" role="img" aria-label="Raspberry Pi 40-pin GPIO header pinmap">
<!-- caption -->
<text class="cap-svg" x="260" y="20">40핀 GPIO 헤더 (J8) · 물리 핀 번호</text>
<text class="cap-svg" x="260" y="20">40-pin GPIO header (J8) · physical pin numbers</text>
<!-- rails -->
<rect class="rail" x="186" y="46" width="28" height="600" rx="14"/>
<rect class="rail" x="306" y="46" width="28" height="600" rx="14"/>
@ -372,56 +372,56 @@
<text class="plbl lbl-r lbl-off" x="344" y="630">GPIO21</text>
<!-- pin1 hint -->
<text class="cap-svg" x="260" y="662" style="font-size:11px;">■ 사각 표시 = 1번 핀 (기판 방향 기준)</text>
<text class="cap-svg" x="260" y="662" style="font-size:11px;">■ square marker = pin 1 (relative to board orientation)</text>
</svg>
<div class="legend">
<span><i class="sw pwr"></i>3.3V 전원 — 핀 1</span>
<span><i class="sw i2c"></i>I2C SDA/SCL — 핀 3·5</span>
<span><i class="sw gnd"></i>GND (사용) — 핀 6</span>
<span><i class="sw altgnd"></i>대체 GND (선택) — 9·14·20·25·30·34·39</span>
<span><i class="sw off"></i>미사용 핀</span>
<span><i class="sw pwr"></i>3.3V power — pin 1</span>
<span><i class="sw i2c"></i>I2C SDA/SCL — pins 3·5</span>
<span><i class="sw gnd"></i>GND (used) — pin 6</span>
<span><i class="sw altgnd"></i>Alt GND (optional) — 9·14·20·25·30·34·39</span>
<span><i class="sw off"></i>Unused pins</span>
</div>
<p class="diagram-cap">본 도해의 핀 번호·신호는 Raspberry Pi 4 Model B (BCM2711) 40핀 헤더 표준 배열과 일치합니다.</p>
<p class="diagram-cap">The pin numbers and signals in this diagram match the standard Raspberry Pi 4 Model B (BCM2711) 40-pin header layout.</p>
</section>
<!-- ============ 3. PINMAP TABLE ============ -->
<section>
<h2><span class="n">3</span>핀맵 표 <small>SHT30 → 40핀 헤더</small></h2>
<h2><span class="n">3</span>Pinmap Table <small>SHT30 → 40-pin header</small></h2>
<table>
<thead>
<tr><th style="width:30%">신호</th><th style="width:24%">BCM</th><th style="width:14%">물리핀</th><th>비고</th></tr>
<tr><th style="width:30%">Signal</th><th style="width:24%">BCM</th><th style="width:14%">Phys pin</th><th>Notes</th></tr>
</thead>
<tbody>
<tr>
<td><b>3.3V 전원</b> (VCC)</td>
<td><b>3.3V power</b> (VCC)</td>
<td class="mono">—</td>
<td><span class="pinbadge pwr">1</span></td>
<td><span class="warn-inline">★ 반드시 3.3V — 5V(핀 2·4) 연결 금지</span></td>
<td><span class="warn-inline">★ Must be 3.3V — do NOT connect 5V (pins 2·4)</span></td>
</tr>
<tr>
<td><b>I2C 데이터</b> (SDA)</td>
<td><b>I2C data</b> (SDA)</td>
<td class="mono">GPIO2 / SDA1</td>
<td><span class="pinbadge">3</span></td>
<td>4.7kΩ 풀업 → 3.3V <span style="color:var(--muted)">(모듈 내장 시 생략)</span></td>
<td><span style="color:var(--muted)">모듈 내장 풀업 → 직결 시 불필요</span> (풀업 없는 베어 칩만 4.7kΩ→3.3V)</td>
</tr>
<tr>
<td><b>I2C 클럭</b> (SCL)</td>
<td><b>I2C clock</b> (SCL)</td>
<td class="mono">GPIO3 / SCL1</td>
<td><span class="pinbadge">5</span></td>
<td>4.7kΩ 풀업 → 3.3V <span style="color:var(--muted)">(모듈 내장 시 생략)</span></td>
<td><span style="color:var(--muted)">모듈 내장 풀업 → 직결 시 불필요</span> (풀업 없는 베어 칩만 4.7kΩ→3.3V)</td>
</tr>
<tr>
<td><b>접지</b> (GND)</td>
<td><b>Ground</b> (GND)</td>
<td class="mono">—</td>
<td><span class="pinbadge gnd">6</span></td>
<td>대체 GND 사용 가능: <span class="mono">9 · 14 · 20 · 25 · 30 · 34 · 39</span></td>
<td>Alt GND available: <span class="mono">9 · 14 · 20 · 25 · 30 · 34 · 39</span></td>
</tr>
<tr>
<td><b>주소 선택</b> (ADDR)</td>
<td><b>Address select</b> (ADDR)</td>
<td class="mono">—</td>
<td><span class="pinbadge na">GND</span></td>
<td>ADDR → GND ⇒ <span class="mono">0x44</span> (선택) · ADDR → VDD ⇒ <span class="mono">0x45</span></td>
<td>ADDR → GND ⇒ <span class="mono">0x44</span> (selected) · ADDR → VDD ⇒ <span class="mono">0x45</span></td>
</tr>
</tbody>
</table>
@ -429,14 +429,14 @@
<!-- ============ 4. SCHEMATIC SVG ============ -->
<section>
<h2><span class="n">4</span>배선 스키매틱 <small>센서 ↔ 헤더 ↔ 풀업 저항</small></h2>
<h2><span class="n">4</span>Wiring Schematic <small>센서 모듈 ↔ 헤더 · 4선 직결(내장 풀업)</small></h2>
<svg class="svg-sch" viewBox="0 0 840 400" role="img" aria-label="SHT30 배선 스키매틱">
<svg class="svg-sch" viewBox="0 0 840 400" role="img" aria-label="SHT30 wiring schematic">
<!-- component boxes -->
<rect class="box" x="70" y="90" width="150" height="290" rx="8"/>
<rect class="box" x="640" y="90" width="150" height="290" rx="8"/>
<text class="boxtitle" x="145" y="116">SHT30 센서 모듈</text>
<text class="boxtitle" x="715" y="116">Pi 40핀 헤더</text>
<text class="boxtitle" x="145" y="116">SHT30 sensor module</text>
<text class="boxtitle" x="715" y="116">Pi 40-pin header</text>
<!-- SHT30 terminal labels (right side) -->
<text class="term" x="210" y="150" text-anchor="end">VCC</text>
@ -446,21 +446,21 @@
<text class="term" x="210" y="350" text-anchor="end">ADDR</text>
<!-- Pi terminal labels (left side) -->
<text class="term" x="650" y="150">3V3 (핀 1)</text>
<text class="term" x="650" y="200">SDA1 (핀 3)</text>
<text class="term" x="650" y="250">SCL1 (핀 5)</text>
<text class="term" x="650" y="305">GND (핀 6)</text>
<text class="term" x="650" y="150">3V3 (pin 1)</text>
<text class="term" x="650" y="200">SDA1 (pin 3)</text>
<text class="term" x="650" y="250">SCL1 (pin 5)</text>
<text class="term" x="650" y="305">GND (pin 6)</text>
<!-- POWER wire (also 3V3 rail for pull-ups) -->
<path class="w-pwr" d="M220 150 H640"/>
<text class="wlbl" x="300" y="140" fill="#b02a2a">전원 3.3V</text>
<text class="wlbl" x="300" y="140" fill="#b02a2a">Power 3.3V</text>
<!-- GND wire -->
<path class="w-gnd" d="M220 305 H640"/>
<text class="wlbl" x="300" y="322" fill="#2b3541">접지 GND</text>
<text class="wlbl" x="300" y="322" fill="#2b3541">Ground GND</text>
<!-- SDA wire (with hop over SCL pull-up branch at x=480) -->
<path class="w-i2c" d="M220 200 H470 A10 9 0 0 1 490 200 H640"/>
<!-- SDA wire (direct, no hop — module has on-board pull-ups) -->
<path class="w-i2c" d="M220 200 H640"/>
<text class="wlbl" x="300" y="216" fill="#155a9c">SDA → GPIO2</text>
<!-- SCL wire -->
@ -472,32 +472,19 @@
<text class="wlbl" x="228" y="368" fill="#3b444d">ADDR → GND = 0x44</text>
<circle class="jn" cx="300" cy="305" r="4"/>
<!-- Pull-up R1 on SDA (x=400) -->
<path class="w-i2c" d="M400 200 V190"/>
<rect class="res" x="390" y="165" width="20" height="25" rx="3"/>
<path class="w-i2c" d="M400 165 V150"/>
<circle class="jn" cx="400" cy="200" r="4"/>
<circle class="jn" cx="400" cy="150" r="4"/>
<text class="rlbl" x="416" y="180">R1 4.7kΩ</text>
<!-- Pull-up R2 on SCL (x=480), branch rises past SDA (hop) up to rail -->
<path class="w-i2c" d="M480 250 V188"/>
<rect class="res" x="470" y="163" width="20" height="25" rx="3"/>
<path class="w-i2c" d="M480 163 V150"/>
<circle class="jn" cx="480" cy="250" r="4"/>
<circle class="jn" cx="480" cy="150" r="4"/>
<text class="rlbl" x="496" y="178">R2 4.7kΩ</text>
<!-- Purchased part is a breakout module with on-board pull-ups → no external resistors (direct 4-wire) -->
<text x="430" y="282" text-anchor="middle" font-size="12" font-weight="700" fill="#2b8a3e">모듈 내장 풀업 · 외부 저항 불필요 (4선 직결)</text>
</svg>
<p class="sch-note">
풀업 저항 R1·R2 (4.7kΩ ×2) 는 3.3V 레일로 연결됩니다. <b>SHT30 모듈에 내장되어 있으면 생략</b>하십시오.
SDA 선의 반원(hop)은 배선 교차 표시일 뿐 전기적 연결이 아닙니다.
구매품은 온보드 풀업이 있는 <b>SHT30 브레이크아웃 모듈</b>이라 외부 풀업 저항이 필요 없다.
VCC·GND·SDA·SCL 4선을 헤더에 직결한다. (풀업 없는 베어 SHT30 칩을 쓸 때만 SDA·SCL에 4.7kΩ×2 → 3.3V 를 추가한다.)
</p>
</section>
<!-- ============ 5. ELECTRICAL NOTES ============ -->
<section>
<h2><span class="n">5</span>전기 설계 · 주의</h2>
<h2><span class="n">5</span>Electrical Design · Cautions</h2>
<div class="warn">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">
@ -506,24 +493,25 @@
<circle cx="12" cy="18" r="1.4" fill="#e03131"/>
</svg>
<div>
<h3>치명적 경고 — 전원은 반드시 3.3V</h3>
<p>SHT30 의 <b>VCC 는 물리 핀 1 (3.3V)</b> 에만 연결하십시오.
<b>5V (핀 2·4) 연결 절대 금지</b> — 소자가 즉시 손상될 수 있습니다.
결선 전 핀 번호를 다시 확인하십시오.</p>
<h3>Critical Warning — On the Pi it MUST be 3.3V</h3>
<p>Connect the SHT30's <b>VCC to physical pin 1 (3.3V)</b>.
<b>Do NOT connect 5V (pins 2·4) on the Pi</b> — the SHT30 module itself tolerates 2.4–5.5V, but
powering it with 5V pulls the SDA/SCL lines up to 5V as well, which <b>damages the 3.3V-only Pi GPIO</b>.
Double-check the pin numbers before wiring.</p>
</div>
</div>
<ul class="notes">
<li><b>I2C 풀업:</b> SDA·SCL 각각 4.7kΩ 풀업 저항을 3.3V 로 연결. 센서 모듈에 내장된 경우 생략 가능.</li>
<li><b>주소 설정:</b> ADDR → GND 이면 <code>0x44</code>(기본), ADDR → VDD 이면 <code>0x45</code>. <code>config.py</code> 의 <code>I2C_ADDR</code> 값과 반드시 일치해야 합니다.</li>
<li><b>I2C 버스:</b> 버스 1 사용. 부팅 후 I2C 가 활성화되어 있어야 함(<code>raspi-config</code> 또는 <code>dtparam=i2c_arm=on</code>).</li>
<li><b>배선 길이:</b> I2C 는 짧은 케이블을 권장. 케이블이 길면 통신 오류·풀업 값 조정이 필요할 수 있습니다.</li>
<li><b>역할 분담:</b> 이 노드는 측정값만 5분 주기로 서버에 보고합니다. 고온·저온·고습·저습 <b>임계 판정과 SMS 발송은 서버</b>가 수행합니다.</li>
<li><b>설정 파일:</b> <code>raspberry_pi4_sht30/config.py</code> (<code>I2C_BUS=1</code>, <code>I2C_ADDR=0x44</code>). 기기별 값은 <code>config_local.py</code> 로 오버라이드.</li>
<li><b>I2C pull-ups:</b> Connect a 4.7kΩ pull-up resistor on each of SDA and SCL to 3.3V. Can be omitted if built into the sensor module.</li>
<li><b>Address setting:</b> ADDR → GND gives <code>0x44</code> (default), ADDR → VDD gives <code>0x45</code>. Must match the <code>I2C_ADDR</code> value in <code>config.py</code>.</li>
<li><b>I2C bus:</b> Uses bus 1. I2C must be enabled after boot (<code>raspi-config</code> or <code>dtparam=i2c_arm=on</code>).</li>
<li><b>Wiring length:</b> Short cables are recommended for I2C. Long cables may cause communication errors or require adjusting the pull-up values.</li>
<li><b>Role split:</b> This node only reports readings to the server every 5 minutes. High/low temperature and high/low humidity <b>threshold decisions and SMS dispatch are done by the server</b>.</li>
<li><b>Config file:</b> <code>raspberry_pi4_sht30/config.py</code> (<code>I2C_BUS=1</code>, <code>I2C_ADDR=0x44</code>). Per-device values are overridden in <code>config_local.py</code>.</li>
</ul>
<div class="cmd">
<span class="cm"># I2C 연결 확인 (0x44 가 표에 보이면 정상)</span>
<span class="cm"># Check I2C connection (OK if 0x44 appears in the grid)</span>
$ i2cdetect -y 1
0 1 2 3 4 5 6 7 8 9 a b c d e f
40: -- -- -- -- <span class="ok">44</span> -- -- -- -- -- -- -- -- -- -- --
@ -532,24 +520,24 @@ $ i2cdetect -y 1
<!-- ============ 6. INSTALL CHECKLIST ============ -->
<section>
<h2><span class="n">6</span>설치 체크리스트</h2>
<h2><span class="n">6</span>Installation Checklist</h2>
<ol class="check">
<li><span class="num"></span><span class="cbox"></span><span class="txt"><b>전원 차단 상태</b>에서 배선 작업 (핫플러그 금지)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">VCC → 물리 핀 1 (3.3V) 연결 — <b>5V 핀(2·4) 아님</b>을 재확인</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">GND → 물리 핀 6 연결 (대체 GND 사용 시 9·14·20·25·30·34·39)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">SDA → 핀 3 (GPIO2), SCL → 핀 5 (GPIO3) 연결</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">풀업 저항 4.7kΩ ×2 를 SDA·SCL → 3.3V 로 연결 (모듈 내장 시 생략)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">ADDR → GND 확인 (주소 0x44 선택)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">결선 전체 재확인 후 Pi 전원 인가</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">I2C 활성화 확인 (raspi-config / dtparam=i2c_arm=on)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt"><code>i2cdetect -y 1</code> 실행 → <code>0x44</code> 표시 확인</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">config.py / config_local.py 의 I2C_BUS=1, I2C_ADDR=0x44 확인</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">측정 로그 및 서버 수신 확인 (5분 주기 보고 정상)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Do wiring work with <b>power disconnected</b> (no hot-plugging)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Connect VCC → physical pin 1 (3.3V) — re-verify it is <b>NOT a 5V pin (2·4)</b></span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Connect GND → physical pin 6 (or 9·14·20·25·30·34·39 for alt GND)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Connect SDA → pin 3 (GPIO2), SCL → pin 5 (GPIO3)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Connect 4.7kΩ ×2 pull-up resistors from SDA·SCL → 3.3V (omit if built into module)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Verify ADDR → GND (address 0x44 selected)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Re-check all wiring, then power up the Pi</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Verify I2C is enabled (raspi-config / dtparam=i2c_arm=on)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Run <code>i2cdetect -y 1</code> → confirm <code>0x44</code> is shown</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Verify I2C_BUS=1, I2C_ADDR=0x44 in config.py / config_local.py</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Check measurement log and server reception (5-min reporting OK)</span></li>
</ol>
</section>
<footer>
온습도 노드 · Pi-B (sensor_id=2, 1번 지점/서버실) · SHT30 I2C 배선 문서 — 현장 인쇄용
Temp/Humidity Node · Pi-B (sensor_id=2, Site 1 / Server Room) · SHT30 I2C wiring doc — for on-site printing
</footer>
</div>

View file

@ -1,74 +1,77 @@
# =============================================================================
# config.py - 온습도(SHT30) 모니터링 모듈 (Pi 4 Model B) 설정 — 비밀이 아닌 기본값
# config.py - Temperature/Humidity (SHT30) monitoring module (Pi 4 Model B) config — non-secret defaults
#
# 서버 URL / API_KEY / 기기 식별자(DEVICE_ID/LOCATION) 등 운영·비밀값은
# config_local.py 에서 로드한다. config_local.py 는 .gitignore 로 커밋 제외.
# 설정 방법: cp config_local.example.py config_local.py → 실제 값으로 채움
# Operational/secret values such as server URL / API_KEY / device identifiers (DEVICE_ID/LOCATION)
# are loaded from config_local.py. config_local.py is excluded from commits via .gitignore.
# Setup: cp config_local.example.py config_local.py → fill in the real values
#
# 이 모듈은 "2번 모듈"(온습도)이다. 서버(php/) 대시보드는 sensor_id=2 를 기본
# 온습도 센서로 표시하므로 SENSOR_ID 기본값은 2 로 둔다.
# This module is "Module 2" (temperature/humidity). The server (php/) dashboard shows sensor_id=2 as the
# default temperature/humidity sensor, so the SENSOR_ID default is set to 2.
# =============================================================================
# ─────────────────────────────────────────────
# 센서 식별 (서버 대시보드 표시용; 비밀 아님)
# SENSOR_ID 는 서버 leak_sensor_metric / leak_sensor_status 의 키. 대시보드 기본 차트=2.
# Sensor identity (for server dashboard display; not secret)
# SENSOR_ID is the key in the server's leak_sensor_metric / leak_sensor_status. Dashboard default chart=2.
# ─────────────────────────────────────────────
SENSOR_ID = 2
SENSOR_NAME = "2번 센서 (SHT30)"
SENSOR_NAME = "Sensor 2 (SHT30)"
# ─────────────────────────────────────────────
# I2C 버스 설정 (Pi 4 40핀 헤더)
# SDA = GPIO2 (물리 3번), SCL = GPIO3 (물리 5번) → I2C 버스 1
# ADDR 핀: GND=0x44, VDD=0x45
# I2C bus config (Pi 4 40-pin header)
# SDA = GPIO2 (physical pin 3), SCL = GPIO3 (physical pin 5) → I2C bus 1
# ADDR pin: GND=0x44, VDD=0x45
# ─────────────────────────────────────────────
I2C_BUS = 1
I2C_ADDR = 0x44
# 강제 시뮬레이션. True 면 하드웨어 없이 더미 측정값으로 동작(서버 연동 점검용).
# False 라도 smbus2 미설치/ I2C 미활성이면 드라이버가 자동 시뮬레이션으로 폴백.
# Force simulation. If True, runs with dummy measurements without hardware (for server integration checks).
# Even if False, the driver auto-falls back to simulation when smbus2 is not installed / I2C is disabled.
FORCE_SIMULATION = False
# ─────────────────────────────────────────────
# 측정/보고 주기 (firmware app_config.h 와 동일 기본값)
# Measurement/report intervals (same defaults as firmware app_config.h)
# ─────────────────────────────────────────────
REPORT_INTERVAL = 300 # 정상 측정·보고 간격 (초) = 5분
MEASURE_RETRY_DELAY = 5 # 측정 실패 후 재시도 대기 (초)
REPORT_INTERVAL = 3600 # Normal measure/report interval (seconds) = 1 hour
# Server offline detection must exceed this: METRIC_HEARTBEAT_TIMEOUT_SEC
# (default 7500s ≈ 2 missed hourly reports + margin) covers it.
# Override per-node in config_local.py if a different cadence is needed.
MEASURE_RETRY_DELAY = 5 # Wait before retry after a failed measurement (seconds)
# ─────────────────────────────────────────────
# 온습도 물리 plausibility 범위 (firmware classify_metric_status 와 동일)
# 이 범위를 벗어나면 metric_status="out_of_range" 로 보고한다.
# ※ 운영 임계(고온/저온/고습/저습) 판정·SMS 는 모두 서버(config.php METRIC_*)가
# 수행한다. 이 모듈은 측정값만 충실히 보고한다.
# Physical plausibility range for temp/humidity (same as firmware classify_metric_status)
# Values outside this range are reported as metric_status="out_of_range".
# * Operational threshold judgments (high/low temp, high/low humidity) and SMS are all handled
# by the server (config.php METRIC_*). This module only faithfully reports measurements.
# ─────────────────────────────────────────────
TEMP_LOW_C = -40.0
TEMP_HIGH_C = 125.0
RH_LOW = 0.0
RH_HIGH = 100.0
# 모듈 식별 버전 (서버 app_version 필드; 서버는 검증하지 않고 기록만 함)
# Module identity version (server app_version field; the server only records it, does not validate)
APP_VERSION = "v2606-sht30-rpi4"
# ─────────────────────────────────────────────
# 로그 설정
# Logging config
# ─────────────────────────────────────────────
LOG_FILE = "/var/log/sht30_sensor.log"
LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB
LOG_BACKUP_COUNT = 3
# ─────────────────────────────────────────────
# HTTP 재시도 설정 (firmware APP_HTTP_* 와 동일 기본값)
# HTTP retry config (same defaults as firmware APP_HTTP_*)
# ─────────────────────────────────────────────
HTTP_TIMEOUT = 10 # 단일 요청 타임아웃 (초)
HTTP_RETRY_COUNT = 3 # 재시도 횟수
HTTP_RETRY_DELAY = 5 # 재시도 간 대기 (초)
HTTP_TIMEOUT = 10 # Single request timeout (seconds)
HTTP_RETRY_COUNT = 3 # Number of retries
HTTP_RETRY_DELAY = 5 # Wait between retries (seconds)
# ─────────────────────────────────────────────
# 운영·비밀값 로드 (fail closed)
# config_local.py 가 없으면 placeholder/하드코딩 비밀로 잘못 기동하는 대신
# 명확한 오류로 즉시 중단한다.
# Load operational/secret values (fail closed)
# If config_local.py is missing, abort immediately with a clear error instead of
# starting incorrectly with placeholder/hardcoded secrets.
# ─────────────────────────────────────────────
try:
from config_local import ( # noqa: F401 (sht30_sensor.py 가 config 에서 재수입)
from config_local import ( # noqa: F401 (sht30_sensor.py re-imports from config)
API_URL,
API_KEY,
DEVICE_ID,
@ -76,7 +79,37 @@ try:
)
except ImportError as e:
raise RuntimeError(
"config_local.py 를 찾을 수 없습니다. "
"`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / "
"기기 정보를 채우세요. (비밀값은 절대 커밋하지 마세요.)"
"config_local.py not found. "
"Run `cp config_local.example.py config_local.py`, then fill in the real server URL / API_KEY / "
"device info. (Never commit secret values.)"
) from e
# ─────────────────────────────────────────────
# Optional override — per-device values from config_local.py.
# Editing the tracked config.py per device causes deployment drift, so a co-located
# node (e.g. a leak node reporting temp/humidity under the SAME sensor_id 1 or 3)
# overrides SENSOR_ID / SENSOR_NAME here instead. If config_local defines neither,
# the defaults above (SENSOR_ID=2) are kept — backward compatible for Pi-B.
# ─────────────────────────────────────────────
try:
from config_local import SENSOR_ID # noqa: F811 — per-device sensor_id override
except ImportError:
pass
try:
from config_local import SENSOR_NAME # noqa: F811 — per-device display name override
except ImportError:
pass
try:
# Per-device report cadence. Leak-node co-sensor (id 1/3) reports every 600s (10 min)
# to match the leak node's cadence; the standalone Pi-B (sensor2) keeps the 3600s (1 h) default.
from config_local import REPORT_INTERVAL # noqa: F811 — per-device report interval override
except ImportError:
pass
# Validate (fail closed) — a typo must not silently start with a wrong/plausible id.
if not (isinstance(SENSOR_ID, int) and 1 <= SENSOR_ID <= 255):
raise RuntimeError(f"SENSOR_ID must be int 1..255 (check config_local.py): {SENSOR_ID!r}")
if not (isinstance(SENSOR_NAME, str) and SENSOR_NAME):
raise RuntimeError("SENSOR_NAME must be a non-empty string (check config_local.py).")
if not (isinstance(REPORT_INTERVAL, int) and REPORT_INTERVAL > 0):
raise RuntimeError(f"REPORT_INTERVAL must be a positive int (seconds) (check config_local.py): {REPORT_INTERVAL!r}")

View file

@ -1,31 +1,31 @@
# =============================================================================
# config_local.example.py - 운영·비밀값 템플릿 (Pi 4 온습도 SHT30 모듈 / 2번 모듈)
# config_local.example.py - Operational / secret-value template (Pi 4 temp-humidity SHT30 module / module #2)
#
# 사용법: cp config_local.example.py config_local.py
# → 실제 값으로 채운 뒤 저장. config_local.py 는 절대 커밋하지 말 것
# (.gitignore 로 제외되어 있음).
# Usage: cp config_local.example.py config_local.py
# -> Fill in the real values and save. Never commit config_local.py
# (it is excluded via .gitignore).
#
# ⚠️ 서버 호환성
# 이 모듈(2번)은 SHT30 온습도 측정을 cafe24 서버 php/api/sensor_data.php 로
# 보고한다. 서버는 raw-body 서명을 요구한다:
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))
# → API_KEY 는 서버 config.local.php 의 API_KEY 와 "정확히 동일"해야 한다.
# 서버 임계 판정(고온/저온/고습/저습)·SMS·대시보드는 모두 서버가 수행한다.
# WARNING: Server compatibility
# This module (#2) reports SHT30 temperature/humidity readings to the cafe24
# server php/api/sensor_data.php. The server requires a raw-body signature:
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))
# -> API_KEY must be "exactly identical" to API_KEY in the server config.local.php.
# Threshold decisions (high/low temp, high/low humidity), SMS, and the dashboard are all done by the server.
# =============================================================================
# ─────────────────────────────────────────────
# 서버 API 엔드포인트 (SHT30 수신 서버 = cafe24 php/)
# INSTALL_PI_SERVER.md 기준 웹 루트: public_html/raspi_leck_detecter/
# Server API endpoint (SHT30 receiving server = cafe24 php/)
# Web root per INSTALL_PI_SERVER.md: public_html/raspi_leck_detecter/
# ─────────────────────────────────────────────
API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php"
# ─────────────────────────────────────────────
# 공유 비밀키 — 서버 config.local.php 의 API_KEY 와 정확히 동일해야 함. 절대 커밋 금지.
# Shared secret key — must be exactly identical to API_KEY in the server config.local.php. Never commit.
# ─────────────────────────────────────────────
API_KEY = "CHANGE_ME"
# ─────────────────────────────────────────────
# 기기 식별 (여러 대 운영 시 구분). sensor_id/sensor_name 은 config.py 기본값(2)을 사용.
# Device identification (to distinguish multiple units). sensor_id/sensor_name use the config.py defaults (2).
# ─────────────────────────────────────────────
DEVICE_ID = "rpi4-sht30-01"
DEVICE_LOCATION = "서버실"
DEVICE_LOCATION = "Server Room"

View file

@ -1,17 +1,17 @@
#!/usr/bin/env bash
# =============================================================================
# 온습도(SHT30) 모듈 v2 (Raspberry Pi 4 Model B) 설치 스크립트 — 2번 모듈
# 대상 OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
# Temp/humidity (SHT30) module v2 (Raspberry Pi 4 Model B) install script — module 2
# Target OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
#
# 폐쇄망(air-gapped) 지원:
# - debs/ 또는 wheelhouse/ 가 있으면 자동으로 "오프라인 모드"로 전환.
# - 오프라인 모드에서는 절대 네트워크에 접근하지 않는다. 번들이 일부만 있으면
# 온라인으로 몰래 빠지지 않고 명확한 오류로 중단한다(폐쇄망 규칙). 사전
# 다운로드는 vendor.sh 로 빌드 머신에서 수행.
# - 환경변수 OFFLINE=1/0 으로 강제 지정 가능.
# - 로그 파일 소유자: SERVICE_USER (기본 pi).
# Air-gapped support:
# - If debs/ or wheelhouse/ is present, automatically switch to "offline mode".
# - In offline mode, never access the network. If the bundle is only partial,
# do not silently fall back to online; stop with a clear error (air-gapped rule). Pre-
# download is done on the build machine via vendor.sh.
# - Can be forced via the OFFLINE=1/0 environment variable.
# - Log file owner: SERVICE_USER (default pi).
#
# 이 스크립트는 I2C 도 활성화한다(dtparam=i2c_arm=on + i2c-dev 로드 + i2c 그룹).
# This script also enables I2C (dtparam=i2c_arm=on + i2c-dev load + i2c group).
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")"
@ -19,132 +19,132 @@ cd "$(dirname "$0")"
SERVICE_USER="${SERVICE_USER:-pi}"
echo "=========================================="
echo " 온습도(SHT30) 모듈 v2 (Pi 4) 설치 — 2번 모듈"
echo " Temp/humidity (SHT30) module v2 (Pi 4) install — module 2"
echo "=========================================="
# 0) 보드 모델 안내 (강제 아님)
# 0) Board model notice (not enforced)
if [ -r /proc/device-tree/model ]; then
MODEL="$(tr -d '\0' < /proc/device-tree/model)"
echo "[INFO] 보드: ${MODEL}"
echo "[INFO] Board: ${MODEL}"
case "${MODEL}" in
*"Raspberry Pi 4"*) : ;;
*) echo "[WARN] 이 폴더는 Raspberry Pi 4 Model B 전용입니다 (계속 진행은 가능)." ;;
*) echo "[WARN] This folder is for Raspberry Pi 4 Model B only (you may continue anyway)." ;;
esac
fi
# 오프라인 모드 판정
# Offline mode detection
OFFLINE="${OFFLINE:-}"
if [ -z "${OFFLINE}" ]; then
if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then
OFFLINE=1
echo "[INFO] 오프라인 번들 감지 → 폐쇄망 오프라인 설치 모드 (네트워크 미사용)"
echo "[INFO] Offline bundle detected → air-gapped offline install mode (no network)"
else
OFFLINE=0
echo "[INFO] 오프라인 번들 없음 → 온라인 설치 모드 (인터넷 필요)"
echo "[INFO] No offline bundle → online install mode (internet required)"
fi
fi
# 1) 시스템 패키지: requests + venv + i2c-tools
echo "[1/4] 시스템 패키지 설치..."
# 1) System packages: requests + venv + i2c-tools
echo "[1/4] Installing system packages..."
if [ "${OFFLINE}" = "1" ]; then
if ! compgen -G "debs/*.deb" >/dev/null; then
echo "[ERROR] 오프라인 모드인데 debs/ 가 비어있습니다. vendor.sh 산출물(debs/)을 이 폴더에 두고 재실행하세요." >&2
echo "[ERROR] Offline mode but debs/ is empty. Place the vendor.sh output (debs/) in this folder and re-run." >&2
exit 1
fi
echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)"
# --no-install-recommends: vendor.sh 가 Recommends 를 안 담으므로(--no-recommends) 맞춘다.
echo " Offline .deb install (apt-get --no-download — dependency ordering/resolution automatic, no network)"
# --no-install-recommends: vendor.sh does not bundle Recommends (--no-recommends), so match that.
sudo apt-get install -y --no-install-recommends --no-download ./debs/*.deb
else
echo " 온라인 apt 설치 (인터넷 필요)"
echo " Online apt install (internet required)"
sudo apt-get update
sudo apt-get install -y python3-requests python3-venv i2c-tools
fi
# 2) I2C 활성화 (dtparam + 모듈 로드 + 그룹)
echo "[2/4] I2C 활성화..."
# 부트 설정 경로: Bookworm=/boot/firmware/config.txt, 구버전=/boot/config.txt
# 2) Enable I2C (dtparam + module load + group)
echo "[2/4] Enabling I2C..."
# Boot config path: Bookworm=/boot/firmware/config.txt, older=/boot/config.txt
BOOT_CFG=""
for c in /boot/firmware/config.txt /boot/config.txt; do
if [ -f "$c" ]; then BOOT_CFG="$c"; break; fi
done
if [ -n "${BOOT_CFG}" ]; then
if ! grep -q "^dtparam=i2c_arm=on" "${BOOT_CFG}"; then
echo " ${BOOT_CFG} 에 dtparam=i2c_arm=on 추가 (재부팅 후 적용)"
echo " Adding dtparam=i2c_arm=on to ${BOOT_CFG} (applied after reboot)"
echo "dtparam=i2c_arm=on" | sudo tee -a "${BOOT_CFG}" >/dev/null
NEED_REBOOT=1
else
echo " dtparam=i2c_arm=on 이미 설정됨"
echo " dtparam=i2c_arm=on already set"
fi
else
echo "[WARN] config.txt 를 찾지 못함 — raspi-config 로 수동 I2C 활성화 필요"
echo "[WARN] config.txt not found — enable I2C manually via raspi-config"
fi
# i2c-dev 모듈을 즉시 로드 + 부팅 시 자동 로드 등록
sudo modprobe i2c-dev 2>/dev/null || echo "[WARN] i2c-dev 즉시 로드 실패 (재부팅 후 적용될 수 있음)"
# Load the i2c-dev module immediately + register it for auto-load at boot
sudo modprobe i2c-dev 2>/dev/null || echo "[WARN] i2c-dev immediate load failed (may apply after reboot)"
if [ -d /etc/modules-load.d ] && ! grep -qrs "i2c-dev" /etc/modules-load.d/ 2>/dev/null; then
echo "i2c-dev" | sudo tee /etc/modules-load.d/i2c.conf >/dev/null
fi
# 서비스 사용자를 i2c 그룹에 추가 (/dev/i2c-1 접근 권한)
# Add the service user to the i2c group (/dev/i2c-1 access permission)
if getent group i2c >/dev/null 2>&1; then
sudo usermod -aG i2c "${SERVICE_USER}" || echo "[WARN] usermod 실패 — SERVICE_USER=${SERVICE_USER} 확인"
sudo usermod -aG i2c "${SERVICE_USER}" || echo "[WARN] usermod failed — check SERVICE_USER=${SERVICE_USER}"
fi
# 3) Python venv + requests + smbus2
echo "[3/4] Python 가상환경 설정..."
echo "[3/4] Setting up Python virtual environment..."
if [ ! -d venv ]; then
python3 -m venv --system-site-packages venv
echo " venv 생성 완료"
echo " venv created"
else
echo " venv 이미 존재 — 건너뜀"
echo " venv already exists — skipping"
fi
# shellcheck disable=SC1091
source venv/bin/activate
if [ "${OFFLINE}" = "1" ]; then
if [ ! -d wheelhouse ]; then
echo "[ERROR] 오프라인 모드인데 wheelhouse/ 가 없습니다. vendor.sh 산출물(wheelhouse/)을 두고 재실행하세요." >&2
echo "[ERROR] Offline mode but wheelhouse/ is missing. Place the vendor.sh output (wheelhouse/) and re-run." >&2
exit 1
fi
echo " 오프라인 wheel 설치 (--no-index — 네트워크 미사용)"
echo " Offline wheel install (--no-index — no network)"
pip install --no-index --find-links=wheelhouse -r requirements.txt
else
echo " 온라인 pip 설치 (인터넷 필요)"
echo " Online pip install (internet required)"
pip install --upgrade pip
pip install -r requirements.txt
fi
echo " 패키지 설치 완료"
echo " Packages installed"
# 4) 로그 파일 (최소 권한: 소유자만 쓰기)
echo "[4/4] 로그 파일 생성..."
# 4) Log file (least privilege: owner-only write)
echo "[4/4] Creating log file..."
sudo mkdir -p /var/log
sudo touch /var/log/sht30_sensor.log
if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/sht30_sensor.log; then
echo "[WARN] chown 실패 — SERVICE_USER=${SERVICE_USER} 존재 여부 확인"
echo "[WARN] chown failed — check whether SERVICE_USER=${SERVICE_USER} exists"
fi
sudo chmod 640 /var/log/sht30_sensor.log
# config_local.py 즉시 잠금 (API_KEY 포함 — harden 전까지 world-readable 방지)
# 하드닝(harden_pi.sh)이 나중에 다시 조이지만, 그 사이 창(window)에도 노출되지
# 않도록 설치 시점에 바로 600 으로 잠근다. harden 여부와 무관하게 보호된다.
# Lock config_local.py immediately (contains API_KEY — prevent world-readable until harden)
# Hardening (harden_pi.sh) tightens it again later, but to avoid exposure even in
# that window, lock it to 600 right at install time. Protected regardless of harden.
if [ -f config_local.py ]; then
chmod 600 config_local.py
sudo chown "${SERVICE_USER}:${SERVICE_USER}" config_local.py 2>/dev/null || true
echo " config_local.py 권한 600 (소유자 ${SERVICE_USER}) 설정"
echo " config_local.py set to permission 600 (owner ${SERVICE_USER})"
fi
# config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨)
# config_local notice (fail closed — if missing, execution stops immediately)
if [ ! -f config_local.py ]; then
echo ""
echo "[!] config_local.py 가 없습니다. 다음을 실행 후 실제 값으로 채우세요:"
echo "[!] config_local.py is missing. Run the following, then fill in real values:"
echo " cp config_local.example.py config_local.py"
fi
echo ""
echo "=========================================="
echo " 설치 완료! (OFFLINE=${OFFLINE})"
echo " Install complete! (OFFLINE=${OFFLINE})"
echo "=========================================="
if [ "${NEED_REBOOT:-0}" = "1" ]; then
echo " ⚠️ I2C 를 방금 활성화했습니다 → 'sudo reboot' 후 센서가 인식됩니다."
echo " ⚠️ I2C was just enabled → the sensor is recognized after 'sudo reboot'."
fi
echo " 센서 확인: i2cdetect -y 1 (0x44 또는 0x45 가 보여야 함)"
echo " 실행: source venv/bin/activate && python3 sht30_sensor.py"
echo " 서비스: README.md 의 systemd 섹션 참고"
echo " Sensor check: i2cdetect -y 1 (should show 0x44 or 0x45)"
echo " Run: source venv/bin/activate && python3 sht30_sensor.py"
echo " Service: see the systemd section in README.md"
echo ""

View file

@ -1,8 +1,8 @@
# Pi 4 온습도(SHT30) 모듈 Python 의존성 (폐쇄망 대비 정확 핀 고정)
# 오프라인 설치: vendor.sh 로 wheelhouse/ 에 미리 받아두고
# Pi 4 temperature/humidity (SHT30) module Python dependencies (exact version pins for air-gapped networks)
# Offline install: pre-download into wheelhouse/ with vendor.sh, then
# pip install --no-index --find-links=wheelhouse -r requirements.txt
#
# smbus2 는 순수 파이썬(arch 무관 py3-none-any wheel)이라 x86 빌드 머신에서 받아도
# Pi 4(aarch64)에서 그대로 동작한다. requests 도 함께 핀 고정.
# smbus2 is pure Python (arch-independent py3-none-any wheel), so even if fetched on an x86 build machine
# it runs as-is on Pi 4 (aarch64). requests is version-pinned as well.
requests==2.32.3
smbus2==0.5.0

View file

@ -1,33 +1,40 @@
# =============================================================================
# sht30_driver.py - SHT30 I2C 측정 드라이버 (Raspberry Pi 4, I2C 버스 1)
# sht30_driver.py - SHT30 I2C measurement driver (Raspberry Pi 4, I2C bus 1)
#
# [이식 출처]
# firmware/common/sht30.c (0x2C06 명령 -> ~20ms 대기 -> 6바이트 read)
# firmware/common/sht30_convert.c (CRC-8 검증 + 물리값 변환)
# firmware/test/host/reference.py (sht30_crc8 / sht30_parse 파이썬 레퍼런스)
# [Ported from]
# firmware/common/sht30.c (STM32=0x2C06; Pi uses 0x2400 -> ~20ms wait -> 6-byte read)
# * The Pi uses the clock-stretch-disabled command (0x2400) — Pi/Arduino I2C masters
# handle clock stretching poorly (Pi BCM I2C stretch bug). The vendor example (P4422-2
# ArtronShop_SHT3x) also avoids stretching for the same reason (periodic + fetch). STM32
# (HAL supports stretching) keeps 0x2C06. Conversion/CRC are identical.
# firmware/common/sht30_convert.c (CRC-8 verification + physical-value conversion)
# firmware/test/host/reference.py (sht30_crc8 / sht30_parse Python reference)
#
# [동작]
# 1) 측정 명령 0x2C 0x06 전송 (고반복성, clock stretching 비활성)
# 2) ~20ms 대기 (고반복성 측정 최대 15ms + 마진)
# 3) 6바이트 read: T_msb,T_lsb,T_crc, RH_msb,RH_lsb,RH_crc
# 4) CRC-8(poly 0x31, init 0xFF) 검증 후 변환:
# [Operation]
# 1) Send measurement command 0x24 0x00 (high-repeatability, clock stretching disabled)
# 2) Wait ~20ms (high-repeatability measurement max 15ms + margin)
# 3) 6-byte read: T_msb,T_lsb,T_crc, RH_msb,RH_lsb,RH_crc
# 4) CRC-8 (poly 0x31, init 0xFF) verification then conversion:
# temp_c = -45 + 175 * raw_t / 65535
# rh = 100 * raw_rh / 65535
#
# [I2C 접근] smbus2(순수 파이썬, arch 무관 wheel) 의 i2c_msg 로 write/read 를
# 명시적으로 분리 전송한다. read_i2c_block_data 는 레지스터 바이트를 먼저
# 쓰므로 SHT30 측정 프레임을 깨뜨린다 → i2c_msg.read 사용.
# [I2C access] Uses smbus2's (pure Python, arch-agnostic wheel) i2c_msg to send write/read
# as explicitly separate transfers. read_i2c_block_data writes a register byte
# first, so it breaks the SHT30 measurement frame -> use i2c_msg.read.
#
# [시뮬레이션] smbus2 미설치 또는 /dev/i2c-* 부재(개발 PC)면 SIMULATION=True 로
# 전환되어 그럴듯한 더미 측정값을 돌려준다(서버 연동/포맷 점검용).
# [Simulation] If smbus2 is not installed or /dev/i2c-* is absent (dev PC), it switches to
# SIMULATION=True and returns plausible dummy measurements (for server integration/format checks).
# =============================================================================
from __future__ import annotations
import time
# ── 상수 (firmware/common/sht30.c, app_config.h 와 동일) ─────────────────────
SHT30_CMD = [0x2C, 0x06] # high-repeatability, clock stretching off
MEAS_DELAY_SEC = 0.02 # 측정 완료 대기 (15ms + 마진)
# ── Constants ────────────────────────────────────────────────────────────────
# 0x2400 = single-shot, high-repeatability, clock stretching DISABLED (Sensirion SHT3x datasheet).
# (0x2C06 = stretch ENABLED — for STM32 firmware. The Pi uses a write->wait->read pattern, so
# the stretch-disabled command is accurate and safe. The vendor example ArtronShop_SHT3x also avoids stretching.)
SHT30_CMD = [0x24, 0x00] # high-repeatability, clock stretching disabled (Pi-safe)
MEAS_DELAY_SEC = 0.02 # wait for measurement completion (15ms + margin)
FRAME_LEN = 6
try:
@ -37,7 +44,7 @@ except ImportError:
SMBUS_AVAILABLE = False
# ── CRC-8 (sht30_convert.c::sht30_crc8 와 동일) ──────────────────────────────
# ── CRC-8 (identical to sht30_convert.c::sht30_crc8) ─────────────────────────
def crc8(data: bytes) -> int:
"""SHT3x CRC-8: poly 0x31, init 0xFF, no reflection, no final xor."""
crc = 0xFF
@ -48,18 +55,18 @@ def crc8(data: bytes) -> int:
return crc
# ── 프레임 파싱 (sht30_convert.c::sht30_parse 와 동일) ───────────────────────
# ── Frame parsing (identical to sht30_convert.c::sht30_parse) ────────────────
def parse_frame(frame: bytes):
"""6바이트 측정 프레임 검증 + 변환 → (temp_c, rh).
"""Verify + convert the 6-byte measurement frame -> (temp_c, rh).
CRC 불일치 시 ValueError 를 던진다(호출측이 재시도/스킵).
Raises ValueError on CRC mismatch (caller retries/skips).
"""
if len(frame) != FRAME_LEN:
raise ValueError(f"프레임 길이 오류: {len(frame)} (기대 {FRAME_LEN})")
raise ValueError(f"frame length error: {len(frame)} (expected {FRAME_LEN})")
if crc8(frame[0:2]) != frame[2]:
raise ValueError("온도 CRC 불일치")
raise ValueError("temperature CRC mismatch")
if crc8(frame[3:5]) != frame[5]:
raise ValueError("습도 CRC 불일치")
raise ValueError("humidity CRC mismatch")
raw_t = (frame[0] << 8) | frame[1]
raw_rh = (frame[3] << 8) | frame[4]
temp_c = -45.0 + 175.0 * (raw_t / 65535.0)
@ -68,11 +75,11 @@ def parse_frame(frame: bytes):
class SHT30:
"""SHT30 단발 측정 드라이버.
"""SHT30 single-shot measurement driver.
bus_num: I2C 버스 번호 (Pi 4 40핀 헤더 = 1)
addr: 7-bit I2C 주소 (ADDR=GND 면 0x44, ADDR=VDD 면 0x45)
simulate: True 면 하드웨어 없이 더미값 반환(강제 시뮬레이션)
bus_num: I2C bus number (Pi 4 40-pin header = 1)
addr: 7-bit I2C address (0x44 if ADDR=GND, 0x45 if ADDR=VDD)
simulate: if True, returns dummy values without hardware (forced simulation)
"""
def __init__(self, bus_num: int = 1, addr: int = 0x44, simulate: bool = False):
@ -85,13 +92,13 @@ class SHT30:
def open(self):
if self.simulate:
return
# /dev/i2c-<bus_num> 가 없으면(예: i2c 미활성) 명확한 오류로 안내한다.
# If /dev/i2c-<bus_num> is missing (e.g. I2C disabled), report a clear error.
try:
self._bus = SMBus(self.bus_num)
except (FileNotFoundError, PermissionError, OSError) as e:
raise RuntimeError(
f"I2C 버스 {self.bus_num} 열기 실패: {e}. "
f"`sudo raspi-config` 로 I2C 활성화 및 사용자 i2c 그룹 확인."
f"Failed to open I2C bus {self.bus_num}: {e}. "
f"Enable I2C via `sudo raspi-config` and verify the user's i2c group."
) from e
def close(self):
@ -102,9 +109,9 @@ class SHT30:
self._bus = None
def measure(self):
"""단발 측정 → (temp_c, rh). 실패 시 예외(통신/CRC).
"""Single-shot measurement -> (temp_c, rh). Raises on failure (comms/CRC).
시뮬레이션 모드에서는 24~26℃, 48~54% 부근의 더미값을 반환한다.
In simulation mode, returns dummy values around 24-26 C and 48-54%.
"""
if self.simulate:
self._sim_tick += 1
@ -115,19 +122,19 @@ class SHT30:
if self._bus is None:
self.open()
# 1) 측정 명령 전송 (write)
# 1) Send measurement command (write)
write = i2c_msg.write(self.addr, SHT30_CMD)
self._bus.i2c_rdwr(write)
# 2) 측정 완료 대기 (clock stretching 비활성 → 호스트가 명시적 대기)
# 2) Wait for measurement completion (clock stretching disabled -> host waits explicitly)
time.sleep(MEAS_DELAY_SEC)
# 3) 6바이트 read
# 3) 6-byte read
read = i2c_msg.read(self.addr, FRAME_LEN)
self._bus.i2c_rdwr(read)
frame = bytes(read)
# 4) CRC 검증 + 변환
# 4) CRC verification + conversion
return parse_frame(frame)
def __enter__(self):

View file

@ -1,30 +1,30 @@
#!/usr/bin/env python3
# =============================================================================
# sht30_sensor.py - SHT30 온습도 모니터링 메인 (2번 모듈, Raspberry Pi 4 전용)
# sht30_sensor.py - SHT30 temperature/humidity monitoring main (Module 2, Raspberry Pi 4 only)
#
# [대상 하드웨어] Raspberry Pi 4 Model B (BCM2711, 40핀 GPIO 헤더)
# - SHT30(Sensirion, 한진데이터 P4422-3) I2C 온습도 센서를 I2C 버스 1에 연결.
# - SDA=GPIO2(물리 3), SCL=GPIO3(물리 5), ADDR=GND → 0x44, 3.3V/GND, 4.7kΩ 풀업.
# [Target hardware] Raspberry Pi 4 Model B (BCM2711, 40-pin GPIO header)
# - SHT30 (Sensirion, Hanjin Data P4422-3) I2C temp/humidity sensor connected to I2C bus 1.
# - SDA=GPIO2 (physical 3), SCL=GPIO3 (physical 5), ADDR=GND → 0x44, 3.3V/GND, 4.7kΩ pull-up.
#
# [이식 출처] STM32 펌웨어 sht30_fw 의 동작을 Raspberry Pi 4 + Python 으로 이식.
# firmware/board_sht30/app_sht30.c (측정 루프 / startup→periodic / 범위 분류)
# firmware/common/reporter.c (본문 생성 → 서명 → POST → 재시도)
# firmware/common/sht30*.c (I2C 측정 / CRC / 변환) → sht30_driver.py
# firmware/test/host/reference.py (wire 포맷) → wire.py
# [Port source] Ported the behavior of STM32 firmware sht30_fw to Raspberry Pi 4 + Python.
# firmware/board_sht30/app_sht30.c (measurement loop / startup→periodic / range classification)
# firmware/common/reporter.c (build body → sign → POST → retry)
# firmware/common/sht30*.c (I2C measurement / CRC / conversion) → sht30_driver.py
# firmware/test/host/reference.py (wire format) → wire.py
#
# [동작 흐름]
# 1. SHT30 드라이버 open (실패/미가용 시 시뮬레이션 폴백)
# 2. 주기(REPORT_INTERVAL)마다 측정 → 서버에 POST
# 3. "최초 성공" 측정만 event_type="startup", 이후 "periodic"
# 4. 측정 실패(통신/CRC)는 크래시하지 않고 로그 후 짧게 쉬고 재시도
# 5. 물리 범위를 벗어나면 metric_status="out_of_range" (서버가 운영 임계 재판정)
# [Operation flow]
# 1. Open SHT30 driver (fall back to simulation on failure/unavailability)
# 2. Measure every interval (REPORT_INTERVAL) → POST to server
# 3. Only the "first success" measurement uses event_type="startup", afterwards "periodic"
# 4. Measurement failure (comm/CRC) does not crash; log, sleep briefly, then retry
# 5. Out of physical range → metric_status="out_of_range" (server re-evaluates operational thresholds)
#
# [인증] 서버는 raw-body HMAC 서명을 요구한다(레거시 body-sig 비활성).
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=전송 본문 바이트))
# → 서명한 바이트와 전송 바이트가 동일하도록 본문을 직접 만들어 raw POST 한다.
# [Auth] The server requires a raw-body HMAC signature (legacy body-sig disabled).
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=sent body bytes))
# → Build the body directly and raw-POST it so the signed bytes match the sent bytes.
#
# [설정·비밀값] config.py 는 비밀이 아닌 기본값만, 서버 URL/API_KEY/기기 식별자는
# config_local.py(미커밋)에서 로드한다. 없으면 기동 시 fail-closed 로 즉시 중단.
# [Config/secrets] config.py holds only non-secret defaults; the server URL/API_KEY/device
# identifiers are loaded from config_local.py (uncommitted). If missing, fail-closed and abort at startup.
# =============================================================================
import time
@ -52,7 +52,7 @@ from sht30_driver import SHT30, SMBUS_AVAILABLE
# ─────────────────────────────────────────────
# 로거 설정
# Logger setup
# ─────────────────────────────────────────────
def setup_logger():
logger = logging.getLogger("sht30_sensor")
@ -71,7 +71,7 @@ def setup_logger():
fh.setFormatter(fmt)
logger.addHandler(fh)
except PermissionError:
pass # 권한 없으면 콘솔만 사용
pass # No permission → console only
ch = logging.StreamHandler(sys.stdout)
ch.setFormatter(fmt)
@ -83,7 +83,7 @@ log = setup_logger()
# ─────────────────────────────────────────────
# 종료 시그널 처리 (systemd stop = SIGTERM 깔끔 종료)
# Shutdown signal handling (systemd stop = SIGTERM clean shutdown)
# ─────────────────────────────────────────────
class StopFlag:
def __init__(self):
@ -91,15 +91,15 @@ class StopFlag:
def request(self, signum, frame):
self.stop = True
log.info(f"[SIGNAL] {signal.Signals(signum).name} 수신 → 종료 준비")
log.info(f"[SIGNAL] {signal.Signals(signum).name} received → preparing to shut down")
stop_flag = StopFlag()
# ─────────────────────────────────────────────
# 온습도 물리 범위 분류 (firmware classify_metric_status 와 동일)
# 서버는 이 값을 신뢰하지 않고 운영 임계로 재판정하므로, 여기서는 센서
# 물리 plausibility(범위/ NaN·Inf)만 본다.
# Temp/humidity physical range classification (same as firmware classify_metric_status)
# The server does not trust this value and re-evaluates with operational thresholds, so
# here we only check sensor physical plausibility (range / NaN·Inf).
# ─────────────────────────────────────────────
def classify_metric_status(temp_c: float, rh: float) -> str:
if not (math.isfinite(temp_c) and math.isfinite(rh)):
@ -112,13 +112,13 @@ def classify_metric_status(temp_c: float, rh: float) -> str:
# ─────────────────────────────────────────────
# 서버 보고 (raw-body + X-Signature HMAC)
# Server reporting (raw-body + X-Signature HMAC)
# ─────────────────────────────────────────────
def post_reading(event_type: str, temp_c: float, rh: float, metric_status: str) -> bool:
"""SHT30 측정값을 서버에 POST. 성공(HTTP 200) 시 True.
"""POST the SHT30 measurement to the server. Returns True on success (HTTP 200).
본문을 직접 만들어 그 바이트에 서명하고 그 바이트를 그대로 전송한다
(서명한 바이트 == 전송 바이트 보장 → 서버 raw-body 검증과 일치).
Build the body directly, sign those bytes, and send those exact bytes
(guarantees signed bytes == sent bytes → matches the server's raw-body verification).
"""
timestamp = int(time.time())
body = wire.sht30_event_body(
@ -145,7 +145,7 @@ def post_reading(event_type: str, temp_c: float, rh: float, metric_status: str)
for attempt in range(1, HTTP_RETRY_COUNT + 1):
try:
# data=body_bytes 로 raw 본문을 그대로 전송(json= 재직렬화 금지).
# Send the raw body as-is via data=body_bytes (do not re-serialize with json=).
resp = requests.post(
API_URL, data=body_bytes, headers=headers, timeout=HTTP_TIMEOUT
)
@ -153,37 +153,37 @@ def post_reading(event_type: str, temp_c: float, rh: float, metric_status: str)
log.info(f"[POST OK] {event_type} T={temp_c:.2f}C RH={rh:.2f}% "
f"status={metric_status} → {resp.text[:100]}")
return True
log.warning(f"[POST {resp.status_code}] 시도 {attempt}/{HTTP_RETRY_COUNT}: "
log.warning(f"[POST {resp.status_code}] attempt {attempt}/{HTTP_RETRY_COUNT}: "
f"{resp.text[:160]}")
except requests.exceptions.Timeout:
log.warning(f"[POST TIMEOUT] 시도 {attempt}/{HTTP_RETRY_COUNT}")
log.warning(f"[POST TIMEOUT] attempt {attempt}/{HTTP_RETRY_COUNT}")
except requests.exceptions.ConnectionError as e:
log.warning(f"[POST CONN ERR] 시도 {attempt}/{HTTP_RETRY_COUNT}: {e}")
log.warning(f"[POST CONN ERR] attempt {attempt}/{HTTP_RETRY_COUNT}: {e}")
except requests.exceptions.RequestException as e:
log.warning(f"[POST ERR] 시도 {attempt}/{HTTP_RETRY_COUNT}: {e}")
log.warning(f"[POST ERR] attempt {attempt}/{HTTP_RETRY_COUNT}: {e}")
if attempt < HTTP_RETRY_COUNT and not stop_flag.stop:
time.sleep(HTTP_RETRY_DELAY)
log.error(f"[POST FAIL] {HTTP_RETRY_COUNT}회 모두 실패 (event={event_type})")
log.error(f"[POST FAIL] all {HTTP_RETRY_COUNT} attempts failed (event={event_type})")
return False
# ─────────────────────────────────────────────
# 측정 루프 (firmware sht30_measure_task 대응)
# Measurement loop (corresponds to firmware sht30_measure_task)
# ─────────────────────────────────────────────
def measure_loop(sensor: SHT30):
# 최초 성공 측정만 startup, 이후 periodic (서버 leak_sensor_log 1회 startup 기록)
# Only the first successful measurement is startup, afterwards periodic (server records startup once in leak_sensor_log)
first_success = True
log.info(f"[LOOP] 측정 시작 (간격={REPORT_INTERVAL}s, addr=0x{I2C_ADDR:02X}, bus={I2C_BUS})")
log.info(f"[LOOP] measurement started (interval={REPORT_INTERVAL}s, addr=0x{I2C_ADDR:02X}, bus={I2C_BUS})")
while not stop_flag.stop:
try:
temp_c, rh = sensor.measure()
except (OSError, ValueError, RuntimeError) as e:
# 측정 실패(통신/CRC): 크래시 금지. 로그 후 짧게 쉬고 재시도.
log.error(f"[MEASURE FAIL] {e} → {MEASURE_RETRY_DELAY}s 후 재시도")
# Measurement failure (comm/CRC): no crash. Log, sleep briefly, then retry.
log.error(f"[MEASURE FAIL] {e} → retry after {MEASURE_RETRY_DELAY}s")
_interruptible_sleep(MEASURE_RETRY_DELAY)
continue
@ -194,22 +194,22 @@ def measure_loop(sensor: SHT30):
ok = post_reading(event_type, temp_c, rh, metric_status)
if ok and first_success:
# 최초 성공 보고 후에만 startup→periodic 전환(최초 보고가 반드시 startup)
# Switch startup→periodic only after the first successful report (first report is always startup)
first_success = False
# 정상 보고 주기 대기 (종료 시그널이 오면 즉시 깸)
# Wait for the normal report interval (wakes immediately on a shutdown signal)
_interruptible_sleep(REPORT_INTERVAL)
def _interruptible_sleep(seconds: float):
"""1초 단위로 나눠 자며 종료 시그널에 빠르게 반응한다."""
"""Sleep in 1-second chunks to react quickly to shutdown signals."""
end = time.time() + seconds
while not stop_flag.stop and time.time() < end:
time.sleep(min(1.0, end - time.time()))
# ─────────────────────────────────────────────
# 메인
# Main
# ─────────────────────────────────────────────
def main():
signal.signal(signal.SIGTERM, stop_flag.request)
@ -217,12 +217,12 @@ def main():
simulate = FORCE_SIMULATION or not SMBUS_AVAILABLE
log.info("=" * 60)
log.info(f"온습도(SHT30) 모니터링 v2 (Pi 4, 2번 모듈) 시작")
log.info(f"기기={DEVICE_ID} | 위치={DEVICE_LOCATION} | 센서={SENSOR_NAME}(id={SENSOR_ID})")
log.info(f"Temp/humidity (SHT30) monitoring v2 (Pi 4, Module 2) starting")
log.info(f"device={DEVICE_ID} | location={DEVICE_LOCATION} | sensor={SENSOR_NAME}(id={SENSOR_ID})")
log.info(f"API={API_URL}")
log.info(f"모드={'시뮬레이션' if simulate else f'실제 I2C (bus={I2C_BUS}, addr=0x{I2C_ADDR:02X})'}")
log.info(f"mode={'simulation' if simulate else f'real I2C (bus={I2C_BUS}, addr=0x{I2C_ADDR:02X})'}")
if simulate and not FORCE_SIMULATION:
log.warning("smbus2 미설치/ I2C 미가용 → 시뮬레이션 모드. 실측은 install.sh 후 재실행.")
log.warning("smbus2 not installed / I2C unavailable → simulation mode. For real readings, run install.sh then restart.")
log.info("=" * 60)
sensor = SHT30(bus_num=I2C_BUS, addr=I2C_ADDR, simulate=simulate)
@ -234,12 +234,12 @@ def main():
try:
measure_loop(sensor)
except Exception as e: # noqa: BLE001 — 최상위 안전망(로그 후 종료)
log.exception(f"[MAIN] 예외 발생: {e}")
except Exception as e: # noqa: BLE001 — top-level safety net (log then exit)
log.exception(f"[MAIN] exception occurred: {e}")
raise
finally:
sensor.close()
log.info("[MAIN] 종료 완료.")
log.info("[MAIN] shutdown complete.")
if __name__ == "__main__":

View file

@ -1,16 +1,16 @@
# =============================================================================
# /etc/systemd/system/sht30-sensor.service
# Raspberry Pi 4 부팅 시 온습도(SHT30) 모니터를 자동 실행하는 서비스 (2번 모듈)
# Service that auto-runs the temperature/humidity (SHT30) monitor on Raspberry Pi 4 boot (Module 2)
#
# 설치 방법:
# Installation:
# sudo cp systemd/sht30-sensor.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable sht30-sensor
# sudo systemctl start sht30-sensor
# sudo systemctl status sht30-sensor
#
# ※ WorkingDirectory/ExecStart 경로는 실제 배치 경로에 맞게 수정.
# 아래는 /home/pi/raspberry_pi4_sht30 에 배치한 경우 기준.
# * Adjust the WorkingDirectory/ExecStart paths to match the actual deployment path.
# The example below assumes deployment under /home/pi/raspberry_pi4_sht30.
# =============================================================================
[Unit]
@ -22,7 +22,7 @@ Wants=network-online.target
Type=simple
User=pi
Group=pi
# /dev/i2c-1 접근에 i2c 그룹 필요
# i2c group required to access /dev/i2c-1
SupplementaryGroups=i2c
WorkingDirectory=/home/pi/raspberry_pi4_sht30
ExecStart=/home/pi/raspberry_pi4_sht30/venv/bin/python sht30_sensor.py
@ -32,19 +32,19 @@ StandardOutput=journal
StandardError=journal
SyslogIdentifier=sht30-sensor
# 환경 변수
# Environment variables
Environment=PYTHONUNBUFFERED=1
# 프로세스 전용 /tmp 격리
# Process-private /tmp isolation
PrivateTmp=true
# ── 샌드박싱(하드닝) — 데몬 침해 시 root 승격·파일시스템 오염을 차단 ──────────
# User=pi 는 NOPASSWD sudo 계정이므로 NoNewPrivileges 로 setuid(sudo) 승격 경로를 막는다.
# -- Sandboxing (hardening) -- blocks root escalation and filesystem tampering if the daemon is compromised --------
# User=pi is a NOPASSWD sudo account, so NoNewPrivileges blocks the setuid(sudo) escalation path.
NoNewPrivileges=true
# 루트파일시스템 읽기전용 + /var/log 만 쓰기 허용(로그 로테이션 포함). /tmp 는 PrivateTmp.
# Root filesystem read-only + only /var/log writable (including log rotation). /tmp is PrivateTmp.
ProtectSystem=strict
ReadWritePaths=/var/log
# /home 는 읽기전용(자기 코드/config_local.py 읽기만; 쓰기·타 계정 홈 접근 차단).
# /home is read-only (reads own code/config_local.py only; blocks writes and access to other accounts' homes).
ProtectHome=read-only
ProtectKernelModules=true
ProtectKernelTunables=true
@ -52,13 +52,13 @@ ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictRealtime=true
# HTTPS(POST)+로컬소켓(DNS/journal)만. I2C 는 /dev/i2c-1 char (소켓 아님)라 무관.
# AF_NETLINK 필수 — glibc getaddrinfo/NSS 가 로컬주소 열거에 netlink 를 쓴다
# (빼면 DNS 해석이 간헐 실패 → 서버 POST 불가). AF_INET/6=HTTPS, AF_UNIX=DNS·journal.
# HTTPS(POST) + local sockets (DNS/journal) only. I2C is unaffected since /dev/i2c-1 is a char device (not a socket).
# AF_NETLINK required -- glibc getaddrinfo/NSS uses netlink to enumerate local addresses
# (omitting it makes DNS resolution intermittently fail -> server POST impossible). AF_INET/6=HTTPS, AF_UNIX=DNS/journal.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
# ※ PrivateDevices 는 쓰지 않는다 — /dev/i2c-1 이 숨겨져 센서 접근 불능.
# * Do not use PrivateDevices -- it hides /dev/i2c-1 and makes the sensor inaccessible.
# 재시작 상한 (10분 내 5번 실패 시 중단)
# Restart cap (stop after 5 failures within 10 minutes)
StartLimitBurst=5
StartLimitInterval=600s

View file

@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""서버 연결 테스트 — Pi 4 SHT30(2번) 모듈에서 실행.
"""Server connection test — run on the Pi 4 SHT30 (Sensor 2) module.
wire.py 로 본문을 만들고 raw-body HMAC(X-Signature)을 붙여 실제로 한 번
서명된 POST 를 보낸다. 비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다.
Builds the body with wire.py, attaches a raw-body HMAC (X-Signature), and
actually sends one signed POST. Secrets (API_URL/API_KEY) are loaded from config_local.py.
"""
import sys
import time
@ -10,7 +10,7 @@ import subprocess
import requests
signed_ok = False # 서명 POST 200 여부 — setup.sh 가 이 exit 코드로 하드닝을 게이트한다
signed_ok = False # whether the signed POST returned 200 — setup.sh gates hardening on this exit code
from config import (
API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION,
@ -21,12 +21,12 @@ import wire
API_BASE = API_URL.rsplit("/", 1)[0]
print("=" * 50)
print(" 서버 연결 테스트 (Pi 4 SHT30 / 2번 모듈)")
print(" Server connection test (Pi 4 SHT30 / Sensor 2 module)")
print("=" * 50)
print(f" API_URL = {API_URL}")
# ── 1단계: curl 로 엔드포인트 도달 확인 (서명 없음 → 403 인증실패가 정상) ──
print("\n[1/2] curl 로 sensor_data.php 도달 확인 (서명 없이 → 인증실패 403 이 정상)...")
# ── Step 1: check endpoint reachability with curl (no signature → 403 auth failure is expected) ──
print("\n[1/2] Checking sensor_data.php reachability with curl (no signature → 403 auth failure is expected)...")
try:
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
@ -35,20 +35,20 @@ try:
capture_output=True, text=True, timeout=10
)
code = result.stdout.strip()
print(f" HTTP 응답코드: {code}")
print(f" HTTP status code: {code}")
if code in ("400", "403"):
print(" → 엔드포인트 도달 OK (서명/필드 없이 거부됨 — 정상)")
print(" → Endpoint reachable OK (rejected without signature/fields — expected)")
elif code == "200":
print(" → 200? 서버가 서명 없이 수락 — 서버 설정 점검 필요")
print(" → 200? Server accepted without a signature — check server configuration")
elif code == "404":
print(" → 404: API_URL 경로 확인 필요")
print(" → 404: check the API_URL path")
else:
print(f" → 응답: {code} (호스팅/방화벽/.htaccess 점검)")
print(f" → Response: {code} (check hosting/firewall/.htaccess)")
except Exception as e:
print(f" → curl 실패: {e}")
print(f" → curl failed: {e}")
# ── 2단계: wire.py 로 서명한 실제 측정 POST (startup, 더미값) ──
print("\n[2/2] 서명된 측정 POST (startup, 더미 24.00C/48.00%)...")
# ── Step 2: real measurement POST signed with wire.py (startup, dummy values) ──
print("\n[2/2] Signed measurement POST (startup, dummy 24.00C/48.00%)...")
try:
ts = int(time.time())
body = wire.sht30_event_body(
@ -56,7 +56,7 @@ try:
"startup", ts, 24.0, 48.0, "normal", APP_VERSION,
)
sig = wire.sign_raw(API_KEY, body)
print(f" 본문: {body}")
print(f" Body: {body}")
print(f" X-Signature: {sig[:16]}...")
resp = requests.post(
API_URL, data=body.encode("utf-8"),
@ -68,23 +68,23 @@ try:
},
timeout=10,
)
print(f" HTTP 응답코드: {resp.status_code}")
print(f" 응답 내용: {resp.text[:200]}")
print(f" HTTP status code: {resp.status_code}")
print(f" Response body: {resp.text[:200]}")
if resp.status_code == 200:
signed_ok = True
print(" → 성공! 서명 검증 통과, 측정 저장됨 (대시보드 sensor_id=2 확인)")
print(" → Success! Signature verified, measurement stored (check dashboard sensor_id=2)")
elif resp.status_code == 403:
print(" → 403 인증 실패: API_KEY 가 서버 config.local.php 와 다르거나 서명 불일치")
print(" → 403 auth failure: API_KEY differs from the server's config.local.php, or signature mismatch")
elif resp.status_code == 400:
print(" → 400: timestamp 신선도(±600s) 확인 — Pi 시각(NTP) 동기 점검")
print(" → 400: check timestamp freshness (±600s) — verify Pi clock (NTP) sync")
else:
print(f" → 응답코드: {resp.status_code}")
print(f" → Status code: {resp.status_code}")
except ImportError:
print(" → requests 미설치. install.sh 실행 또는 sudo apt install python3-requests")
print(" → requests not installed. Run install.sh or sudo apt install python3-requests")
except Exception as e:
print(f" → 실패: {e}")
print(f" → Failed: {e}")
print("\n" + "=" * 50)
print(" 테스트 완료" + ("" if signed_ok else " — 서명 POST 실패(위 로그 확인)"))
print(" Test complete" + ("" if signed_ok else " — signed POST failed (see log above)"))
print("=" * 50)
sys.exit(0 if signed_ok else 1)

View file

@ -1,24 +1,24 @@
#!/usr/bin/env python3
"""센서/포맷 자가점검 — Pi 4 SHT30(2번) 모듈.
"""Sensor/format self-check — Pi 4 SHT30 (Sensor 2) module.
1) CRC-8 / fixed2 / 본문 포맷이 펌웨어·서버 규약과 일치하는지 단위 점검
2) 실제(또는 시뮬레이션) SHT30 측정 1회 → 변환값 출력
1) Unit-check that CRC-8 / fixed2 / body format match the firmware and server spec
2) Take one real (or simulated) SHT30 measurement -> print converted values
하드웨어 없이도 1)은 항상 통과해야 한다(서명 호환성의 핵심 보증).
Step 1) must always pass even without hardware (core guarantee of signature compatibility).
"""
import sys
import wire
from sht30_driver import crc8, parse_frame, SHT30, SMBUS_AVAILABLE
# 포맷/CRC 자가점검은 비밀값(config_local.py) 없이도 가능해야 한다.
# config.py 는 config_local.py 가 없으면 import 시 fail-closed 로 중단하므로,
# 개발 PC 점검을 위해 기본값으로 폴백한다(서버 연결은 test_connection.py 담당).
# The format/CRC self-check must work even without secrets (config_local.py).
# Since config.py fails closed and aborts on import when config_local.py is missing,
# fall back to defaults for dev-PC checks (server connection is handled by test_connection.py).
try:
from config import I2C_BUS, I2C_ADDR, FORCE_SIMULATION
except RuntimeError:
I2C_BUS, I2C_ADDR, FORCE_SIMULATION = 1, 0x44, False
print(" [info] config_local.py 없음 → I2C 기본값(bus=1, 0x44)으로 점검")
print(" [info] config_local.py missing -> checking with I2C defaults (bus=1, 0x44)")
def check(name, got, want):
@ -28,25 +28,25 @@ def check(name, got, want):
print("=" * 50)
print(" SHT30 자가점검 (2번 모듈)")
print(" SHT30 self-check (Sensor 2 module)")
print("=" * 50)
passed = True
# ── 1) CRC-8 점검 (Sensirion 표준 벡터 0xBEEF → 0x92) ──
# ── 1) CRC-8 check (Sensirion standard vector 0xBEEF → 0x92) ──
print("\n[1] CRC-8 (poly 0x31, init 0xFF):")
passed &= check("crc8(0xBEEF)", crc8(b"\xBE\xEF"), 0x92)
# ── 2) fixed2 점검 (round-half-up, reference.py/jb_fixed2 동일) ──
print("\n[2] fixed2 (소수 2자리 round-half-up):")
# ── 2) fixed2 check (round-half-up, same as reference.py/jb_fixed2) ──
print("\n[2] fixed2 (2 decimal places, round-half-up):")
passed &= check("24.0", wire.fixed2(24.0), "24.00")
passed &= check("48.5", wire.fixed2(48.5), "48.50")
passed &= check("-3.245", wire.fixed2(-3.245), "-3.25")
passed &= check("0.0", wire.fixed2(0.0), "0.00")
# ── 3) 프레임 파싱 점검 (CRC 포함 합성 프레임) ──
print("\n[3] parse_frame (합성 프레임 변환):")
raw_t, raw_rh = 0x6400, 0x8000 # 임의 raw 값
# ── 3) Frame parsing check (synthetic frame including CRC) ──
print("\n[3] parse_frame (synthetic frame conversion):")
raw_t, raw_rh = 0x6400, 0x8000 # arbitrary raw values
frame = bytes([
raw_t >> 8, raw_t & 0xFF, crc8(bytes([raw_t >> 8, raw_t & 0xFF])),
raw_rh >> 8, raw_rh & 0xFF, crc8(bytes([raw_rh >> 8, raw_rh & 0xFF])),
@ -57,8 +57,8 @@ exp_rh = 100.0 * (raw_rh / 65535.0)
passed &= check("temp_c", round(t, 4), round(exp_t, 4))
passed &= check("rh", round(rh, 4), round(exp_rh, 4))
# ── 4) 본문 포맷 점검 (필드 순서/타입) ──
print("\n[4] 본문 포맷 (필드 순서 고정):")
# ── 4) Body format check (field order/type) ──
print("\n[4] Body format (fixed field order):")
body = wire.sht30_event_body("dev", "loc", 2, "s2", "periodic",
1700000000, 24.0, 48.0, "normal", "vX")
expected = ('{"device_id":"dev","device_location":"loc","sensor_id":2,'
@ -66,22 +66,22 @@ expected = ('{"device_id":"dev","device_location":"loc","sensor_id":2,'
'"metric_type":"sht30","temperature_c":24.00,"humidity_percent":48.00,'
'"metric_status":"normal","app_version":"vX"}')
passed &= check("body", body, expected)
print(f" 서명(key=test): {wire.sign_raw('test', body)[:16]}...")
print(f" Signature(key=test): {wire.sign_raw('test', body)[:16]}...")
# ── 5) 실제/시뮬레이션 측정 1회 ──
print("\n[5] 측정 1회:")
# ── 5) One real/simulated measurement ──
print("\n[5] One measurement:")
sim = FORCE_SIMULATION or not SMBUS_AVAILABLE
print(f" 모드: {'시뮬레이션' if sim else f'실제 I2C(bus={I2C_BUS},0x{I2C_ADDR:02X})'}")
print(f" Mode: {'simulation' if sim else f'real I2C(bus={I2C_BUS},0x{I2C_ADDR:02X})'}")
try:
with SHT30(bus_num=I2C_BUS, addr=I2C_ADDR, simulate=sim) as s:
t, rh = s.measure()
print(f" 측정값: T={t:.2f}C RH={rh:.2f}%")
print(f" Reading: T={t:.2f}C RH={rh:.2f}%")
except Exception as e:
print(f" 측정 실패: {e}")
print(f" Measurement failed: {e}")
if not sim:
print(" (i2cdetect -y 1 로 0x44 응답 확인 / 배선·풀업·I2C 활성화 점검)")
print(" (check 0x44 response with i2cdetect -y 1 / check wiring, pull-ups, I2C enabled)")
print("\n" + "=" * 50)
print(f" 결과: {'전체 통과 ✅' if passed else '실패 항목 있음 ❌'}")
print(f" Result: {'ALL PASSED ✅' if passed else 'SOME FAILED ❌'}")
print("=" * 50)
sys.exit(0 if passed else 1)

View file

@ -1,13 +1,13 @@
#!/usr/bin/env bash
# =============================================================================
# vendor.sh - 폐쇄망 설치용 의존성 사전 다운로드 (SHT30 2번 모듈)
# 인터넷에 연결된 빌드/스테이징 머신에서 실행한다.
# 산출물: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → 타깃 Pi 4 로 전송.
# vendor.sh - Pre-download dependencies for air-gapped install (SHT30 sensor 2 module)
# Run this on a build/staging machine that has internet access.
# Outputs: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → transfer to the target Pi 4.
#
# requests 는 arch 종속이 적으나 smbus2 는 순수 파이썬(py3-none-any)이라 어떤
# 빌드 머신에서 받아도 Pi 4 에서 동작한다. wheel 핀은 타깃(Bookworm 64-bit,
# aarch64, Python 3.11)에 맞춘다. .deb 는 타깃 OS/arch 에서 받는 것이 가장 안전.
# 환경변수: TARGET_PLATFORM(기본 manylinux2014_aarch64), TARGET_PYVER(기본 311)
# requests has few arch dependencies, but smbus2 is pure Python (py3-none-any), so it
# works on the Pi 4 no matter which build machine downloads it. Pin wheels to the target
# (Bookworm 64-bit, aarch64, Python 3.11). The .deb files are safest downloaded on the target OS/arch.
# Env vars: TARGET_PLATFORM (default manylinux2014_aarch64), TARGET_PYVER (default 311)
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")"
@ -18,58 +18,58 @@ TARGET_ABI="${TARGET_ABI:-cp${TARGET_PYVER}}"
echo "[vendor] (1/2) Python wheel → wheelhouse/ (platform=${TARGET_PLATFORM}, py=${TARGET_PYVER})"
mkdir -p wheelhouse
# requests 등 arch 종속 wheel: 타깃 플랫폼 명시 핀.
# requests and other arch-dependent wheels: pin the explicit target platform.
python3 -m pip download -r requirements.txt -d wheelhouse \
--only-binary=:all: \
--platform "${TARGET_PLATFORM}" \
--python-version "${TARGET_PYVER}" \
--implementation cp \
--abi "${TARGET_ABI}" \
|| echo "[WARN] 일부 wheel 의 타깃 플랫폼 핀 실패 — 순수파이썬(any) 폴백을 아래에서 받는다."
# smbus2(py3-none-any) 등 순수 파이썬 wheel 및 sdist 폴백.
|| echo "[WARN] Target-platform pin failed for some wheels — pure-Python (any) fallback is downloaded below."
# smbus2 (py3-none-any) and other pure-Python wheels plus sdist fallback.
python3 -m pip download -r requirements.txt -d wheelhouse \
|| echo "[WARN] sdist/any 폴백 일부 실패 (바이너리 wheel 이 모두 있으면 무시 가능)"
|| echo "[WARN] Some sdist/any fallbacks failed (safe to ignore if all binary wheels are present)"
WHEEL_COUNT="$(ls -A wheelhouse 2>/dev/null | wc -l)"
echo " wheelhouse 파일 수: ${WHEEL_COUNT}"
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse 비어있음"; exit 1; }
echo " wheelhouse file count: ${WHEEL_COUNT}"
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse is empty"; exit 1; }
echo "[vendor] (2/2) apt .deb → debs/"
mkdir -p debs
# nftables·unattended-upgrades 도 포함 → harden_pi.sh 를 폐쇄망에서 오프라인 실행 가능.
# nftables and unattended-upgrades are included too → harden_pi.sh can run offline in the air-gapped env.
BASE_PKGS="python3-requests python3-venv i2c-tools nftables unattended-upgrades"
DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
| awk '/^\w/{print $1}' | sort -u)"
( cd debs && apt-get download ${BASE_PKGS} ${DEPS} ) 2>/dev/null \
|| echo "[WARN] 일부 .deb 다운로드 실패 (가상패키지/전이 의존성일 수 있음 — 필수 패키지는 아래에서 검증)"
|| echo "[WARN] Some .deb downloads failed (may be virtual/transitive deps — required packages are verified below)"
MISS=0
for p in ${BASE_PKGS}; do
if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then
echo "[ERROR] 필수 패키지 .deb 누락: ${p}"
echo "[ERROR] Required package .deb missing: ${p}"
MISS=1
fi
done
[ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; }
echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)"
[ "${MISS}" = 0 ] || { echo "[ERROR] Required .deb missing — you must run vendor.sh on the target arch (aarch64 Bookworm)."; exit 1; }
echo " debs file count: $(ls debs/*.deb 2>/dev/null | wc -l) (all required packages verified)"
# 아키텍처 실검사 — 파일명이 아니라 .deb 내부 Architecture 필드로 타깃(arm64/all) 검증.
# (호스트가 amd64면 apt-get download 가 amd64 .deb 를 받아, 타깃 Pi 에서 dpkg 가
# "wrong architecture" 로 깨진다. 파일명만 보는 검사는 이를 못 잡는다.)
# Real architecture check — verify the target (arm64/all) via the .deb's internal Architecture field, not the filename.
# (If the host is amd64, apt-get download fetches amd64 .deb files, and dpkg on the target Pi
# breaks with "wrong architecture". A filename-only check can't catch this.)
BAD_ARCH=0
for deb in debs/*.deb; do
[ -e "$deb" ] || continue
arch="$(dpkg-deb --field "$deb" Architecture 2>/dev/null || echo '?')"
case "$arch" in
arm64|all) : ;;
*) echo "[ERROR] arch 불일치: $(basename "$deb") = ${arch} (arm64/all 아님)"; BAD_ARCH=1 ;;
*) echo "[ERROR] arch mismatch: $(basename "$deb") = ${arch} (not arm64/all)"; BAD_ARCH=1 ;;
esac
done
if [ "${BAD_ARCH}" != 0 ]; then
echo "[ERROR] 타깃(aarch64/arm64) 이 아닌 .deb 가 있습니다. 호스트 arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2
echo " 반드시 arm64 Pi(또는 arm64 chroot/qemu)에서 vendor.sh 를 실행하세요." >&2
echo "[ERROR] There are .deb files that are not the target (aarch64/arm64). host arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2
echo " You must run vendor.sh on an arm64 Pi (or an arm64 chroot/qemu)." >&2
exit 1
fi
echo " arch 검사 통과 (모든 .deb = arm64/all)"
echo " arch check passed (all .deb = arm64/all)"
echo "[vendor] 완료. wheelhouse/ 와 debs/ 를 타깃 Pi 4 로 전송 후 install.sh 실행."
echo "[vendor] Done. Transfer wheelhouse/ and debs/ to the target Pi 4, then run install.sh."

View file

@ -1,18 +1,18 @@
# =============================================================================
# wire.py - 서버 전송용 wire 포맷 (본문 JSON + raw-body HMAC 서명)
# wire.py - wire format for server transmission (body JSON + raw-body HMAC signature)
#
# [왜 별도 모듈인가]
# 서버(php/api/sensor_data.php)는 받은 "본문 바이트 그대로"에 대해
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)) 를 검증한다.
# 따라서 "서명한 바이트"와 "실제 전송한 바이트"가 100% 동일해야 한다.
# 본문을 직접 문자열로 만들고 그 바이트에 서명한 뒤, 그 바이트를 그대로 POST 한다.
# (requests 의 json= 직렬화에 맡기면 부동소수/공백/순서가 달라져 서명이 깨질 수 있다.)
# [Why a separate module]
# The server (php/api/sensor_data.php) verifies, against the received "body bytes as-is",
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)).
# Therefore the "signed bytes" and the "actually transmitted bytes" must be 100% identical.
# We build the body directly as a string, sign those bytes, then POST those same bytes as-is.
# (Leaving serialization to requests' json= can change float/whitespace/order and break the signature.)
#
# [바이트 단위 호환 대상] — 아래 넷과 출력이 동일해야 한다.
# - firmware/common/jsonbody.c::jb_sht30_event (STM32 본문 생성)
# - firmware/common/sig.c::sig_raw_body (STM32 HMAC 서명)
# - firmware/test/host/reference.py (검증된 Python 레퍼런스)
# - php/config.php::verify_signature_raw (서버 검증부)
# [Byte-level compatibility targets] — output must be identical to the four below.
# - firmware/common/jsonbody.c::jb_sht30_event (STM32 body generation)
# - firmware/common/sig.c::sig_raw_body (STM32 HMAC signature)
# - firmware/test/host/reference.py (verified Python reference)
# - php/config.php::verify_signature_raw (server verification part)
#
# fixed2() <-> jb_fixed2() / reference.fixed2()
# sht30_event_body() <-> jb_sht30_event() / reference.sht30_event_body()
@ -25,9 +25,9 @@ import hmac
def fixed2(v: float) -> str:
"""소수 2자리 고정 포맷 (round-half-up, locale 무관).
"""Fixed 2-decimal format (round-half-up, locale-independent).
jb_fixed2() / reference.fixed2() 와 바이트 동일.
Byte-identical to jb_fixed2() / reference.fixed2().
24.0 -> "24.00"
48.5 -> "48.50"
-3.245 -> "-3.25"
@ -43,10 +43,10 @@ def fixed2(v: float) -> str:
def _esc(s: str) -> str:
r"""JSON 문자열 값 이스케이프 (jb_estr / reference._esc 와 동일).
r"""Escape JSON string value (identical to jb_estr / reference._esc).
'"' 와 '\\' 와 제어문자(<0x20)만 이스케이프한다. '/' 는 그대로 둔다.
멀티바이트 UTF-8(한글 등)은 그대로 둔다(서버가 raw 바이트로 검증).
Escapes only '"', '\\', and control characters (<0x20). '/' is left as-is.
Multibyte UTF-8 (e.g. Korean) is left as-is (server verifies raw bytes).
"""
out = []
for ch in s:
@ -66,10 +66,10 @@ def sht30_event_body(device_id: str, device_location: str, sensor_id: int,
sensor_name: str, event_type: str, timestamp: int,
temperature_c: float, humidity_percent: float,
metric_status: str, app_version: str) -> str:
"""SHT30 이벤트 본문 JSON 문자열 생성.
"""Generate SHT30 event body JSON string.
필드 순서는 펌웨어 jb_sht30_event() 와 동일하게 고정한다(서명 호환).
metric_type 은 항상 "sht30" 고정. 온도/습도는 fixed2 로 2자리 직렬화.
Field order is fixed to match firmware jb_sht30_event() (signature compatibility).
metric_type is always fixed to "sht30". Temperature/humidity serialized to 2 decimals via fixed2.
"""
return (
'{'
@ -89,10 +89,10 @@ def sht30_event_body(device_id: str, device_location: str, sensor_id: int,
def sign_raw(api_key: str, body: str) -> str:
"""raw-body 서명: lowercase_hex(HMAC-SHA256(key=api_key, msg=body)).
"""raw-body signature: lowercase_hex(HMAC-SHA256(key=api_key, msg=body)).
sig_raw_body() / reference.sign_raw() / php verify_signature_raw 와 동일.
본문은 UTF-8 로 인코딩한 뒤 서명한다(전송 바이트와 동일).
Identical to sig_raw_body() / reference.sign_raw() / php verify_signature_raw.
The body is encoded as UTF-8 then signed (same as transmitted bytes).
"""
return hmac.new(api_key.encode("utf-8"), body.encode("utf-8"),
hashlib.sha256).hexdigest()

View file

@ -0,0 +1,48 @@
# 미니 상태 표시기 — Pi Zero 2 W + ST7789V (GMT020-02 2.0" 240×320)
관제 서버의 요약 상태를 2인치 IPS 화면에 상시 표시하는 글랜스 장치.
서버 `api/status_summary.php` 를 30초 폴링(읽기 전용, X-Status-Key HMAC 인증),
페이지 자동 순환: 시스템 요약 → 센서별(누수/온습도) 상세.
배경색 = 시스템 상태(정상 녹색 / 온습도 경보 주황 / 누수 청색 / 오프라인 회색 /
서버 무응답 적색 STALE).
## 배선 (GMT020-02 7핀 ↔ Zero 2 W 40핀, BCM)
| LCD 핀 | 기능 | Pi 핀(BCM) | 물리 핀 |
|---|---|---|---|
| VCC | 3.3V | 3V3 | 17 |
| GND | GND | GND | 20 |
| SCL | SPI SCLK | GPIO11 | 23 |
| SDA | SPI MOSI | GPIO10 | 19 |
| RES | Reset | GPIO24 | 18 |
| DC | Data/Cmd | GPIO25 | 22 |
| CS | Chip Select | GPIO8 (CE0) | 24 |
핀 변경 시 `config.py` 의 PIN_* 수정. 상세 그림: `WIRING.html`.
## 설치
```bash
cp config_local.example.py config_local.py # STATUS_URL / STATUS_KEY 입력(파생키 — API_KEY 아님)
./install.sh # SPI 활성 + apt + venv (번들 있으면 오프라인)
python3 test_connection.py # 서버 연결 검증
sudo cp systemd/display-dashboard.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now display-dashboard
```
폐쇄망: 빌드 PC에서 `bash vendor.sh` → `debs/`+`wheelhouse/` 동봉 후 USB 이송.
하드닝: 센서 노드와 동일 `harden_pi.sh` 사용 가능(아웃바운드 전용 장치).
## 화면 텍스트
기본 폰트 DejaVu → 라벨은 영문 축약(ALL OK / LEAK! / TEMP C …).
한글 표시를 원하면 한글 TTF 를 복사하고 `config.py` FONT_PATH 를 바꾼다
(폐쇄망이므로 폰트 파일도 USB 로 반입).
## 문제 해결
- 화면 백지: SPI 활성 여부(`ls /dev/spidev0.0`), 배선(DC/RES 스왑 흔함)
- 색 이상: `config.py` INVERT 토글
- 403: STATUS_KEY 불일치 — `php -r 'echo hash_hmac("sha256","status","<서버 API_KEY>");'` 로 재계산
- 서비스 로그: `journalctl -u display-dashboard -e`
- 하드웨어 없이 렌더 확인: `FORCE_SIMULATION = True` → `/tmp/display_preview.png`

View file

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>배선도 — Pi Zero 2 W + ST7789V (GMT020-02)</title>
<style>
body { font-family: system-ui, "Malgun Gothic", sans-serif; max-width: 720px; margin: 30px auto; padding: 0 16px; line-height: 1.6; }
h1 { font-size: 1.75rem; } h2 { font-size: 1.25rem; margin-top: 28px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #bbb; padding: 8px 12px; text-align: left; font-size: .9rem; }
th { background: #f2f2f5; }
.pin { font-family: Consolas, monospace; font-weight: 700; }
.warn { background: #fff6e0; border: 1px solid #e5c56a; border-radius: 8px; padding: 10px 14px; font-size: .9rem; }
/* 핀맵(보드 위에서 본 40핀 헤더) */
.pinmap { border-collapse: collapse; margin: 12px auto; }
.pinmap td { border: 1px solid #999; padding: 4px 8px; font-size: .78rem; text-align: center; min-width: 108px; }
.pinmap .no { min-width: 34px; font-family: Consolas, monospace; font-weight: 700; background: #f2f2f5; }
.pinmap .use { background: #dff3e4; font-weight: 700; }
.pinmap .use small { display: block; color: #1a7a3c; font-weight: 700; font-size: .7rem; }
.pinmap .pwr3 { background: #ffe9e0; } .pinmap .pwr5 { background: #ffd9d9; } .pinmap .gnd { background: #e8e8ec; }
.pinmap .pwr3.use, .pinmap .gnd.use { background: #dff3e4; }
.legend { display: flex; gap: 14px; justify-content: center; font-size: .78rem; margin-bottom: 4px; flex-wrap: wrap; }
.legend i { display: inline-block; width: 12px; height: 12px; border: 1px solid #999; vertical-align: -2px; margin-right: 4px; }
</style>
</head>
<body>
<h1>배선도 — Pi Zero 2 W + ST7789V GMT020-02 (2.0" 240×320 IPS)</h1>
<p class="warn">⚠️ 반드시 <b>3.3V</b> 사용. 5V 연결 시 패널 손상. 배선은 전원 차단 상태에서.</p>
<h2>연결표 (7핀)</h2>
<table>
<tr><th>LCD 핀</th><th>기능</th><th>Pi GPIO (BCM)</th><th>물리 핀 번호</th><th>선색 예시</th></tr>
<tr><td class="pin">VCC</td><td>전원 3.3V</td><td class="pin">3V3</td><td>17</td><td>빨강</td></tr>
<tr><td class="pin">GND</td><td>접지</td><td class="pin">GND</td><td>20</td><td>검정</td></tr>
<tr><td class="pin">SCL</td><td>SPI 클럭(SCLK)</td><td class="pin">GPIO11</td><td>23</td><td>주황</td></tr>
<tr><td class="pin">SDA</td><td>SPI 데이터(MOSI)</td><td class="pin">GPIO10</td><td>19</td><td>노랑</td></tr>
<tr><td class="pin">RES</td><td>리셋 (RST/RESET 과 동일 표기)</td><td class="pin">GPIO24</td><td>18</td><td>초록</td></tr>
<tr><td class="pin">DC</td><td>데이터/명령 선택</td><td class="pin">GPIO25</td><td>22</td><td>파랑</td></tr>
<tr><td class="pin">CS</td><td>칩 선택(CE0)</td><td class="pin">GPIO8</td><td>24</td><td>보라</td></tr>
</table>
<h2>Zero 2 W 40핀 핀맵 (위에서 본 그림)</h2>
<p><b>배선 7가닥 전부 물리 17~24번 블록</b>에 모여 있다 — 헤더 중간 8핀 구간만 쓰면 된다.</p>
<p>보드 부품면(위)에서 본 헤더. <b>SD 카드 슬롯이 위</b>, GPIO 헤더가 오른쪽 세로로 오는 방향 기준
— <b>핀 1 = 헤더 맨 위 왼쪽(사각 납땜 패드)</b>. 왼쪽 열=홀수 핀(보드 안쪽), 오른쪽 열=짝수 핀(보드 가장자리).</p>
<div class="legend">
<span><i style="background:#dff3e4"></i>이번 배선 사용</span>
<span><i style="background:#ffe9e0"></i>3.3V</span>
<span><i style="background:#ffd9d9"></i>5V</span>
<span><i style="background:#e8e8ec"></i>GND</span>
</div>
<table class="pinmap">
<tr><td class="pwr3">3V3</td><td class="no">1</td><td class="no">2</td><td class="pwr5">5V</td></tr>
<tr><td>GPIO2 (SDA1)</td><td class="no">3</td><td class="no">4</td><td class="pwr5">5V</td></tr>
<tr><td>GPIO3 (SCL1)</td><td class="no">5</td><td class="no">6</td><td class="gnd">GND</td></tr>
<tr><td>GPIO4</td><td class="no">7</td><td class="no">8</td><td>GPIO14 (TXD)</td></tr>
<tr><td class="gnd">GND</td><td class="no">9</td><td class="no">10</td><td>GPIO15 (RXD)</td></tr>
<tr><td>GPIO17</td><td class="no">11</td><td class="no">12</td><td>GPIO18</td></tr>
<tr><td>GPIO27</td><td class="no">13</td><td class="no">14</td><td class="gnd">GND</td></tr>
<tr><td>GPIO22</td><td class="no">15</td><td class="no">16</td><td>GPIO23</td></tr>
<tr><td class="pwr3 use">3V3<small>→ VCC</small></td><td class="no use">17</td><td class="no use">18</td><td class="use">GPIO24<small>→ RES</small></td></tr>
<tr><td class="use">GPIO10 (MOSI)<small>→ SDA</small></td><td class="no use">19</td><td class="no use">20</td><td class="gnd use">GND<small>→ GND</small></td></tr>
<tr><td>GPIO9 (MISO)</td><td class="no">21</td><td class="no use">22</td><td class="use">GPIO25<small>→ DC</small></td></tr>
<tr><td class="use">GPIO11 (SCLK)<small>→ SCL</small></td><td class="no use">23</td><td class="no use">24</td><td class="use">GPIO8 (CE0)<small>→ CS</small></td></tr>
<tr><td class="gnd">GND</td><td class="no">25</td><td class="no">26</td><td>GPIO7 (CE1)</td></tr>
<tr><td>ID_SD</td><td class="no">27</td><td class="no">28</td><td>ID_SC</td></tr>
<tr><td>GPIO5</td><td class="no">29</td><td class="no">30</td><td class="gnd">GND</td></tr>
<tr><td>GPIO6</td><td class="no">31</td><td class="no">32</td><td>GPIO12</td></tr>
<tr><td>GPIO13</td><td class="no">33</td><td class="no">34</td><td class="gnd">GND</td></tr>
<tr><td>GPIO19</td><td class="no">35</td><td class="no">36</td><td>GPIO16</td></tr>
<tr><td>GPIO26</td><td class="no">37</td><td class="no">38</td><td>GPIO20</td></tr>
<tr><td class="gnd">GND</td><td class="no">39</td><td class="no">40</td><td>GPIO21</td></tr>
</table>
<p class="warn">방향 확인법: 헤더 납땜면에서 <b>핀 1만 사각 패드</b>(나머지는 원형). 핀 1 은 SD 카드 슬롯과 같은 쪽 모서리다.</p>
<h2>점검</h2>
<ol>
<li>SPI 활성 확인: <code>ls /dev/spidev0.0</code> (install.sh 가 dtparam=spi=on 설정, 재부팅 필요할 수 있음)</li>
<li>화면 백지면 DC/RES 스왑 여부부터 확인(가장 흔한 실수)</li>
<li>색이 반전돼 보이면 <code>config.py</code> 의 <code>INVERT</code> 토글</li>
<li>렌더만 검증: <code>FORCE_SIMULATION = True</code> → <code>/tmp/display_preview.png</code></li>
</ol>
</body>
</html>

View file

@ -0,0 +1,50 @@
# =============================================================================
# config.py — 미니 상태 표시기 (Pi Zero 2 W + ST7789V GMT020-02 2.0" 240x320)
# 비밀값(STATUS_URL/STATUS_KEY)은 config_local.py 에서 로드한다(커밋 금지).
# cp config_local.example.py config_local.py 후 실값 입력.
# =============================================================================
# ─────────────────────────────────────────────
# 폴링/화면 주기
# ─────────────────────────────────────────────
POLL_INTERVAL = 30 # 서버 상태 폴링 주기(초) — 대시보드 자동 새로고침과 동일
PAGE_INTERVAL = 8 # 페이지 자동 순환 주기(초): 요약 ↔ 센서별
FETCH_TIMEOUT = 10 # HTTP 타임아웃(초)
STALE_AFTER = 120 # 마지막 성공 수신 후 이 시간(초) 지나면 STALE 표시
# ─────────────────────────────────────────────
# ST7789V SPI 배선 (BCM 번호) — README.md 배선표와 일치
# GMT020-02(7핀): GND / VCC / SCL=SCLK / SDA=MOSI / RES / DC / CS (BLK 없음)
# ─────────────────────────────────────────────
SPI_BUS = 0 # SPI0
SPI_DEVICE = 0 # CE0 (GPIO8)
SPI_SPEED_HZ = 40_000_000
PIN_DC = 25 # Data/Command
PIN_RST = 24 # Reset — 물리 18번(SDA 옆). 7핀을 물리 17~24 블록에 집약
PIN_BL = None # 7핀 모듈은 백라이트 핀 없음(내부 상시 점등). 8핀이면 BCM 번호(예: 18)
WIDTH = 240
HEIGHT = 320
ROTATION = 0 # 0/90/180/270 — 설치 방향에 맞춰 조정
INVERT = True # GMT020-02(IPS) 는 색반전 ON 이 정색
# 하드웨어 없이 개발/검증: True 면 SPI 대신 /tmp/display_preview.png 로 렌더 저장.
# spidev/RPi.GPIO 미설치 환경에서는 자동으로 시뮬레이션 폴백.
FORCE_SIMULATION = False
# 한글 라벨이 필요하면 한글 TTF 경로 지정(기본은 DejaVu — 영문/숫자만).
FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
FONT_PATH_LIGHT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
LOG_FILE = "/var/log/display_dashboard.log"
# ─────────────────────────────────────────────
# 비밀값 로드 (fail closed — config_local.py 없으면 즉시 중단)
# ─────────────────────────────────────────────
try:
from config_local import * # noqa: F401,F403 — STATUS_URL/STATUS_KEY/DEVICE_NAME
except ImportError as e:
raise RuntimeError(
"config_local.py not found. Run `cp config_local.example.py config_local.py`, "
"then fill in STATUS_URL / STATUS_KEY."
) from e

View file

@ -0,0 +1,409 @@
#!/usr/bin/env python3
# =============================================================================
# display_dashboard.py — 미니 상태 표시기 메인 루프 (Pi Zero 2 W + ST7789V 2.0")
#
# 서버 api/status_summary.php 를 30초 폴링 → 240x320 화면 렌더(Pillow) → SPI 블릿.
# 페이지 자동 순환: [0] 시스템 요약 → [1..n] 센서별 상세.
# 배경색 = 시스템 상태(정상 녹색 / 온습도 주황 / 미세먼지 마젠타 / 누수 청색 / 오프라인 회색).
# 서버 미응답 시 마지막 데이터 + STALE 배너(경과 시간) 표시.
#
# 글꼴: 기본 DejaVu(영문/숫자) — 2인치 글랜스 화면이라 라벨은 영문 축약.
# 한글 라벨 원하면 config FONT_PATH 에 한글 TTF 지정.
# =============================================================================
import sys
import time
import requests
from PIL import Image, ImageDraw, ImageFont
import config
from st7789_driver import open_display
W, H = config.WIDTH, config.HEIGHT
# 상태색 (배경, 강조) — 대시보드 팔레트와 동일 계열
STATE_COLORS = {
"ok": ((10, 40, 28), (53, 214, 160)),
"alert": ((52, 30, 6), (255, 178, 62)),
"pm": ((42, 12, 48), (208, 120, 236)), # 미세먼지 — 마젠타/바이올렛(온습도 주황과 명확히 구분)
"leak": ((8, 30, 52), (63, 199, 230)),
"offline": ((30, 32, 44), (120, 133, 184)),
"stale": ((40, 12, 20), (255, 84, 112)),
}
INK = (234, 238, 255) # 1차: 값/제목
INK2 = (150, 160, 200) # 2차: 보조 텍스트
INK3 = (96, 104, 140) # 3차: eyebrow 라벨·눈금
HAIR = (42, 48, 72) # 헤어라인 구분선
TEMP_C = (255, 149, 100)
HUM_C = (74, 194, 224)
PM_C = (208, 120, 236) # 미세먼지 수치 — 마젠타(온습도경보로 오독 방지)
PAD = 16 # 좌우 여백(8px 그리드)
def font(size, bold=True):
path = config.FONT_PATH if bold else config.FONT_PATH_LIGHT
try:
return ImageFont.truetype(path, size)
except OSError:
return ImageFont.load_default()
# 타이포 위계: huge(위젯 히어로) > hero(상태) > value(수치) > label > eyebrow
F_HUGE = font(66)
F_HERO = font(44)
F_BIG = font(44) # draw_error 호환 별칭
F_VALUE = font(30)
F_MED = font(30) # 호환 별칭
F_LABEL = font(16)
F_SM = font(16) # 호환 별칭
F_XS = font(13, bold=False)
F_EYE = font(12, bold=False)
def _dim(color, f=0.30):
"""색을 배경쪽으로 눌러 채움용 저채도 버전 생성."""
return tuple(int(c * f) for c in color)
def _eyebrow(d, xy, text, fill=INK3, ls=2):
"""트래킹된 대문자 소형 라벨(Apple eyebrow). 그린 뒤 끝 x 반환."""
x, y = xy
for ch in text.upper():
d.text((x, y), ch, font=F_EYE, fill=fill)
x += d.textlength(ch, font=F_EYE) + ls
return x
def fetch_status():
# 최소 권한: config_local 에는 파생 STATUS_KEY 만 있다(API_KEY 비보유).
r = requests.get(
config.STATUS_URL,
headers={
"X-Status-Key": config.STATUS_KEY,
# 커스텀 UA — 공유호스팅 WAF가 기본 python-requests UA를 403 차단하므로
"User-Agent": "LeakDisplay/2.0",
},
timeout=config.FETCH_TIMEOUT,
)
r.raise_for_status()
return r.json()
def state_label(state):
return {"ok": "ALL OK", "alert": "ALERT", "pm": "PM ALERT",
"leak": "LEAK!", "offline": "OFFLINE"}.get(state, state.upper())
def sensor_state(s):
"""센서 1개의 표시 상태 — 우선순위 누수 > 미세먼지 > 온습도 > offline > ok.
(기존 버그: offline 을 leak/미세먼지보다 먼저 판정 → 오프라인이 누수를 가림.
또한 미세먼지 티어 부재. 이 순서로 재정렬해 실제 경보가 오프라인/온습도에 묻히지 않게.)
누수·미세먼지는 값이 있으면 오프라인이라도 우선 노출한다.
"""
if s.get("leak") == 1:
return "leak", "LEAK!"
pm_status = s.get("pm_status", "normal") or "normal"
if pm_status != "normal":
return "pm", {"high_pm25": "PM2.5", "high_pm10": "PM10"}.get(pm_status, "DUST")
if s.get("pending"):
return "alert", "CHECK.."
# th_status 신규 키 우선, 없으면 metric_status 별칭(구 API 호환)
th_status = s.get("th_status", s.get("metric_status", "normal")) or "normal"
if th_status != "normal":
return "alert", th_status.replace("_", " ").upper()
if s.get("offline"):
return "offline", "OFFLINE"
return "ok", "OK"
def new_frame(state):
bg, accent = STATE_COLORS.get(state, STATE_COLORS["offline"])
img = Image.new("RGB", (W, H), bg)
d = ImageDraw.Draw(img)
d.rectangle([0, 0, W, 4], fill=accent) # 상단 상태 스트립(얇게)
return img, d, accent
def _hairline(d, y):
d.line([(PAD, y), (W - PAD, y)], fill=HAIR, width=1)
def draw_summary(data, stale_sec):
sys_ = data["system"]
state = "stale" if stale_sec is not None else sys_["state"]
img, d, accent = new_frame(state)
# ── 헤더: eyebrow → hero 상태 → 시각 ──────────────────────────────
_eyebrow(d, (PAD, 22), "SYSTEM STATUS")
d.text((PAD, 38), state_label(sys_["state"]), font=F_HERO, fill=accent)
if stale_sec is not None:
d.text((PAD, 90), f"STALE {int(stale_sec // 60)}m · no server",
font=F_XS, fill=(255, 150, 170))
else:
d.text((PAD, 90), data["ts"][5:16].replace("-", "."), font=F_XS, fill=INK2)
_hairline(d, 116)
# ── KPI 목록: 라벨(eyebrow) 좌 / 값 우 ───────────────────────────
rows = [
("ONLINE", f"{sys_['online']}/{sys_['total']}"),
("LEAK OPEN", str(sys_["unrecovered"])),
("TODAY ALERTS", str(sys_["today_alerts"])),
("SMS LEFT", str(sys_["sms_balance"]) if sys_["sms_balance"] is not None else "-"),
]
y = 130
for label, val in rows:
_eyebrow(d, (PAD, y + 4), label)
d.text((W - PAD, y), val, font=F_LABEL, fill=INK, anchor="ra")
y += 34
# ── 푸터: 센서 상태 점 ───────────────────────────────────────────
_hairline(d, H - 44)
x = PAD
for s in data["sensors"]:
st, _ = sensor_state(s)
_, dot = STATE_COLORS.get(st, STATE_COLORS["offline"])
cy = H - 24
d.ellipse([x, cy - 5, x + 10, cy + 5], fill=dot)
d.text((x + 16, cy - 9), f"S{s['id']}", font=F_LABEL, fill=INK)
x += 68
return img
def _sparkline(d, x, y, w, h, values, color):
"""면적 채움 + 추세선 + 현재값 도트. 반환: (lo, hi) 또는 None(데이터부족)."""
pts = [(i, v) for i, v in enumerate(values) if v is not None]
if len(pts) < 2:
d.text((x, y + h // 2 - 7), "no data", font=F_XS, fill=INK3)
return None
vals = [v for _, v in pts]
lo, hi = min(vals), max(vals)
span = (hi - lo) or 1.0
n = len(values)
coords = [
(x + w * (i / (n - 1) if n > 1 else 0), y + h - h * ((v - lo) / span))
for i, v in pts
]
# 저채도 면적 채움(라인 아래) → 라인 → 현재값 도트
poly = coords + [(coords[-1][0], y + h), (coords[0][0], y + h)]
d.polygon(poly, fill=_dim(color))
d.line(coords, fill=color, width=2)
cx, cyy = coords[-1]
d.ellipse([cx - 3, cyy - 3, cx + 3, cyy + 3], fill=color, outline=INK)
return lo, hi
def _leak_timeline(d, x, y, w, events, accent):
"""24h(0~1440분) 축에 누수 이벤트 시각(HH:MM)을 틱+도트로 표시."""
base = y + 12
d.line([(x, base), (x + w, base)], fill=HAIR, width=1)
d.text((x, base + 6), "0h", font=F_XS, fill=INK3)
d.text((x + w, base + 6), "24h", font=F_XS, fill=INK3, anchor="ra")
if not events:
d.text((x + w / 2, base - 18), "no events", font=F_XS, fill=INK3, anchor="ma")
return
for t in events:
try:
hh, mm = t.split(":")
frac = (int(hh) * 60 + int(mm)) / 1440.0
except (ValueError, AttributeError):
continue
px = x + w * frac
d.line([(px, base - 9), (px, base + 9)], fill=accent, width=2)
d.ellipse([px - 3, base - 13, px + 3, base - 7], fill=accent)
def _wide_spark(d, x, y, w, h, values, color):
"""넓은 히어로 추세: 면적+라인+도트, min/max 를 우측 상·하단 코너에."""
rng = _sparkline(d, x, y, w, h, values, color)
if rng is not None:
lo, hi = rng
d.text((x + w, y - 14), f"{hi:.0f}", font=F_XS, fill=INK3, anchor="ra")
d.text((x + w, y + h + 2), f"{lo:.0f}", font=F_XS, fill=INK3, anchor="ra")
def _leak_sub(s):
"""누수 서브상태 텍스트/색 — 결합 노드 ZONE A용(전체 상태와 독립 표기)."""
if s.get("leak") == 1:
return "LEAK!", STATE_COLORS["leak"][1]
return "DRY", STATE_COLORS["ok"][1]
def _pm_fmt(v):
"""PM 정수 표기 — 값 없으면 em dash."""
return "—" if v is None else str(int(round(v)))
def _zone_leak(d, s, y0):
"""ZONE A(y46~120) — 누수: 큰 DRY/LEAK! + 24h 이벤트 수."""
_eyebrow(d, (PAD, y0), "LEAK STATUS")
txt, col = _leak_sub(s)
d.text((PAD, y0 + 14), txt, font=F_HERO, fill=col)
d.text((W - PAD, y0 + 2), "24H EVENTS", font=F_EYE, fill=INK3, anchor="ra")
d.text((W - PAD, y0 + 18), str(len(s.get("leak_events") or [])),
font=F_VALUE, fill=INK, anchor="ra")
def _zone_pm(d, s, y0):
"""ZONE B(y128~198) — 미세먼지: PM2.5 큰 값(마젠타) + PM10 보조. 값 없으면 '—'."""
_eyebrow(d, (PAD, y0), "FINE DUST · PM2.5")
pm25 = _pm_fmt(s.get("pm25"))
d.text((PAD, y0 + 14), pm25, font=F_HERO, fill=PM_C)
w = d.textlength(pm25, font=F_HERO)
d.text((PAD + w + 8, y0 + 40), "µg/m³", font=F_XS, fill=INK3)
d.text((W - PAD, y0 + 2), "PM10", font=F_EYE, fill=INK3, anchor="ra")
d.text((W - PAD, y0 + 18), _pm_fmt(s.get("pm10")), font=F_VALUE, fill=PM_C, anchor="ra")
def _zone_th(d, s, y0):
"""ZONE C(y208~300) — 온습도: TEMP / HUM 2단(오직 sht30 행 값)."""
temp_txt = f"{s['temp']:.1f}°" if s.get("temp") is not None else "—"
hum_txt = f"{s['hum']:.0f}%" if s.get("hum") is not None else "—"
_eyebrow(d, (PAD, y0), "TEMP °C")
d.text((PAD, y0 + 16), temp_txt, font=F_HERO, fill=TEMP_C)
xr = W // 2 + 8
_eyebrow(d, (xr, y0), "HUM %")
d.text((xr, y0 + 16), hum_txt, font=F_HERO, fill=HUM_C)
def _draw_combined(d, s):
"""결합 노드(sensor 1·3): 240x320 3분할 — LEAK / 미세먼지 / 온습도."""
_zone_leak(d, s, 48)
_hairline(d, 124)
_zone_pm(d, s, 130)
_hairline(d, 202)
_zone_th(d, s, 210)
def _draw_metric(d, s):
"""온습도 전용 노드(sensor 2): 온도 히어로 + 습도 보조 (기존 레이아웃)."""
hist = s.get("history") or []
temps = [p.get("temp") for p in hist]
hums = [p.get("hum") for p in hist]
_eyebrow(d, (PAD, 46), "TEMPERATURE °C")
temp_txt = f"{s['temp']:.1f}°" if s.get("temp") is not None else "—"
# 이상치(예: -999.0)로 240px 를 넘으면 폭에 맞는 작은 폰트로 폴백(무단 클립 방지)
hero_font = F_HUGE if d.textlength(temp_txt, font=F_HUGE) <= W - 2 * PAD else F_HERO
d.text((PAD, 56), temp_txt, font=hero_font, fill=TEMP_C)
_wide_spark(d, PAD, 146, W - 2 * PAD, 58, temps, TEMP_C)
_hairline(d, 226)
_eyebrow(d, (PAD, 238), "HUMIDITY %")
hum_txt = f"{s['hum']:.0f}%" if s.get("hum") is not None else "—"
d.text((PAD, 252), hum_txt, font=F_VALUE, fill=HUM_C)
_sparkline(d, W - PAD - 96, 254, 96, 30, hums, HUM_C)
def _draw_leak_only(d, s, st_text, accent):
"""누수 전용 노드: 누수 히어로 + 24h 타임라인 (기존 레이아웃)."""
_eyebrow(d, (PAD, 46), "LEAK STATUS")
d.text((PAD, 64), st_text, font=F_HUGE, fill=accent)
_hairline(d, 190)
_eyebrow(d, (PAD, 202), "LEAK · 24H")
_leak_timeline(d, PAD, 224, W - 2 * PAD, s.get("leak_events") or [], accent)
def draw_sensor(data, idx, stale_sec):
s = data["sensors"][idx]
st, st_text = sensor_state(s)
frame_state = "stale" if stale_sec is not None else st
img, d, accent = new_frame(frame_state)
leak_node = bool(s.get("leak_node"))
metric_node = bool(s.get("metric_node"))
pm_node = bool(s.get("pm_node")) # 구 API 는 키 부재 → False
# ── 헤더 2행: (1) SENSOR id 좌 · 상태 우 (2) 능력 칩 ─────────
# 칩+상태를 한 줄에 몰면 결합 노드(칩 3개)에서 우측 상태와 겹친다 →
# 식별/상태는 위 행, 능력 칩은 아래 행으로 분리(충돌 없음).
chips = []
if leak_node:
chips.append("LEAK")
if metric_node:
chips.append("T/H")
if pm_node:
chips.append("PM")
_eyebrow(d, (PAD, 12), f"SENSOR {s['id']}")
d.text((W - PAD, 10), st_text, font=F_LABEL, fill=accent, anchor="ra")
_eyebrow(d, (PAD, 30), " · ".join(chips), fill=INK3)
# ── 본문: 능력 조합으로 분기(우발적 순서 아님) ────────────────
if leak_node and (metric_node or pm_node):
_draw_combined(d, s) # 결합 노드 3분할
elif metric_node:
_draw_metric(d, s) # 온습도 전용
elif leak_node:
_draw_leak_only(d, s, st_text, accent) # 누수 전용
else:
d.text((PAD, 120), st_text, font=F_HUGE, fill=accent)
# ── 푸터: 갱신 시각 ──────────────────────────────────────────
if s.get("last_seen"):
d.text((PAD, H - 22), f"upd {s['last_seen'][5:16]}", font=F_XS, fill=INK3)
return img
def draw_error(err_text):
img, d, accent = new_frame("stale")
_eyebrow(d, (PAD, 22), "CONNECTION")
d.text((PAD, 38), "NO DATA", font=F_HERO, fill=accent)
_hairline(d, 116)
d.text((PAD, 130), "Server unreachable", font=F_LABEL, fill=INK)
d.text((PAD, 158), str(err_text)[:64], font=F_XS, fill=INK2)
d.text((PAD, H - 30), time.strftime("%m-%d %H:%M"), font=F_XS, fill=INK3)
return img
def main():
print(f"[INFO] display_dashboard start — {getattr(config, 'DEVICE_NAME', 'display')}")
disp, simulated = open_display(config)
if simulated:
print("[INFO] simulation mode — frames saved to /tmp/display_preview.png")
data = None
last_ok = 0.0
last_fetch = 0.0
last_err = "not fetched yet"
page = 0
last_page_flip = 0.0
while True:
now = time.time()
if now - last_fetch >= config.POLL_INTERVAL or (data is None and now - last_fetch >= 5):
last_fetch = now
try:
data = fetch_status()
last_ok = now
except Exception as e:
last_err = e
print(f"[WARN] fetch failed: {e}")
stale_sec = None
if data is not None and (now - last_ok) > config.STALE_AFTER:
stale_sec = now - last_ok
try:
if data is None:
img = draw_error(last_err)
else:
pages = 1 + len(data["sensors"])
page = min(page, pages - 1) # 센서 수 감소 시 즉시 클램프(IndexError 방지)
if now - last_page_flip >= config.PAGE_INTERVAL:
page = (page + 1) % pages
last_page_flip = now
img = draw_summary(data, stale_sec) if page == 0 else draw_sensor(data, page - 1, stale_sec)
disp.display(img)
except Exception as e:
print(f"[ERROR] render/display failed: {e}")
time.sleep(1)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(0)

View file

@ -0,0 +1,114 @@
#!/usr/bin/env bash
# =============================================================================
# 미니 상태 표시기 설치 (Pi Zero 2 W + ST7789V) — SPI 활성 + 패키지 + venv
# 대상 OS: Raspberry Pi OS Bookworm (64-bit)
#
# 폐쇄망: debs/ 또는 wheelhouse/ 가 있으면 자동 오프라인 모드(네트워크 0).
# 번들이 불완전하면 온라인 폴백 없이 명확히 중단(폐쇄망 규칙). OFFLINE=1/0 강제 가능.
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")"
SERVICE_USER="${SERVICE_USER:-pi}"
echo "=========================================="
echo " 미니 상태 표시기 (ST7789V) 설치"
echo "=========================================="
if [ -r /proc/device-tree/model ]; then
MODEL="$(tr -d '\0' < /proc/device-tree/model)"
echo "[INFO] Board: ${MODEL}"
case "${MODEL}" in
*"Zero 2"*) : ;;
*) echo "[WARN] 이 폴더는 Pi Zero 2 W 용입니다 (다른 보드도 진행은 가능)." ;;
esac
fi
OFFLINE="${OFFLINE:-}"
if [ -z "${OFFLINE}" ]; then
if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then
OFFLINE=1
echo "[INFO] 오프라인 번들 감지 → 폐쇄망 설치 모드(네트워크 없음)"
else
OFFLINE=0
echo "[INFO] 오프라인 번들 없음 → 온라인 설치 모드(인터넷 필요)"
fi
fi
# 1) 시스템 패키지: requests/Pillow/numpy/GPIO/spidev + 폰트
echo "[1/4] 시스템 패키지 설치..."
if [ "${OFFLINE}" = "1" ]; then
if ! compgen -G "debs/*.deb" >/dev/null; then
echo "[ERROR] 오프라인 모드인데 debs/ 가 비어 있습니다. vendor.sh 산출물을 넣고 재실행." >&2
exit 1
fi
sudo apt-get install -y --no-install-recommends --no-download ./debs/*.deb
else
sudo apt-get update
sudo apt-get install -y python3-requests python3-venv python3-pil python3-numpy \
python3-rpi.gpio python3-spidev fonts-dejavu-core
fi
# 2) SPI 활성화 (dtparam + 그룹)
echo "[2/4] SPI 활성화..."
BOOT_CFG=""
for c in /boot/firmware/config.txt /boot/config.txt; do
if [ -f "$c" ]; then BOOT_CFG="$c"; break; fi
done
if [ -n "${BOOT_CFG}" ]; then
if ! grep -q "^dtparam=spi=on" "${BOOT_CFG}"; then
echo " dtparam=spi=on 추가 (${BOOT_CFG}) — 재부팅 후 적용"
echo "dtparam=spi=on" | sudo tee -a "${BOOT_CFG}" >/dev/null
NEED_REBOOT=1
else
echo " dtparam=spi=on 이미 설정됨"
fi
else
echo "[WARN] config.txt 를 찾지 못함 — raspi-config 로 SPI 수동 활성화 필요"
fi
for grp in spi gpio; do
if getent group "$grp" >/dev/null 2>&1; then
sudo usermod -aG "$grp" "${SERVICE_USER}" || echo "[WARN] usermod $grp 실패"
fi
done
# 3) venv (+시스템 패키지 상속) + requests
echo "[3/4] Python venv..."
if [ ! -d venv ]; then
python3 -m venv --system-site-packages venv
fi
# shellcheck disable=SC1091
source venv/bin/activate
if [ "${OFFLINE}" = "1" ]; then
if [ ! -d wheelhouse ]; then
echo "[ERROR] 오프라인 모드인데 wheelhouse/ 가 없습니다. vendor.sh 산출물을 넣고 재실행." >&2
exit 1
fi
pip install --no-index --find-links=wheelhouse -r requirements.txt
else
pip install -r requirements.txt
fi
# 4) 로그 파일 + 설정 잠금
echo "[4/4] 로그·권한..."
sudo touch /var/log/display_dashboard.log
sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/display_dashboard.log || true
sudo chmod 640 /var/log/display_dashboard.log
if [ -f config_local.py ]; then
chmod 600 config_local.py
sudo chown "${SERVICE_USER}:${SERVICE_USER}" config_local.py 2>/dev/null || true
fi
if [ ! -f config_local.py ]; then
echo ""
echo "[!] config_local.py 가 없습니다: cp config_local.example.py config_local.py 후 실값 입력"
fi
echo ""
echo "=========================================="
echo " 설치 완료 (OFFLINE=${OFFLINE})"
echo "=========================================="
if [ "${NEED_REBOOT:-0}" = "1" ]; then
echo " ⚠️ SPI 방금 활성화 → 'sudo reboot' 후 화면이 동작합니다."
fi
echo " 단독 실행: source venv/bin/activate && python3 display_dashboard.py"
echo " 서비스 등록: README.md systemd 절 참고"

View file

@ -0,0 +1,209 @@
#!/usr/bin/env python3
# =============================================================================
# render_preview.py — 디스플레이 렌더 검증 하네스 (하드웨어 없이 PNG 로 프레임 생성)
#
# iot-verification 규칙: 디스플레이 변경은 코드 리딩만으로 배포 금지 —
# 합성(worst-case) status JSON 을 실제 draw 코드에 먹여 240x320 PNG 로 렌더하고 눈으로 확인.
#
# ST7789 하드웨어는 건드리지 않는다(draw_summary/draw_sensor 는 순수 Pillow 반환).
# 폰트는 리눅스 DejaVu 경로 대신 로컬(윈도우 Arial 등)로 오버라이드 후 모듈을 임포트.
#
# 실행: python render_preview.py
# 출력: ./render_preview/*.png (정확히 패널 해상도 240x320)
# =============================================================================
import os
import sys
import types
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "render_preview")
os.makedirs(OUT, exist_ok=True)
sys.path.insert(0, HERE)
# ── requests 는 이 하네스에서 불필요 — 미설치 환경에서도 임포트되도록 스텁 ──
if "requests" not in sys.modules:
sys.modules["requests"] = types.ModuleType("requests")
def _pick_font(candidates):
for p in candidates:
if os.path.exists(p):
return p
return None
# ── 폰트 오버라이드: display_dashboard 임포트 전에 config 에 심어야
# 모듈 로드 시점의 F_HUGE/F_HERO/... 가 실제 TTF 로 초기화된다 ──
import config # noqa: E402
_bold = _pick_font([
"C:/Windows/Fonts/arialbd.ttf", "C:/Windows/Fonts/segoeuib.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
])
_reg = _pick_font([
"C:/Windows/Fonts/arial.ttf", "C:/Windows/Fonts/segoeui.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
])
if _bold:
config.FONT_PATH = _bold
if _reg:
config.FONT_PATH_LIGHT = _reg
config.FORCE_SIMULATION = True
print(f"[harness] fonts: bold={config.FONT_PATH}\n reg ={config.FONT_PATH_LIGHT}")
import display_dashboard as dd # noqa: E402
# ── 합성 센서 팩토리 — 모든 계약 필드 기본값(누락 필드 graceful 테스트도 가능) ──
def sensor(sid, *, leak_node=False, metric_node=False, pm_node=False,
offline=False, leak=0, pending=False,
metric_status="normal", th_status=None, pm_status="normal",
temp=None, hum=None, pm25=None, pm10=None, pm1_0=None,
leak_events=None, history=None, last_seen="2026-07-19 14:32:07",
include_pm_keys=True):
s = {
"id": sid, "name": f"node{sid}", "location": f"zone-{sid}",
"leak_node": leak_node, "metric_node": metric_node,
"offline": offline, "leak": leak, "pending": pending,
"metric_status": metric_status,
"temp": temp, "hum": hum,
"last_seen": last_seen,
"history": history or [],
"leak_events": leak_events or [],
}
if th_status is not None:
s["th_status"] = th_status
else:
s["th_status"] = metric_status
if include_pm_keys:
s.update({"pm_node": pm_node, "pm_status": pm_status,
"pm25": pm25, "pm10": pm10, "pm1_0": pm1_0})
return s
def hist(n=18, t0=6.0, h0=48.0):
out = []
for i in range(n):
out.append({"t": f"{(i + 6) % 24:02d}:00",
"temp": round(t0 + i * 0.4, 1),
"hum": round(h0 + (i % 5) * 2.0)})
return out
def wrap(sensors, state, *, unrecovered=0, sms=182, today=3):
online = sum(0 if x["offline"] else 1 for x in sensors)
return {
"ts": "2026-07-19 14:32:07",
"system": {"state": state, "online": online, "total": len(sensors),
"unrecovered": unrecovered, "sms_balance": sms,
"today_alerts": today},
"sensors": sensors,
}
def save(img, name):
p = os.path.join(OUT, name)
assert img.size == (config.WIDTH, config.HEIGHT), f"bad size {img.size}"
img.save(p)
print(f" saved {name} {img.size}")
return p
# ── 시나리오 정의 ──────────────────────────────────────────────────────────
# 결합 노드(1·3) = 누수 + 온습도 + 미세먼지 한 sensor_id 아래
COMBINED_LEAK = sensor(1, leak_node=True, metric_node=True, pm_node=True,
leak=1, temp=22.4, hum=61, pm25=14, pm10=22,
leak_events=["03:12", "03:15", "07:48"], history=hist())
COMBINED_PM = sensor(3, leak_node=True, metric_node=True, pm_node=True,
leak=0, pm_status="high_pm25", pm25=88, pm10=57,
temp=25.7, hum=54, leak_events=["01:20"], history=hist())
COMBINED_OK = sensor(1, leak_node=True, metric_node=True, pm_node=True,
leak=0, pm25=11, pm10=19, temp=23.1, hum=49,
leak_events=[], history=hist())
COMBINED_OFFLINE_LEAK = sensor(3, leak_node=True, metric_node=True, pm_node=True,
offline=True, leak=1, pm25=None, pm10=None,
temp=None, hum=None, leak_events=["22:05"])
COMBINED_PM10 = sensor(1, leak_node=True, metric_node=True, pm_node=True,
leak=0, pm_status="high_pm10", pm25=41, pm10=173,
temp=24.0, hum=58, history=hist())
# 온습도 전용 노드(2)
METRIC_OK = sensor(2, metric_node=True, temp=24.8, hum=52, history=hist())
# 오프라인(누수·미세먼지 없음 → OFFLINE 로 표기)
OFFLINE_NODE = sensor(2, metric_node=True, offline=True, temp=None, hum=None,
last_seen="2026-07-19 11:02:41")
# 구 API(=미세먼지 키 자체 없음) — pm_node 부재 → graceful, zone B '—'
LEGACY_COMBINED = sensor(1, leak_node=True, metric_node=True, leak=0,
temp=23.3, hum=50, history=hist(), include_pm_keys=False)
print("[harness] rendering frames ->", OUT)
paths = []
# 시스템 요약 — 누수 승격(푸터 점: leak/ok/pm/offline 색 자동)
summary_data = wrap([COMBINED_LEAK, METRIC_OK, COMBINED_PM, OFFLINE_NODE],
"leak", unrecovered=1, today=5)
paths.append(save(dd.draw_summary(summary_data, None), "01_summary_leak.png"))
# 시스템 요약 — 미세먼지 티어가 시스템 상태로 승격된 경우
summary_pm = wrap([COMBINED_OK, METRIC_OK, COMBINED_PM], "pm", today=4)
paths.append(save(dd.draw_summary(summary_pm, None), "02_summary_pm.png"))
# 결합 노드 상세
paths.append(save(dd.draw_sensor(wrap([COMBINED_LEAK], "leak"), 0, None),
"03_combined_leak.png"))
paths.append(save(dd.draw_sensor(wrap([COMBINED_PM], "pm"), 0, None),
"04_combined_pm25.png"))
paths.append(save(dd.draw_sensor(wrap([COMBINED_PM10], "pm"), 0, None),
"05_combined_pm10.png"))
paths.append(save(dd.draw_sensor(wrap([COMBINED_OK], "ok"), 0, None),
"06_combined_normal.png"))
paths.append(save(dd.draw_sensor(wrap([COMBINED_OFFLINE_LEAK], "leak"), 0, None),
"07_combined_offline_leak.png"))
paths.append(save(dd.draw_sensor(wrap([LEGACY_COMBINED], "ok"), 0, None),
"08_combined_legacy_no_pm.png"))
# 온습도 전용 노드(2)
paths.append(save(dd.draw_sensor(wrap([METRIC_OK], "ok"), 0, None),
"09_metric_normal.png"))
# 오프라인 노드
paths.append(save(dd.draw_sensor(wrap([OFFLINE_NODE], "offline"), 0, None),
"10_offline.png"))
# STALE(서버 미응답) 오버레이 — 요약 페이지
paths.append(save(dd.draw_summary(summary_data, 372), "11_summary_stale.png"))
# ── 정밀 검증: 상태/색 precedence 를 코드가 아니라 결과로 단언 ──────────────
def _assert_state(s, expect_st, expect_txt):
st, txt = dd.sensor_state(s)
ok = (st == expect_st and txt == expect_txt)
print(f" [{'OK ' if ok else 'ERR'}] sensor_state -> ({st!r},{txt!r}) "
f"expect ({expect_st!r},{expect_txt!r})")
return ok
print("[harness] precedence assertions:")
allok = True
allok &= _assert_state(COMBINED_OFFLINE_LEAK, "leak", "LEAK!") # 누수 > offline
allok &= _assert_state(COMBINED_PM, "pm", "PM2.5") # 미세먼지 > 온습도
allok &= _assert_state(COMBINED_PM10, "pm", "PM10")
allok &= _assert_state(
sensor(9, leak_node=True, metric_node=True, pm_node=True, leak=1,
pm_status="high_pm25"), "leak", "LEAK!") # 누수 > 미세먼지
allok &= _assert_state(
sensor(9, metric_node=True, offline=True, th_status="condensation_risk"),
"alert", "CONDENSATION RISK") # 온습도 > offline
allok &= _assert_state(sensor(9, metric_node=True, offline=True),
"offline", "OFFLINE")
allok &= _assert_state(METRIC_OK, "ok", "OK")
# 구 API(th_status 없음, metric_status 별칭 사용)
allok &= _assert_state(
{"id": 9, "leak": 0, "metric_status": "high_humidity",
"leak_node": True, "metric_node": True, "offline": False, "pending": False},
"alert", "HIGH HUMIDITY")
print("\n[harness] done.", len(paths), "PNGs")
print("[harness] precedence:", "ALL PASS" if allok else "FAILURES ABOVE")
for p in paths:
print(" ", p)
if not allok:
sys.exit(1)

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Some files were not shown because too many files have changed in this diff Show more