Compare commits

..

43 commits

Author SHA1 Message Date
cw2007
5d6dbd8dd2 docs(index): 배선 핀 1/9/11 정정·리포트 그림 재생성·소개서 기록
f29dc7c(배선/그림) + 01770e2(소개서). base64 그림 사본 드리프트 교훈 명시.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Dj4n17ZvGAhga6D4pNRjn
2026-07-20 09:28:23 +09:00
cw2007
01770e21d0 docs: 프로젝트 소개서 추가 (시스템 개요·SMS 발송 시점·대시보드)
담당자 대상 소개 문서 — 센서 소개가 아닌 프로젝트 전체 관점:
- 시스템 흐름: 현장 노드 → 서버(서명검증·임계판정) → 담당자 SMS·대시보드.
- 언제·누구에게 문자가 가나: 문자 14종 발송 조건(화재의심 PM>35/75, 온습도, 결로,
  누수, 오프라인, 에스컬레이션, 잔액, 정기점검) + 수신 계층(현장별 담당/전역/야간 2차)
  + 쿨다운·디바운스.
- 웹 대시보드 구성: 상태 배너·KPI 6타일·알림/대응 패널·탭 5종·예방 신호.
- 운영 임계 요약 + PMS7003 화재 보조 조기징후 유의(인증 감지기 아님).
자기완결 HTML, 브라우저 렌더 검증.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Dj4n17ZvGAhga6D4pNRjn
2026-07-20 09:27:26 +09:00
cw2007
f29dc7cc65 docs(wiring): 누수 핀 1/9/11 정정 + 리포트 배선 그림 재생성
- 누수 노드 실제 사용핀 = 1(3V3)·9(GND)·11(GPIO17). GND Pin 6→9 정정
  (WIRING.html §2 핀맵·§3 표·§4 회로·범례·힌트).
- 리포트 임베드 base64 SVG(옛 3선/외부풀업) 재생성 — 텍스트만 고쳤던 그림을 실제 교체:
  · [그림 3-1a] combined 종합핀맵 → 센서 1(누수 단독) 40핀 연결 현황(1/9/11만 사용)
  · [그림 3-1b] e-con 5C-08 3선 → 2선 프로브(전극 A→3V3 Pin1, 전극 B→GPIO17 Pin11, 10MΩ 풀다운=GPIO17↔GND Pin9)
  · [그림 3-3b] SHT30 외부 풀업 R1/R2 → 모듈 4선 직결(VCC·GND·SDA·SCL, 내장 풀업)
- 브라우저 렌더 검증: 세 그림 텍스트·선 겹침 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Dj4n17ZvGAhga6D4pNRjn
2026-07-20 09:27:06 +09:00
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
cw2007
de51d1c3d4 fix(security): Pi 노드 하드닝 6종 medium 갭 보완 + 회귀 수정
적대적 보안 감사에서 확정된 medium 6건 보완 (critical/high 0):
- systemd 유닛(양 노드): NoNewPrivileges + ProtectSystem=strict/
  ProtectHome=read-only/ProtectKernel*/ProtectControlGroups/RestrictSUIDSGID/
  LockPersonality/RestrictRealtime/RestrictAddressFamilies(+AF_NETLINK) 샌드박싱.
- harden_pi.sh: SSH 차단을 마지막 단계([6/6])로 재정렬 — 앞 단계 실패 시 set -e 로
  SSH 가 살아있는 상태에서 중단(원격 복구 가능). nftables·unattended-upgrades 를
  오프라인(debs/ --no-download)으로도 설치 지원(폐쇄망 harden). 미발견 secret 경고.
- install.sh(양 노드): config_local.py 설치 즉시 chmod 600 (harden 전 노출창 제거).
- vendor.sh(양 노드): .deb 를 파일명이 아닌 dpkg-deb Architecture 필드로 실검사
  (arm64/all 만 허용, 그 외 hard-fail). nftables·unattended-upgrades 를 번들에 포함.

검증(3-리뷰어 워크플로)이 잡은 회귀 동반 수정:
- [critical] ProtectHome=read-only 가 lgpio 의 작업디렉터리(.lgd-nfy 파이프) 쓰기를
  막아 add_event_detect(누수 인터럽트 등록) 실패 → 데몬 크래시루프 → 감지 불능.
  Environment=LG_WD=/tmp 로 PrivateTmp 쓰기가능 /tmp 이동(누수 노드 한정, SHT30 무관).
- [medium] 오프라인 설치에 --no-install-recommends 추가(vendor --no-recommends 정합,
  네트워크 폴백 방지) — install.sh x2 + harden_pi.sh.
- SupplementaryGroups=video gpio 명시 · nft|head 에 ||true(SIGPIPE 중단 방지) ·
  online harden 분기에 apt-get update.

※ 온디바이스 런타임 테스트 1회 필요(빌드머신 Windows 에선 systemd 실행 불가):
  실 Pi 에서 systemctl start 후 add_event_detect 콜백·카메라 촬영·업로드 정상 확인.
2026-07-03 09:28:44 +09:00
cw2007
96c5205f61 feat(deploy): 센서별 배선도·핀맵 HTML + 번들 생성기 반영
- raspberry_pi4/WIRING.html, raspberry_pi4_sht30/WIRING.html 추가:
  자체 완결형(인라인 CSS/SVG, 외부참조 0 → 폐쇄망 오프라인 열람) 40핀 핀맵 도해
  + 핀맵 표 + 배선 스키매틱 + 전기설계 경고 + 설치 체크리스트. 인쇄 대응.
  누수 노드(Pi-A/Pi-C 공통, GPIO17)·SHT30 노드(I2C1, 0x44) 각 1장.
- make_deploy_bundles.py: TEXT_EXTS 에 .html 추가(번들 복사 시 CRLF→LF 정규화),
  Pi SETUP.md 에 WIRING.html 안내 줄 추가. copy_tree 가 각 Pi 번들에 자동 포함.
  (동반: 기존 dotfile .htaccess/.gitignore LF 정규화 반영.)
2026-07-03 09:28:16 +09:00
cw2007
59d952953d chore: 실수로 커밋된 tools/__pycache__ 제거 + __pycache__ gitignore
git add tools/ 가 컴파일 .pyc(문자열 상수에 도메인 포함)까지 스테이징한 것을 정리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC
2026-07-02 18:56:33 +09:00
cw2007
fb3630d868 docs: 문서 현행화(leak_ 접두사·경로 스크럽) + 코드 투어 + settings deny 수정
- 전 운영 문서 테이블명 leak_ 접두사 반영, 삭제된 마이그레이션 참조 정리,
  구경로 raspi_leck_detecter→<WEB_DIR> 스크럽(DEPLOYMENT_PLAN).
- docs/DEPLOYMENT_PLAN_RPI4x3.md(신규): 3대 Pi + 신규 서버 배포 로드맵.
- .tours/architect-full-system.tour(신규): 19스텝 전체 아키텍처 투어.
- README.md(루트, 0바이트였음): 시스템 인덱스.
- .claude/settings.local.json: /doctor 지적 deny 규칙 5건 수정
  (파이프 4건 와일드카드형, 포크밤 삭제) + 죽은 allow 항목 3건 정리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC
2026-07-02 18:52:15 +09:00
cw2007
7a3b571b05 feat(deploy): 기기별 턴키 배포 번들 + Pi OS 하드닝 + 원샷 설치
tools/make_deploy_bundles.py: deploy/ 아래 서버1+Pi3대 폴더 생성 —
API_KEY 1개 자동 생성·4곳 동일 주입, 관리자 bcrypt(비번 stdin 전달), LF 정규화,
단일 schema.sql, var/ 런타임 상태 제외, CRON_SECRET 사전 계산. 난독화 경로
WEB_DIR 은 소스가 아닌 deploy/.secrets/web_dir.txt 에서 로드(커밋 트리 유출 차단).
시크릿 생성 전에 deploy/.gitignore 선작성 + 루트 .gitignore 에 deploy/ 이중 차단.

tools/harden_pi.sh: SSH 완전 비활성(outbound 전용 설계) · 인바운드 전부 차단
(nftables) · 불필요 서비스 제거(avahi/bluetooth/cups 등) · 자동 보안 패치
(unattended-upgrades, 자동 리부팅 끔) · config_local.py 권한 600 · 검증 증적 출력.

setup.sh(번들 생성): 설치→서비스→검증→하드닝 원샷. 검증 실패 시 set -e 로
하드닝(SSH 차단) 전에 중단 → SSH 유지되어 디버깅 가능.

검색 비노출 하드닝: php/.htaccess X-Robots-Tag noindex + Referrer-Policy
no-referrer, api/photo_upload.php CORS 제거, test_mobile.php 세션 인증 가드.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC
2026-07-02 18:51:46 +09:00
cw2007
12b0c74806 feat: sensor_id 3 신규 누수 지점 노드 지원
3번째 Pi(신규 지점, 누수+카메라)를 코드 수정 없이 배포 가능하게:
- raspberry_pi4/config.py: config_local.py 에서 SENSORS 오버라이드 허용
  + fail-closed 형식 검증(id 1..255/gpio/name, 중복 금지) — 오설정으로
  기본 sensor_id 1 로 조용히 기동해 타 지점 상태를 덮어쓰는 사고 방지.
- REPORT_INTERVAL 3600→600: 서버 HEARTBEAT_TIMEOUT_SEC(1200) 절반 —
  기존 하트비트 오탐(매시간 오프라인/복귀 SMS) 해소.
- php/config.php LEAK_SENSOR_IDS 상수(기본 [1], 1..255 정규화·빈값 복귀).
- php/dashboard.php: 누수 카드 sensor_id===1 하드코딩 → LEAK_SENSOR_IDS 루프.
- php/api/sensor_data.php: periodic 보고 전면 skip_log(누수 노드 증가 시
  이벤트 로그 창 보존) + CORS 헤더 제거(서버간 POST 전용, 도메인 지문 노출 차단).
- test_connection.py(양 모듈): 하드코딩 sensor_id 제거→기기 SENSORS 기반,
  서명 200 실패 시 exit 1(setup.sh 원샷 게이트용).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC
2026-07-02 18:51:18 +09:00
cw2007
9fef7031fc refactor(db)!: DB 테이블 전체 leak_ 접두사 통일 + 구 마이그레이션 폐기
같은 cafe24 DB의 다른 시스템 테이블과 혼동/충돌을 막기 위해 5개 테이블을
leak_ 네임스페이스로 격리:
  sensor_log→leak_sensor_log, sensor_status→leak_sensor_status,
  sensor_metric→leak_sensor_metric, sms_log→leak_sms_log,
  ingest_nonce→leak_ingest_nonce (leak_incident/leak_photo는 이미 접두사).
컬럼명은 불변(sensor_id, is_leak 등) — 테이블명만 변경.

sql/migration_add_leak.sql, migration_drop_leak.sql 삭제: 리네임 후 구버전
무접두 테이블 대상이라 실행 즉시 에러. 신규 설치는 schema_sht30.sql 하나로 완결.

BREAKING CHANGE: 기존 무접두 테이블 배포와 호환 안 됨(신규 설치 전용).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC
2026-07-02 18:50:56 +09:00
cw2007
c8b448c225 chore: 온습도 임계 예시 갱신 (고온 27℃·고습 60%, 저습·결로 경보 비활성)
- METRIC_TEMP_HIGH_C 30→27
- METRIC_RH_HIGH 70→60
- METRIC_RH_LOW 20→-1 (저습 경보 OFF; RH 항상 ≥0 라 미발화)
- METRIC_DEWPOINT_MARGIN_C 추가 0 (결로 경보 OFF; >0 분기 스킵)
- 기본값 주석에서 결로 항목 제거
2026-06-24 16:07:40 +09:00
163 changed files with 18751 additions and 2799 deletions

View file

@ -1,10 +1,7 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(tail:*)", "Bash(tail:*)"
"Bash(cd \"C:/Users/Posafe/leak_sms_v2/windows_checker\" && pyinstaller --onefile --noconsole --name sensor_checker sensor_checker.py 2>&1)",
"Bash(cd \"C:/Users/Posafe/leak_sms_v2/windows_checker\" && python sensor_checker.py 2>&1)",
"Bash(sort -k2)"
], ],
"deny": [ "deny": [
"Bash(rm -rf:*)", "Bash(rm -rf:*)",
@ -14,13 +11,12 @@
"Bash(chmod 777:*)", "Bash(chmod 777:*)",
"Bash(dd:*)", "Bash(dd:*)",
"Bash(mkfs:*)", "Bash(mkfs:*)",
"Bash(:(){ :|:& };:)",
"Bash(git push --force:*)", "Bash(git push --force:*)",
"Bash(git push -f:*)", "Bash(git push -f:*)",
"Bash(curl:* | sh)", "Bash(curl* | sh)",
"Bash(curl:* | bash)", "Bash(curl* | bash)",
"Bash(wget:* | sh)", "Bash(wget* | sh)",
"Bash(wget:* | bash)", "Bash(wget* | bash)",
"Bash(ssh:*)", "Bash(ssh:*)",
"Bash(scp:*)", "Bash(scp:*)",
"Read(./**/config.local.php)", "Read(./**/config.local.php)",

14
.gitignore vendored
View file

@ -5,6 +5,9 @@
# ── 운영 비밀값 / 로컬 설정 (절대 커밋 금지) ────────────────────────────── # ── 운영 비밀값 / 로컬 설정 (절대 커밋 금지) ──────────────────────────────
php/config.local.php php/config.local.php
firmware/**/secrets.h firmware/**/secrets.h
raspberry_pi_zero2w_display/config_local.py
raspberry_pi4/config_local.py
raspberry_pi4_sht30/config_local.py
*.env *.env
*.env.* *.env.*
!*.env.example !*.env.example
@ -12,6 +15,13 @@ firmware/**/secrets.h
# ── 레거시 백업: 하드코딩된 실 운영 비밀값(DB 암호/API 키/SMS 키) 포함 → 미공개 # ── 레거시 백업: 하드코딩된 실 운영 비밀값(DB 암호/API 키/SMS 키) 포함 → 미공개
_backup/ _backup/
# ── 배포 번들: 라이브 API 키·관리자 비밀번호·난독화 경로 포함 (생성기가 내부
# .gitignore 도 만들지만, 생성 실패 시에도 새지 않도록 루트에서 이중 차단) ──
deploy/
# ── 배포판 zip: 서버 번들(API 키·관리자 비밀번호 포함)을 담으므로 절대 커밋 금지 ──
dist/
# ── 런타임 상태 (접속 IP·감사로그·브루트포스 카운터·하트비트 throttle) ──── # ── 런타임 상태 (접속 IP·감사로그·브루트포스 카운터·하트비트 throttle) ────
php/var/* php/var/*
!php/var/.gitkeep !php/var/.gitkeep
@ -30,3 +40,7 @@ firmware/build/
# ── OS 잡파일 ───────────────────────────────────────────────────────────── # ── OS 잡파일 ─────────────────────────────────────────────────────────────
Thumbs.db Thumbs.db
.DS_Store .DS_Store
# ── 파이썬 캐시 ──
__pycache__/
*.pyc

View file

@ -0,0 +1,119 @@
{
"$schema": "https://aka.ms/codetour-schema",
"title": "전체 시스템 아키텍처 투어 — 누수+온습도 모니터링 (RPi4×3 → cafe24)",
"description": "센서 노드(RPi4) → HMAC 서명 HTTPS 수집 → MySQL(leak_ 접두사 테이블) → SMS 경보/대시보드까지 전체 데이터 경로를 따라가는 아키텍처 투어. v2607 기준 (테이블 leak_ 접두사, sensor_id 3 신규 지점, 턴키 배포 번들).",
"ref": "feat/pi-c-sensor3-leak-node",
"steps": [
{
"file": "SYSTEM_OVERVIEW.md",
"line": 1,
"title": "1. 전체 그림",
"description": "**상황**: 2종 센서 모듈(누수+카메라 / SHT30 온습도)이 단일 cafe24 PHP 서버로 보고하는 통합 시스템. **구조**: 모든 모듈이 동일한 raw-body HMAC-SHA256 X-Signature 스킴 사용. **왜 중요**: 이 문서가 모듈↔서버 계약(서명·sensor_id·엔드포인트)의 단일 출처. **주의**: v2607부터 DB 테이블 전체가 leak_ 접두사 — 같은 DB의 타 시스템 테이블과 격리."
},
{
"directory": "raspberry_pi4",
"title": "2. 모듈 1 — 누수 + 카메라 (sensor_id 1, 3)",
"description": "**상황**: e-con 5C-08 저항형 누수센서(GPIO17, 외부 4.7MΩ 풀다운) + Pi Camera. **구조**: leak_sensor.py 단일 데몬 + config.py(비밀 아님) + config_local.py(비밀, 미커밋). **왜 중요**: Pi-A(sensor 1)와 Pi-C(sensor 3, 신규 지점)가 같은 코드 폴더를 씀 — 기기별 차이는 전부 config_local.py로."
},
{
"file": "raspberry_pi4/config.py",
"line": 13,
"title": "3. 센서 정의 + 보고 주기",
"description": "**상황**: SENSORS 리스트가 이 기기의 sensor_id/GPIO를 정의. **구조**: REPORT_INTERVAL=600초 — 서버 HEARTBEAT_TIMEOUT_SEC(1200초)의 절반이라 2회 누락 시 오프라인 판정. **주의**: 예전 3600초는 매시간 오프라인 오탐을 냈던 버그 — 주기를 늘릴 땐 반드시 서버 타임아웃과 함께 판단."
},
{
"file": "raspberry_pi4/config.py",
"line": 92,
"title": "4. 기기별 오버라이드 (Pi-C 핵심)",
"description": "**상황**: config_local.py에 SENSORS가 있으면 기본값을 덮어씀. **구조**: 2단 import — 필수 비밀값은 fail-closed(없으면 기동 거부), SENSORS는 선택. **왜 중요**: Pi-C(sensor_id 3)는 추적 파일(config.py)을 건드리지 않고 배포 가능 — 기기별 drift 방지. **주의**: sensor_id 1/2 재사용 시 기존 지점 상태를 조용히 덮어씀(leak_sensor_status는 sensor_id 단독 키)."
},
{
"file": "raspberry_pi4/leak_sensor.py",
"line": 194,
"title": "5. 클라이언트 서명 — raw-body HMAC",
"description": "**상황**: 요청 본문 바이트에 HMAC-SHA256(API_KEY)을 계산해 X-Signature 헤더로 전송. **구조**: 본문을 고정 필드 순서로 직렬화한 뒤 그 바이트 그대로 서명·전송 — 서버도 같은 바이트로 검증. **주의**: 구버전 sha256(API_KEY+정렬JSON) 스킴은 폐기 — 서버가 기본 거부(ALLOW_LEGACY_BODY_SIGNATURE=false)."
},
{
"file": "raspberry_pi4/leak_sensor.py",
"line": 451,
"title": "6. 보고 상태머신",
"description": "**상황**: 메인 루프의 주기 선택 — 정상 600초, 누수 중 10초(LEAK_REPORT_INTERVAL). **구조**: 누수 감지 → leak_alert 즉시 보고 + 사진 촬영/업로드 → 지속 재보고 → 복구 시 leak_recovery. **왜 중요**: SMS 중복 방지는 클라이언트가 아니라 서버 쿨다운이 담당 — 클라이언트는 상태만 계속 밀어넣는 단순 설계."
},
{
"file": "raspberry_pi4_sht30/sht30_sensor.py",
"line": 179,
"title": "7. 모듈 2 — SHT30 온습도 (sensor_id 2)",
"description": "**상황**: I2C(0x44) SHT30을 300초 주기로 측정·보고하는 메인 루프. **구조**: 측정 → CRC 검증 → 같은 HMAC 서명 → 같은 엔드포인트. 임계 판정은 전부 서버 몫. **주의**: smbus2 부재/I2C 실패 시 시뮬레이션 값으로 폴백(WARN 로그만) — 배포 검증 때 journalctl에서 시뮬레이션 경고 부재를 반드시 확인."
},
{
"file": "php/api/sensor_data.php",
"line": 73,
"title": "8. 수집 경계 — fail-closed 인증",
"description": "**상황**: 모든 센서 보고가 들어오는 단일 진입점. **구조**: API_KEY가 기본값이면 503 거부 → 크기 상한 → 타임스탬프 ±600초 → raw-body HMAC 검증 순서. **왜 중요**: 여기가 시스템의 유일한 신뢰 경계 — 이 파일 통과 = DB 기록 + SMS 발송 권한."
},
{
"file": "php/api/sensor_data.php",
"line": 112,
"title": "9. 재전송(replay) 멱등 방어",
"description": "**상황**: 서명 자체를 nonce로 leak_ingest_nonce에 기록. **구조**: 같은 서명 재수신 = 중복 → 재저장/재발송 생략. 시계오차 창 지나면 자동 정리. **주의**: 테이블 없으면 조용히 비활성(graceful degrade) — setup_wizard가 이 테이블 누락을 검사하지 않으므로 신규 설치 후 7개 테이블 존재를 직접 확인."
},
{
"file": "php/api/sensor_data.php",
"line": 371,
"title": "10. 누수 사고(incident) 추적",
"description": "**상황**: leak_alert 수신 시 leak_incident 에피소드 개시, leak_recovery 시 종료. **구조**: sensor_id 단위로 열린 사고를 찾아 갱신 — sensor_id 3도 코드 수정 없이 동작(자동 등록 upsert). **왜 중요**: 사고 단위 추적이라 SMS·사진·대시보드 이력이 에피소드로 묶임."
},
{
"file": "php/api/photo_upload.php",
"line": 221,
"title": "11. 사진 업로드 검증 체인",
"description": "**상황**: 누수 사진 전용 업로드 엔드포인트. **구조**: canonical 문자열(device_id|sensor_id|log_id|timestamp) HMAC 서명 → log_id 실존 확인 → finfo MIME(image/jpeg만) → 크기 상한 → 서버 생성 무작위 파일명. **주의**: 파일명을 클라이언트가 정하지 않음 — 경로 조작 원천 차단. 저장 위치 php/var/photos/는 웹에서 403."
},
{
"file": "php/sms_send.php",
"line": 546,
"title": "12. SMS 경보 상태머신",
"description": "**상황**: 누수 SMS 발송 함수 — 쿨다운(기본 30분) 적용, 복구 SMS는 경보 후에만. **구조**: 별도 상태 테이블 없이 leak_sms_log의 메시지 접두 태그([누수감지] 등)가 사실상 상태 키 — 태그는 config.php SMS_TAG_* 상수가 단일 출처. **주의**: 태그 문자열을 직접 쓰면 여러 파일의 LIKE 검색이 어긋나 무성 장애 — 반드시 상수 참조."
},
{
"file": "php/cron_heartbeat.php",
"line": 22,
"title": "13. 오프라인 감지 (cron 1분)",
"description": "**상황**: last_seen이 HEARTBEAT_TIMEOUT_SEC(1200초) 초과한 센서를 찾아 오프라인 SMS. **구조**: offline_alerted 0→1 원자적 선점(UPDATE ... WHERE offline_alerted=0)으로 다중 진입점 동시 실행에도 중복 발송 방지. **주의**: 타임아웃은 전 센서 공통 — 어떤 모듈의 보고 주기보다 길어야 오탐 없음."
},
{
"file": "php/config.php",
"line": 217,
"title": "14. LEAK_SENSOR_IDS — 지점 확장점",
"description": "**상황**: 대시보드 누수 카드 표시 대상 sensor_id 목록 (기본 [1]). **구조**: config.local.php에서 [1,3] 오버라이드 — 1..255 정수만 통과, 잘못된 값은 버리고 비면 [1] 복귀. **왜 중요**: 신규 누수 지점 추가 시 서버 코드 수정 없이 이 설정 하나로 대시보드 노출."
},
{
"file": "php/dashboard.php",
"line": 700,
"title": "15. 대시보드 — 누수 카드 렌더",
"description": "**상황**: LEAK_SENSOR_IDS 루프로 지점별 누수 카드 렌더 (v2607 이전엔 sensor_id 1 하드코딩). **구조**: 우선순위 오프라인 > 누수 > 정상, 미수신이면 '데이터 없음' 카드. **주의**: 온습도 차트는 여전히 sensor 2 고정 — SHT30 지점을 추가하면 차트 선택 UI 작업 필요(누수 지점은 무관)."
},
{
"file": "php/login.php",
"line": 110,
"title": "16. 관리자 인증 — 비밀번호 + TOTP",
"description": "**상황**: bcrypt 해시 검증 + TOTP MFA. **구조**: ADMIN_TOTP_SECRET 미설정이면 로그인 자체가 불가(fail-closed) — setup_mfa.php?token= 으로 최초 1회 등록 후 MFA_SETUP_TOKEN을 반드시 비움. **주의**: 세션 쿠키 Secure라 HTTPS 없이는 로그인 불가 — cafe24 패널에서 HTTPS 강제가 선행 조건."
},
{
"file": "sql/schema_sht30.sql",
"line": 24,
"title": "17. 스키마 — 단일 파일, leak_ 네임스페이스",
"description": "**상황**: 신규 설치용 완전 스키마 — 테이블 7개(leak_sensor_log/status/metric, leak_sms_log, leak_ingest_nonce, leak_incident, leak_photo) 전부 이 파일 하나. **왜 중요**: v2607에서 구버전 마이그레이션 파일 폐기 — 이 파일이 유일한 DB 설치 경로. **주의**: 컬럼명은 접두사 없음(sensor_id, is_leak 등) — 테이블명만 leak_."
},
{
"file": "tools/make_deploy_bundles.py",
"line": 29,
"title": "18. 턴키 배포 번들 생성기",
"description": "**상황**: deploy/ 아래 서버 1 + Pi 3대 폴더를 생성 — API_KEY 자동 생성·4곳 동일 주입, 관리자 bcrypt, LF 정규화. **구조**: WEB_DIR/BASE_URL만 고치고 재실행하면 전체 재생성(.secrets/ 비밀 보존). **주의**: deploy/는 비밀 포함이라 gitignore — 절대 커밋 금지. 남는 수동 설정은 DB 계정·SMS 계정·TOTP 등록 3가지뿐."
},
{
"title": "다음 단계",
"description": "이제 센서 GPIO 인터럽트부터 SMS 발송까지 전체 경로를 파일 단위로 추적할 수 있다. 배포 절차는 docs/DEPLOYMENT_PLAN_RPI4x3.md, 기기별 설치는 deploy/*/SETUP.md, 보안 증적은 docs/SOURCE_SECURITY_EVIDENCE.md 참고. 신규 지점 추가 = Pi 폴더 복사 + config_local.py(SENSORS id) + 서버 LEAK_SENSOR_IDS 갱신 — 코드 수정 불필요."
}
]
}

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);
}

View file

@ -0,0 +1,23 @@
# 서버실 통합 모니터링 시스템 (누수 + 온습도)
Raspberry Pi 4 센서 모듈 2대 — **1번: 누수 감지 + 카메라**, **2번: SHT30 온습도** — 와
선택적 **3번째 노드**(신규 지점, sensor_id 3)가 **동일한 cafe24 PHP 서버**로 HTTPS 보고
(raw-body 서명 `X-Signature = HMAC-SHA256(API_KEY, 요청본문)`)를 하고, 서버가 MySQL 저장,
임계/누수 판정, SMS 발송, 통합 대시보드(`dashboard.php`) 표시를 담당하는 단일 시스템입니다.
## 모듈 구성
| 폴더 | 역할 | sensor_id |
|---|---|---|
| `raspberry_pi4/` | 누수 감지(e-con 5C-08) + Pi Camera 촬영·사진 업로드 | 1 (신규 지점 노드는 3) |
| `raspberry_pi4_sht30/` | SHT30 온습도 측정(I2C) | 2 |
| `php/` | cafe24 서버 — API 수신, DB 저장, 판정/SMS, 대시보드 | — |
| `sql/` | DB 스키마(`schema_sht30.sql` = 신규 설치 완결) + 마이그레이션 | — |
| `firmware/` | STM32F407 마이그레이션 트랙(별도) — **RPi4 배포와 무관** | — |
## 문서
- [SYSTEM_OVERVIEW.md](./SYSTEM_OVERVIEW.md) — 전체 아키텍처·모듈 매핑·서명 스킴 (기준 문서)
- [docs/DEPLOYMENT_PLAN_RPI4x3.md](./docs/DEPLOYMENT_PLAN_RPI4x3.md) — RPi4 × 3 + cafe24 신규 설치 배포 계획
- [docs/INSTALL_PI_SERVER.md](./docs/INSTALL_PI_SERVER.md) — cafe24 서버 설치 절차 (STM32 펌웨어 중심)
- [docs/OPERATIONS_SECURITY_CHECKLIST.md](./docs/OPERATIONS_SECURITY_CHECKLIST.md) — 운영·보안 체크리스트

View file

@ -25,7 +25,7 @@
| 구분 | 폴더 | 보드 | 센서 | sensor_id | 서버 보고 | | 구분 | 폴더 | 보드 | 센서 | sensor_id | 서버 보고 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| **1번 모듈** | `raspberry_pi4/` | Raspberry Pi 4 | e-con 5C-08 누수 + Pi Camera | 1 | `leak_alert`/`leak_recovery`/`periodic` + `photo_upload.php` | | **1번 모듈** | `raspberry_pi4/` | Raspberry Pi 4 | e-con 5C-08 누수 + Pi Camera | 1 | `leak_alert`/`leak_recovery`/`periodic` + `photo_upload.php` |
| **2번 모듈** | `raspberry_pi4_sht30/` | Raspberry Pi 4 | SHT30 온습도 (I2C) | 2 | `startup`/`periodic` + 온도/습도 (`sensor_metric`) | | **2번 모듈** | `raspberry_pi4_sht30/` | Raspberry Pi 4 | SHT30 온습도 (I2C) | 2 | `startup`/`periodic` + 온도/습도 (`leak_sensor_metric`) |
두 모듈 모두: 두 모듈 모두:
- 비밀값은 `config_local.py`(미커밋)에만 — `cp config_local.example.py config_local.py` 후 채움. - 비밀값은 `config_local.py`(미커밋)에만 — `cp config_local.example.py config_local.py` 후 채움.
@ -36,8 +36,8 @@
## 서버 (cafe24 `php/`) ## 서버 (cafe24 `php/`)
- 웹 루트(예): `public_html/raspi_leck_detecter/` — `docs/INSTALL_PI_SERVER.md` 참고. - 웹 루트(예): `public_html/raspi_leck_detecter/` — `docs/INSTALL_PI_SERVER.md` 참고.
- DB 스키마: `sql/schema_sht30.sql` (신규) 또는 `sql/migration_drop_leak.sql` (전환). - DB 스키마: `sql/schema_sht30.sql` — 신규 설치는 이 파일 하나로 완결(테이블 전체 `leak_` 접두사).
- 대시보드: `dashboard.php` — `sensor_status`/`sensor_metric`/`sms_log` 표시, 기본 차트 sensor 2. - 대시보드: `dashboard.php` — `leak_sensor_status`/`leak_sensor_metric`/`leak_sms_log` 표시, 기본 차트 sensor 2.
- 인증: `X-Signature = HMAC-SHA256(API_KEY, raw_body)`. 모든 모듈의 `API_KEY` = 서버 `config.local.php` 의 `API_KEY`. - 인증: `X-Signature = HMAC-SHA256(API_KEY, raw_body)`. 모든 모듈의 `API_KEY` = 서버 `config.local.php` 의 `API_KEY`.
--- ---
@ -54,21 +54,20 @@
본문 JSON 을 고정 필드 순서 문자열로 만들어 그 바이트에 서명하고 그대로 POST 합니다. 본문 JSON 을 고정 필드 순서 문자열로 만들어 그 바이트에 서명하고 그대로 POST 합니다.
(구버전의 `sha256(API_KEY + 정렬 JSON)` body-field 서명은 폐기 — 서버 기본 설정이 거부.) (구버전의 `sha256(API_KEY + 정렬 JSON)` body-field 서명은 폐기 — 서버 기본 설정이 거부.)
사진 업로드 서명 canonical = `device_id|sensor_id|log_id|timestamp` (공백 없음). 사진 업로드 서명 canonical = `device_id|sensor_id|log_id|timestamp` (공백 없음).
- **스키마(가산·멱등):** `sensor_log.is_leak` / `sensor_status.is_leak` 컬럼, `event_type` 의 - **스키마(가산·멱등):** `leak_sensor_log.is_leak` / `leak_sensor_status.is_leak` 컬럼, `event_type` 의
`leak_alert`/`leak_recovery`, `leak_incident`(사고 추적) + `leak_photo`(사진 메타) 테이블을 `leak_alert`/`leak_recovery`, `leak_incident`(사고 추적) + `leak_photo`(사진 메타) 테이블을
다시 추가했습니다. `sensor_metric`(SHT30)은 그대로 보존합니다. 다시 추가했습니다. `leak_sensor_metric`(SHT30)은 그대로 보존합니다.
- **수신:** `api/sensor_data.php` 가 누수 이벤트를 받아 `sensor_log`/`sensor_status` 갱신, - **수신:** `api/sensor_data.php` 가 누수 이벤트를 받아 `leak_sensor_log`/`leak_sensor_status` 갱신,
`leak_incident` 개시/종료, `leak_alert` 응답에 `log_id` 반환. SHT30 측정 경로는 100% 유지. `leak_incident` 개시/종료, `leak_alert` 응답에 `log_id` 반환. SHT30 측정 경로는 100% 유지.
- **사진:** 새 `api/photo_upload.php` (서명·신선도·MIME(finfo)·크기 상한·서버 생성 무작위 파일명). - **사진:** 새 `api/photo_upload.php` (서명·신선도·MIME(finfo)·크기 상한·서버 생성 무작위 파일명).
- **SMS:** `sms_send.php` 에 `send_leak_alert_sms`(쿨다운) / `send_leak_recovery_sms`(경보 후에만). - **SMS:** `sms_send.php` 에 `send_leak_alert_sms`(쿨다운) / `send_leak_recovery_sms`(경보 후에만).
### 운영 적용 절차 (기존 SHT30 서버에 누수 통합) ### 운영 적용 절차 (신규 설치)
1. 기존 서버에서 **`sql/migration_add_leak.sql` 1회 실행**(가산·멱등 — 운영 데이터 보존, 재실행 안전). 1. **`sql/schema_sht30.sql` 1회 실행** — 이 파일 하나로 완결(누수 요소 포함, 테이블 전체 `leak_` 접두사).
신규(통합) 설치는 `sql/schema_sht30.sql` 하나만 실행하면 됩니다(이미 누수 요소 포함). (구버전 무접두 테이블 대상 마이그레이션 파일들은 v2607 테이블 리네임과 함께 폐기되었습니다.)
2. 아래 서버 파일을 (재)배포: 2. 아래 서버 파일을 (재)배포:
`api/sensor_data.php`, `api/photo_upload.php`, `sms_send.php`, `dashboard.php`, `config.php`, `api/sensor_data.php`, `api/photo_upload.php`, `sms_send.php`, `dashboard.php`, `config.php`.
`sql/migration_add_leak.sql`.
3. **쓰기 가능한 `php/var/photos/`** 디렉터리 확보(웹서버 사용자 쓰기 권한; 없으면 업로드 시 자동 생성 시도). 3. **쓰기 가능한 `php/var/photos/`** 디렉터리 확보(웹서버 사용자 쓰기 권한; 없으면 업로드 시 자동 생성 시도).
4. 1번 모듈 `raspberry_pi4/config_local.py` 의 `API_URL`/`PHOTO_UPLOAD_URL` 을 이 통합 서버로, 4. 1번 모듈 `raspberry_pi4/config_local.py` 의 `API_URL`/`PHOTO_UPLOAD_URL` 을 이 통합 서버로,
`API_KEY` 를 서버 `config.local.php` 의 `API_KEY` 와 동일하게 설정. `API_KEY` 를 서버 `config.local.php` 의 `API_KEY` 와 동일하게 설정.

View file

@ -0,0 +1,279 @@
# 배포 계획: 누수+온습도 통합 모니터링 시스템 (cafe24 서버 1 + Raspberry Pi 4 × 3)
기준 저장소: `C:/Users/USER/Desktop/complete/leak_sms_v3`
확정 조건: RPi4 3대 모두 신품(OS 없음, SD 새로 플래시), 설치 현장 인터넷 가용(오프라인 vendor 번들은 선택이나 재현성 위해 권장), cafe24 서버 신규 설치.
---
## 1. 전체 로드맵
**순서 원칙: 서버 먼저.** 모든 Pi의 `test_connection.py` 검증과 API_KEY 발급이 서버에 의존하므로 서버가 선행되어야 한다 (`raspberry_pi4/config_local.example.py`, `raspberry_pi4_sht30/README.md` — API_KEY는 서버 `config.local.php`와 byte-단위 동일해야 함).
```
[0단계] cafe24 서버 신규 설치 ──────────────► API_KEY 확정, 엔드포인트 URL 확정
│
├─ (병렬 가능) SD 카드 3장 OS 플래시 + 공통 OS 프로비저닝
│ ※ 서버와 의존성 없음 — 서버 작업과 동시 진행 가능
│
▼
[1단계] Pi-A (모듈1: 누수+카메라, sensor_id=1) ┐
[2단계] Pi-B (모듈2: SHT30, sensor_id=2) ├─ 서로 독립, 병렬 설치 가능
[3단계] Pi-C (sensor_id=3, 신규 지점) ┘ (단, Pi-C는 센서 종류 결정 선행 — §6)
│
▼
[4단계] end-to-end 검증 (대시보드 / SMS / 사진 / 오프라인 감지) + 보안 체크리스트 (§7)
```
**병렬 가능 구간:**
- 서버 설치(0단계)와 Pi 3대의 OS 플래시·공통 프로비저닝(§3)은 완전 병렬.
- Pi-A / Pi-B / Pi-C의 모듈 설치는 서로 독립 — 서버의 API_KEY만 확정되면 동시 진행 가능.
- (선택) 오프라인 vendor 번들 생성(`vendor.sh`)은 인터넷 연결된 aarch64 Bookworm Pi 1대에서 선행 — 첫 번째로 부팅한 Pi에서 만들어 나머지에 복사하는 흐름 권장 (`raspberry_pi4/vendor.sh`, `raspberry_pi4_sht30/vendor.sh`의 .deb은 타깃 아키텍처에서만 받을 수 있음).
**Pi-C 블로커:** 센서 종류(누수 / SHT30 / 둘 다) 미결정 → §6의 작업량 비교로 먼저 결정 필요.
---
## 2. 0단계: cafe24 서버 신규 설치
### 2.1 DB 스키마
- [ ] cafe24 phpMyAdmin(또는 MySQL 콘솔)에서 **`sql/schema_sht30.sql` 1회 실행**. 신규 설치는 이 파일 하나로 완결 — leak_sensor_log, leak_sensor_status, leak_sensor_metric, leak_sms_log, leak_ingest_nonce, leak_incident, leak_photo 및 leak 컬럼/ENUM 전부 포함 (`sql/schema_sht30.sql:25-167`).
- [ ] 마이그레이션 파일 없음 — v2607 테이블 리네임(`leak_` 접두사)과 함께 폐기. 신규 설치는 스키마 파일 하나로 완결.
### 2.2 파일 업로드
- [ ] `php/` 전체를 웹루트 `public_html/<WEB_DIR>/`에 업로드 (`docs/INSTALL_PI_SERVER.md` §3, `php/.htaccess` 헤더).
- [ ] **주의: `docs/INSTALL_PI_SERVER.md:33-56`의 업로드 목록은 STALE** — `api/photo_upload.php`, `photo.php`, `var/.htaccess`, `var/index.html`, `var/photos/`가 누락되어 있음. 누수 사진 파이프라인에 필수이므로 **php/ 트리 전체**를 기준으로 업로드할 것.
- [ ] `php/var/` 를 PHP 쓰기 가능하게 설정 (heartbeat_last.txt, ingest_rejections.json, login_attempts.json, admin_audit.log 저장). 코드 기준 디렉터리 0750 / 파일 0640 (`php/api/photo_upload.php:303,321`) — cafe24 공유호스팅에서 755/775가 필요할 수 있음(갭, §8). `var/photos/`는 첫 업로드 시 자동 생성 + .htaccess 가드 자동 작성 (`php/api/photo_upload.php:139-153, 301-321`).
- [ ] 웹에서 `/var/` 접근이 403인지 확인 (`php/.htaccess:14` + `php/var/.htaccess`).
### 2.3 config.local.php / API_KEY
- [ ] `php/config.local.example.php` 복사 → `config.local.php` 작성:
- `DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASS` — cafe24 MySQL 자격증명
- `API_KEY` — **긴 랜덤 비밀키 생성.** 기본값 `'change-this-api-key'`이면 ingest가 503으로 거부됨(fail-closed, `php/api/sensor_data.php:73-77`). 이 키가 Pi 3대 전부의 `config_local.py` API_KEY와 byte-단위 동일해야 함.
- `ALLOW_LEGACY_BODY_SIGNATURE` — **false 유지** (`php/config.php:60`, `php/ops_checks.php:101`)
- `SMS_USER_ID / SMS_SECURE / SMS_SENDER / SMS_RECIPIENTS` — cafe24 SMS 계정, **사전 등록된 발신번호**, 잔액 충전 필요 (게이트웨이 `php/config.php:66`)
- 임계값: `METRIC_TEMP_HIGH_C=27`, `METRIC_RH_HIGH=60`, `METRIC_RH_LOW=-1`(저습 비활성), `METRIC_DEWPOINT_MARGIN_C=0`(결로 비활성) — 예시값 기준 (`php/config.local.example.php:44-50`, commit c8b448c)
### 2.4 관리자 인증 (fail-closed — 미설정 시 로그인 자체가 불가)
- [ ] `php setup_hash.php "새-관리자-비밀번호"` 실행 → bcrypt 출력물을 `ADMIN_PASSWORD_HASH`에 (`docs/INSTALL_PI_SERVER.md:135-141`)
- [ ] MFA 등록: `MFA_SETUP_TOKEN`에 임시 랜덤값 설정 → `setup_mfa.php?token=<토큰>` 접속 → TOTP 앱 등록 → 표시된 `ADMIN_TOTP_SECRET`을 config.local.php에 → **`MFA_SETUP_TOKEN` 반드시 비우기** (`docs/INSTALL_PI_SERVER.md:100-106`, `php/ops_checks.php:100`)
- [ ] `ADMIN_TOTP_SECRET`은 서버에서 생성하기 전까지 **절대 자리표시자 값을 넣지 말 것** (`php/config.local.example.php:33-40`)
### 2.5 HTTPS / cron
- [ ] HTTPS 리다이렉트는 **cafe24 호스팅 관리 패널에서** 설정 — .htaccess 리다이렉트는 리버스프록시 뒤에서 무한루프 위험으로 의도적으로 비활성 (`php/.htaccess:6-8`, `docs/SOURCE_SECURITY_EVIDENCE.md` §2.1). 세션 쿠키가 Secure라 HTTPS 없이는 로그인 불가.
- [ ] cron 1분 주기: CLI형 `* * * * * php /home/<user>/public_html/<WEB_DIR>/cron_heartbeat.php`. HTTP형 사용 시 **`?key=<CRON_SECRET>` 필수** — `CRON_SECRET = hash_hmac('sha256','cron',API_KEY)` (`php/config.php:199`, `php/cron_heartbeat.php:163-169`). 이 값은 UI 어디에도 표시되지 않아 직접 계산해야 함(갭). `docs/INSTALL_PI_SERVER.md:165`의 URL 예시는 key 파라미터가 빠져 있어 그대로 쓰면 403.
- [ ] (선택) 보존기간 정리: `php retention_cleanup.php --execute` CLI 전용 (`php/retention_cleanup.php:11-16`) — cron 등록 안 하면 365일 보존이 실제로 집행되지 않음.
### 2.6 서버 검증
- [ ] `login.php` 로그인(비밀번호+TOTP) → `setup_wizard.php`에서 전체 점검: DB 연결, 필수 테이블, API_KEY 교체 여부, 관리자 해시/TOTP, MFA 토큰 제거, 레거시 서명 비활성, SMS 수신자, 임계값, var 쓰기권한, **테스트 SMS 발송 버튼** (`php/setup_wizard.php:29-45`, `php/ops_checks.php:44-111`)
- [ ] 주의: setup_wizard는 4개 코어 테이블만 검사 — `leak_incident`/`leak_photo`/`leak_ingest_nonce` 누락은 감지 못 하고 조용히 기능 저하됨 (`php/ops_checks.php:51`). 스키마 실행 후 phpMyAdmin에서 7개 테이블 존재를 직접 확인할 것.
- [ ] CORS 오리진이 `https://pores.kr`로 하드코딩되어 있음 (`php/api/sensor_data.php:49`, `php/api/photo_upload.php:29`) — 다른 도메인 배포 시 수정 필요(갭).
---
## 3. 공통: 새 Pi OS 프로비저닝 (3대 공통)
### 3.1 문서화된 요구사항 (repo에 근거 있음)
| 항목 | 값 | 근거 |
|---|---|---|
| OS | Raspberry Pi OS **Bookworm 64-bit (aarch64)** | `raspberry_pi4/install.sh:4`, `raspberry_pi4_sht30/install.sh:4`, 양쪽 README §2 |
| Python | 시스템 3.11 (Bookworm 기본; wheel이 cp311로 핀) | `vendor.sh` TARGET_PYVER=311 |
| 사용자 | `pi` (sudo 가능) — systemd 유닛 User=pi, 경로 /home/pi/* 하드코딩 | 양쪽 `systemd/*.service`, install.sh SERVICE_USER 기본값 |
| 네트워크 | 유선 Gigabit 또는 WiFi, 서버로 HTTPS egress | 양쪽 README §2 |
| 시각 동기 | NTP 필수 — 서버가 타임스탬프 ±600초 밖이면 거부 | `raspberry_pi4_sht30/README.md:61`, `php/config.php:45` |
| I2C (Pi-B) | install.sh가 자동 처리 (dtparam=i2c_arm=on 등) — raspi-config는 수동 폴백만 | `raspberry_pi4_sht30/install.sh:62-88` |
### 3.2 문서에 없어 현장에서 결정해야 하는 것 (전부 갭 — repo에 절차 없음)
- **SD 이미징 절차 없음**: OS 이미지 버전/체크섬/Imager 사용법 미문서화. → 결정: Raspberry Pi Imager 등으로 Bookworm 64-bit(Lite 권장 여부도 미명시)를 플래시하되, 사용한 이미지 버전을 기록해 3대 동일하게 맞출 것.
- **`pi` 사용자 생성 절차 없음**: Bookworm 이미지는 기본 `pi` 계정이 없음 — Imager 커스텀 설정 또는 userconf.txt로 `pi` 계정을 만들어야 systemd 유닛/경로가 무수정으로 동작. 비밀번호 정책 미문서화.
- **SSH/헤드리스 활성화 절차 없음**: Imager 설정 또는 boot 파티션 `ssh` 파일 방식 중 결정 필요.
- **호스트네임 규약 없음**: DEVICE_ID(rpi4-leak-01 등)는 애플리케이션 식별자일 뿐 OS 호스트네임 아님. → 결정 권장: 호스트네임을 DEVICE_ID와 일치시켜 운영 혼선 방지.
- **네트워크 설정(고정 IP vs DHCP, WiFi 프로비저닝) 없음.**
- **로케일/타임존, apt 보안 업데이트 정책, OS 하드닝 절차 없음** (§7·§8 참조).
### 3.3 (선택·권장) 오프라인 vendor 번들
현장에 인터넷이 있으므로 install.sh 온라인 경로로도 설치 가능하나, 재현성을 위해:
- [ ] 첫 부팅한 Pi(인터넷 연결, aarch64 Bookworm) 1대에서 각 모듈의 `./vendor.sh` 실행 → `wheelhouse/` + `debs/` 생성. **.deb은 반드시 타깃 아키텍처(aarch64 Bookworm)에서만 받을 수 있음** (`vendor.sh:7-10,55`) — x86 빌드머신 불가.
- [ ] 생성된 번들을 나머지 Pi의 모듈 폴더에 복사 → `install.sh`가 자동으로 오프라인 모드 진입(불완전 번들은 하드 에러, 온라인 폴백 없음).
- [ ] 갭: 번들 무결성 해시 검증 절차 없음 — sha256sum 목록을 직접 만들어 전달 권장.
---
## 4. Pi-A: 모듈 1 (누수 + Pi Camera, sensor_id=1)
소스: `raspberry_pi4/` — 근거는 `raspberry_pi4/README.md`, `install.sh`, `config.py`, `systemd/leak-sensor.service`.
### 4.1 배선 (README.md §2.1)
- [ ] e-con 5C-08 누수센서 (~1.5MΩ 고임피던스): 센서 입력 → **GPIO17(BCM) = 물리핀 11**, 3.3V → 핀 1, GND → 핀 6
- [ ] **외부 4.7MΩ 풀다운 필수**, 내부 풀 OFF 유지 (`config.py` USE_INTERNAL_PULL=False → PUD_OFF; 내부 50kΩ 풀다운이 1.5MΩ 센서 신호를 죽임 — README.md:49-53)
- [ ] 로직: ACTIVE_LOW=False — 건조=LOW, 누수=HIGH; 디바운스 500ms (`config.py:20-24`)
- [ ] Pi Camera: Pi 4의 **15핀 1.0mm CSI 커넥터** (Zero 2 W용 22핀 리본 비호환 — 15핀 리본 또는 어댑터 필요). 전원 USB-C 5V/3A(카메라 사용 시 여유 권장).
### 4.2 설치
- [ ] 폴더를 `/home/pi/raspberry_pi4`에 배치 (유닛 파일이 경로 하드코딩 — `systemd/leak-sensor.service:12-13,26-27`; 다르면 유닛 수정)
- [ ] `cp config_local.example.py config_local.py` 후 5개 값 기입:
- `API_URL` = `https://<서버>/<WEB_DIR>/api/sensor_data.php`
- `PHOTO_UPLOAD_URL` = `.../api/photo_upload.php`
- `API_KEY` = 서버 config.local.php와 byte-단위 동일
- `DEVICE_ID` = 예: `rpi4-leak-01`, `DEVICE_LOCATION` = 설치 장소
- 누락 시 fail-closed로 기동 거부 (`config.py:69-83`); 절대 커밋 금지 (`.gitignore`)
- [ ] `chmod +x install.sh && ./install.sh` — 온라인: apt로 `python3-rpi-lgpio python3-requests python3-venv rpicam-apps`(또는 libcamera-apps) 설치; 오프라인 번들 있으면 자동 오프라인. venv(--system-site-packages) + `requests==2.32.3`, 로그파일 `/var/log/leak_sensor.log` (640, pi 소유) 생성.
- [ ] **주의(모순 문서)**: `raspberry_pi4/README.md` §3(57-68행)의 "php/ 서버는 누수 이벤트 거부, 레거시 서버로 향하라"는 안내는 **STALE** — `config_local.example.py:8-16`과 머지된 PR #1 기준 **통합 서버가 정답**. README §3을 따르지 말 것.
### 4.3 systemd
- [ ] `sudo cp systemd/leak-sensor.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable leak-sensor && sudo systemctl start leak-sensor`
- [ ] 유닛 특성: User=pi, **SupplementaryGroups=video**(카메라 접근 필수), Restart=always/10s, PrivateTmp=true, network-online.target 이후 기동
### 4.4 End-to-end 검증
- [ ] `source venv/bin/activate && python3 test_connection.py` — 3단계: test.php POST / 무서명 POST(거부 확인) / HMAC 서명 POST(200 확인)
- [ ] 수동 실행 `python3 leak_sensor.py` → startup 이벤트가 서버 sensor_log에 기록되는지 대시보드 확인
- [ ] **카메라 사전 검증(갭 — 문서화된 절차 없음)**: 설치 절차에 카메라 감지 확인이 없어 첫 누수 때에야 실패가 드러남. `rpicam-still`로 수동 테스트샷을 찍어볼 것을 권장.
- [ ] 누수 모의 시험: 센서 단락 → 대시보드 누수 카드(sensor_id=1 전용, `php/dashboard.php:256-264`) 표시 + `[누수감지]` SMS + leak_incident 행 + `photo.php?id=N`으로 사진 열람(관리자 세션 필요)
- [ ] `systemctl status leak-sensor`, `journalctl -u leak-sensor -f` 정상 확인
---
## 5. Pi-B: 모듈 2 (SHT30 온습도, sensor_id=2)
소스: `raspberry_pi4_sht30/` — 근거는 해당 README.md, install.sh, config.py, systemd/sht30-sensor.service.
### 5.1 배선 (README.md §2.1)
- [ ] Sensirion SHT30 (한진데이타 P4422-3), I2C 버스 1: VCC → **3.3V 핀 1 (5V 금지)**, GND → 핀 6 등, SDA → GPIO2/핀 3, SCL → GPIO3/핀 5
- [ ] **ADDR → GND = 주소 0x44** (VDD면 0x45 — `config.py` I2C_ADDR와 일치시킬 것)
- [ ] SDA/SCL에 3.3V 풀업 4.7kΩ ×2 (모듈 온보드 풀업 있으면 생략)
### 5.2 설치
- [ ] 폴더를 `/home/pi/raspberry_pi4_sht30`에 배치 (유닛 경로 하드코딩)
- [ ] `cp config_local.example.py config_local.py` → `API_URL`(Pi-A와 동일 엔드포인트) / `API_KEY`(동일 키) / `DEVICE_ID`(예: rpi4-sht30-01) / `DEVICE_LOCATION` 기입 (fail-closed)
- [ ] `./install.sh` — [1/4] apt: `python3-requests python3-venv i2c-tools`; **[2/4] I2C 자동 활성화**: `/boot/firmware/config.txt`에 dtparam=i2c_arm=on, i2c-dev 모듈 로드+영속화, `usermod -aG i2c pi`; [3/4] venv + `requests==2.32.3, smbus2==0.5.0`; [4/4] `/var/log/sht30_sensor.log`
- [ ] install.sh가 리부트 안내를 출력하면 `sudo reboot` (I2C 신규 활성화 시 — README §4.2). **주의(갭)**: `usermod -aG i2c`도 재로그인/리부트 후 반영 — 리부트 없이 곧바로 test_sensor.py를 돌리면 /dev/i2c-1 권한 오류 가능.
### 5.3 systemd
- [ ] `sudo cp systemd/sht30-sensor.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable sht30-sensor && sudo systemctl start sht30-sensor`
- [ ] 유닛 특성: User=pi, **SupplementaryGroups=i2c**, Restart=always/10s, PrivateTmp=true. install.sh는 유닛을 설치하지 않으므로 이 수동 단계 누락 금지(갭).
### 5.4 End-to-end 검증
- [ ] `i2cdetect -y 1` → **0x44** 표시 확인 (README §5)
- [ ] `python3 test_sensor.py` (CRC/포맷 자가검사 + 1회 측정) — **주의: 하드웨어 없이도 시뮬레이션으로 통과하므로**, i2cdetect 확인을 반드시 선행. 시뮬레이션 폴백이 배선 오류를 가릴 수 있음(갭): 서비스 로그에 시뮬레이션 WARN이 없는지 `journalctl`로 확인할 것.
- [ ] `python3 test_connection.py` — 무서명 400/403, 서명 200; 403이면 API_KEY 불일치, 400이면 NTP/타임스탬프 의심
- [ ] 수동 실행 후 startup 이벤트 → 이후 300초 주기 보고. 대시보드에서 sensor_id=2 카드 + 차트(기본 sensor 2, `php/dashboard.php:125-129`) + leak_sensor_metric 5분 축적 확인
- [ ] 임계값 시험: 고온/고습 유발 → 태그된 SMS + 30분 쿨다운 + 회복 SMS 확인
---
## 6. Pi-C: sensor_id=3 신규 지점 (extension-pattern 기준)
근거: `docs/solutions/best-practices/sensor-telemetry-extension-pattern-2026-05-21.md` (단, 이 문서 자체가 legacy 선언 — 현행 코드 기준으로 재검증된 아래 내용을 따를 것).
### 6.1 그냥 되는 것 (서버 무변경)
- **등록 절차 없음**: sensor_id는 HMAC 서명된 본문으로 도착, 1..255 범위검사 후 `leak_sensor_status`에 UPSERT로 자동 등록 (`php/api/sensor_data.php:199-205, 316-340`). SQL 마이그레이션 불필요.
- 온습도 보고 시 metric 저장, **임계값 SMS/회복 SMS(센서별 쿨다운)**, 오프라인/온라인 SMS, 에스컬레이션, stuck 감지, 월간 리포트 GROUP BY — 전부 sensor_id 단위로 자동 동작 (`php/cron_heartbeat.php:22-83`, `php/sms_send.php:426-509`).
- 대시보드 **센서 상태 카드 목록**에는 sensor 3이 자동 표시 (`php/dashboard.php:87, 635-690`).
- 누수 이벤트도 서버 처리 자체는 됨: `[누수감지]` SMS + leak_incident 생성 + 사고 테이블 표시 (`php/sms_send.php:546-608`, `php/api/sensor_data.php:371-411`, incident 쿼리는 무필터).
### 6.2 코드/스키마 작업이 필요한 것
| 항목 | 내용 | 근거 |
|---|---|---|
| 차트 | sensor_id=2 하드코딩, 선택 UI 없음 — sensor 3 이력은 저장되지만 차트로 못 봄 | `php/dashboard.php:126-129` |
| 최근 온습도 카드 | 차트 센서(=2)의 최신값만 표시 | `php/dashboard.php:218-220, 596-601` |
| 30일 평균 | 전 센서 혼합 집계(GROUP BY 없음) — 지점이 다른 sensor 3이 들어오면 평균이 무의미해짐 | `php/dashboard.php:210-216` |
| 누수 상태 카드 | sensor_id===1 하드코딩 — Pi-C가 누수형이면 실시간 카드 미표시 | `php/dashboard.php:256-264, 693` |
| 임계값/쿨다운 | METRIC_* 전역 단일값 — 지점별 임계 필요 시 코드/스키마 변경 | `php/config.php:134-153` |
| 하트비트 타임아웃 | 전역 1200초 — **누수형 Pi-C(보고 주기 3600초)는 매 사이클 오프라인 오탐** → 전역 상향 시 5분 센서의 감지 지연 | `php/config.php:197`, `raspberry_pi4/config.py` REPORT_INTERVAL=3600 |
| SENSOR_ID 위치 (SHT30형) | git 추적되는 `config.py:16-17`을 직접 수정해야 함 — config_local.py에 넣어도 무시됨(import 목록 고정, `config.py:70-76`) | drift 위험 |
### 6.3 센서 종류별 작업량 비교
| | **SHT30형 (권장 최소)** | **누수형** | **둘 다 (한 Pi)** |
|---|---|---|---|
| Pi측 | `raspberry_pi4_sht30/` 복사, `config.py` SENSOR_ID=3/SENSOR_NAME 수정(추적 파일!), config_local.py 작성(DEVICE_ID 예: rpi4-sht30-02) | `raspberry_pi4/` 복사, `config.py` SENSORS에 `{"id":3,"gpio":22,...}` 추가(주석 예시 기존재, `config.py:17`), config_local.py + PHOTO_UPLOAD_URL | 두 모듈 폴더 모두 설치 — 핀 충돌 없음(PR_BODY.md §7). 단 두 모듈이 각각 sensor_id를 가지므로 id 3·4 두 개를 쓸지 결정 필요(갭 — 문서화된 패턴 없음) |
| 서버측 필수 | **없음** | **없음** (수신·SMS·incident 동작) | 없음 |
| 서버측 권장 수정 | 차트/최근값 카드가 sensor 2 고정 → 보고 싶으면 dashboard.php 수정; 30일 평균 혼합 문제 | + 누수 카드 sensor 1 고정 수정, **HEARTBEAT_TIMEOUT_SEC 충돌 해결(가장 큰 이슈)** | 양쪽 전부 |
| 결론 | **작업량 최소** — 대시보드 차트만 감수하면 무수정 운영 가능 | 서버 무수정 시 오프라인 오탐 + 대시보드 비가시 감수 필요 | 최대 작업량 |
공통 주의: sensor_status는 sensor_id 단독 키 — Pi-C가 실수로 id 1/2를 재사용하면 기존 지점 상태를 **조용히 덮어씀** (`php/api/sensor_data.php:316-340`). DEVICE_ID는 표시용일 뿐 인증 정체성이 아님.
---
## 7. 배포 시점 보안 체크리스트 (repo 근거 항목만)
**서버 (근거: `docs/OPERATIONS_SECURITY_CHECKLIST.md`, `docs/SOURCE_SECURITY_EVIDENCE.md`, `php/ops_checks.php`)**
- [ ] API_KEY 기본값 교체 확인 (기본값이면 ingest 503 — `php/api/sensor_data.php:73-77`)
- [ ] `ALLOW_LEGACY_BODY_SIGNATURE=false` (`php/ops_checks.php:101`)
- [ ] MFA_SETUP_TOKEN 비움 확인 (`php/ops_checks.php:100`)
- [ ] HTTPS를 cafe24 패널에서 강제 + 인증서/리다이렉트 화면 증적 캡처 (`docs/SOURCE_SECURITY_EVIDENCE.md` §2.1 — .htaccess는 의도적으로 리다이렉트 안 함, **조용히 누락되기 쉬운 수동 단계**)
- [ ] 위조 서명 403 / 정상 서명 200 테스트 증적 (`docs/SOURCE_SECURITY_EVIDENCE.md` §5)
- [ ] config.local.php 등 민감 파일 직접 접근 차단 확인 (`php/.htaccess` FilesMatch — `docs/SOURCE_SECURITY_EVIDENCE.md` §2.8)
- [ ] `php/var/admin_audit.log`에 로그인/로그아웃/MFA 이벤트 기록 확인
- [ ] `curl -I https://<도메인>/<WEB_DIR>/login.php` 응답 헤더 점검 (checklist §3)
- [ ] SMS 잔액/발신번호/수신자 최신성 확인; 백업 + 복원 테스트(`php scripts/backup_evidence.php`) — checklist §9
**Pi 3대 공통**
- [ ] `config_local.py` 커밋 금지(.gitignore 확인); 노출 시 서버·전 노드 동시 API_KEY 교체 (양쪽 README §7)
- [ ] 단일 공유 API_KEY 구조 인지: Pi 1대 탈취 = 전 지점 ingest 자격 노출, 기기별 폐기 불가 (extension-pattern 갭) — 지점 추가마다 노출면 확대
- [ ] 기기 인바운드 차단 증적: `ss -lntup`으로 불필요 리스닝 데몬 없음 확인 (`docs/SOURCE_SECURITY_EVIDENCE.md` §4 — 단 달성 방법은 미문서화, §8 갭)
- [ ] `systemctl status leak-sensor`(및 sht30-sensor) 화면 증적 (§5 최소 증적 세트)
- [ ] NTP 동기 확인 (±600초 초과 시 전 POST 거부)
**repo에 없어 자체 수립 필요 (문서화 갭 — §8):** Pi 계정/SSH 하드닝, 호스트 방화벽(ufw 등), 시크릿 파일 권한(chmod 600), OS 패치 정책.
---
## 8. 문서화 갭 / 결정 필요 사항 (통합·우선순위)
### P0 — 배포를 잘못되게 만들 수 있는 것
1. **STALE 문서 모순 — 모듈1 대상 서버**: `raspberry_pi4/README.md` §3·§7은 "php/ 서버가 누수 거부 / 레거시 sha256 서명"이라 함. 현행은 통합 서버 + raw-body HMAC (`config_local.example.py:8-16`, SYSTEM_OVERVIEW.md, PR #1). README만 보고 배포하면 오설정. → README §3/§7 갱신 필요.
2. **`docs/INSTALL_PI_SERVER.md` 업로드 목록 STALE**: photo_upload.php/photo.php/var 가드 누락 (33-56행) — 그대로 따르면 누수 사진 파이프라인 결손. 또한 이 문서 자체가 STM32(v2606) 중심이며 RPi4 신규 설치 통합 가이드는 부재 (root README.md는 0바이트).
3. **cron URL 오류**: INSTALL_PI_SERVER.md:165에 `?key=<CRON_SECRET>` 누락 → 403. CRON_SECRET 표시 UI 없음, 수동 계산 필요.
4. **신품 Pi 프로비저닝 절차 전무**: SD 이미징, `pi` 계정 생성(Bookworm 기본 미존재), SSH 활성화, 호스트네임, 네트워크 설정 — 전부 미문서화. **결정 필요**: 이미지 버전, 계정/비밀번호 정책, 고정IP vs DHCP, 호스트네임 규약.
5. **Pi-C 센서 종류 결정**: §6.3 비교 기준으로 결정. 누수형 선택 시 HEARTBEAT_TIMEOUT_SEC 충돌(전역 1200초 vs 보고 3600초)을 반드시 먼저 해결.
### P1 — 운영 중 사고로 이어질 수 있는 것
6. **Pi OS 하드닝 부재**: 계정/SSH/방화벽/시크릿 파일 권한(600)/패치 정책이 4개 보안 문서 어디에도 없음 — `docs/SOURCE_SECURITY_EVIDENCE.md`도 "코드로 증명 불가, 기관 자료 필요"로만 표기. → 자체 표준 수립 필요.
7. **단일 공유 API_KEY / 센서 레지스트리 부재**: 키 보유자는 임의 sensor_id 생성·기존 id 덮어쓰기 가능; 기기별 폐기 불가. 지점이 늘수록(Pi-C) 위험 증가 — device_id↔sensor_id 서버측 바인딩 검토 권장.
8. **시뮬레이션 폴백이 오류 은폐**: SHT30 모듈은 I2C 실패 시 그럴듯한 더미값을 운영 서버로 전송(WARN 로그만) — FORCE_REAL 옵션 없음. 배포 검증 시 journalctl 확인을 절차화할 것.
9. **NTP 의존 미절차화**: 서버가 ±600초 강제하는데 시각동기 확인 단계가 설치 절차에 없음 (현장 인터넷 가용이라 기본 NTP로 충족 예상되나 확인 단계 추가 권장).
10. **카메라 검증 단계 부재** (Pi-A): 첫 누수 때에야 실패 발견 — 설치 시 테스트샷 절차 추가 필요.
### P2 — 정리/개선
11. **설정 규약 이원화**: `docs/SHT30_SENSOR2_SETUP.md` §3과 `php/ops_checks.php:97`은 `/etc/sht30-sensor.env`를 참조하나 실제 모듈은 `config_local.py`만 읽음 — 문서 갱신 필요.
12. **SENSOR_ID가 추적 파일(config.py)에 위치** (SHT30형): config_local 오버라이드 허용하는 소규모 수정 권장 (Pi-C에서 실익).
13. **systemd 유닛 경로/User=pi 하드코딩** 2개 파일 — 비표준 배치 시 수동 수정 2곳.
14. **센서 폐기 UI 없음**: leak_sensor_status 행 수동 SQL 삭제 전까지 영구 오프라인 표시.
15. **임계값 문서 드리프트**: SOURCE_SECURITY_EVIDENCE.md §2.4(30℃/70%)와 현행 권장값(27℃/60%, commit c8b448c) 불일치.
16. **retention_cleanup cron 미문서화**; **오프라인 번들 해시 검증 부재**(--require-hashes 없음); **CORS pores.kr 잔재**; **로그 권한 실패 시 조용한 콘솔 폴백**(양 모듈); **var/ 권한 비트 미문서화**(cafe24에서 setup_wizard '상태 파일 폴더' 체크로 확인).
17. **firmware/(STM32) 트랙은 이번 배포와 무관** — RPi4 배포에 불필요 (`docs/stm32f407_migration_plan.md`는 별도 마이그레이션 트랙, 링크/실행 미검증 상태).
---
## 9. [확정] Pi-C = 누수 + 카메라 (sensor_id 3) — 결정 반영 부록
> ✅ **v2607 구현 완료:** §9.2 필수 코드 작업 3건 전부 반영됨 — REPORT_INTERVAL=600(하트비트 오탐 해소), 대시보드 LEAK_SENSOR_IDS 루프(누수 카드 다중 지점), config_local.py SENSORS 오버라이드(+형식 검증 fail-closed). 테이블은 전부 leak_ 접두사로 리네임됨. 배포는 tools/make_deploy_bundles.py 번들 사용.
사용자 결정(2026-07-02): Pi-C는 **누수 + 카메라** 구성.
### 9.1 누수형 선택의 이점 (SHT30형 대비)
- 대시보드 차트 sensor 2 하드코딩 문제(§6.2) **해당 없음** — Pi-C는 metric을 안 보냄.
- 30일 평균 혼합 오염(§6.2) **해당 없음** — 동일 이유.
- SENSOR_ID 추적파일(config.py) 문제는 누수 모듈도 동일하나, `raspberry_pi4/config.py:13` SENSORS 리스트에 id=3 항목 추가 형태라 구조는 명확.
### 9.2 필수 코드 작업 (검증 완료 — file:line 확인됨)
1. **하트비트 오탐 (P0, Pi-A에도 이미 존재하는 버그)**
- `php/cron_heartbeat.php:25-30` — leak_sensor_status 전체를 전역 `HEARTBEAT_TIMEOUT_SEC`(기본 1200s, `php/config.php:197`)로 검사. 필터 없음.
- `raspberry_pi4/config.py:35` — 누수 모듈 정상 주기 보고 3600s.
- 결과: 누수 노드(id 1, id 3)는 **매 사이클 오프라인 SMS 오탐 → 복귀 SMS 반복**.
- 권장 수정(택1):
- (a) **최소 변경**: 누수 모듈 `REPORT_INTERVAL` 3600 → 600 (Pi-A·Pi-C 공통). 주기 POST는 소형 JSON이라 부하 무시 가능. 서버 무수정.
- (b) **구조적**: sensor_status에 per-sensor timeout 컬럼 추가 + cron_heartbeat/dashboard 반영 (스키마+코드 변경).
2. **대시보드 누수 카드 (Pi-C 가시성)**
- `php/dashboard.php:260` — `(int)$s['sensor_id'] === 1` 하드코딩. Pi-C 누수 상태 카드 미표시.
- 수정: is_leak 값이 있는 모든 센서에 대해 카드 렌더 (사고 테이블은 이미 무필터 — 수정 불요).
3. **Pi-C 설정값**
- `raspberry_pi4/config.py` SENSORS에 `{"id": 3, ...}` (gpio 17 유지 — 별도 Pi이므로 핀 충돌 없음), DEVICE_ID 예: `rpi4-leak-02`, DEVICE_LOCATION = 신규 지점명.
### 9.3 Pi-C 추가 하드웨어 (구매 확인 필요)
- e-con 5C-08 누수센서 1개 + 4.7MΩ 풀다운 저항
- Pi Camera (15핀 CSI 리본) 1개
- SD카드·USB-C 5V/3A 전원 (3대 공통)
### 9.4 보안 유의 (extension-pattern 갭)
- 단일 공유 API_KEY로 지점 3개 — 노출면 확대. sensor_status는 sensor_id 단독 키이므로 Pi-C가 id 1/2 오설정 시 기존 지점 상태를 조용히 덮어씀 (`php/api/sensor_data.php:316-340`). 배포 전 config 교차 점검 필수.

70
docs/INDEX.md Normal file
View file

@ -0,0 +1,70 @@
# 프로젝트 진행 INDEX
작업 단락(커밋·빌드·배포·결정) 완료 기록. 완료 결과만.
## 2026-07-20
- **배선 핀 정정·리포트 그림 재생성·프로젝트 소개서** (feat/pi-c-sensor3-leak-node)
- `f29dc7c` docs(wiring): 누수 실제 사용핀 **1(3V3)·9(GND)·11(GPIO17)** 로 정정(GND Pin 6→9,
`WIRING.html` §2/§3/§4/범례). 리포트 임베드 base64 SVG 재생성 — [그림 3-1a] combined→
**센서 1(누수 단독) 40핀 연결 현황(1/9/11만)**, [그림 3-1b] 3선→**2선 회로**,
[그림 3-3b] SHT30 외부 풀업→**모듈 4선 직결**. 브라우저 렌더 겹침 0.
- `01770e2` docs: **프로젝트 소개서**(`docs/시스템_소개서-v2606-20260719.html`) — 시스템 흐름
(노드→서버→SMS·대시보드), SMS 발송 시점(14종 조건·수신 계층 현장/전역/야간2차·쿨다운),
대시보드 구성(히어로·KPI6·탭5), 운영 임계 요약.
- 교훈: 리포트 그림은 WIRING.html SVG의 **base64 별도 사본** — 리포트 텍스트만 고치면
그림은 안 바뀐다. 소스 수정 후 그림 재생성·재임베드 필요.
## 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

@ -281,13 +281,12 @@ return [
<div class="step"> <div class="step">
<h3>스키마 실행</h3> <h3>스키마 실행</h3>
<p><b>신규 설치</b>는 <code>sql/schema_sht30.sql</code> 전체를 복사해 <span class="kbd">SQL</span> 탭에 붙여넣고 <span class="kbd">실행</span>. <p><b>신규 설치</b>는 <code>sql/schema_sht30.sql</code> 전체를 복사해 <span class="kbd">SQL</span> 탭에 붙여넣고 <span class="kbd">실행</span>.
생성 테이블: <code>sensor_log</code>, <code>sensor_status</code>, <code>sensor_metric</code>, <code>sms_log</code> 생성 테이블: <code>leak_sensor_log</code>, <code>leak_sensor_status</code>, <code>leak_sensor_metric</code>, <code>leak_sms_log</code>
(+ 재전송 방어용 <code>ingest_nonce</code>).</p> (+ 재전송 방어용 <code>leak_ingest_nonce</code>).</p>
<table> <table>
<thead><tr><th>SQL 파일</th><th>적용 상황</th></tr></thead> <thead><tr><th>SQL 파일</th><th>적용 상황</th></tr></thead>
<tbody> <tbody>
<tr><td><code>sql/schema_sht30.sql</code></td><td>신규 설치(통합 스키마)</td></tr> <tr><td><code>sql/schema_sht30.sql</code></td><td>신규 설치(통합 스키마)</td></tr>
<tr><td><code>sql/migration_drop_leak.sql</code></td><td>기존 누수(v2605) 설치 → 온습도 전용 전환. <b>비가역 — 실행 전 DB 전체 백업</b></td></tr>
</tbody> </tbody>
</table> </table>
<div class="crit"><b>중복 실행 주의.</b> 이미 적용한 마이그레이션을 다시 실행하면 오류가 날 수 있습니다. <div class="crit"><b>중복 실행 주의.</b> 이미 적용한 마이그레이션을 다시 실행하면 오류가 날 수 있습니다.
@ -301,7 +300,7 @@ return [
<div class="ph-title">캡처 위치 — 그림 3 <span class="fname">→ screenshots/03-db-phpmyadmin.png</span></div> <div class="ph-title">캡처 위치 — 그림 3 <span class="fname">→ screenshots/03-db-phpmyadmin.png</span></div>
<div class="ph-what">phpMyAdmin <b>SQL 탭에 <code>schema_sht30.sql</code> 붙여넣고 [실행]한 결과</b>(“쿼리 실행 성공” + 좌측 4~5개 테이블 생성된 화면)</div> <div class="ph-what">phpMyAdmin <b>SQL 탭에 <code>schema_sht30.sql</code> 붙여넣고 [실행]한 결과</b>(“쿼리 실행 성공” + 좌측 4~5개 테이블 생성된 화면)</div>
</div> </div>
<figcaption>그림 3. DB 스키마 실행 결과 (sensor_log/sensor_status/sensor_metric/sms_log/ingest_nonce)</figcaption> <figcaption>그림 3. DB 스키마 실행 결과 (leak_sensor_log/leak_sensor_status/leak_sensor_metric/leak_sms_log/leak_ingest_nonce)</figcaption>
</figure> </figure>
</section> </section>
@ -575,7 +574,7 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php</code></pr
<ul style="margin:6px 0 0;"> <ul style="margin:6px 0 0;">
<li><code>setup_wizard.php</code> 필수 점검 항목 통과 + 테스트 SMS 수신</li> <li><code>setup_wizard.php</code> 필수 점검 항목 통과 + 테스트 SMS 수신</li>
<li><code>dashboard.php</code> 에 최신 온/습도와 센서 상태(<code>sensor_id=2</code>) 표시</li> <li><code>dashboard.php</code> 에 최신 온/습도와 센서 상태(<code>sensor_id=2</code>) 표시</li>
<li><code>sensor_metric</code> 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록</li> <li><code>leak_sensor_metric</code> 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록</li>
<li>임계 초과 테스트 시 <code>metric_status</code> 경보 + SMS 수신 / 월간 보고서에 내역 조회</li> <li>임계 초과 테스트 시 <code>metric_status</code> 경보 + SMS 수신 / 월간 보고서에 내역 조회</li>
</ul> </ul>
</div> </div>
@ -599,8 +598,8 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php</code></pr
<tr><td>API 403</td><td>서버 <code>API_KEY</code> ↔ 펌웨어 <code>APP_API_KEY</code> 일치, raw-body 서명</td></tr> <tr><td>API 403</td><td>서버 <code>API_KEY</code> ↔ 펌웨어 <code>APP_API_KEY</code> 일치, raw-body 서명</td></tr>
<tr><td>API 500</td><td>Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부</td></tr> <tr><td>API 500</td><td>Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부</td></tr>
<tr><td>로그인 불가</td><td><code>ADMIN_PASSWORD_HASH</code>, <code>ADMIN_TOTP_SECRET</code>, 인증 앱 시간 동기</td></tr> <tr><td>로그인 불가</td><td><code>ADMIN_PASSWORD_HASH</code>, <code>ADMIN_TOTP_SECRET</code>, 인증 앱 시간 동기</td></tr>
<tr><td>SMS 미발송</td><td>Cafe24 SMS 계정·secure key·발신번호 등록·<b>잔액</b>, <code>sms_log</code>. 대시보드 ‘수집 거부/발송 실패’ 배너 확인</td></tr> <tr><td>SMS 미발송</td><td>Cafe24 SMS 계정·secure key·발신번호 등록·<b>잔액</b>, <code>leak_sms_log</code>. 대시보드 ‘수집 거부/발송 실패’ 배너 확인</td></tr>
<tr><td>온습도 미표시</td><td>I²C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, <code>sensor_metric</code>, 펌웨어 측정 로그</td></tr> <tr><td>온습도 미표시</td><td>I²C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, <code>leak_sensor_metric</code>, 펌웨어 측정 로그</td></tr>
<tr><td>TLS 실패</td><td><code>server_ca.c</code> 실제 CA 반영, <b>SNTP 시간 동기</b>(<code>APP_SNTP_SERVER</code>=LAN NTP)</td></tr> <tr><td>TLS 실패</td><td><code>server_ca.c</code> 실제 CA 반영, <b>SNTP 시간 동기</b>(<code>APP_SNTP_SERVER</code>=LAN NTP)</td></tr>
<tr><td>데이터 전혀 안 들어옴</td><td>대시보드 <b>수집 거부 사유 카운터</b>(서명불일치→키/펌웨어, 시각오차→SNTP/RTC)로 원인 분별</td></tr> <tr><td>데이터 전혀 안 들어옴</td><td>대시보드 <b>수집 거부 사유 카운터</b>(서명불일치→키/펌웨어, 시각오차→SNTP/RTC)로 원인 분별</td></tr>
<tr><td>오프라인 알림 과다/지연</td><td><code>HEARTBEAT_TIMEOUT_SEC</code>(1200초), cron 주기</td></tr> <tr><td>오프라인 알림 과다/지연</td><td><code>HEARTBEAT_TIMEOUT_SEC</code>(1200초), cron 주기</td></tr>

View file

@ -281,13 +281,12 @@ return [
<div class="step"> <div class="step">
<h3>스키마 실행</h3> <h3>스키마 실행</h3>
<p><b>신규 설치</b>는 <code>sql/schema_sht30.sql</code> 전체를 복사해 <span class="kbd">SQL</span> 탭에 붙여넣고 <span class="kbd">실행</span>. <p><b>신규 설치</b>는 <code>sql/schema_sht30.sql</code> 전체를 복사해 <span class="kbd">SQL</span> 탭에 붙여넣고 <span class="kbd">실행</span>.
생성 테이블: <code>sensor_log</code>, <code>sensor_status</code>, <code>sensor_metric</code>, <code>sms_log</code> 생성 테이블: <code>leak_sensor_log</code>, <code>leak_sensor_status</code>, <code>leak_sensor_metric</code>, <code>leak_sms_log</code>
(+ 재전송 방어용 <code>ingest_nonce</code>).</p> (+ 재전송 방어용 <code>leak_ingest_nonce</code>).</p>
<table> <table>
<thead><tr><th>SQL 파일</th><th>적용 상황</th></tr></thead> <thead><tr><th>SQL 파일</th><th>적용 상황</th></tr></thead>
<tbody> <tbody>
<tr><td><code>sql/schema_sht30.sql</code></td><td>신규 설치(통합 스키마)</td></tr> <tr><td><code>sql/schema_sht30.sql</code></td><td>신규 설치(통합 스키마)</td></tr>
<tr><td><code>sql/migration_drop_leak.sql</code></td><td>기존 누수(v2605) 설치 → 온습도 전용 전환. <b>비가역 — 실행 전 DB 전체 백업</b></td></tr>
</tbody> </tbody>
</table> </table>
<div class="crit"><b>중복 실행 주의.</b> 이미 적용한 마이그레이션을 다시 실행하면 오류가 날 수 있습니다. <div class="crit"><b>중복 실행 주의.</b> 이미 적용한 마이그레이션을 다시 실행하면 오류가 날 수 있습니다.
@ -301,7 +300,7 @@ return [
<div class="ph-title">캡처 위치 — 그림 3 <span class="fname">→ screenshots/03-db-phpmyadmin.png</span></div> <div class="ph-title">캡처 위치 — 그림 3 <span class="fname">→ screenshots/03-db-phpmyadmin.png</span></div>
<div class="ph-what">phpMyAdmin <b>SQL 탭에 <code>schema_sht30.sql</code> 붙여넣고 [실행]한 결과</b>(“쿼리 실행 성공” + 좌측 4~5개 테이블 생성된 화면)</div> <div class="ph-what">phpMyAdmin <b>SQL 탭에 <code>schema_sht30.sql</code> 붙여넣고 [실행]한 결과</b>(“쿼리 실행 성공” + 좌측 4~5개 테이블 생성된 화면)</div>
</div> </div>
<figcaption>그림 3. DB 스키마 실행 결과 (sensor_log/sensor_status/sensor_metric/sms_log/ingest_nonce)</figcaption> <figcaption>그림 3. DB 스키마 실행 결과 (leak_sensor_log/leak_sensor_status/leak_sensor_metric/leak_sms_log/leak_ingest_nonce)</figcaption>
</figure> </figure>
</section> </section>
@ -575,7 +574,7 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php</code></pr
<ul style="margin:6px 0 0;"> <ul style="margin:6px 0 0;">
<li><code>setup_wizard.php</code> 필수 점검 항목 통과 + 테스트 SMS 수신</li> <li><code>setup_wizard.php</code> 필수 점검 항목 통과 + 테스트 SMS 수신</li>
<li><code>dashboard.php</code> 에 최신 온/습도와 센서 상태(<code>sensor_id=2</code>) 표시</li> <li><code>dashboard.php</code> 에 최신 온/습도와 센서 상태(<code>sensor_id=2</code>) 표시</li>
<li><code>sensor_metric</code> 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록</li> <li><code>leak_sensor_metric</code> 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록</li>
<li>임계 초과 테스트 시 <code>metric_status</code> 경보 + SMS 수신 / 월간 보고서에 내역 조회</li> <li>임계 초과 테스트 시 <code>metric_status</code> 경보 + SMS 수신 / 월간 보고서에 내역 조회</li>
</ul> </ul>
</div> </div>
@ -599,8 +598,8 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php</code></pr
<tr><td>API 403</td><td>서버 <code>API_KEY</code> ↔ 펌웨어 <code>APP_API_KEY</code> 일치, raw-body 서명</td></tr> <tr><td>API 403</td><td>서버 <code>API_KEY</code> ↔ 펌웨어 <code>APP_API_KEY</code> 일치, raw-body 서명</td></tr>
<tr><td>API 500</td><td>Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부</td></tr> <tr><td>API 500</td><td>Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부</td></tr>
<tr><td>로그인 불가</td><td><code>ADMIN_PASSWORD_HASH</code>, <code>ADMIN_TOTP_SECRET</code>, 인증 앱 시간 동기</td></tr> <tr><td>로그인 불가</td><td><code>ADMIN_PASSWORD_HASH</code>, <code>ADMIN_TOTP_SECRET</code>, 인증 앱 시간 동기</td></tr>
<tr><td>SMS 미발송</td><td>Cafe24 SMS 계정·secure key·발신번호 등록·<b>잔액</b>, <code>sms_log</code>. 대시보드 ‘수집 거부/발송 실패’ 배너 확인</td></tr> <tr><td>SMS 미발송</td><td>Cafe24 SMS 계정·secure key·발신번호 등록·<b>잔액</b>, <code>leak_sms_log</code>. 대시보드 ‘수집 거부/발송 실패’ 배너 확인</td></tr>
<tr><td>온습도 미표시</td><td>I²C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, <code>sensor_metric</code>, 펌웨어 측정 로그</td></tr> <tr><td>온습도 미표시</td><td>I²C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, <code>leak_sensor_metric</code>, 펌웨어 측정 로그</td></tr>
<tr><td>TLS 실패</td><td><code>server_ca.c</code> 실제 CA 반영, <b>SNTP 시간 동기</b>(<code>APP_SNTP_SERVER</code>=LAN NTP)</td></tr> <tr><td>TLS 실패</td><td><code>server_ca.c</code> 실제 CA 반영, <b>SNTP 시간 동기</b>(<code>APP_SNTP_SERVER</code>=LAN NTP)</td></tr>
<tr><td>데이터 전혀 안 들어옴</td><td>대시보드 <b>수집 거부 사유 카운터</b>(서명불일치→키/펌웨어, 시각오차→SNTP/RTC)로 원인 분별</td></tr> <tr><td>데이터 전혀 안 들어옴</td><td>대시보드 <b>수집 거부 사유 카운터</b>(서명불일치→키/펌웨어, 시각오차→SNTP/RTC)로 원인 분별</td></tr>
<tr><td>오프라인 알림 과다/지연</td><td><code>HEARTBEAT_TIMEOUT_SEC</code>(1200초), cron 주기</td></tr> <tr><td>오프라인 알림 과다/지연</td><td><code>HEARTBEAT_TIMEOUT_SEC</code>(1200초), cron 주기</td></tr>

View file

@ -2,6 +2,8 @@
이 문서는 서버실 온습도(SHT30) 모니터링 시스템 v2606을 실제 운영 환경에 배포할 때 STM32F407VGT6 펌웨어와 Cafe24 PHP/MySQL 서버에 각각 무엇을 설치하고 설정해야 하는지 정리한 설치용 문서입니다. 이 문서는 서버실 온습도(SHT30) 모니터링 시스템 v2606을 실제 운영 환경에 배포할 때 STM32F407VGT6 펌웨어와 Cafe24 PHP/MySQL 서버에 각각 무엇을 설치하고 설정해야 하는지 정리한 설치용 문서입니다.
> **참고:** 이 문서는 **STM32 펌웨어 중심**의 설치 문서입니다. Raspberry Pi 4 모듈(누수+카메라 / SHT30) 배포자는 [docs/DEPLOYMENT_PLAN_RPI4x3.md](./DEPLOYMENT_PLAN_RPI4x3.md)와 [SYSTEM_OVERVIEW.md](../SYSTEM_OVERVIEW.md)를 먼저 참고하세요. 서버 설치 절차(§3~§6)는 공통입니다.
## 1. 설치 대상 ## 1. 설치 대상
| 구분 | 설치 위치 | 역할 | | 구분 | 설치 위치 | 역할 |
@ -28,19 +30,20 @@
## 3. 서버에 설치할 파일 ## 3. 서버에 설치할 파일
Cafe24 웹 루트 아래에 다음 구조로 PHP 파일을 업로드합니다. Cafe24 웹 루트 아래에 **repo 의 `php/` 트리 전체를 그대로 업로드**합니다. 개별 파일을 선별해 올리면 누수 사진 파이프라인(`api/photo_upload.php`, `photo.php`, `var/photos/`)이 빠지기 쉽습니다.
```text ```text
public_html/raspi_leck_detecter/ public_html/raspi_leck_detecter/ ← php/ 트리 전체 업로드
.htaccess .htaccess
blocked.php blocked.php
config.php config.php
config.local.php config.local.php ← 서버에서 example 복사 후 작성(저장소에 없음)
config.local.example.php config.local.example.php
ops_checks.php ops_checks.php
sms_send.php sms_send.php
cron_heartbeat.php cron_heartbeat.php
dashboard.php dashboard.php
photo.php ← 누수 사진 조회(관리자 인증 필요)
setup_mfa.php setup_mfa.php
admin_security.php admin_security.php
setup_wizard.php setup_wizard.php
@ -49,10 +52,17 @@ public_html/raspi_leck_detecter/
login.php login.php
retention_cleanup.php retention_cleanup.php
setup_hash.php setup_hash.php
test_mobile.php
api/ api/
sensor_data.php sensor_data.php
photo_upload.php ← 누수 사진 업로드 API
var/ var/
.gitkeep .gitkeep
.htaccess ← var/ 웹 직접 접근 차단(403)
index.html
photos/ ← 누수 사진 저장 폴더(.htaccess/index.html 포함,
.htaccess 없으면 첫 업로드 시 자동 생성 시도)
index.html
``` ```
운영 서버에는 `config.local.php`가 반드시 필요합니다. `config.local.example.php`를 복사해서 만들고 실제 운영 값으로 교체합니다. 운영 서버에는 `config.local.php`가 반드시 필요합니다. `config.local.example.php`를 복사해서 만들고 실제 운영 값으로 교체합니다.
@ -113,16 +123,7 @@ https://your-domain.example/raspi_leck_detecter/setup_mfa.php?token=임시토큰
SOURCE /path/to/sql/schema_sht30.sql; SOURCE /path/to/sql/schema_sht30.sql;
``` ```
phpMyAdmin에서는 [schema_sht30.sql](../sql/schema_sht30.sql) 내용을 복사해서 SQL 실행 창에 붙여넣습니다. 생성 테이블은 `sensor_log`, `sensor_status`, `sensor_metric`, `sms_log`입니다. phpMyAdmin에서는 [schema_sht30.sql](../sql/schema_sht30.sql) 내용을 복사해서 SQL 실행 창에 붙여넣습니다. 생성 테이블은 총 7개 — `leak_sensor_log`, `leak_sensor_status`, `leak_sensor_metric`, `leak_sms_log`, `leak_ingest_nonce`(재전송 방어), `leak_incident`, `leak_photo`(누수 사고/사진) — 이며, 신규 설치는 이 파일 하나로 완결됩니다(누수 컬럼/테이블 포함). 모든 테이블은 `leak_` 접두사를 사용해 같은 DB 의 다른 테이블과 격리됩니다.
기존 누수 설치를 온습도 전용으로 전환하는 경우에는 전환 마이그레이션을 적용합니다.
| SQL 파일 | 적용 상황 |
|---|---|
| [schema_sht30.sql](../sql/schema_sht30.sql) | 신규 설치(통합 스키마) |
| [migration_drop_leak.sql](../sql/migration_drop_leak.sql) | 기존 누수 설치 → 온습도 전용 전환(레거시 컬럼/테이블 정리, `sensor_metric` 보장) |
이미 적용된 마이그레이션을 중복 실행하면 DB 오류가 날 수 있으므로, 기존 설치에서는 `setup_wizard.php`의 점검 결과를 먼저 확인합니다.
## 5. 서버 권한 및 관리자 설정 ## 5. 서버 권한 및 관리자 설정
@ -149,7 +150,7 @@ https://your-domain.example/raspi_leck_detecter/setup_wizard.php
점검 화면에서 확인할 항목은 다음과 같습니다. 점검 화면에서 확인할 항목은 다음과 같습니다.
- DB 연결 - DB 연결
- 필수 테이블 존재 여부(`sensor_log`, `sensor_status`, `sensor_metric`, `sms_log`) - 필수 테이블 존재 여부(`leak_sensor_log`, `leak_sensor_status`, `leak_sensor_metric`, `leak_sms_log`)
- API 키 기본값 교체 여부 - API 키 기본값 교체 여부
- 관리자 해시 설정 여부 - 관리자 해시 설정 여부
- 관리자 TOTP 설정 여부 - 관리자 TOTP 설정 여부
@ -162,10 +163,12 @@ https://your-domain.example/raspi_leck_detecter/setup_wizard.php
장비 오프라인 감지를 위해 `cron_heartbeat.php`를 주기 실행합니다. Cafe24 cron 또는 외부 스케줄러에서 다음 URL을 1분 간격으로 호출합니다. 장비 오프라인 감지를 위해 `cron_heartbeat.php`를 주기 실행합니다. Cafe24 cron 또는 외부 스케줄러에서 다음 URL을 1분 간격으로 호출합니다.
```text ```text
https://your-domain.example/raspi_leck_detecter/cron_heartbeat.php https://your-domain.example/raspi_leck_detecter/cron_heartbeat.php?key=<CRON_SECRET>
``` ```
서버 CLI cron을 사용할 수 있다면 다음처럼 구성할 수 있습니다. HTTP 호출에는 `?key=<CRON_SECRET>` 파라미터가 **필수**입니다(누락 시 403). `CRON_SECRET`은 `API_KEY`에서 파생되며 다음으로 계산합니다: `php -r "echo hash_hmac('sha256','cron','<API_KEY>');"`
서버 CLI cron을 사용할 수 있다면 다음처럼 구성할 수 있습니다(CLI 실행에는 key가 필요 없습니다).
```cron ```cron
* * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php * * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php
@ -223,7 +226,7 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php
- `dashboard.php`에 최신 온도/습도와 센서 상태(`sensor_id=2`)가 표시 - `dashboard.php`에 최신 온도/습도와 센서 상태(`sensor_id=2`)가 표시
- `security_evidence.php`에서 운영 점검 결과와 보안통제 매트릭스 조회 가능 - `security_evidence.php`에서 운영 점검 결과와 보안통제 매트릭스 조회 가능
- STM32 콘솔 로그에 서버 200 응답 기록 확인 - STM32 콘솔 로그에 서버 200 응답 기록 확인
- `sensor_metric`에 주기 측정값(periodic) 저장 - `leak_sensor_metric`에 주기 측정값(periodic) 저장
- 임계 초과 테스트 시 `metric_status` 경보 기록 및 SMS 수신 - 임계 초과 테스트 시 `metric_status` 경보 기록 및 SMS 수신
- 월간 보고서에서 임계 경보/복귀/오프라인 내역 조회 가능 - 월간 보고서에서 임계 경보/복귀/오프라인 내역 조회 가능
@ -234,8 +237,8 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php
| API 403 | 서버 `API_KEY`와 펌웨어 `APP_API_KEY` 일치 여부, raw-body 서명 | | API 403 | 서버 `API_KEY`와 펌웨어 `APP_API_KEY` 일치 여부, raw-body 서명 |
| API 500 | Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부 | | API 500 | Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부 |
| 로그인 불가 | `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, 인증 앱 시간 동기화 | | 로그인 불가 | `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, 인증 앱 시간 동기화 |
| SMS 미발송 | Cafe24 SMS 계정, secure key, 발신번호 등록, 잔액, `sms_log` | | SMS 미발송 | Cafe24 SMS 계정, secure key, 발신번호 등록, 잔액, `leak_sms_log` |
| 온습도 미표시 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `sensor_metric` 테이블, 펌웨어 측정 로그 | | 온습도 미표시 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `leak_sensor_metric` 테이블, 펌웨어 측정 로그 |
| 임계 경보 미발송 | `config.php`/`config.local.php`의 `METRIC_*`, `METRIC_ALERT_COOLDOWN_SEC`(1800초) | | 임계 경보 미발송 | `config.php`/`config.local.php`의 `METRIC_*`, `METRIC_ALERT_COOLDOWN_SEC`(1800초) |
| TLS 실패 | `firmware/certs/server_ca.c` 실제 CA 반영, SNTP 시간 동기(인증서 유효기간 검증) | | TLS 실패 | `firmware/certs/server_ca.c` 실제 CA 반영, SNTP 시간 동기(인증서 유효기간 검증) |
| 오프라인 알림 과다/지연 | `HEARTBEAT_CHECK_INTERVAL_SEC`, `HEARTBEAT_TIMEOUT_SEC`(기본 1200초), cron 주기 | | 오프라인 알림 과다/지연 | `HEARTBEAT_CHECK_INTERVAL_SEC`, `HEARTBEAT_TIMEOUT_SEC`(기본 1200초), cron 주기 |
@ -252,4 +255,4 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php
- SMS 수신자 번호 - SMS 수신자 번호
- 관리자 계정과 원문 비밀번호, `ADMIN_TOTP_SECRET` - 관리자 계정과 원문 비밀번호, `ADMIN_TOTP_SECRET`
- 서버 업로드 경로 - 서버 업로드 경로
- 장비 ID(`stm32-sht30-01`)와 설치 위치 - 장비 ID(`rpi4-sht30-01`, 배포=Raspberry Pi 4)와 설치 위치

View file

@ -10,7 +10,7 @@
| 주기 | 점검 목적 | 보관 증적 | | 주기 | 점검 목적 | 보관 증적 |
|---|---|---| |---|---|---|
| 일일 | 온습도 임계 경보, 장비 오프라인, SMS 실패 여부 확인 | 대시보드 화면 또는 이상 없음 기록 | | 일일 | 온습도 임계 경보, 장비 오프라인, SMS 실패 여부 확인 | 대시보드 화면 또는 이상 없음 기록 |
| 주간 | STM32 보고 수신 상태, 최근 측정값, 측정 공백 여부 확인 | 대시보드 온습도 추이, `sensor_metric` 최근 기록 | | 주간 | STM32 보고 수신 상태, 최근 측정값, 측정 공백 여부 확인 | 대시보드 온습도 추이, `leak_sensor_metric` 최근 기록 |
| 월간 | 보안통제 매트릭스 점검, 백업, 수신자 현행화 | 보안 증적 보고서, 월간 보고서, 백업 목록 | | 월간 | 보안통제 매트릭스 점검, 백업, 수신자 현행화 | 보안 증적 보고서, 월간 보고서, 백업 목록 |
| 월간 | 관리자 MFA, 감사로그, 보관기간 정리 확인 | MFA 등록 확인표, `php/var/admin_audit.log`, retention 실행 결과 | | 월간 | 관리자 MFA, 감사로그, 보관기간 정리 확인 | MFA 등록 확인표, `php/var/admin_audit.log`, retention 실행 결과 |
| 변경 시 | API 키, 서버 경로, 수신자, 임계값, 네트워크 변경 검증 | 변경 승인 기록, 재점검 결과 | | 변경 시 | API 키, 서버 경로, 수신자, 임계값, 네트워크 변경 검증 | 변경 승인 기록, 재점검 결과 |
@ -23,9 +23,9 @@
| 설치 점검 | `setup_wizard.php` 접속 | 중요 항목 0건 또는 조치계획 기록 | 설치 점검 캡처 | | 설치 점검 | `setup_wizard.php` 접속 | 중요 항목 0건 또는 조치계획 기록 | 설치 점검 캡처 |
| 보안 증적 | `security_evidence.php` 접속 | 운영 점검 결과와 보안통제 매트릭스 출력 | Markdown 다운로드 | | 보안 증적 | `security_evidence.php` 접속 | 운영 점검 결과와 보안통제 매트릭스 출력 | Markdown 다운로드 |
| 월간 보고 | `monthly_report.php` 조회 | 임계 경보, 복귀, 오프라인, SMS 현황 표시 | PDF 또는 CSV | | 월간 보고 | `monthly_report.php` 조회 | 임계 경보, 복귀, 오프라인, SMS 현황 표시 | PDF 또는 CSV |
| 장비 상태 | `sensor_status`(`sensor_id=2`) | 최근 수신 시각이 `HEARTBEAT_TIMEOUT_SEC` 이내 | 대시보드 센서 카드 | | 장비 상태 | `leak_sensor_status`(`sensor_id=2`) | 최근 수신 시각이 `HEARTBEAT_TIMEOUT_SEC` 이내 | 대시보드 센서 카드 |
| 측정 수집 | `sensor_metric` | 5분 주기 측정값 누적 | 온습도 추이 캡처 | | 측정 수집 | `leak_sensor_metric` | 5분 주기 측정값 누적 | 온습도 추이 캡처 |
| SMS | 최근 실패 | 반복 실패 없음 | `sms_log` 또는 대시보드 | | SMS | 최근 실패 | 반복 실패 없음 | `leak_sms_log` 또는 대시보드 |
| 임계 설정 | `METRIC_*` | 기관 기준과 일치 | `config.local.php` 발췌(값만) | | 임계 설정 | `METRIC_*` | 기관 기준과 일치 | `config.local.php` 발췌(값만) |
| 백업 | DB/설정 백업 | 정해진 주기대로 백업 존재 | 백업 파일 목록 | | 백업 | DB/설정 백업 | 정해진 주기대로 백업 존재 | 백업 파일 목록 |
| 관리자 MFA | `ADMIN_TOTP_SECRET` | 인증 앱 등록 완료 | MFA 등록 확인표 | | 관리자 MFA | `ADMIN_TOTP_SECRET` | 인증 앱 등록 완료 | MFA 등록 확인표 |
@ -65,7 +65,7 @@ USART3(PD8/PD9, 115200) 콘솔 로그로 다음을 확인합니다.
|---|---|---| |---|---|---|
| 임계 초과 감지 | 측정값이 임계 초과 시 `metric_status` 경보가 기록되는가 | 감지 시각/항목 | | 임계 초과 감지 | 측정값이 임계 초과 시 `metric_status` 경보가 기록되는가 | 감지 시각/항목 |
| SMS 발송 | 수신자가 임계 경보 SMS를 받는가 | 수신 시각 | | SMS 발송 | 수신자가 임계 경보 SMS를 받는가 | 수신 시각 |
| 쿨다운 | 동일 종류 경보가 30분 내 중복 발송되지 않는가 | `sms_log` 간격 | | 쿨다운 | 동일 종류 경보가 30분 내 중복 발송되지 않는가 | `leak_sms_log` 간격 |
| 정상복귀 | 히스테리시스 포함 정상 회복 시 복귀 SMS 1회 발송되는가 | 복귀 시각 | | 정상복귀 | 히스테리시스 포함 정상 회복 시 복귀 SMS 1회 발송되는가 | 복귀 시각 |
| 보고서 반영 | 월간 보고서에 경보/복귀가 반영되는가 | 보고서 파일 | | 보고서 반영 | 월간 보고서에 경보/복귀가 반영되는가 | 보고서 파일 |
@ -76,7 +76,7 @@ USART3(PD8/PD9, 115200) 콘솔 로그로 다음을 확인합니다.
| 임계 경보 발생 | 당일 원인 확인 후 조치 결과 기록(공조/환기/장비 점검) | | 임계 경보 발생 | 당일 원인 확인 후 조치 결과 기록(공조/환기/장비 점검) |
| SMS 반복 실패 | Cafe24 SMS 잔액, 발신번호, secure key, 수신자 확인 | | SMS 반복 실패 | Cafe24 SMS 잔액, 발신번호, secure key, 수신자 확인 |
| 장비 오프라인 | 전원, LAN 연결, PHY 링크, 펌웨어 콘솔 로그 확인 | | 장비 오프라인 | 전원, LAN 연결, PHY 링크, 펌웨어 콘솔 로그 확인 |
| 온습도 미수집 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `sensor_metric` 테이블 확인 | | 온습도 미수집 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `leak_sensor_metric` 테이블 확인 |
| HTTPS/TLS 미확인 | Cafe24 SSL 설정, 펌웨어 CA 반영, SNTP 시간 동기 확인 | | HTTPS/TLS 미확인 | Cafe24 SSL 설정, 펌웨어 CA 반영, SNTP 시간 동기 확인 |
| 백업 미수행 | 백업 주기 재설정 및 다음 월 점검에 포함 | | 백업 미수행 | 백업 주기 재설정 및 다음 월 점검에 포함 |

View file

@ -1,32 +1,32 @@
# 서버실 온습도(SHT30) 모니터링 시스템 v2606 # 서버실 온습도(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 ```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] [MySQL DB]
| |
[Cafe24 SMS / Dashboard] [Cafe24 SMS / Dashboard]
``` ```
- 단일 보드 `sht30_fw` (`sensor_id=2`, `device_id=stm32-sht30-01`). - Raspberry Pi 4 노드 `raspberry_pi4_sht30`(= `deploy/03_pi_b_sht30_id2`) — `sensor_id=2`, `device_id=rpi4-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 참조. - 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분(펌웨어 `APP_SHT30_REPORT_INTERVAL_SEC=300`). 최초 1회 startup, 이후 periodic. - 측정 주기: 5분(`raspberry_pi4_sht30/config.py REPORT_INTERVAL=300`). 최초 1회 startup, 이후 periodic.
- 인증: 펌웨어가 `X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`(raw-body) 헤더로 서명하고 서버가 검증합니다. 관리자 로그인은 비밀번호 해시 + TOTP MFA + 감사로그로 보호합니다. - 인증: Pi 가 `X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`(raw-body) 헤더로 서명하고 서버가 검증합니다. 관리자 로그인은 비밀번호 해시 + TOTP MFA + 감사로그로 보호합니다.
## 폴더 구조 ## 폴더 구조
```text ```text
docs/ docs/
README.md # 설치/운영 허브 문서 README.md # 설치/운영 허브 문서
INSTALL_PI_SERVER.md # STM32 펌웨어 및 Cafe24 서버 설치 설명서 INSTALL_PI_SERVER.md # Cafe24 서버 설치 설명서 (Pi/STM32 공통)
OPERATIONS_SECURITY_CHECKLIST.md # 월간 운영·보안 점검표 OPERATIONS_SECURITY_CHECKLIST.md # 월간 운영·보안 점검표
SECURITY_EVIDENCE_PACKAGE.md # 보안대책서 첨부자료 패키지 SECURITY_EVIDENCE_PACKAGE.md # 보안대책서 첨부자료 패키지
wiring_diagram.md # SHT30 I2C 배선도 wiring_diagram.md # SHT30 I2C 배선도
SHT30_SENSOR2_SETUP.md # SHT30 설치 가이드(STM32 기본 / RPi 대안) SHT30_SENSOR2_SETUP.md # SHT30 설치 가이드(Raspberry Pi 4 기본 / STM32 마이그레이션 대안)
solutions/ # 실행-검토 사이클에서 얻은 교훈/반복 방지 기록 solutions/ # 실행-검토 사이클에서 얻은 교훈/반복 방지 기록
firmware/ # STM32F407VGT6 베어메탈 펌웨어 (sht30_fw) firmware/ # STM32F407VGT6 베어메탈 펌웨어 (sht30_fw)
common/ # 이식 코어(sha256/sig/jsonbody/sht30_convert/httpapi/net 등) common/ # 이식 코어(sha256/sig/jsonbody/sht30_convert/httpapi/net 등)
@ -51,8 +51,7 @@ php/
blocked.php # 차단 안내 화면 blocked.php # 차단 안내 화면
api/sensor_data.php # STM32 센서 데이터 수신 api/sensor_data.php # STM32 센서 데이터 수신
sql/ sql/
schema_sht30.sql # 신규 설치용 통합 스키마 schema_sht30.sql # 신규 설치용 통합 스키마 (이 파일 하나로 완결)
migration_drop_leak.sql # 기존 누수 설치 → 온습도 전용 전환 마이그레이션
_backup/ _backup/
pre_v2605_20260519/ # 정리 전 원본 백업 pre_v2605_20260519/ # 정리 전 원본 백업
``` ```
@ -69,7 +68,7 @@ _backup/
SOURCE /path/to/sql/schema_sht30.sql; SOURCE /path/to/sql/schema_sht30.sql;
``` ```
phpMyAdmin에서 실행할 때는 `sql/schema_sht30.sql` 내용을 복사해 실행합니다. 생성 테이블은 `sensor_log`, `sensor_status`, `sensor_metric`, `sms_log`입니다. phpMyAdmin에서 실행할 때는 `sql/schema_sht30.sql` 내용을 복사해 실행합니다. 생성 테이블은 `leak_sensor_log`, `leak_sensor_status`, `leak_sensor_metric`, `leak_sms_log`입니다.
3. PHP 파일을 업로드합니다. 3. PHP 파일을 업로드합니다.
@ -126,29 +125,21 @@ _backup/
* * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php * * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php
``` ```
## 기존 누수 설치 → 온습도 전용 전환 ## 설치 스키마
1. 현재 운영 파일과 DB를 백업합니다. 신규 설치는 `sql/schema_sht30.sql` 하나만 실행합니다(누수+온습도 통합, 테이블 전체 `leak_` 접두사).
2. `php/config.local.example.php` 기준으로 `config.local.php`를 갱신합니다(METRIC_* 임계 확인). 구버전 무접두 테이블 대상 마이그레이션 파일들은 v2607 테이블 리네임과 함께 폐기되었습니다(git 이력 참고).
3. DB에 전환 마이그레이션을 적용합니다.
```sql
SOURCE /path/to/sql/migration_drop_leak.sql;
```
이 마이그레이션은 레거시 누수 컬럼/테이블(`leak_photo`, `leak_incident`, `is_leak` 등)을 정리하고 `sensor_metric`을 보장합니다.
4. 신규 설치라면 위 마이그레이션 대신 `sql/schema_sht30.sql`만 사용합니다.
## 이벤트 정책 ## 이벤트 정책
| 이벤트 | 조건 | DB | SMS | | 이벤트 | 조건 | DB | SMS |
|---|---|---|---| |---|---|---|---|
| `startup` | 기기 시작 첫 측정 | `sensor_log` | X | | `startup` | 기기 시작 첫 측정 | `leak_sensor_log` | X |
| `periodic` | 정상 주기 측정(5분) | `sensor_metric` | X | | `periodic` | 정상 주기 측정(5분) | `leak_sensor_metric` | X |
| 고온/저온/고습/저습 경보 | 측정값이 임계 초과 | `sensor_metric.metric_status` | O (종류별 30분 쿨다운) | | 고온/저온/고습/저습 경보 | 측정값이 임계 초과 | `leak_sensor_metric.metric_status` | O (종류별 30분 쿨다운) |
| 정상복귀 | 경보 후 히스테리시스 포함 정상 회복 | `sensor_metric` | O (1회) | | 정상복귀 | 경보 후 히스테리시스 포함 정상 회복 | `leak_sensor_metric` | O (1회) |
| 장비 오프라인 | 마지막 수신 후 `HEARTBEAT_TIMEOUT_SEC`(기본 1200초=20분) 초과 | `sensor_status` | O | | 장비 오프라인 | 마지막 수신 후 `HEARTBEAT_TIMEOUT_SEC`(기본 1200초=20분) 초과 | `leak_sensor_status` | O |
| 장비 복구 | 오프라인 후 재수신 | `sensor_status` | O | | 장비 복구 | 오프라인 후 재수신 | `leak_sensor_status` | O |
## 운영 기준 ## 운영 기준
@ -161,7 +152,7 @@ _backup/
- 로그인 성공/실패, 로그아웃, MFA 등록 검증은 `php/var/admin_audit.log`에 감사로그로 기록합니다. - 로그인 성공/실패, 로그아웃, MFA 등록 검증은 `php/var/admin_audit.log`에 감사로그로 기록합니다.
- heartbeat 체크 실행 주기와 오프라인 판정 시간(`HEARTBEAT_TIMEOUT_SEC`, 기본 1200초)은 분리되어 있습니다. - heartbeat 체크 실행 주기와 오프라인 판정 시간(`HEARTBEAT_TIMEOUT_SEC`, 기본 1200초)은 분리되어 있습니다.
- 대시보드는 위험 상태를 첫 화면에 우선 표시하고, 최신 온습도, 운영 자가진단, 30일 운영 요약을 함께 보여줍니다. - 대시보드는 위험 상태를 첫 화면에 우선 표시하고, 최신 온습도, 운영 자가진단, 30일 운영 요약을 함께 보여줍니다.
- 운영 자가진단은 API 키, 관리자 해시, SMS 수신자, 필수 테이블(`sensor_metric` 포함), 상태 파일 폴더 권한, 최근 SMS 실패를 확인합니다. - 운영 자가진단은 API 키, 관리자 해시, SMS 수신자, 필수 테이블(`leak_sensor_metric` 포함), 상태 파일 폴더 권한, 최근 SMS 실패를 확인합니다.
- 월간 보고서는 온습도 임계 경보, 정상복귀, 오프라인/복구, SMS 현황을 조회하고 CSV/인쇄/PDF로 출력할 수 있습니다. - 월간 보고서는 온습도 임계 경보, 정상복귀, 오프라인/복구, SMS 현황을 조회하고 CSV/인쇄/PDF로 출력할 수 있습니다.
- 보관기간 정리는 `php/retention_cleanup.php --dry-run`으로 삭제 대상을 먼저 확인한 뒤, 운영 백업 완료 후 `--execute`로 수행합니다. - 보관기간 정리는 `php/retention_cleanup.php --dry-run`으로 삭제 대상을 먼저 확인한 뒤, 운영 백업 완료 후 `--execute`로 수행합니다.
- 실행-검토 과정에서 나온 실수와 교훈은 `docs/solutions/`에 남겨 다음 개선 사이클에서 반복하지 않습니다. - 실행-검토 과정에서 나온 실수와 교훈은 `docs/solutions/`에 남겨 다음 개선 사이클에서 반복하지 않습니다.
@ -198,10 +189,10 @@ php scripts/backup_evidence.php --backup-dir /path/to/backups --restore-test "YY
| 증상 | 확인 지점 | | 증상 | 확인 지점 |
|---|---| |---|---|
| API 403 | 펌웨어 `APP_API_KEY`와 서버 `API_KEY` 일치 여부, raw-body 서명 | | API 403 | 펌웨어 `APP_API_KEY`와 서버 `API_KEY` 일치 여부, raw-body 서명 |
| API 500 | Cafe24 PHP 오류 로그, DB 접속 정보, `sensor_metric` 등 테이블 생성 여부 | | API 500 | Cafe24 PHP 오류 로그, DB 접속 정보, `leak_sensor_metric` 등 테이블 생성 여부 |
| 로그인 불가 | `config.local.php`의 `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, 인증 앱 시간 동기화 | | 로그인 불가 | `config.local.php`의 `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, 인증 앱 시간 동기화 |
| SMS 미발송 | Cafe24 SMS 인증값, 발신번호 등록, 잔액, `sms_log` | | SMS 미발송 | Cafe24 SMS 인증값, 발신번호 등록, 잔액, `leak_sms_log` |
| 온습도 미표시 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `sensor_metric` 테이블, 펌웨어 측정 로그 | | 온습도 미표시 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `leak_sensor_metric` 테이블, 펌웨어 측정 로그 |
| 임계 경보 미발송 | `config.php`/`config.local.php`의 `METRIC_*` 임계, `METRIC_ALERT_COOLDOWN_SEC`(30분) | | 임계 경보 미발송 | `config.php`/`config.local.php`의 `METRIC_*` 임계, `METRIC_ALERT_COOLDOWN_SEC`(30분) |
| 오프라인 알림 지연 | `HEARTBEAT_CHECK_INTERVAL_SEC`, `HEARTBEAT_TIMEOUT_SEC`(기본 1200초) | | 오프라인 알림 지연 | `HEARTBEAT_CHECK_INTERVAL_SEC`, `HEARTBEAT_TIMEOUT_SEC`(기본 1200초) |
| 대시보드 진단 경고 | `config.local.php`, DB 마이그레이션, `php/var` 권한 | | 대시보드 진단 경고 | `config.local.php`, DB 마이그레이션, `php/var` 권한 |

View file

@ -14,9 +14,9 @@
| raw-body API 서명 검증 (HMAC-SHA256) | `config.php` `verify_signature_raw()`=`hash_hmac('sha256', $raw_body, API_KEY)`, 펌웨어 `sig.c`/`secrets.h` (`X-Signature`) | 반영 (서명 방식 정정 후 일치, 아래 §2.1 참조) | | raw-body API 서명 검증 (HMAC-SHA256) | `config.php` `verify_signature_raw()`=`hash_hmac('sha256', $raw_body, API_KEY)`, 펌웨어 `sig.c`/`secrets.h` (`X-Signature`) | 반영 (서명 방식 정정 후 일치, 아래 §2.1 참조) |
| 측정값 범위 검증 | `api/sensor_data.php` (온도 -40~125℃ / 습도 0~100%) | 반영 | | 측정값 범위 검증 | `api/sensor_data.php` (온도 -40~125℃ / 습도 0~100%) | 반영 |
| 서버 임계 판정·쿨다운 | `config.php` `METRIC_*`(고온30/저온10·고습70/저습20, 30분 쿨다운, 히스테리시스), `api/sensor_data.php` | 반영 | | 서버 임계 판정·쿨다운 | `config.php` `METRIC_*`(고온30/저온10·고습70/저습20, 30분 쿨다운, 히스테리시스), `api/sensor_data.php` | 반영 |
| 임계 경보/정상복귀 SMS | `api/sensor_data.php`, `sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) | 반영 | | 임계 경보/정상복귀 SMS | `api/sensor_data.php`, `leak_sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) | 반영 |
| 장비 오프라인/복구 SMS | `cron_heartbeat.php`, `sensor_status.offline_alerted`, `sms_log` | 반영 | | 장비 오프라인/복구 SMS | `cron_heartbeat.php`, `leak_sensor_status.offline_alerted`, `leak_sms_log` | 반영 |
| 측정 이력 저장 | `sensor_metric` 테이블 | 반영 | | 측정 이력 저장 | `leak_sensor_metric` 테이블 | 반영 |
| 보관기간 정리 | `retention_cleanup.php --dry-run`, `--execute` (SMS/센서/측정/감사로그) | 반영 | | 보관기간 정리 | `retention_cleanup.php --dry-run`, `--execute` (SMS/센서/측정/감사로그) | 반영 |
| 백업·복구 증빙 | `scripts/backup_evidence.php` | 반영 | | 백업·복구 증빙 | `scripts/backup_evidence.php` | 반영 |
| 보안 증적 보고서 | `security_evidence.php?format=md` | 반영 | | 보안 증적 보고서 | `security_evidence.php?format=md` | 반영 |

View file

@ -16,7 +16,7 @@
| 5 | 네트워크 증적 | 방화벽 정책, 단말 인바운드 차단 확인, HTTPS 인증서 화면 | | 5 | 네트워크 증적 | 방화벽 정책, 단말 인바운드 차단 확인, HTTPS 인증서 화면 |
| 6 | 물리 설치 증적 | STM32 보드, SHT30 센서 설치 사진과 장비 ID | | 6 | 물리 설치 증적 | STM32 보드, SHT30 센서 설치 사진과 장비 ID |
| 7 | 관리자 접근통제 증적 | MFA 등록 화면, 로그인 화면, 감사로그 존재 확인 | | 7 | 관리자 접근통제 증적 | MFA 등록 화면, 로그인 화면, 감사로그 존재 확인 |
| 8 | 측정/임계 경보 증적 | 측정값 저장(`sensor_metric`), 종류별 경보·정상복귀 SMS(`sms_log`) 화면 | | 8 | 측정/임계 경보 증적 | 측정값 저장(`leak_sensor_metric`), 종류별 경보·정상복귀 SMS(`leak_sms_log`) 화면 |
| 9 | 운영 절차 증적 | 운영·보안 점검표, 보관기간 정리 dry-run, 백업 파일 목록, 수신자 현행화 확인 | | 9 | 운영 절차 증적 | 운영·보안 점검표, 보관기간 정리 dry-run, 백업 파일 목록, 수신자 현행화 확인 |
| 10 | 소스코드 기반 설명 | `SOURCE_SECURITY_EVIDENCE.md`의 기술통제 설명 | | 10 | 소스코드 기반 설명 | `SOURCE_SECURITY_EVIDENCE.md`의 기술통제 설명 |
@ -79,7 +79,7 @@ security-evidence-YYYYMMDD/
- [ ] `security_evidence.php` 점검 결과에 중요 실패 항목이 있으면 조치 결과 또는 예외 사유를 적는다. - [ ] `security_evidence.php` 점검 결과에 중요 실패 항목이 있으면 조치 결과 또는 예외 사유를 적는다.
- [ ] 단말 증적에는 환경변수 존재 여부만 있고 원문 값은 없다. - [ ] 단말 증적에는 환경변수 존재 여부만 있고 원문 값은 없다.
- [ ] MFA 증빙은 운영 비밀키가 아닌 증빙용 임시 키 또는 마스킹된 키를 사용한다. - [ ] MFA 증빙은 운영 비밀키가 아닌 증빙용 임시 키 또는 마스킹된 키를 사용한다.
- [ ] 측정/경보 증빙은 측정값 저장(`sensor_metric`)과 종류별 경보·정상복귀 SMS(`sms_log`)를 함께 보여준다. - [ ] 측정/경보 증빙은 측정값 저장(`leak_sensor_metric`)과 종류별 경보·정상복귀 SMS(`leak_sms_log`)를 함께 보여준다.
- [ ] 실제 도메인은 `https://`로 접속되는 화면을 포함한다. - [ ] 실제 도메인은 `https://`로 접속되는 화면을 포함한다.
- [ ] 네트워크 증적에는 단말로 들어오는 불필요한 포트가 없다는 근거가 포함된다. - [ ] 네트워크 증적에는 단말로 들어오는 불필요한 포트가 없다는 근거가 포함된다.
- [ ] 월간 보고서에는 생성 시각과 보고 범위가 보인다. - [ ] 월간 보고서에는 생성 시각과 보고 범위가 보인다.

View file

@ -210,7 +210,7 @@ DB 백업 주기: 매일 / 매주 / 매월
| 관리자 인증 | 관리자 로그인 화면, MFA 등록 증빙, 감사로그 | 비밀번호 해시, TOTP MFA, 세션 보호, 실패 제한 코드 설명 | | 관리자 인증 | 관리자 로그인 화면, MFA 등록 증빙, 감사로그 | 비밀번호 해시, TOTP MFA, 세션 보호, 실패 제한 코드 설명 |
| 측정/임계 | 임계 운영 기준(`METRIC_*`) | 측정값 범위 검증, 서버 임계 판정·쿨다운 설명 | | 측정/임계 | 임계 운영 기준(`METRIC_*`) | 측정값 범위 검증, 서버 임계 판정·쿨다운 설명 |
| 개인정보 | SMS 수신자 관리 기준 | SMS 설정 구조, 로그 테이블 설명 | | 개인정보 | SMS 수신자 관리 기준 | SMS 설정 구조, 로그 테이블 설명 |
| 로그관리 | 로그 보관기간, 관리자 감사로그 | `sensor_log`, `sensor_metric`, `sms_log`, `php/var/admin_audit.log` 설명 | | 로그관리 | 로그 보관기간, 관리자 감사로그 | `leak_sensor_log`, `leak_sensor_metric`, `leak_sms_log`, `php/var/admin_audit.log` 설명 |
| 백업 | DB/설정 백업 주기, 복구 테스트 결과 | `backup_evidence.php` 결과와 백업·복구 문구 | | 백업 | DB/설정 백업 주기, 복구 테스트 결과 | `backup_evidence.php` 결과와 백업·복구 문구 |
| 패치 | 월간 패치 점검 담당자 | 점검표 양식 | | 패치 | 월간 패치 점검 담당자 | 점검표 양식 |
| 물리보안 | 설치 사진, 자산 정보 | 자산대장 양식 | | 물리보안 | 설치 사진, 자산 정보 | 자산대장 양식 |

View file

@ -14,7 +14,7 @@
현재 HWP 보안대책서는 사업 목적, 추진체계, 관리적·물리적·기술적 보안대책의 큰 틀은 갖추고 있습니다. 다만 실제 구현된 시스템과 비교하면 본문이 과거 범위(화재·누수·현장 사진·사고 대응)를 기준으로 작성되어 있어, 현재 구현 범위인 **서버실 온습도(SHT30) 모니터링 + 임계 SMS 경보 + 장비 오프라인 알림**으로 정정해야 합니다. 현재 HWP 보안대책서는 사업 목적, 추진체계, 관리적·물리적·기술적 보안대책의 큰 틀은 갖추고 있습니다. 다만 실제 구현된 시스템과 비교하면 본문이 과거 범위(화재·누수·현장 사진·사고 대응)를 기준으로 작성되어 있어, 현재 구현 범위인 **서버실 온습도(SHT30) 모니터링 + 임계 SMS 경보 + 장비 오프라인 알림**으로 정정해야 합니다.
- 사업 범위가 `화재/누수/장비 전원 이상`으로 넓게 쓰여 있으나, 현재 구현은 `서버실 온습도(SHT30) 측정 + 임계(고온/저온/고습/저습) SMS 경보 + 정상복귀 SMS + 장비 오프라인/복구 SMS + 대시보드`입니다. 누수센서·카메라·현장 사진 기능은 제거되었습니다. - 사업 범위가 `화재/누수/장비 전원 이상`으로 넓게 쓰여 있으나, 현재 구현은 `서버실 온습도(SHT30) 측정 + 임계(고온/저온/고습/저습) SMS 경보 + 정상복귀 SMS + 장비 오프라인/복구 SMS + 대시보드`입니다. 누수센서·카메라·현장 사진 기능은 제거되었습니다.
- 구성도에는 큰 흐름이 있으나, 실제 구성요소인 STM32 보드, SHT30 온습도 센서, Cafe24 PHP API, MySQL, 측정 이력 테이블(`sensor_metric`), SMS 이력(`sms_log`), 월간 보고서가 명확히 표현되어 있지 않습니다. - 구성도에는 큰 흐름이 있으나, 실제 구성요소인 STM32 보드, SHT30 온습도 센서, Cafe24 PHP API, MySQL, 측정 이력 테이블(`leak_sensor_metric`), SMS 이력(`leak_sms_log`), 월간 보고서가 명확히 표현되어 있지 않습니다.
- API 키, 서명 검증, `config.local.php`, `firmware/common/secrets.h`(`APP_API_KEY`), 서버 임계 판정(`config.php`의 `METRIC_*`), 관리자 TOTP MFA, 관리자 감사로그 같은 실제 보안 구현 항목이 본문에 구체적으로 반영되어야 합니다. - API 키, 서명 검증, `config.local.php`, `firmware/common/secrets.h`(`APP_API_KEY`), 서버 임계 판정(`config.php`의 `METRIC_*`), 관리자 TOTP MFA, 관리자 감사로그 같은 실제 보안 구현 항목이 본문에 구체적으로 반영되어야 합니다.
- 로그 보관기간, SMS 수신자 개인정보 관리, `retention_cleanup.php` 기반 정리 절차, 백업/복구 증빙, 장애 대응 절차가 선언 수준에 머물러 있어 운영 기준표가 필요합니다. - 로그 보관기간, SMS 수신자 개인정보 관리, `retention_cleanup.php` 기반 정리 절차, 백업/복구 증빙, 장애 대응 절차가 선언 수준에 머물러 있어 운영 기준표가 필요합니다.
- '현장 사진/시설정보'는 더 이상 처리하지 않으므로 해당 절은 삭제하고, 통제 공백처럼 보이지 않도록 **온습도 임계 경보 + 오발송 방지(30분 쿨다운 / 복구 히스테리시스)**로 대체 서술합니다. - '현장 사진/시설정보'는 더 이상 처리하지 않으므로 해당 절은 삭제하고, 통제 공백처럼 보이지 않도록 **온습도 임계 경보 + 오발송 방지(30분 쿨다운 / 복구 히스테리시스)**로 대체 서술합니다.
@ -35,7 +35,7 @@
| 권한 및 계정관리 (L106~L110) | 관리자 계정 최소 부여, 비밀번호 정책 | `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, `MFA_SETUP_TOKEN`, 세션 쿠키 옵션, 로그인 실패 잠금, 감사로그, 비밀번호/TOTP 교체 절차 추가 | 높음 | | 권한 및 계정관리 (L106~L110) | 관리자 계정 최소 부여, 비밀번호 정책 | `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, `MFA_SETUP_TOKEN`, 세션 쿠키 옵션, 로그인 실패 잠금, 감사로그, 비밀번호/TOTP 교체 절차 추가 | 높음 |
| 단말기 보안대책 (L135~L143) | 기본 계정 변경, 하드코딩 금지 | 비밀값은 `firmware/common/secrets.h`(`APP_API_KEY`)에 두고 미커밋(.gitignore)으로 빌드 머신에서만 보관, API 키 서버 `config.php`의 `API_KEY`와 일치, 불필요 기능 비활성화 추가. (카메라 권한 관리 문구 불필요) | 높음 | | 단말기 보안대책 (L135~L143) | 기본 계정 변경, 하드코딩 금지 | 비밀값은 `firmware/common/secrets.h`(`APP_API_KEY`)에 두고 미커밋(.gitignore)으로 빌드 머신에서만 보관, API 키 서버 `config.php`의 `API_KEY`와 일치, 불필요 기능 비활성화 추가. (카메라 권한 관리 문구 불필요) | 높음 |
| 플랫폼/서비스 보안대책 (L150~L156) | 인증, 패치, 기본 서비스 제거 | API 서명 검증, 측정값 범위 검증(온도 -40~125℃ / 습도 0~100%), DB prepared statement, TOTP MFA, `config.local.php` 비공개 관리 추가. (사진 업로드/MIME/매직바이트 검증 문구 삭제) | 높음 | | 플랫폼/서비스 보안대책 (L150~L156) | 인증, 패치, 기본 서비스 제거 | API 서명 검증, 측정값 범위 검증(온도 -40~125℃ / 습도 0~100%), DB prepared statement, TOTP MFA, `config.local.php` 비공개 관리 추가. (사진 업로드/MIME/매직바이트 검증 문구 삭제) | 높음 |
| 로그관리 (L94~L95) | 로그 보관기간 정의, 위변조 방지 | `sensor_log`, `sensor_metric`, `sms_log`, 서버 `php/var/admin_audit.log`, `retention_cleanup.php` 기준으로 보관·점검 기준 추가. (`leak_incident`/`leak_photo`/Pi 로그 삭제) | 높음 | | 로그관리 (L94~L95) | 로그 보관기간 정의, 위변조 방지 | `leak_sensor_log`, `leak_sensor_metric`, `leak_sms_log`, 서버 `php/var/admin_audit.log`, `retention_cleanup.php` 기준으로 보관·점검 기준 추가. (`leak_incident`/`leak_photo`/Pi 로그 삭제) | 높음 |
| 장비 설치·보호 (L114, L118~L120) | "화재·누수 감지센서, IoT 보드 및 **카메라**" | "SHT30 온습도 센서, STM32 보드"로 교체. 카메라 문구 삭제. 자산대장 등록은 유지 | 높음 | | 장비 설치·보호 (L114, L118~L120) | "화재·누수 감지센서, IoT 보드 및 **카메라**" | "SHT30 온습도 센서, STM32 보드"로 교체. 카메라 문구 삭제. 자산대장 등록은 유지 | 높음 |
## 3. HWP에 추가해야 할 장·절 ## 3. HWP에 추가해야 할 장·절
@ -50,7 +50,7 @@
| SHT30 온습도 센서 | I2C 온습도 측정(5분 주기) | 서버실 고정 설치, 임의 탈거 방지, 자산대장 등록 | | SHT30 온습도 센서 | I2C 온습도 측정(5분 주기) | 서버실 고정 설치, 임의 탈거 방지, 자산대장 등록 |
| PHP API | `api/sensor_data.php` | API 키 서명 검증, HTTPS, 측정값 범위 검증, 서버 임계 판정 | | PHP API | `api/sensor_data.php` | API 키 서명 검증, HTTPS, 측정값 범위 검증, 서버 임계 판정 |
| 임계 판정 | `config.php`의 `METRIC_*` 상수 | 고온30/저온10℃·고습70/저습20% 임계, 복구 히스테리시스(1.0℃/3.0%), 30분 쿨다운 | | 임계 판정 | `config.php`의 `METRIC_*` 상수 | 고온30/저온10℃·고습70/저습20% 임계, 복구 히스테리시스(1.0℃/3.0%), 30분 쿨다운 |
| MySQL DB | `sensor_log`, `sensor_status`, `sensor_metric`, `sms_log` | 최소 권한 DB 계정, 백업, 보관기간 관리 | | MySQL DB | `leak_sensor_log`, `leak_sensor_status`, `leak_sensor_metric`, `leak_sms_log` | 최소 권한 DB 계정, 백업, 보관기간 관리 |
| 대시보드 | `dashboard.php` | 관리자 로그인, TOTP MFA, 세션 보호, 온습도/경보 현황 표시 | | 대시보드 | `dashboard.php` | 관리자 로그인, TOTP MFA, 세션 보호, 온습도/경보 현황 표시 |
| MFA 등록 | `setup_mfa.php` | 임시 등록 토큰, 인증 앱 등록 검증, 외부 QR/API 미사용 | | MFA 등록 | `setup_mfa.php` | 임시 등록 토큰, 인증 앱 등록 검증, 외부 QR/API 미사용 |
| 설치 점검 | `setup_wizard.php` | DB/권한/SMS/임계 설정 점검 | | 설치 점검 | `setup_wizard.php` | DB/권한/SMS/임계 설정 점검 |
@ -69,7 +69,7 @@
2. STM32 보드가 측정값을 수집하여 정상 보고(`periodic`/`startup`)를 구성한다. 2. STM32 보드가 측정값을 수집하여 정상 보고(`periodic`/`startup`)를 구성한다.
3. 단말이 HTTPS로 PHP API에 측정값을 전송한다(인바운드 포트 미개방, 아웃바운드 POST만). 3. 단말이 HTTPS로 PHP API에 측정값을 전송한다(인바운드 포트 미개방, 아웃바운드 POST만).
4. 서버가 API 서명, API 키 일치, 필수 필드, 측정값 범위(온도 -40~125℃ / 습도 0~100%)를 검증한다. 4. 서버가 API 서명, API 키 일치, 필수 필드, 측정값 범위(온도 -40~125℃ / 습도 0~100%)를 검증한다.
5. 서버가 운영 임계(`METRIC_*`)로 고온/저온/고습/저습을 재판정하여 `sensor_metric`에 저장하고, 정상 보고는 `sensor_status`를 갱신한다. 5. 서버가 운영 임계(`METRIC_*`)로 고온/저온/고습/저습을 재판정하여 `leak_sensor_metric`에 저장하고, 정상 보고는 `leak_sensor_status`를 갱신한다.
6. 임계 이탈 시 30분 쿨다운과 복구 히스테리시스를 적용하여 종류별 SMS를 발송하고, 임계 안쪽으로 회복하면 정상복귀 SMS를 발송한다. 6. 임계 이탈 시 30분 쿨다운과 복구 히스테리시스를 적용하여 종류별 SMS를 발송하고, 임계 안쪽으로 회복하면 정상복귀 SMS를 발송한다.
7. heartbeat 타임아웃이 지나면 장비 오프라인 SMS를, 보고 재개 시 복구 SMS를 발송한다. 7. heartbeat 타임아웃이 지나면 장비 오프라인 SMS를, 보고 재개 시 복구 SMS를 발송한다.
8. 대시보드와 월간 보고서에서 온습도·경보·SMS·오프라인 현황을 표시한다. 8. 대시보드와 월간 보고서에서 온습도·경보·SMS·오프라인 현황을 표시한다.
@ -85,12 +85,12 @@
| 단계 | 처리 내용 | 증적 | | 단계 | 처리 내용 | 증적 |
|---|---|---| |---|---|---|
| 측정 | SHT30 측정값 수신, `sensor_metric` 기록, 임계 판정 | `sensor_metric.metric_status` | | 측정 | SHT30 측정값 수신, `leak_sensor_metric` 기록, 임계 판정 | `leak_sensor_metric.metric_status` |
| 경보 | 임계 이탈 시 종류별 SMS 발송(쿨다운 적용) | `sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) | | 경보 | 임계 이탈 시 종류별 SMS 발송(쿨다운 적용) | `leak_sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) |
| 확인 | 담당자가 대시보드에서 온습도/경보 상태 확인 | 대시보드 캡처 | | 확인 | 담당자가 대시보드에서 온습도/경보 상태 확인 | 대시보드 캡처 |
| 조치 | 공조/환기 등 현장 점검 및 환경 정상화 | 운영 점검 기록 | | 조치 | 공조/환기 등 현장 점검 및 환경 정상화 | 운영 점검 기록 |
| 복구 | 임계 안쪽으로 회복 시 정상복귀 SMS | `sms_log` 복구 메시지 | | 복구 | 임계 안쪽으로 회복 시 정상복귀 SMS | `leak_sms_log` 복구 메시지 |
| 오프라인 | heartbeat 타임아웃 시 오프라인 SMS, 복구 시 복구 SMS | `cron_heartbeat.php` 실행 로그, `sms_log` | | 오프라인 | heartbeat 타임아웃 시 오프라인 SMS, 복구 시 복구 SMS | `cron_heartbeat.php` 실행 로그, `leak_sms_log` |
| 보고 | 월간 보고서에 경보·SMS·오프라인 현황 반영 | `monthly_report.php` | | 보고 | 월간 보고서에 경보·SMS·오프라인 현황 반영 | `monthly_report.php` |
### 3.4 설치·운영 점검 절차 ### 3.4 설치·운영 점검 절차
@ -100,8 +100,8 @@
| 점검 항목 | 확인 방법 | 불합격 시 조치 | | 점검 항목 | 확인 방법 | 불합격 시 조치 |
|---|---|---| |---|---|---|
| DB 연결 | 설치 점검 화면 | `config.local.php` DB 설정 수정 | | DB 연결 | 설치 점검 화면 | `config.local.php` DB 설정 수정 |
| 필수 테이블 | 설치 점검 화면 | `sql/schema_sht30.sql` 또는 `sql/migration_drop_leak.sql` 적용 | | 필수 테이블 | 설치 점검 화면 | `sql/schema_sht30.sql` 적용 |
| 측정 이력 테이블 | 설치 점검 화면 | `sensor_metric` 생성 확인 | | 측정 이력 테이블 | 설치 점검 화면 | `leak_sensor_metric` 생성 확인 |
| 임계 설정 | 설치 점검 화면 | `config.php`/`config.local.php`의 `METRIC_*` 값 확인 | | 임계 설정 | 설치 점검 화면 | `config.php`/`config.local.php`의 `METRIC_*` 값 확인 |
| API 키 | 설치 점검 화면 | 서버와 단말의 키 일치 확인 | | API 키 | 설치 점검 화면 | 서버와 단말의 키 일치 확인 |
| 관리자 해시 | 설치 점검 화면 | `php setup_hash.php`로 해시 생성 | | 관리자 해시 | 설치 점검 화면 | `php setup_hash.php`로 해시 생성 |
@ -120,9 +120,9 @@ HWP의 "보관기간은 최소한으로 설정" 문구를 아래처럼 구체화
| 데이터 | 보관 위치 | 권장 보관기간 | 파기/정리 기준 | | 데이터 | 보관 위치 | 권장 보관기간 | 파기/정리 기준 |
|---|---|---:|---| |---|---|---:|---|
| 센서 이벤트 로그 | `sensor_log` | 365일 | 백업 후 기간 초과분 삭제(`retention_cleanup.php`) | | 센서 이벤트 로그 | `leak_sensor_log` | 365일 | 백업 후 기간 초과분 삭제(`retention_cleanup.php`) |
| 온습도 측정 이력 | `sensor_metric` | 365일 | 기간 초과분 삭제 | | 온습도 측정 이력 | `leak_sensor_metric` | 365일 | 기간 초과분 삭제 |
| SMS 발송 로그 | `sms_log` | 365일 | 개인정보 최소화 후 기간 초과분 삭제 | | SMS 발송 로그 | `leak_sms_log` | 365일 | 개인정보 최소화 후 기간 초과분 삭제 |
| 관리자 감사로그 | `php/var/admin_audit.log` | 365일 | 기간 초과분 정리 | | 관리자 감사로그 | `php/var/admin_audit.log` | 365일 | 기간 초과분 정리 |
| 설정 비밀값 | `config.local.php`, `firmware/common/secrets.h`(`APP_API_KEY`, 미커밋) | 운영 중 | 담당자 변경 또는 유출 의심 시 즉시 교체 | | 설정 비밀값 | `config.local.php`, `firmware/common/secrets.h`(`APP_API_KEY`, 미커밋) | 운영 중 | 담당자 변경 또는 유출 의심 시 즉시 교체 |
@ -196,7 +196,7 @@ HWP의 "보관기간은 최소한으로 설정" 문구를 아래처럼 구체화
교체 문구: 교체 문구:
> 본 시스템은 현장 사진 등 시설정보를 수집·저장하지 않으며, 처리하는 개인정보는 SMS 수신자 휴대전화번호로 한정한다. 수신자 번호는 알림 발송 목적에 한하여 최소 수집·이용하고, 담당자 변경 시 즉시 현행화한다. 온습도 임계 경보는 동일 종류 30분 쿨다운과 복구 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. SMS 발송 이력(`sms_log`)은 장애 대응·감사 목적 범위에서만 열람하고, 보관기간 경과 시 삭제 또는 비식별 처리한다. > 본 시스템은 현장 사진 등 시설정보를 수집·저장하지 않으며, 처리하는 개인정보는 SMS 수신자 휴대전화번호로 한정한다. 수신자 번호는 알림 발송 목적에 한하여 최소 수집·이용하고, 담당자 변경 시 즉시 현행화한다. 온습도 임계 경보는 동일 종류 30분 쿨다운과 복구 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. SMS 발송 이력(`leak_sms_log`)은 장애 대응·감사 목적 범위에서만 열람하고, 보관기간 경과 시 삭제 또는 비식별 처리한다.
## 6. 추가 증적 목록 ## 6. 추가 증적 목록

View file

@ -100,10 +100,10 @@ HWP 추가 문구:
| 전송구간 | 센서 데이터 탈취·변조 | HTTPS/TLS 사용, API 서명 검증 | `sensor_data.php`, 단말 펌웨어 | 실제 HTTPS 화면, `security_evidence.php?format=md` | | 전송구간 | 센서 데이터 탈취·변조 | HTTPS/TLS 사용, API 서명 검증 | `sensor_data.php`, 단말 펌웨어 | 실제 HTTPS 화면, `security_evidence.php?format=md` |
| API 인증 | 임의 측정값 주입 | 공유 API 키와 요청 서명 검증 | PHP API, `firmware/common/secrets.h`(`APP_API_KEY`) | 403 테스트 결과, 보안 증적 보고서 | | API 인증 | 임의 측정값 주입 | 공유 API 키와 요청 서명 검증 | PHP API, `firmware/common/secrets.h`(`APP_API_KEY`) | 403 테스트 결과, 보안 증적 보고서 |
| 측정값 검증 | 비정상 측정값 저장 | 필수 필드·측정값 범위(온도 -40~125℃ / 습도 0~100%) 검증 | `sensor_data.php` | 비정상 요청 차단 테스트 결과 | | 측정값 검증 | 비정상 측정값 저장 | 필수 필드·측정값 범위(온도 -40~125℃ / 습도 0~100%) 검증 | `sensor_data.php` | 비정상 요청 차단 테스트 결과 |
| 임계 경보 | 오발송·미발송 | 서버 임계 판정(`METRIC_*`), 30분 쿨다운, 복구 히스테리시스 | `config.php`, `sensor_data.php` | `sms_log`, `sensor_metric` 상태 | | 임계 경보 | 오발송·미발송 | 서버 임계 판정(`METRIC_*`), 30분 쿨다운, 복구 히스테리시스 | `config.php`, `sensor_data.php` | `leak_sms_log`, `leak_sensor_metric` 상태 |
| 비밀값 | DB/SMS/API 키 유출 | 서버는 `config.local.php`, STM32 단말은 `firmware/common/secrets.h`(`APP_API_KEY`)로 분리, 저장소 미포함(.gitignore) | 서버/단말 설정 | STM32 콘솔 로그, .gitignore 설정, 저장소 검색 결과 | | 비밀값 | DB/SMS/API 키 유출 | 서버는 `config.local.php`, STM32 단말은 `firmware/common/secrets.h`(`APP_API_KEY`)로 분리, 저장소 미포함(.gitignore) | 서버/단말 설정 | STM32 콘솔 로그, .gitignore 설정, 저장소 검색 결과 |
| 관리자 인증 | 대시보드 무단 접근 | 비밀번호 해시, TOTP MFA, 세션 쿠키 보호, 로그인 실패 제한, 감사로그 | `login.php`, `setup_mfa.php`, `admin_security.php`, `dashboard.php`, `security_evidence.php` | 로그인 화면, MFA 등록 증빙, 감사로그, 보안 증적 보고서 | | 관리자 인증 | 대시보드 무단 접근 | 비밀번호 해시, TOTP MFA, 세션 쿠키 보호, 로그인 실패 제한, 감사로그 | `login.php`, `setup_mfa.php`, `admin_security.php`, `dashboard.php`, `security_evidence.php` | 로그인 화면, MFA 등록 증빙, 감사로그, 보안 증적 보고서 |
| 개인정보 | SMS 수신자 번호 노출 | 최소 수집, 담당자 변경 시 현행화, 로그 접근 제한 | `config.local.php`, `sms_log` | 수신자 목록 관리대장 | | 개인정보 | SMS 수신자 번호 노출 | 최소 수집, 담당자 변경 시 현행화, 로그 접근 제한 | `config.local.php`, `leak_sms_log` | 수신자 목록 관리대장 |
| 로그관리 | 추적 불가 | 센서/측정/SMS 로그와 관리자 감사로그 저장, 보관기간 정리 | MySQL, `php/var/admin_audit.log`, `retention_cleanup.php` | 월간 보고서, 단말 증적 보고서, 감사로그, retention dry-run 결과 | | 로그관리 | 추적 불가 | 센서/측정/SMS 로그와 관리자 감사로그 저장, 보관기간 정리 | MySQL, `php/var/admin_audit.log`, `retention_cleanup.php` | 월간 보고서, 단말 증적 보고서, 감사로그, retention dry-run 결과 |
| 백업 | 장애 시 복구 불가 | DB·설정 파일 백업, 복구 점검, 백업 증빙 생성 | 운영 절차, `scripts/backup_evidence.php` | 백업 이력, 복구 테스트 결과, backup-evidence 보고서 | | 백업 | 장애 시 복구 불가 | DB·설정 파일 백업, 복구 점검, 백업 증빙 생성 | 운영 절차, `scripts/backup_evidence.php` | 백업 이력, 복구 테스트 결과, backup-evidence 보고서 |
| 패치 | 취약한 OS/PHP 사용 | 단말 OS, PHP, DB 정기 업데이트 | 운영 절차 | `OPERATIONS_SECURITY_CHECKLIST.md`, 월간 점검표 | | 패치 | 취약한 OS/PHP 사용 | 단말 OS, PHP, DB 정기 업데이트 | 운영 절차 | `OPERATIONS_SECURITY_CHECKLIST.md`, 월간 점검표 |
@ -126,13 +126,13 @@ HWP에 아래 문구를 추가합니다.
### 5.2 측정값 검증 및 서버 임계 판정 ### 5.2 측정값 검증 및 서버 임계 판정
> 센서 데이터 API는 POST JSON 요청과 필수 필드(장비 ID·센서 ID·이벤트 유형·타임스탬프)를 검증하고, 온도(-40~125℃)·습도(0~100%) 범위를 벗어난 값은 무효 처리한다. 임계 판정은 펌웨어가 아니라 서버 `config.php`의 운영 임계(`METRIC_*`: 고온30/저온10℃, 고습70/저습20%)로 재수행하여 `sensor_metric`에 상태를 기록한다. 동일 종류 경보는 30분 쿨다운, 복구는 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 SMS 오발송을 방지한다. > 센서 데이터 API는 POST JSON 요청과 필수 필드(장비 ID·센서 ID·이벤트 유형·타임스탬프)를 검증하고, 온도(-40~125℃)·습도(0~100%) 범위를 벗어난 값은 무효 처리한다. 임계 판정은 펌웨어가 아니라 서버 `config.php`의 운영 임계(`METRIC_*`: 고온30/저온10℃, 고습70/저습20%)로 재수행하여 `leak_sensor_metric`에 상태를 기록한다. 동일 종류 경보는 30분 쿨다운, 복구는 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 SMS 오발송을 방지한다.
확인 증적: 확인 증적:
- 정상 측정값 저장 결과(`sensor_metric`) - 정상 측정값 저장 결과(`leak_sensor_metric`)
- 비정상 메소드/필드 누락/범위 초과 차단 결과 - 비정상 메소드/필드 누락/범위 초과 차단 결과
- 임계 초과 시 종류별 SMS, 회복 시 정상복귀 SMS 발송 이력(`sms_log`) - 임계 초과 시 종류별 SMS, 회복 시 정상복귀 SMS 발송 이력(`leak_sms_log`)
### 5.3 관리자 페이지 접근통제 ### 5.3 관리자 페이지 접근통제
@ -164,7 +164,7 @@ STM32 단말 firmware/common/secrets.h: 저장소 미커밋(.gitignore), 빌드
확인 증적: 확인 증적:
- `sensor_status` 마지막 보고 시각 - `leak_sensor_status` 마지막 보고 시각
- `cron_heartbeat.php` 실행 로그 - `cron_heartbeat.php` 실행 로그
- 오프라인 SMS 발송 이력 - 오프라인 SMS 발송 이력
@ -213,7 +213,7 @@ HWP에 아래 책임표를 추가합니다.
|---|---|---:|---| |---|---|---:|---|
| SMS 수신자 번호 | 개인정보 | 담당자 재직/업무 담당 기간 | 담당자 변경 즉시 삭제·수정 | | SMS 수신자 번호 | 개인정보 | 담당자 재직/업무 담당 기간 | 담당자 변경 즉시 삭제·수정 |
| SMS 발송 로그 | 개인정보 포함 가능 | 1년 | DB 삭제 또는 비식별 처리 | | SMS 발송 로그 | 개인정보 포함 가능 | 1년 | DB 삭제 또는 비식별 처리 |
| 온습도 측정 이력 | 운영 데이터 | 1년 | 기간 초과 DB 삭제(`sensor_metric`) | | 온습도 측정 이력 | 운영 데이터 | 1년 | 기간 초과 DB 삭제(`leak_sensor_metric`) |
| 센서 로그 | 운영 로그 | 1년 | 기간 초과 DB 삭제 | | 센서 로그 | 운영 로그 | 1년 | 기간 초과 DB 삭제 |
| 관리자 감사로그 | 감사/운영 이력 | 1년 | 기간 초과분 정리 | | 관리자 감사로그 | 감사/운영 이력 | 1년 | 기간 초과분 정리 |
| API 키/SMS 인증키 | 인증정보 | 운영 중 | 교체 후 구 키 폐기 | | API 키/SMS 인증키 | 인증정보 | 운영 중 | 교체 후 구 키 폐기 |
@ -266,9 +266,9 @@ HWP에 아래 책임표를 추가합니다.
| 정보 | 예시 | 목적 | 접근자 | 저장 위치 | | 정보 | 예시 | 목적 | 접근자 | 저장 위치 |
|---|---|---|---|---| |---|---|---|---|---|
| 담당자 휴대전화번호 | SMS 수신자 번호 | 이상상태 알림 | 운영 관리자 | `config.local.php`, `sms_log` | | 담당자 휴대전화번호 | SMS 수신자 번호 | 이상상태 알림 | 운영 관리자 | `config.local.php`, `leak_sms_log` |
| 온습도 측정값 | 온도, 습도, 시각, 임계 상태 | 환경 모니터링 | 운영 관리자 | `sensor_metric`, MySQL | | 온습도 측정값 | 온도, 습도, 시각, 임계 상태 | 환경 모니터링 | 운영 관리자 | `leak_sensor_metric`, MySQL |
| 센서 이벤트 | 센서 ID, 상태, 시각 | 장애 감지 | 운영 관리자 | `sensor_log`, MySQL | | 센서 이벤트 | 센서 ID, 상태, 시각 | 장애 감지 | 운영 관리자 | `leak_sensor_log`, MySQL |
| 관리자 계정 | 관리자 ID, 비밀번호 해시, TOTP 비밀키 | 대시보드 접근 | 운영 관리자 | `config.local.php`, `php/var/admin_audit.log` | | 관리자 계정 | 관리자 ID, 비밀번호 해시, TOTP 비밀키 | 대시보드 접근 | 운영 관리자 | `config.local.php`, `php/var/admin_audit.log` |
| 장비 식별자 | Device ID, 위치명 | 장비 관리 | 운영 관리자 | 단말 설정, DB | | 장비 식별자 | Device ID, 위치명 | 장비 관리 | 운영 관리자 | 단말 설정, DB |
@ -278,7 +278,7 @@ HWP에 아래 책임표를 추가합니다.
### 9.3 경보 오발송 방지 문구 ### 9.3 경보 오발송 방지 문구
> 본 시스템은 현장 사진 등 시설정보를 수집·저장하지 않는다. 온습도 임계 경보는 서버에서 운영 임계(`METRIC_*`)로 판정하며, 동일 종류 경보는 30분 쿨다운, 정상복귀 판정은 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. 임계 초과·정상복귀·장비 오프라인·복구 이력은 `sms_log`에 기록하며, 보관기간 경과 시 정리한다. > 본 시스템은 현장 사진 등 시설정보를 수집·저장하지 않는다. 온습도 임계 경보는 서버에서 운영 임계(`METRIC_*`)로 판정하며, 동일 종류 경보는 30분 쿨다운, 정상복귀 판정은 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. 임계 초과·정상복귀·장비 오프라인·복구 이력은 `leak_sms_log`에 기록하며, 보관기간 경과 시 정리한다.
## 10. 보안성 검토 제출 관점의 필수 첨부물 ## 10. 보안성 검토 제출 관점의 필수 첨부물
@ -317,7 +317,7 @@ HWP 부록에 아래 체크리스트를 넣습니다.
| 11 | 장비 오프라인 감지가 동작하는가 | heartbeat 기준 알림 | | | | 11 | 장비 오프라인 감지가 동작하는가 | heartbeat 기준 알림 | | |
| 12 | DB 백업과 복구 점검을 수행했는가 | 복구 테스트 완료 | | | | 12 | DB 백업과 복구 점검을 수행했는가 | 복구 테스트 완료 | | |
| 13 | 단말이 부팅 후 자동 보고를 시작하는가 | 전원 인가 시 startup 보고·주기 보고 동작 | | | | 13 | 단말이 부팅 후 자동 보고를 시작하는가 | 전원 인가 시 startup 보고·주기 보고 동작 | | |
| 14 | 온습도 측정·저장이 검증되었는가 | `sensor_metric` 측정 이력 저장 | | | | 14 | 온습도 측정·저장이 검증되었는가 | `leak_sensor_metric` 측정 이력 저장 | | |
| 15 | 월간 보고서가 생성되는가 | 월별 조회/CSV 가능 | | | | 15 | 월간 보고서가 생성되는가 | 월별 조회/CSV 가능 | | |
| 16 | 관리자 감사로그가 남는가 | 로그인 성공/실패, 로그아웃 기록 | | | | 16 | 관리자 감사로그가 남는가 | 로그인 성공/실패, 로그아웃 기록 | | |
| 17 | 보관기간 정리 절차가 있는가 | `retention_cleanup.php --dry-run` 결과 확인 | | | | 17 | 보관기간 정리 절차가 있는가 | `retention_cleanup.php --dry-run` 결과 확인 | | |
@ -336,8 +336,8 @@ HWP 부록에 아래 체크리스트를 넣습니다.
| 월간 보고서 화면 캡처 | 경보 처리 및 SMS/온습도 현황 확인 | | 월간 보고서 화면 캡처 | 경보 처리 및 SMS/온습도 현황 확인 |
| API 정상/비정상 테스트 로그 | 정상 요청 저장, 비정상 키 거부 확인 | | API 정상/비정상 테스트 로그 | 정상 요청 저장, 비정상 키 거부 확인 |
| 테스트 SMS 수신 화면 | 담당자 알림 경로 확인 | | 테스트 SMS 수신 화면 | 담당자 알림 경로 확인 |
| 측정값 저장 결과 | `sensor_metric` 기록 및 임계 상태 확인 | | 측정값 저장 결과 | `leak_sensor_metric` 기록 및 임계 상태 확인 |
| 임계 경보/복구 SMS 결과 | 종류별 경보·정상복귀 SMS 발송(`sms_log`) 확인 | | 임계 경보/복구 SMS 결과 | 종류별 경보·정상복귀 SMS 발송(`leak_sms_log`) 확인 |
| 관리자 감사로그 | 로그인 성공/실패, 로그아웃 기록 확인 | | 관리자 감사로그 | 로그인 성공/실패, 로그아웃 기록 확인 |
| 보관기간 정리 dry-run | 측정/로그/감사로그 정리 대상 확인 | | 보관기간 정리 dry-run | 측정/로그/감사로그 정리 대상 확인 |
| 백업·복구 증빙 보고서 | 백업 파일 목록, 설정/감사로그 존재, 복구 테스트 결과 | | 백업·복구 증빙 보고서 | 백업 파일 목록, 설정/감사로그 존재, 복구 테스트 결과 |

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. 서버 준비 ## 1. 서버 준비
@ -10,12 +13,6 @@
SOURCE sql/schema_sht30.sql; SOURCE sql/schema_sht30.sql;
``` ```
기존 누수 설치를 온습도 전용으로 전환하는 경우에는 전환 마이그레이션을 적용합니다.
```sql
SOURCE sql/migration_drop_leak.sql;
```
2. 서버 파일을 배포합니다. 2. 서버 파일을 배포합니다.
```text ```text
@ -26,94 +23,102 @@
sql/schema_sht30.sql sql/schema_sht30.sql
``` ```
API는 `sht30` 측정을 수신하면 `sensor_status`를 갱신하고 측정값을 `sensor_metric`에 저장합니다. 서버가 `METRIC_*` 임계로 고온/저온/고습/저습을 판정하고, 초과 시 종류별 30분 쿨다운으로 SMS를 발송합니다. 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 ```text
SHT30 VDD -> STM32 3.3V 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 -> STM32 GND SHT30 GND -> Raspberry Pi GND (핀 6; 대체 9·14·20·25·30·34·39)
SHT30 SCL -> STM32 PB6 (I2C1_SCL, AF4) + 4.7kΩ 풀업 -> 3.3V SHT30 SDA -> Raspberry Pi GPIO2 / SDA1 (물리 핀 3) + 4.7kΩ 풀업 -> 3.3V
SHT30 SDA -> STM32 PB7 (I2C1_SDA, AF4) + 4.7kΩ 풀업 -> 3.3V SHT30 SCL -> Raspberry Pi GPIO3 / SCL1 (물리 핀 5) + 4.7kΩ 풀업 -> 3.3V
SHT30 ADDR -> GND (주소 0x44; VDD 면 0x45) SHT30 ADDR -> GND (주소 0x44; VDD면 0x45)
``` ```
- 7-bit 주소 `0x44`(`APP_SHT30_I2C_ADDR`), I2C 100kHz 권장. - 7-bit 주소 `0x44`(ADDR→GND). `config.py`의 `I2C_ADDR`와 반드시 일치.
- 명령 `0x2C06`(high-repeatability) → 약 20ms 대기 → 6바이트 read. - I2C 풀업 4.7kΩ ×2 — **SHT30 모듈에 온보드 풀업이 있으면 생략**.
- 명령 `0x2C06`(high-repeatability) → 약 20ms 대기 → 6바이트 read (드라이버 `sht30_driver.py`).
### 2.2 펌웨어 설정·빌드 (폐쇄망) ### 2.2 설정값 준비 (`config_local.py`)
```bash ```bash
cp firmware/common/secrets.h.example firmware/common/secrets.h cd /home/pi/raspberry_pi4_sht30
# secrets.h 의 APP_API_KEY 를 서버 config.local.php 의 API_KEY 와 동일하게 채운다. 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 = 온습도 측정 위치(예: 서버실)
``` ```
| 위치 | 설정 | `config_local.py`가 없으면 실행 시 **명확한 오류로 즉시 중단**(fail closed)됩니다. `config_local.py`·`API_KEY`는 **절대 커밋하지 마세요**(`.gitignore` 처리됨). `sensor_id`(=2)/`sensor_name`은 `config.py` 기본값을 씁니다.
|---|---|
| `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 |
벤더링·빌드·플래시 절차는 [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 ```bash
sudo raspi-config # Interface Options -> I2C -> enable cd /home/pi/raspberry_pi4_sht30 && bash setup.sh # 설치→서비스 등록→검증→하드닝 (sudo 붙이지 말 것)
sudo apt-get update
sudo apt-get install -y python3-venv python3-smbus i2c-tools
``` ```
센서가 보이는지 확인합니다. 또는 단계별:
```bash ```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 ### 2.4 systemd 자동 실행
API_URL=https://your-domain.example/raspi_leck_detecter/api/sensor_data.php
API_KEY=서버와 동일한 API_KEY ```bash
DEVICE_ID=rpi-sht30-01 sudo cp systemd/sht30-sensor.service /etc/systemd/system/
DEVICE_LOCATION=온습도 측정 위치 sudo systemctl daemon-reload
SHT30_SENSOR_ID=2 sudo systemctl enable --now sht30-sensor
SHT30_I2C_ADDR=0x44 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. 대시보드 결과 ## 4. 대시보드 결과
첫 정상 보고 후: 첫 정상 보고 후:
- `sensor_status`에 `sensor_id=2` 항목이 생성됩니다. - `leak_sensor_status`에 `sensor_id=2` 항목이 생성됩니다.
- `sensor_metric`에 온도/습도 이력이 누적됩니다. - `leak_sensor_metric`에 온도/습도 이력이 누적됩니다.
- `dashboard.php`가 센서 카드에 최신 온도/습도와 임계 상태를 표시합니다. - `dashboard.php`가 센서 카드에 최신 온도/습도와 임계 상태를 표시합니다.
장비가 `HEARTBEAT_TIMEOUT_SEC`(기본 1200초=20분)을 초과해 보고를 멈추면 서버가 오프라인으로 판정하고 SMS를 발송합니다. 장비가 `HEARTBEAT_TIMEOUT_SEC`(기본 1200초=20분)을 초과해 보고를 멈추면 서버가 오프라인으로 판정하고 SMS를 발송합니다.

View file

@ -103,14 +103,14 @@ HWP 반영 문구:
사용자가 제공해야 하는 증적: 사용자가 제공해야 하는 증적:
- 정상 측정값 저장 화면 또는 DB 기록(`sensor_metric`) - 정상 측정값 저장 화면 또는 DB 기록(`leak_sensor_metric`)
- 비정상 메소드/필드 누락/범위 초과 요청 차단 테스트 결과 - 비정상 메소드/필드 누락/범위 초과 요청 차단 테스트 결과
### 2.4 서버 임계 판정 및 SMS 오발송 방지 ### 2.4 서버 임계 판정 및 SMS 오발송 방지
HWP 반영 문구: HWP 반영 문구:
> 온습도 임계 판정은 펌웨어가 아니라 서버에서 수행한다. 서버는 `config.php`의 운영 임계(`METRIC_*`: 고온30/저온10℃, 고습70/저습20%)로 고온·저온·고습·저습을 재판정하여 `sensor_metric.metric_status`에 기록하고, 임계 이탈 시 종류별 SMS를 발송한다. 동일 종류 경보는 30분 쿨다운을 적용하고, 정상복귀 판정은 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. 임계 안쪽으로 회복하면 정상복귀 SMS를 발송한다. > 온습도 임계 판정은 펌웨어가 아니라 서버에서 수행한다. 서버는 `config.php`의 운영 임계(`METRIC_*`: 고온30/저온10℃, 고습70/저습20%)로 고온·저온·고습·저습을 재판정하여 `leak_sensor_metric.metric_status`에 기록하고, 임계 이탈 시 종류별 SMS를 발송한다. 동일 종류 경보는 30분 쿨다운을 적용하고, 정상복귀 판정은 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. 임계 안쪽으로 회복하면 정상복귀 SMS를 발송한다.
코드 근거: 코드 근거:
@ -119,13 +119,13 @@ HWP 반영 문구:
- `METRIC_TEMP_HYSTERESIS_C`(1.0), `METRIC_RH_HYSTERESIS`(3.0) - `METRIC_TEMP_HYSTERESIS_C`(1.0), `METRIC_RH_HYSTERESIS`(3.0)
- `METRIC_ALERT_COOLDOWN_SEC`(1800, 30분) - `METRIC_ALERT_COOLDOWN_SEC`(1800, 30분)
- `php/api/sensor_data.php` - `php/api/sensor_data.php`
- `evaluate_metric_thresholds()`로 서버 재판정 → `sensor_metric` - `evaluate_metric_thresholds()`로 서버 재판정 → `leak_sensor_metric`
- 경보 종류(`high_temp`/`low_temp`/`high_humidity`/`low_humidity`)별 SMS, 쿨다운/복구 판정 - 경보 종류(`high_temp`/`low_temp`/`high_humidity`/`low_humidity`)별 SMS, 쿨다운/복구 판정
- 임계 이탈 시 `send_metric_alert_sms()`, 회복 시 `send_metric_recovery_sms()` - 임계 이탈 시 `send_metric_alert_sms()`, 회복 시 `send_metric_recovery_sms()`
사용자가 제공해야 하는 증적: 사용자가 제공해야 하는 증적:
- 임계 초과 시 종류별 경보 SMS 발송 이력(`sms_log`: `[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) - 임계 초과 시 종류별 경보 SMS 발송 이력(`leak_sms_log`: `[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`)
- 정상복귀 SMS 발송 이력 - 정상복귀 SMS 발송 이력
- 쿨다운 적용으로 동일 경보가 30분 내 재발송되지 않는 결과 - 쿨다운 적용으로 동일 경보가 30분 내 재발송되지 않는 결과
@ -140,15 +140,15 @@ HWP 반영 문구:
- `php/config.php` - `php/config.php`
- `HEARTBEAT_TIMEOUT_SEC`(1200), `HEARTBEAT_CHECK_INTERVAL_SEC`(300) - `HEARTBEAT_TIMEOUT_SEC`(1200), `HEARTBEAT_CHECK_INTERVAL_SEC`(300)
- `php/cron_heartbeat.php` - `php/cron_heartbeat.php`
- 마지막 보고 시각 기준 오프라인 판정, `sensor_status.offline_alerted` 관리 - 마지막 보고 시각 기준 오프라인 판정, `leak_sensor_status.offline_alerted` 관리
- `php/api/sensor_data.php` - `php/api/sensor_data.php`
- 오프라인 상태였던 센서가 다시 보고하면 복구 SMS 발송 - 오프라인 상태였던 센서가 다시 보고하면 복구 SMS 발송
사용자가 제공해야 하는 증적: 사용자가 제공해야 하는 증적:
- `sensor_status` 마지막 보고 시각 - `leak_sensor_status` 마지막 보고 시각
- `cron_heartbeat.php` 실행 로그 - `cron_heartbeat.php` 실행 로그
- 오프라인/복구 SMS 발송 이력(`sms_log`) - 오프라인/복구 SMS 발송 이력(`leak_sms_log`)
### 2.6 비밀값 코드 분리 ### 2.6 비밀값 코드 분리
@ -270,6 +270,6 @@ curl -I https://example.com/sht30_monitor/login.php
2. `setup_wizard.php` 점검 화면 2. `setup_wizard.php` 점검 화면
3. API 정상/403 테스트 결과 3. API 정상/403 테스트 결과
4. 단말 `systemctl status leak-sensor` 화면 4. 단말 `systemctl status leak-sensor` 화면
5. 측정값 저장(`sensor_metric`) 및 임계 경보 SMS(`sms_log`) 화면 5. 측정값 저장(`leak_sensor_metric`) 및 임계 경보 SMS(`leak_sms_log`) 화면
이 5개가 있으면 소스코드 기반 보안대책과 운영 적용 증적을 어느 정도 연결할 수 있습니다. 이 5개가 있으면 소스코드 기반 보안대책과 운영 적용 증적을 어느 정도 연결할 수 있습니다.

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

@ -117,7 +117,7 @@
<h2>운영 자가진단</h2> <h2>운영 자가진단</h2>
<div class="diag-list"> <div class="diag-list">
<div class="diag"><i class="dot"></i><div><strong>API 키 · 정상</strong><span>RPi와 PHP API_KEY 일치</span></div></div> <div class="diag"><i class="dot"></i><div><strong>API 키 · 정상</strong><span>RPi와 PHP API_KEY 일치</span></div></div>
<div class="diag"><i class="dot"></i><div><strong>SHT30 metric table · 정상</strong><span>sensor_metric 저장 가능</span></div></div> <div class="diag"><i class="dot"></i><div><strong>SHT30 metric table · 정상</strong><span>leak_sensor_metric 저장 가능</span></div></div>
<div class="diag warn"><i class="dot"></i><div><strong>최근 SMS 실패 · 확인 필요</strong><span>최근 7일 실패 1건</span></div></div> <div class="diag warn"><i class="dot"></i><div><strong>최근 SMS 실패 · 확인 필요</strong><span>최근 7일 실패 1건</span></div></div>
</div> </div>
</aside> </aside>
@ -142,7 +142,7 @@
</article> </article>
<article class="sensor"> <article class="sensor">
<h3>2번 센서 (SHT30 온습도)</h3> <h3>2번 센서 (SHT30 온습도)</h3>
<small>서버실 상단 랙 / rpi-sht30-01</small><br> <small>서버실 상단 랙 / rpi4-sht30-01</small><br>
<span class="badge">정상</span> <span class="badge">정상</span>
<div class="metric"><span>온도 24.3C</span><span>습도 48.1%</span></div> <div class="metric"><span>온도 24.3C</span><span>습도 48.1%</span></div>
<div class="last">측정: 2026-05-21 09:29:18</div> <div class="last">측정: 2026-05-21 09:29:18</div>

View file

@ -316,7 +316,7 @@
<div class="grid"> <div class="grid">
<div class="tile"><strong>상단 상태바</strong><span><span class="state ok">정상</span>이면 운영 경고 없음. <span class="state warn">주의</span>나 <span class="state danger">위험</span>이면 바로 하단 세부 항목을 확인한다.</span></div> <div class="tile"><strong>상단 상태바</strong><span><span class="state ok">정상</span>이면 운영 경고 없음. <span class="state warn">주의</span>나 <span class="state danger">위험</span>이면 바로 하단 세부 항목을 확인한다.</span></div>
<div class="tile"><strong>요약 카드</strong><span>등록 센서, 임계 경보, 오프라인, 30일 임계 SMS, SMS 실패를 빠르게 확인한다.</span></div> <div class="tile"><strong>요약 카드</strong><span>등록 센서, 임계 경보, 오프라인, 30일 임계 SMS, SMS 실패를 빠르게 확인한다.</span></div>
<div class="tile"><strong>운영 자가진단</strong><span>API 키, DB 테이블, sensor_metric 저장, SMS 실패 등 운영 준비 상태를 보여준다.</span></div> <div class="tile"><strong>운영 자가진단</strong><span>API 키, DB 테이블, leak_sensor_metric 저장, SMS 실패 등 운영 준비 상태를 보여준다.</span></div>
</div> </div>
</section> </section>
@ -401,7 +401,7 @@
<tr> <tr>
<td><span class="state warn">센서 온습도 미표시</span></td> <td><span class="state warn">센서 온습도 미표시</span></td>
<td>SHT30 데이터가 저장되지 않았거나 측정이 실패했다.</td> <td>SHT30 데이터가 저장되지 않았거나 측정이 실패했다.</td>
<td><code>sensor_metric</code> 테이블, <code>sht30-sensor</code> 서비스, I2C 주소 <code>0x44/0x45</code> 확인.</td> <td><code>leak_sensor_metric</code> 테이블, <code>sht30-sensor</code> 서비스, I2C 주소 <code>0x44/0x45</code> 확인.</td>
</tr> </tr>
<tr> <tr>
<td><span class="state warn">SMS 실패</span></td> <td><span class="state warn">SMS 실패</span></td>

View file

@ -317,7 +317,7 @@
<div class="hero-copy"> <div class="hero-copy">
<div class="eyebrow">설치 목표</div> <div class="eyebrow">설치 목표</div>
<h2>SHT30 온도와 습도를 5분마다 서버로 보내고 대시보드에 최신값을 표시한다.</h2> <h2>SHT30 온도와 습도를 5분마다 서버로 보내고 대시보드에 최신값을 표시한다.</h2>
<p>두 장비 모두 SHT30 센서를 I2C로 읽어 `sensor_metric`에 온습도 이력을 남기며, 임계 초과 시 서버에서 경보 SMS를 발송한다.</p> <p>두 장비 모두 SHT30 센서를 I2C로 읽어 `leak_sensor_metric`에 온습도 이력을 남기며, 임계 초과 시 서버에서 경보 SMS를 발송한다.</p>
<div class="footer-actions"> <div class="footer-actions">
<button class="btn" type="button" onclick="window.print()">인쇄 또는 PDF 저장</button> <button class="btn" type="button" onclick="window.print()">인쇄 또는 PDF 저장</button>
<a class="btn secondary" href="../SHT30_SENSOR2_SETUP.md">Markdown 설명서</a> <a class="btn secondary" href="../SHT30_SENSOR2_SETUP.md">Markdown 설명서</a>
@ -336,12 +336,18 @@
<a href="#trouble">문제 대처</a> <a href="#trouble">문제 대처</a>
</nav> </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"> <section id="prepare">
<h2>1. 준비물</h2> <h2>1. 준비물</h2>
<div class="grid"> <div class="grid">
<div class="tile"> <div class="tile">
<strong>라즈베리파이 1대</strong> <strong>라즈베리파이 1대</strong>
<span>온습도 전용 장비. 장비 ID 예시는 `rpi-sht30-01`.</span> <span>온습도 전용 장비. 장비 ID 예시는 `rpi4-sht30-01`.</span>
</div> </div>
<div class="tile"> <div class="tile">
<strong>SHT30 P4422-3 센서</strong> <strong>SHT30 P4422-3 센서</strong>
@ -521,7 +527,7 @@ journalctl -u sht30-sensor -f</code></pre>
</tr> </tr>
<tr> <tr>
<td><span class="status warn">metric_saved false</span></td> <td><span class="status warn">metric_saved false</span></td>
<td>서버는 받았지만 `sensor_metric` 테이블이 없음.</td> <td>서버는 받았지만 `leak_sensor_metric` 테이블이 없음.</td>
<td>`sql/migration_sensor_metric.sql` 실행 후 다시 `--once` 테스트.</td> <td>`sql/migration_sensor_metric.sql` 실행 후 다시 `--once` 테스트.</td>
</tr> </tr>
<tr> <tr>

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.
## 센서 동작 개요 ## 센서 동작 개요
@ -69,7 +74,7 @@ SHT30 모듈은 3.3V 단일 전원으로 동작합니다. SCL/SDA는 오픈드
정상 측정 범위(벗어나면 펌웨어가 `metric_status="out_of_range"` 로 표시): 온도 `[-40, 125]°C`, 상대습도 `[0, 100]%`. 정상 측정 범위(벗어나면 펌웨어가 `metric_status="out_of_range"` 로 표시): 온도 `[-40, 125]°C`, 상대습도 `[0, 100]%`.
> 참고: `out_of_range` 는 **펌웨어 전용 타당성(plausibility) 분류**입니다. 서버는 이 값을 무시하고 운영 임계(`METRIC_*`)로 재판정하므로, DB `sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장되며 `out_of_range` 는 저장되지 않습니다. > 참고: `out_of_range` 는 **펌웨어 전용 타당성(plausibility) 분류**입니다. 서버는 이 값을 무시하고 운영 임계(`METRIC_*`)로 재판정하므로, DB `leak_sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장되며 `out_of_range` 는 저장되지 않습니다.
--- ---

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,280 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>서버실 통합 모니터링 시스템 · 프로젝트 소개서</title>
<style>
:root{
--bg:#eef1f5; --card:#fff; --ink:#1f2733; --ink2:#51607a; --ink3:#8a95a6;
--line:#e3e8ef; --head:#0b2545; --accent:#134074; --accent2:#1971c2;
--fire:#e8590c; --leak:#1971c2; --th:#7048e8; --off:#868e96; --ok:#2b8a3e;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Malgun Gothic","Apple SD Gothic Neo","Noto Sans KR",sans-serif;
--mono:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;
}
*{box-sizing:border-box}
html,body{margin:0}
body{font-family:var(--sans);background:var(--bg);color:var(--ink);line-height:1.65;
-webkit-font-smoothing:antialiased;padding:26px 16px 70px}
.wrap{max-width:940px;margin:0 auto}
header.hero{background:linear-gradient(135deg,#0b2545 0%,#134074 55%,#1971c2 100%);
color:#fff;border-radius:16px;padding:30px 30px;box-shadow:0 10px 28px rgba(11,37,69,.18)}
.hero .eyebrow{font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:#a9c6e8;font-weight:700;margin:0 0 8px}
.hero h1{margin:0 0 12px;font-size:27px;line-height:1.25;font-weight:800}
.hero p{margin:0;font-size:15px;color:#dce7f5;max-width:74ch}
.hero .meta{margin-top:16px;display:flex;flex-wrap:wrap;gap:8px}
.hero .meta span{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.24);
border-radius:999px;padding:5px 13px;font-size:12.5px;font-weight:600}
section{background:var(--card);border:1px solid var(--line);border-radius:14px;
padding:24px 26px;margin-top:20px;box-shadow:0 2px 8px rgba(16,24,40,.05)}
h2{display:flex;align-items:center;gap:12px;margin:0 0 8px;font-size:20px;font-weight:800;color:var(--ink)}
h2 .n{flex:none;width:30px;height:30px;border-radius:9px;background:var(--accent);color:#fff;
font-size:15px;display:flex;align-items:center;justify-content:center;font-weight:800}
h2 + .lead{margin:0 0 18px;color:var(--ink2);font-size:14.5px}
h3{margin:24px 0 10px;font-size:15.5px;font-weight:800;color:var(--accent)}
.flow{display:flex;flex-wrap:wrap;align-items:stretch;gap:10px;margin:6px 0 4px}
.flow .step{flex:1 1 150px;min-width:140px;background:#f7f9fc;border:1px solid var(--line);
border-radius:11px;padding:13px 14px}
.flow .step b{display:block;font-size:14px;margin-bottom:3px}
.flow .step span{font-size:12.5px;color:var(--ink2)}
.flow .arrow{align-self:center;color:var(--ink3);font-weight:800;font-size:18px}
table{width:100%;border-collapse:collapse;font-size:13.5px;margin-top:6px}
th,td{padding:10px 12px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}
thead th{background:#f3f6fa;color:var(--accent);font-weight:800;font-size:12.5px;border-bottom:2px solid #d6e0ec}
td.mono,th.mono{font-family:var(--mono);white-space:nowrap;font-size:12.5px}
tbody tr:nth-child(even){background:#fbfcfe}
.scroll{overflow-x:auto}
.tag{display:inline-block;font-family:var(--mono);font-size:12px;font-weight:700;
padding:2px 8px;border-radius:6px;white-space:nowrap}
.t-fire{background:#fff1e6;color:var(--fire)}
.t-th{background:#efeafe;color:var(--th)}
.t-leak{background:#e7f1fb;color:var(--leak)}
.t-off{background:#eef0f2;color:#495661}
.t-ok{background:#e7f6ec;color:var(--ok)}
.t-sys{background:#f0f2f5;color:#51607a}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px;margin-top:6px}
.box{border:1px solid var(--line);border-radius:11px;padding:14px 15px;background:#fbfcfe}
.box h4{margin:0 0 6px;font-size:14px;color:var(--ink)}
.box p{margin:0;font-size:13px;color:var(--ink2)}
.box .k{font-family:var(--mono);font-size:12px;color:var(--ink3)}
.note{border:1px solid #ffd8a8;background:#fff7ed;border-radius:11px;padding:14px 16px;margin-top:16px}
.note b{color:var(--fire)}
.note p{margin:0;font-size:13.5px;color:#7a4a1a}
.pills{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0 2px}
.pill{background:#f3f6fa;border:1px solid var(--line);border-radius:999px;padding:5px 12px;font-size:12.5px;font-weight:600;color:var(--ink2)}
code,.ic{font-family:var(--mono);background:#eef2f7;padding:1px 6px;border-radius:5px;font-size:12.5px}
ul.tight{margin:6px 0 0;padding-left:20px}
ul.tight li{margin:4px 0;font-size:13.5px}
.muted{color:var(--ink3);font-size:12.5px}
footer{color:var(--ink3);font-size:12px;text-align:center;margin-top:26px}
@media (max-width:640px){ .hero h1{font-size:22px} }
@media print{
body{background:#fff;padding:0;font-size:12px}
.wrap{max-width:100%}
.hero{background:#fff!important;color:#000;border:2px solid var(--accent);box-shadow:none}
.hero .eyebrow{color:var(--accent)} .hero p{color:#333} .hero .meta span{background:#fff;border-color:#999;color:#000}
section{box-shadow:none;border:1px solid #bbb;page-break-inside:avoid}
h2 .n{background:var(--accent)!important;-webkit-print-color-adjust:exact;print-color-adjust:exact}
table,tr,td,th,.box,.note{page-break-inside:avoid}
*{-webkit-print-color-adjust:exact;print-color-adjust:exact}
}
</style>
</head>
<body>
<div class="wrap">
<header class="hero">
<p class="eyebrow">서버실 통합 모니터링 · leak_sms_v3</p>
<h1>서버실 통합 모니터링 시스템 — 프로젝트 소개서</h1>
<p>센서가 감지한 이상을 <b>담당자 문자(SMS)</b>로 즉시 알리고, <b>웹 대시보드</b>에서 전체 현황을
한눈에 보는 시스템입니다. 이 문서는 시스템이 어떻게 동작하는지, <b>언제 누구에게 문자가 가는지</b>,
<b>웹사이트에서 무엇을 볼 수 있는지</b>를 정리합니다.</p>
<div class="meta">
<span>대상: 서버실 · 문서고 (항온항습)</span>
<span>감시: 누수 · 화재의심(PM) · 온습도 · 결로 · 장비상태</span>
<span>버전 v2606 · 2026-07</span>
</div>
</header>
<!-- 1. 시스템 개요 -->
<section>
<h2><span class="n">1</span>시스템 개요 — 무엇을, 어떻게</h2>
<p class="lead">현장 센서 장비(라즈베리파이)가 측정·감지한 값을 중앙 서버로 보내면, 서버가 이상 여부를
판정해 담당자에게 문자를 보내고 대시보드에 표시합니다. 이상 판정 기준은 모두 <b>서버</b>에 있어,
장비를 다시 만지지 않고 값만 바꿀 수 있습니다.</p>
<div class="flow">
<div class="step"><b>① 현장 센서 노드</b><span>누수·온습도·미세먼지(PM)·카메라. 측정/감지 후 서버로 전송.</span></div>
<div class="arrow">→</div>
<div class="step"><b>② 중앙 서버</b><span>서명 검증 → 저장(MySQL) → 이상 판정(임계값) → 문자·대시보드.</span></div>
<div class="arrow">→</div>
<div class="step"><b>③ 통지</b><span>담당자 SMS 발송.</span></div>
<div class="arrow">→</div>
<div class="step"><b>④ 웹 대시보드</b><span>전 현장 현황·추이·이력 표시.</span></div>
</div>
<p class="muted" style="margin-top:12px">전송 구간은 전 노드 공통으로 <code>HMAC-SHA256</code> 서명해 위·변조를 차단합니다.
판정 기준(임계값)은 서버 설정 파일 <code>config.local.php</code> 에서 현장에 맞춰 조정합니다.</p>
<h3>노드 구성</h3>
<div class="scroll">
<table>
<thead><tr><th>노드</th><th>탑재</th><th>측정/감지</th><th>보고 주기(기본)</th></tr></thead>
<tbody>
<tr><td><b>누수 노드</b></td>
<td>누수 프로브 (필요 시 카메라·SHT30·PMS7003 확장)</td>
<td>누수 유무 (확장 시 온습도·미세먼지·현장 사진)</td>
<td>정상 10분 / 누수 중 10초</td></tr>
<tr><td><b>온습도 노드</b></td>
<td>SHT30 (온습도 모듈)</td>
<td>온도 · 습도</td>
<td>1시간</td></tr>
<tr><td><b>상태 표시기</b> <span class="muted">(선택)</span></td>
<td>소형 디스플레이 (Zero 2 W)</td>
<td>센서 아님 — 현황을 현장에 표시</td>
<td>—</td></tr>
</tbody>
</table>
</div>
<p class="muted" style="margin-top:10px">확장 시 미세먼지(PM)·온습도는 같은 노드에 함께 실려 하나의 현장 번호로 보고되며,
서버가 종류를 구분해 별도로 판정합니다. 배선 상세는 <b>센서 형태·내용 보고서</b>를 참조하세요.</p>
</section>
<!-- 2. 언제 문자가 가나 -->
<section>
<h2><span class="n">2</span>언제 · 누구에게 문자가 가나</h2>
<p class="lead">이상이 생기면 해당 <b>현장 담당자</b>에게 문자가 즉시 발송됩니다. 문자는 짧은 단문(1건)으로
<code>[종류] 위치 / 값·상태 / 시각</code> 형식입니다.</p>
<h3>수신자 — 담당자 지정 방식</h3>
<div class="cards">
<div class="box"><h4>현장별 담당 그룹</h4><p>현장(센서)마다 받을 담당자를 따로 지정합니다. 그 현장의 경보는 지정된 담당자에게만 갑니다.</p><p class="k">SENSOR_RECIPIENTS</p></div>
<div class="box"><h4>기본(전역) 수신자</h4><p>현장별 지정이 없으면 기본 담당자에게 갑니다. 시스템 공통 통지(정기점검·잔액)도 여기로.</p><p class="k">SMS_RECIPIENTS</p></div>
<div class="box"><h4>2차(야간) 담당</h4><p>경보가 오래 미복구되면 상급/야간 담당에게 <b>추가로</b> 한 번 더 알립니다(에스컬레이션).</p><p class="k">SMS_RECIPIENTS_TIER2</p></div>
</div>
<h3>문자 종류 · 발송 조건</h3>
<div class="scroll">
<table>
<thead><tr><th>문자(태그)</th><th>언제 가나 (기본값)</th><th>수신</th><th>반복 억제</th></tr></thead>
<tbody>
<tr><td><span class="tag t-fire">[화재의심]</span></td>
<td>미세먼지 <b>PM2.5 &gt; 35</b> 또는 <b>PM10 &gt; 75</b> ㎍/㎥ (연기 급증 = 화재 조기징후)</td>
<td>현장 담당</td><td>30분</td></tr>
<tr><td><span class="tag t-th">[고온경보]</span></td><td>온도 <b>&gt; 30℃</b></td><td>현장 담당</td><td>30분</td></tr>
<tr><td><span class="tag t-th">[저온경보]</span></td><td>온도 <b>&lt; 10℃</b></td><td>현장 담당</td><td>30분</td></tr>
<tr><td><span class="tag t-th">[고습경보]</span></td><td>습도 <b>&gt; 70%</b></td><td>현장 담당</td><td>30분</td></tr>
<tr><td><span class="tag t-th">[저습경보]</span></td><td>습도 <b>&lt; 20%</b></td><td>현장 담당</td><td>30분</td></tr>
<tr><td><span class="tag t-th">[결로주의]</span></td><td>결로마진(온도−이슬점) <b>≤ 3℃</b> — 표면 결로·장비 손상 위험</td><td>현장 담당</td><td>30분</td></tr>
<tr><td><span class="tag t-leak">[누수감지]</span></td><td>누수 확정(<b>60초</b> 지속 확인 후). 물 튐 오탐 방지</td><td>현장 담당</td><td>30분</td></tr>
<tr><td><span class="tag t-leak">[누수복구]</span></td><td>누수 해소 확정(<b>180초</b> 지속 확인). 직전 감지가 있었을 때만</td><td>현장 담당</td><td>1회</td></tr>
<tr><td><span class="tag t-ok">[정상복귀]</span></td><td>온습도가 임계 안쪽으로 회복(여유 확보) 시</td><td>현장 담당</td><td>1회</td></tr>
<tr><td><span class="tag t-off">[장비 오프라인]</span></td><td>보고가 끊김 — 누수/PM 노드 <b>약 20분</b>, 온습도 노드 <b>약 125분</b> 무응답</td><td>현장 담당</td><td>재시도 보장</td></tr>
<tr><td><span class="tag t-off">[장비 복구]</span></td><td>끊겼던 통신이 재개될 때</td><td>현장 담당</td><td>1회</td></tr>
<tr><td><span class="tag t-fire">[에스컬레이션]</span></td><td>임계 경보가 <b>30분</b> 이상 미복구로 지속 → <b>2차(야간) 담당</b>에게 추가 통지</td><td>2차(TIER2)</td><td>에피소드당 1회</td></tr>
<tr><td><span class="tag t-sys">[SMS 잔액부족]</span></td><td>문자 발송 잔여가 <b>30건 이하</b> — 경보 채널이 조용히 죽는 것 사전 방지</td><td>기본(전역)</td><td>6시간</td></tr>
<tr><td><span class="tag t-ok">[정기점검]</span></td><td>매일 <b>09시</b> 이후 1회 — 센서 정상수·최근수신·잔여 요약(생존신호)</td><td>기본(전역)</td><td>1일 1회</td></tr>
</tbody>
</table>
</div>
<div class="note">
<p><b>“정기점검” 문자의 뜻:</b> 매일 1통 오는 안부 문자입니다. <b>이 문자가 안 오면</b> 경보 채널
전체(서버·문자망)에 문제가 있다는 신호이니 점검하세요. 즉, 담당자는 <b>“이상 문자가 오면 대응, 안부 문자가 안 오면 점검”</b>만 기억하면 됩니다.</p>
</div>
<h3>같은 경보가 문자 폭탄이 되지 않는 이유</h3>
<ul class="tight">
<li><b>재발송 억제(쿨다운):</b> 같은 종류 경보는 30분에 1번만. 이상이 지속돼도 계속 울리지 않습니다.</li>
<li><b>누수 떨림 방지(디바운스):</b> 감지 60초·복구 180초를 지속해야 “확정”해 발송 — 물기 튐으로 감지↔복구가 반복되는 것을 차단.</li>
<li><b>에피소드 인지:</b> 정상 복귀 후 다시 이상이 생기면 별개 사건으로 보고 즉시 재발송(놓치지 않음).</li>
<li><b>미복구 에스컬레이션:</b> 30분 지나도 안 풀리면 2차(야간) 담당에게 한 번 더.</li>
</ul>
<p class="muted" style="margin-top:8px">항온항습 환경 특성상 값이 원래 평탄하므로 <b>“센서 값 고착” 경보는 꺼 두었습니다</b>(정상 평탄값 오탐 방지).</p>
</section>
<!-- 3. 웹사이트에서 보는 것 -->
<section>
<h2><span class="n">3</span>웹사이트(대시보드)에서 보는 것</h2>
<p class="lead">브라우저로 접속해 로그인(2단계 인증)하면 전 현장 현황을 한 화면에서 봅니다. 화면은 자동 새로고침됩니다.</p>
<h3>화면 상단 — 항상 고정</h3>
<div class="cards">
<div class="box"><h4>시스템 상태 배너</h4><p>지금 가장 중요한 상태를 큰 글씨로. 경보 시 <b>어느 센서·값·언제부터·문자 발송 결과</b>까지 명시. 우선순위: 누수 → 화재의심 → 온습도 → 오프라인 → 정상.</p></div>
<div class="box"><h4>핵심 KPI 6타일</h4><p>경보 센서 · 오프라인(온라인 수) · 미복구 누수 · 오늘 경보 · SMS 잔여 · 중요 진단. 타일을 누르면 해당 탭으로.</p></div>
<div class="box"><h4>알림 스트립 · 대응 패널</h4><p>즉시 조치가 필요한 것만 1줄씩. 위험 시 상황별 <b>대응 체크리스트</b>와 담당 연락처를 함께 표시.</p></div>
</div>
<h3>탭 — 상세 화면</h3>
<div class="scroll">
<table>
<thead><tr><th>탭</th><th>보는 내용</th></tr></thead>
<tbody>
<tr><td><b>실시간 현황</b> <span class="muted">live</span></td>
<td>현장별 카드 — 온습도·화재감시(PM)·누수 상태와 최신 측정값, 오프라인/경보 배지.</td></tr>
<tr><td><b>추이·통계</b> <span class="muted">trend</span></td>
<td>온습도 추이 차트, 화재감시(PM) 추이 차트, 7일 경보 추이, 경보 종류 도넛(30일), 경보 최다 센서 순위.</td></tr>
<tr><td><b>이력 조회</b> <span class="muted">history</span></td>
<td>최근 온습도 측정 30건, 최근 화재감시(PM) 측정 30건, 문자 발송 이력.</td></tr>
<tr><td><b>시스템 점검</b> <span class="muted">system</span></td>
<td>자가진단 결과, 적용 중인 임계·설정 요약, SMS 잔여·발송 실패 현황.</td></tr>
<tr><td><b>전체(인쇄용)</b> <span class="muted">all</span></td>
<td>위 탭을 한 페이지로 합본 — 출력·보고용.</td></tr>
</tbody>
</table>
</div>
<h3>예방 신호 — “터지기 전”</h3>
<p>측정 추세를 분석해 <b>경보가 발생하기 전</b> 여유·도달 예상 시간(ETA)·이슬점 마진을 대시보드에 미리 표시하고,
위험 시 대응 절차를 안내합니다(추가 장비·DB 변경 없이 기존 측정만으로 산출).</p>
<div class="pills">
<span class="pill">설치 점검 (테스트 문자 발송)</span>
<span class="pill">월간 보고</span>
<span class="pill">보안 증적</span>
</div>
<p class="muted" style="margin-top:8px">상단 메뉴에서 위 부가 페이지로 이동할 수 있습니다.</p>
</section>
<!-- 4. 운영 요약 -->
<section>
<h2><span class="n">4</span>운영 요약 · 유의사항</h2>
<h3>기본 임계값 (조정 가능)</h3>
<div class="scroll">
<table>
<thead><tr><th>항목</th><th>기본값</th><th>항목</th><th>기본값</th></tr></thead>
<tbody>
<tr><td>화재의심 PM2.5</td><td class="mono">&gt; 35 ㎍/㎥</td><td>고온</td><td class="mono">&gt; 30 ℃</td></tr>
<tr><td>화재의심 PM10</td><td class="mono">&gt; 75 ㎍/㎥</td><td>저온</td><td class="mono">&lt; 10 ℃</td></tr>
<tr><td>고습 / 저습</td><td class="mono">&gt; 70% / &lt; 20%</td><td>결로마진</td><td class="mono">≤ 3 ℃</td></tr>
<tr><td>경보 재발송 쿨다운</td><td class="mono">30 분</td><td>에스컬레이션</td><td class="mono">30 분 미복구</td></tr>
<tr><td>오프라인 판정(누수/PM)</td><td class="mono">약 20 분</td><td>오프라인 판정(온습도)</td><td class="mono">약 125 분</td></tr>
<tr><td>SMS 잔액 경고</td><td class="mono">≤ 30 건</td><td>정기점검 발송</td><td class="mono">매일 09 시</td></tr>
</tbody>
</table>
</div>
<p class="muted" style="margin-top:8px">모든 값은 서버 설정(<code>config.local.php</code>)에서 현장에 맞게 조정할 수 있으며, 현장 장비를 다시 만질 필요가 없습니다.</p>
<div class="note">
<p><b>화재 감지 관련 유의:</b> 미세먼지 센서(PMS7003)는 <b>광학 먼지 센서</b>로, 연기·먼지를 완벽히
구분하지 못합니다. 항온항습 서버실은 평상시 먼지가 거의 없어 <b>먼지 급증을 화재의 조기 징후</b>로
활용하는 것이며, <b>인증 화재감지기가 아닙니다.</b> 보조 조기경보로 사용하고, 법정 소방 설비(인증
연기/열 감지기)는 별도로 병행해야 합니다.</p>
</div>
</section>
<footer>서버실 통합 모니터링 시스템 (leak_sms_v3) · 프로젝트 소개서 · v2606</footer>
</div>
</body>
</html>

View file

@ -74,7 +74,7 @@
| 프로세스 관리 | systemd 재시작 | IWDG 워치독 + 재연결 | 변경(이식) | | 프로세스 관리 | systemd 재시작 | IWDG 워치독 + 재연결 | 변경(이식) |
| 로깅 | 로테이팅 파일 로그 | UART 콘솔 | 변경(축소) | | 로깅 | 로테이팅 파일 로그 | UART 콘솔 | 변경(축소) |
> **임계 판정의 위치:** 펌웨어는 `temperature_c`/`humidity_percent` 원값과 `metric_status`(센서 물리/타당성 범위 기준 `normal`/`out_of_range`)만 보고한다. 고온/저온/고습/저습 경보와 SMS 트리거는 **서버 `config.php` 의 `METRIC_*` 임계**가 담당한다. 폐쇄망에서 임계값을 조정할 때 펌웨어 재플래시가 불필요하도록 의도된 분리다. `out_of_range` 는 **펌웨어 전용 타당성 분류**이며 서버가 무시·재판정하므로, DB `sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장된다(`out_of_range` 는 DB에 저장되지 않음). > **임계 판정의 위치:** 펌웨어는 `temperature_c`/`humidity_percent` 원값과 `metric_status`(센서 물리/타당성 범위 기준 `normal`/`out_of_range`)만 보고한다. 고온/저온/고습/저습 경보와 SMS 트리거는 **서버 `config.php` 의 `METRIC_*` 임계**가 담당한다. 폐쇄망에서 임계값을 조정할 때 펌웨어 재플래시가 불필요하도록 의도된 분리다. `out_of_range` 는 **펌웨어 전용 타당성 분류**이며 서버가 무시·재판정하므로, DB `leak_sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장된다(`out_of_range` 는 DB에 저장되지 않음).
자세한 함수 단위 매핑은 [`docs/PORTING_NOTES.md`](docs/PORTING_NOTES.md) 참고. 자세한 함수 단위 매핑은 [`docs/PORTING_NOTES.md`](docs/PORTING_NOTES.md) 참고.

View file

@ -100,7 +100,7 @@ Sensirion SHT30 (한진데이터 P4422-3 모듈), I2C. RPi `sht30_monitor.py`
- 풀업: SCL/SDA 각각 **4.7kΩ → 3.3V** (모듈에 내장 풀업이 있으면 생략). I2C 속도 100kHz(Standard) 권장. - 풀업: SCL/SDA 각각 **4.7kΩ → 3.3V** (모듈에 내장 풀업이 있으면 생략). I2C 속도 100kHz(Standard) 권장.
- plausibility 범위(벗어나면 펌웨어가 `metric_status="out_of_range"` 로 표시): T `[-40, 125]°C`, RH `[0, 100]%` ([`common/app_config.h`](../common/app_config.h)). - plausibility 범위(벗어나면 펌웨어가 `metric_status="out_of_range"` 로 표시): T `[-40, 125]°C`, RH `[0, 100]%` ([`common/app_config.h`](../common/app_config.h)).
운영 경보 임계(고온/저온/고습/저습 → SMS)는 **서버 `php/config.php` 의 `METRIC_*` 상수**가 판정한다(펌웨어는 원값만 보고). 운영 경보 임계(고온/저온/고습/저습 → SMS)는 **서버 `php/config.php` 의 `METRIC_*` 상수**가 판정한다(펌웨어는 원값만 보고).
`out_of_range` 는 **펌웨어 전용 타당성 분류**이며 서버가 무시·재판정하므로, DB `sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장된다(`out_of_range` 는 DB에 남지 않음). `out_of_range` 는 **펌웨어 전용 타당성 분류**이며 서버가 무시·재판정하므로, DB `leak_sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장된다(`out_of_range` 는 DB에 남지 않음).
``` ```
SHT30 모듈 STM32 SHT30 모듈 STM32

View file

@ -120,7 +120,7 @@ SHT30 측정에서 SMS 발송까지의 책임 분담:
센서 물리/타당성 범위(`APP_SHT30_TEMP/RH_LOW/HIGH`) 기준으로 `metric_status`(`normal`/`out_of_range`)만 부여해 센서 물리/타당성 범위(`APP_SHT30_TEMP/RH_LOW/HIGH`) 기준으로 `metric_status`(`normal`/`out_of_range`)만 부여해
`sensor_data.php` 로 POST 한다. 펌웨어는 **고온/저온/고습/저습 같은 운영 임계를 판정하지 않는다.** `sensor_data.php` 로 POST 한다. 펌웨어는 **고온/저온/고습/저습 같은 운영 임계를 판정하지 않는다.**
여기서 `out_of_range` 는 **펌웨어 전용 타당성 분류**다. 서버는 이 값을 무시하고 운영 임계로 재판정하므로 여기서 `out_of_range` 는 **펌웨어 전용 타당성 분류**다. 서버는 이 값을 무시하고 운영 임계로 재판정하므로
DB `sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장되고 `out_of_range` 는 저장되지 않는다. DB `leak_sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장되고 `out_of_range` 는 저장되지 않는다.
2. **서버(임계·SMS)**: `sensor_data.php` 가 본문을 DB 에 적재하고, `config.php` 의 `METRIC_*` 임계와 비교해 2. **서버(임계·SMS)**: `sensor_data.php` 가 본문을 DB 에 적재하고, `config.php` 의 `METRIC_*` 임계와 비교해
고온/저온/고습/저습 조건이면 **SMS 게이트웨이로 문자**를 발송한다. 고온/저온/고습/저습 조건이면 **SMS 게이트웨이로 문자**를 발송한다.

View file

@ -1,6 +1,5 @@
# ============================================================================= # =============================================================================
# .htaccess - 누수감지 시스템 보안 설정 # .htaccess - 누수감지 시스템 보안 설정
# 배포 경로: pores.kr/raspi_leck_detecter/
# ============================================================================= # =============================================================================
# ─── HTTPS 강제 리다이렉트 ───────────────────────────────── # ─── HTTPS 강제 리다이렉트 ─────────────────────────────────
@ -22,7 +21,10 @@
</IfModule> </IfModule>
# ─── 민감 PHP 파일 직접 접근 차단 ───────────────────────── # ─── 민감 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> <IfModule mod_authz_core.c>
Require all denied Require all denied
</IfModule> </IfModule>
@ -37,7 +39,11 @@
Header always set X-Content-Type-Options "nosniff" Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY" Header always set X-Frame-Options "DENY"
Header always set X-XSS-Protection "1; mode=block" Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin" # 내부 관리 시스템 — 검색엔진 색인 금지(HTTP 레벨, 전 응답 공통).
# robots.txt 는 쓰지 않는다: 공개 파일이라 난독화 경로를 오히려 광고하게 됨.
Header always set X-Robots-Tag "noindex, nofollow, noarchive"
# 외부로 Referer 자체를 보내지 않음(도메인 확인 유출 차단). 내부 이동은 영향 없음.
Header always set Referrer-Policy "no-referrer"
# TODO(follow-up): 'unsafe-inline' 제거 — 대시보드 인라인 스크립트/스타일을 외부 # TODO(follow-up): 'unsafe-inline' 제거 — 대시보드 인라인 스크립트/스타일을 외부
# 파일로 분리한 후 script-src/style-src 에서 'unsafe-inline' 을 삭제해야 한다. # 파일로 분리한 후 script-src/style-src 에서 'unsafe-inline' 을 삭제해야 한다.
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; form-action 'self'" Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; form-action 'self'"

View file

@ -26,9 +26,7 @@
// [응답] 200 OK application/json {"status":"ok","photo_id":..,"filename":".."} // [응답] 200 OK application/json {"status":"ok","photo_id":..,"filename":".."}
// ============================================================================= // =============================================================================
header('Access-Control-Allow-Origin: https://pores.kr'); // CORS 헤더 없음 — 기기 서버측 POST 전용(브라우저 미관여), Allow-Origin 은 도메인 지문만 노출.
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Device-Id, X-Signature');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204); http_response_code(204);
@ -101,7 +99,7 @@ function photo_nonce_claim(PDO $db, string $sig): string {
return 'unavailable'; return 'unavailable';
} }
try { try {
$ins = $db->prepare("INSERT INTO ingest_nonce (signature, seen_at) VALUES (:sig, NOW())"); $ins = $db->prepare("INSERT INTO leak_ingest_nonce (signature, seen_at) VALUES (:sig, NOW())");
$ins->execute([':sig' => substr($sig, 0, 128)]); $ins->execute([':sig' => substr($sig, 0, 128)]);
} catch (PDOException $e) { } catch (PDOException $e) {
$code = $e->getCode(); $code = $e->getCode();
@ -115,7 +113,7 @@ function photo_nonce_claim(PDO $db, string $sig): string {
} }
try { try {
$ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2); $ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2);
$db->exec("DELETE FROM ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)"); $db->exec("DELETE FROM leak_ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
} catch (Throwable $e) { } catch (Throwable $e) {
/* 정리는 실패해도 무방 */ /* 정리는 실패해도 무방 */
} }
@ -127,7 +125,7 @@ function photo_nonce_release(PDO $db, string $sig): void {
return; return;
} }
try { try {
$db->prepare("DELETE FROM ingest_nonce WHERE signature = :sig") $db->prepare("DELETE FROM leak_ingest_nonce WHERE signature = :sig")
->execute([':sig' => substr($sig, 0, 128)]); ->execute([':sig' => substr($sig, 0, 128)]);
} catch (Throwable $e) { } catch (Throwable $e) {
error_log("[PHOTO] nonce release 오류: " . $e->getMessage()); error_log("[PHOTO] nonce release 오류: " . $e->getMessage());
@ -248,9 +246,9 @@ $filename = '';
try { try {
$db = get_db(); $db = get_db();
// log_id 가 sensor_log 에 실제로 존재하는지 + 어느 센서/디바이스의 로그인지 확인한다 // log_id 가 leak_sensor_log 에 실제로 존재하는지 + 어느 센서/디바이스의 로그인지 확인한다
// (임의 log_id 로 사진 누적 방지, 교차 센서 결속). 권위값은 로그 행의 sensor_id/device_id. // (임의 log_id 로 사진 누적 방지, 교차 센서 결속). 권위값은 로그 행의 sensor_id/device_id.
$chk = $db->prepare("SELECT sensor_id, device_id FROM sensor_log WHERE id = :id"); $chk = $db->prepare("SELECT sensor_id, device_id FROM leak_sensor_log WHERE id = :id");
$chk->execute([':id' => $log_id]); $chk->execute([':id' => $log_id]);
$log_row = $chk->fetch(); $log_row = $chk->fetch();
if (!$log_row) { if (!$log_row) {

View file

@ -5,7 +5,7 @@
// //
// [요청 형식 — 온습도(SHT30, 2번 모듈)] POST application/json // [요청 형식 — 온습도(SHT30, 2번 모듈)] POST application/json
// { // {
// "device_id": "stm32-sht30-01", // "device_id": "rpi4-sht30-01", // 배포=Raspberry Pi 4 (STM32 펌웨어면 "stm32-sht30-01")
// "device_location": "서버실", // "device_location": "서버실",
// "sensor_id": 2, // "sensor_id": 2,
// "sensor_name": "2번 센서 (SHT30)", // "sensor_name": "2번 센서 (SHT30)",
@ -15,7 +15,7 @@
// "temperature_c": 24.50, // "temperature_c": 24.50,
// "humidity_percent": 48.30, // "humidity_percent": 48.30,
// "metric_status": "normal", // 펌웨어 plausibility 판정(참고용; 서버가 재판정) // "metric_status": "normal", // 펌웨어 plausibility 판정(참고용; 서버가 재판정)
// "app_version": "v2606-sht30" // "app_version": "v2606-sht30-rpi4" // Pi 배포 (STM32 펌웨어면 "v2606-sht30"); 서버는 검증 안 함
// } // }
// //
// [요청 형식 — 누수(1번 모듈)] POST application/json // [요청 형식 — 누수(1번 모듈)] POST application/json
@ -45,10 +45,8 @@
// {"status":"ok","message":"저장 완료", ...} // {"status":"ok","message":"저장 완료", ...}
// ============================================================================= // =============================================================================
// CORS 허용 (기기 → 서버 POST 전용, 브라우저 접근 불필요) // CORS 헤더 없음 — 기기(서버측 cURL/requests) → 서버 POST 전용이라 브라우저
header('Access-Control-Allow-Origin: https://pores.kr'); // same-origin 정책 대상이 아니며, Allow-Origin 헤더는 도메인 지문만 노출한다.
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Device-Id, X-Signature');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204); http_response_code(204);
@ -109,7 +107,7 @@ function ingest_nonce_claim(PDO $db, string $sig): string {
return 'unavailable'; return 'unavailable';
} }
try { try {
$ins = $db->prepare("INSERT INTO ingest_nonce (signature, seen_at) VALUES (:sig, NOW())"); $ins = $db->prepare("INSERT INTO leak_ingest_nonce (signature, seen_at) VALUES (:sig, NOW())");
$ins->execute([':sig' => substr($sig, 0, 128)]); $ins->execute([':sig' => substr($sig, 0, 128)]);
} catch (PDOException $e) { } catch (PDOException $e) {
$code = $e->getCode(); $code = $e->getCode();
@ -124,7 +122,7 @@ function ingest_nonce_claim(PDO $db, string $sig): string {
// 선점 성공 후, 신선도 창의 2배보다 오래된 nonce 정리(베스트 에포트, 인덱스로 가벼움). // 선점 성공 후, 신선도 창의 2배보다 오래된 nonce 정리(베스트 에포트, 인덱스로 가벼움).
try { try {
$ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2); $ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2);
$db->exec("DELETE FROM ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)"); $db->exec("DELETE FROM leak_ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
} catch (Throwable $e) { } catch (Throwable $e) {
/* 정리는 실패해도 무방 */ /* 정리는 실패해도 무방 */
} }
@ -138,7 +136,7 @@ function ingest_nonce_release(PDO $db, string $sig): void {
return; return;
} }
try { try {
$db->prepare("DELETE FROM ingest_nonce WHERE signature = :sig") $db->prepare("DELETE FROM leak_ingest_nonce WHERE signature = :sig")
->execute([':sig' => substr($sig, 0, 128)]); ->execute([':sig' => substr($sig, 0, 128)]);
} catch (Throwable $e) { } catch (Throwable $e) {
error_log("[API] nonce release 오류: " . $e->getMessage()); error_log("[API] nonce release 오류: " . $e->getMessage());
@ -180,7 +178,9 @@ if ($has_raw_sig) {
} }
if (!$sig_ok) { if (!$sig_ok) {
error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?') . " (mode={$sig_mode})"); 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); json_response(['status' => 'error', 'message' => '인증 실패'], 403);
} }
@ -242,29 +242,46 @@ if ($humidity_percent !== null && ($humidity_percent < 0 || $humidity_percent >
$humidity_percent = null; $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_*) ───────────────── // ── 서버측 임계 판정 (config.php METRIC_*) ─────────────────
// 펌웨어 metric_status 는 물리 plausibility 판정이라 운영 경보로는 신뢰하지 않고, // 펌웨어 metric_status 는 물리 plausibility 판정이라 운영 경보로는 신뢰하지 않고,
// 서버가 운영 임계로 다시 판정하여 sensor_metric.metric_status 에 저장한다. // 서버가 운영 임계로 다시 판정하여 leak_sensor_metric.metric_status 에 저장한다.
$metric_eval = $has_metric_payload $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']; : ['breaches' => [], 'status' => 'normal'];
$metric_status = $metric_eval['status']; $metric_status = $metric_eval['status'];
$metric_saved = false; $metric_saved = false;
// ── DB 저장 ─────────────────────────────────────────────── // ── DB 저장 ───────────────────────────────────────────────
// 주기(periodic) 온습도 측정 보고는 sensor_log 에 남기지 않고 sensor_metric/상태만 갱신 // 주기(periodic) 보고는 순수 하트비트 — leak_sensor_log 에 남기지 않고 상태 upsert
// (대시보드 로그 정리). startup 은 1회 sensor_log 기록. // (+온습도면 leak_sensor_metric)만 갱신한다. 누수 노드가 10분 주기로 늘어나면
// 누수 이벤트(leak_alert/leak_recovery)는 항상 sensor_log 에 기록한다 // periodic 행이 이벤트 로그 창을 밀어내므로(지점 2개 = 일 288행) 전부 스킵.
// startup 은 1회 기록. 누수 이벤트(leak_alert/leak_recovery)는 항상 기록한다
// (leak_alert 는 log_id 가 사진 업로드에 필요하고, 누수 이력 추적 대상). // (leak_alert 는 log_id 가 사진 업로드에 필요하고, 누수 이력 추적 대상).
$skip_log = ($event_type === 'periodic' && $has_metric_payload); $skip_log = ($event_type === 'periodic');
$log_id = null; $log_id = null;
try { try {
$db = get_db(); $db = get_db();
// 누수 디바운스 사용 여부(마스터 스위치 + 컬럼 존재). 컬럼 없는 구배포는 레거시
// 즉시 발송 경로로 graceful degrade. 아래 사고추적/누수 SMS 분기에서 참조.
$use_debounce = LEAK_DEBOUNCE_ENABLED && leak_status_has_debounce_cols($db);
// ── 재전송(replay) 멱등 선점 ── // ── 재전송(replay) 멱등 선점 ──
// 인증·신선도를 통과한 서명을 nonce 로 원자적 기록한다. 같은 서명 본문이 시계오차 // 인증·신선도를 통과한 서명을 nonce 로 원자적 기록한다. 같은 서명 본문이 시계오차
// 창 안에서 다시 오면(펌웨어 재시도/캡처 재생) 중복 저장·중복 SMS 없이 멱등 200. // 창 안에서 다시 오면(펌웨어 재시도/캡처 재생) 중복 저장·중복 SMS 없이 멱등 200.
@ -280,7 +297,7 @@ try {
if (!$skip_log) { if (!$skip_log) {
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO sensor_log INSERT INTO leak_sensor_log
(device_id, device_location, sensor_id, sensor_name, (device_id, device_location, sensor_id, sensor_name,
event_type, is_leak, sensor_time, created_at) event_type, is_leak, sensor_time, created_at)
VALUES VALUES
@ -302,7 +319,7 @@ try {
// ── 오프라인 복구 감지: UPSERT 전에 offline_alerted 확인 ── // ── 오프라인 복구 감지: UPSERT 전에 offline_alerted 확인 ──
$was_offline = false; $was_offline = false;
$chk = $db->prepare("SELECT offline_alerted FROM sensor_status WHERE sensor_id = :sensor_id"); $chk = $db->prepare("SELECT offline_alerted FROM leak_sensor_status WHERE sensor_id = :sensor_id");
$chk->execute([':sensor_id' => $sensor_id]); $chk->execute([':sensor_id' => $sensor_id]);
$offline_flag = $chk->fetchColumn(); $offline_flag = $chk->fetchColumn();
if ($offline_flag === '1' || $offline_flag === 1) { if ($offline_flag === '1' || $offline_flag === 1) {
@ -314,7 +331,7 @@ try {
// (startup/periodic)는 누수 상태를 건드리지 않도록 기존 값을 보존한다(통합 서버에서 // (startup/periodic)는 누수 상태를 건드리지 않도록 기존 값을 보존한다(통합 서버에서
// 2번 모듈 보고가 1번 모듈 누수 상태를 덮어쓰지 않게). // 2번 모듈 보고가 1번 모듈 누수 상태를 덮어쓰지 않게).
$stmt2 = $db->prepare(" $stmt2 = $db->prepare("
INSERT INTO sensor_status INSERT INTO leak_sensor_status
(sensor_id, device_id, sensor_name, device_location, (sensor_id, device_id, sensor_name, device_location,
last_event_type, is_leak, last_seen, offline_alerted) last_event_type, is_leak, last_seen, offline_alerted)
VALUES VALUES
@ -339,15 +356,17 @@ try {
':is_leak_event' => $is_leak_event ? 1 : 0, ':is_leak_event' => $is_leak_event ? 1 : 0,
]); ]);
if ($has_metric_payload && api_table_exists($db, 'sensor_metric')) { if ($has_metric_payload && api_table_exists($db, 'leak_sensor_metric')) {
$metric_stmt = $db->prepare(" $metric_stmt = $db->prepare("
INSERT INTO sensor_metric INSERT INTO leak_sensor_metric
(device_id, device_location, sensor_id, sensor_name, (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) measured_at, created_at)
VALUES VALUES
(:device_id, :device_location, :sensor_id, :sensor_name, (: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()) :measured_at, NOW())
"); ");
$metric_stmt->execute([ $metric_stmt->execute([
@ -358,17 +377,22 @@ try {
':metric_type' => $metric_type, ':metric_type' => $metric_type,
':temperature_c' => $temperature_c, ':temperature_c' => $temperature_c,
':humidity_percent' => $humidity_percent, ':humidity_percent' => $humidity_percent,
':pm1_0' => $pm1_0,
':pm2_5' => $pm2_5,
':pm10' => $pm10,
':metric_status' => $metric_status, ':metric_status' => $metric_status,
':measured_at' => $sensor_dt, ':measured_at' => $sensor_dt,
]); ]);
$metric_saved = true; $metric_saved = true;
} }
// ── 누수 사고(에피소드) 추적 (leak_incident) ────────── // ── 누수 사고(에피소드) 추적 (leak_incident) — 레거시(디바운스 미사용) 경로 ──
// leak_alert: 미복구 사고가 없으면 새 사고를 연다(중복 leak_alert 는 새로 열지 않음). // leak_alert: 미복구 사고가 없으면 새 사고를 연다(중복 leak_alert 는 새로 열지 않음).
// leak_recovery: 같은 센서의 가장 최근 미복구 사고를 닫는다(recovered_at/복구 log_id 채움). // leak_recovery: 같은 센서의 가장 최근 미복구 사고를 닫는다(recovered_at/복구 log_id 채움).
// leak_incident 테이블이 없으면(누수 미통합 서버) graceful 하게 건너뛴다. // 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') { if ($event_type === 'leak_alert') {
$open = $db->prepare(" $open = $db->prepare("
SELECT id FROM leak_incident SELECT id FROM leak_incident
@ -448,6 +472,10 @@ if ($has_metric_payload) {
$value = condensation_margin_c($temperature_c, $humidity_percent); // 표시값=결로마진(℃) $value = condensation_margin_c($temperature_c, $humidity_percent); // 표시값=결로마진(℃)
} elseif (in_array($kind, ['high_temp', 'low_temp'], true)) { } elseif (in_array($kind, ['high_temp', 'low_temp'], true)) {
$value = $temperature_c; $value = $temperature_c;
} elseif ($kind === 'high_pm25') {
$value = $pm2_5; // 표시값=PM2.5(µg/m³)
} elseif ($kind === 'high_pm10') {
$value = $pm10; // 표시값=PM10(µg/m³)
} else { } else {
$value = $humidity_percent; $value = $humidity_percent;
} }
@ -455,8 +483,14 @@ if ($has_metric_payload) {
$sms_sent = true; $sms_sent = true;
} }
} }
} elseif (metric_was_alerting($db, $sensor_id) && metric_is_recovered($temperature_c, $humidity_percent)) { } elseif (($temperature_c !== null || $humidity_percent !== null)
// 직전까지 경보 상태였고 이제 (히스테리시스 포함) 정상으로 복귀 → 복귀 SMS 1회 && 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( $sms_sent = send_metric_recovery_sms(
$sensor_info, $sensor_info,
(float)($temperature_c ?? 0.0), (float)($temperature_c ?? 0.0),
@ -466,10 +500,17 @@ if ($has_metric_payload) {
} }
// ── 누수 경보 / 복구 SMS (1번 모듈) ──────────────────────── // ── 누수 경보 / 복구 SMS (1번 모듈) ────────────────────────
// leak_alert: [누수감지] SMS(쿨다운). 누수 지속 중 주기 보고가 와도 쿨다운으로 1회. // [디바운스 경로] 감지/복구 전이를 확인창 동안 지속돼야 확정하고, 확정 엣지에서만 SMS 1회.
// leak_recovery: 직전에 누수 경보가 있었을 때만 [누수복구] SMS 발송. // 감지↔복구 채터링(플래핑)이 SMS 폭탄으로 번지는 것을 원천 차단한다. 누수 센서의 주기
// sms_send.php 에 헬퍼가 없거나(미통합 배포) 함수 미정의면 graceful 하게 건너뛴다. // 보고(periodic, is_leak 포함)도 확정 타이머를 전진시키므로 이벤트/주기 모두에서 호출한다.
if ($is_leak_event) { // [레거시 경로] 디바운스 컬럼이 없거나 비활성이면 종전대로 즉시 발송(쿨다운/복구 판정).
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 ($event_type === 'leak_alert' && function_exists('send_leak_alert_sms')) {
if (send_leak_alert_sms($sensor_info)) { if (send_leak_alert_sms($sensor_info)) {
$sms_sent = true; $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> <!doctype html><html lang="ko"><head>
<meta charset="utf-8"><title>접근 거부</title> <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"> <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> <style>
</head><body><div class="card"> /* 페이지 전용: 단독 안내 카드 중앙 정렬(대시보드 셸 없음) */
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> <h1>접근이 거부되었습니다</h1>
<p>관리자에게 연락하세요.</p> <p class="sub">관리자에게 연락하세요.</p>
<p class="small">요청 ID: <code><?=htmlspecialchars($id)?></code></p> <p class="caption">요청 ID: <code><?=htmlspecialchars($id)?></code></p>
</div></body></html> </div></body></html>

View file

@ -28,6 +28,14 @@ return [
'01000000000', '01000000000',
], ],
// 센서별 수신자 그룹 — 지정한 센서의 경보 SMS 는 그 그룹으로만 발송된다. 지정 없는
// 센서는 위 전역 SMS_RECIPIENTS 로 폴백한다(헬스/잔액 등 시스템 통지도 전역 사용).
// 예: 누수(1번)는 시설팀, 3번은 고객센터로. 비우면(기본) 전역만 사용.
'SENSOR_RECIPIENTS' => [
// 1 => ['010-1111-1111', '010-2222-2222'],
// 3 => ['010-3333-3333'],
],
'ADMIN_USER' => 'admin', 'ADMIN_USER' => 'admin',
'ADMIN_PASSWORD_HASH' => '$2y$10$replace.with.password_hash.output', '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 // Base32 TOTP secret. MUST be left EMPTY here and set to a UNIQUE generated value in
@ -41,19 +49,67 @@ return [
// Temporary bootstrap token for setup_mfa.php. Remove or empty after MFA enrollment. // Temporary bootstrap token for setup_mfa.php. Remove or empty after MFA enrollment.
'MFA_SETUP_TOKEN' => 'replace-with-temporary-random-token', 'MFA_SETUP_TOKEN' => 'replace-with-temporary-random-token',
// 누수 모듈 sensor_id 목록 — 대시보드 누수 상태 카드 표시 대상 (기본 [1]).
// 신규 누수 지점(예: 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). 운영 환경에 맞게 조정. // 온습도 임계 경보 (서버 판정 → SMS). 운영 환경에 맞게 조정.
'METRIC_TEMP_HIGH_C' => 30, // 고온 임계(℃) 'METRIC_TEMP_HIGH_C' => 27, // 고온 임계(℃)
'METRIC_TEMP_LOW_C' => 10, // 저온 임계(℃) 'METRIC_TEMP_LOW_C' => 10, // 저온 임계(℃)
'METRIC_RH_HIGH' => 70, // 고습 임계(%) 'METRIC_RH_HIGH' => 60, // 고습 임계(%)
'METRIC_RH_LOW' => 20, // 저습 임계(%) 'METRIC_RH_LOW' => -1, // 저습 경보 비활성(RH 는 항상 ≥0 → 절대 발화 안 함)
'METRIC_DEWPOINT_MARGIN_C' => 0, // 결로(이슬점) 경보 비활성(0 이면 판정 분기 스킵)
'METRIC_ALERT_COOLDOWN_SEC' => 1800, // 동일 종류 경보 재발송 억제(초, 기본 30분) '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). // 미복귀 경보 2차(야간) 에스컬레이션 수신자. 비우면 비활성(opt-in).
'SMS_RECIPIENTS_TIER2' => [], 'SMS_RECIPIENTS_TIER2' => [],
'ESCALATE_AFTER_SEC' => 1800, // 경보 미복귀 30분 후 2차 통지 'ESCALATE_AFTER_SEC' => 1800, // 경보 미복귀 30분 후 2차 통지
// 아래는 기본값으로 동작(필요 시에만 override): // 아래는 기본값으로 동작(필요 시에만 override):
// METRIC_DEWPOINT_MARGIN_C(3℃ 결로), SMS_BALANCE_WARN_THRESHOLD(30건), // SMS_BALANCE_WARN_THRESHOLD(30건), HEALTH_SMS_HOUR(9시),
// 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, 'SMS_LOG_RETENTION_DAYS' => 365,
'SENSOR_LOG_RETENTION_DAYS' => 365, 'SENSOR_LOG_RETENTION_DAYS' => 365,

View file

@ -9,6 +9,11 @@
define('APP_VERSION', 'v2606'); 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_file = __DIR__ . '/config.local.php';
$local_config = is_file($local_config_file) ? require $local_config_file : []; $local_config = is_file($local_config_file) ? require $local_config_file : [];
@ -43,6 +48,8 @@ function api_key_is_default(): bool {
// 수신 본문 timestamp 허용 시계 오차(초). 이 범위를 벗어난(과거/미래) 서명 요청은 // 수신 본문 timestamp 허용 시계 오차(초). 이 범위를 벗어난(과거/미래) 서명 요청은
// 재전송(replay) 방어를 위해 거부한다. SHT30 보고 주기(5분) 대비 넉넉히 둔다. // 재전송(replay) 방어를 위해 거부한다. SHT30 보고 주기(5분) 대비 넉넉히 둔다.
define('INGEST_MAX_CLOCK_SKEW_SEC', (int)cfg('INGEST_MAX_CLOCK_SKEW_SEC', 600)); 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 미만. 인증 전 대용량 // 수신 본문 최대 크기(바이트). 정상 디바이스 본문은 ~1KB 미만. 인증 전 대용량
// 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 를 차단한다. // 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 를 차단한다.
@ -51,7 +58,7 @@ define('INGEST_MAX_BODY_BYTES', (int)cfg('INGEST_MAX_BODY_BYTES', 8192));
// 재전송(replay) 멱등 방어. 서명(X-Signature)을 nonce 로 기록해, 시계오차 창 // 재전송(replay) 멱등 방어. 서명(X-Signature)을 nonce 로 기록해, 시계오차 창
// (INGEST_MAX_CLOCK_SKEW_SEC) 안에서 동일 서명 본문이 재수신되면 멱등 처리(재저장/ // (INGEST_MAX_CLOCK_SKEW_SEC) 안에서 동일 서명 본문이 재수신되면 멱등 처리(재저장/
// 재발송 생략)한다. 펌웨어 재시도(같은 본문)·캡처 재생 모두 한 번만 반영된다. // 재발송 생략)한다. 펌웨어 재시도(같은 본문)·캡처 재생 모두 한 번만 반영된다.
// ingest_nonce 테이블이 없는 기존 배포에서는 자동으로 비활성(graceful degrade). // leak_ingest_nonce 테이블이 없는 기존 배포에서는 자동으로 비활성(graceful degrade).
define('INGEST_NONCE_ENABLED', (bool)cfg('INGEST_NONCE_ENABLED', true)); define('INGEST_NONCE_ENABLED', (bool)cfg('INGEST_NONCE_ENABLED', true));
// 레거시 본문 필드 서명(verify_signature: 키-접두 sha256) 수용 여부. 기본 비활성. // 레거시 본문 필드 서명(verify_signature: 키-접두 sha256) 수용 여부. 기본 비활성.
@ -70,7 +77,59 @@ $sms_recipients = cfg('SMS_RECIPIENTS', ['01000000000']);
if (is_string($sms_recipients)) { if (is_string($sms_recipients)) {
$sms_recipients = array_filter(array_map('trim', explode(',', $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바이트까지만 단문으로 발송된다. 초과하면 // Cafe24 SMS('S' 타입)는 EUC-KR 기준 약 90바이트까지만 단문으로 발송된다. 초과하면
// 게이트웨이가 잘라/거부하므로, 발송 직전에 EUC-KR 바이트 기준으로 안전하게 자른다 // 게이트웨이가 잘라/거부하므로, 발송 직전에 EUC-KR 바이트 기준으로 안전하게 자른다
@ -78,7 +137,7 @@ define('SMS_RECIPIENTS', $sms_recipients);
define('SMS_MAX_EUCKR_BYTES', (int)cfg('SMS_MAX_EUCKR_BYTES', 90)); define('SMS_MAX_EUCKR_BYTES', (int)cfg('SMS_MAX_EUCKR_BYTES', 90));
// ─── SMS 메시지 태그 (경보 상태머신의 단일 출처) ────────────────────────────── // ─── SMS 메시지 태그 (경보 상태머신의 단일 출처) ──────────────────────────────
// sms_log.message 의 접두 태그로 쿨다운/복구/대시보드/월간보고서가 경보 상태를 // leak_sms_log.message 의 접두 태그로 쿨다운/복구/대시보드/월간보고서가 경보 상태를
// 재구성한다. 별도 상태 테이블을 두지 않으므로 이 태그 문자열이 사실상의 상태 키다. // 재구성한다. 별도 상태 테이블을 두지 않으므로 이 태그 문자열이 사실상의 상태 키다.
// 여러 파일(sms_send/dashboard/monthly_report)의 LIKE 문자열이 서로 어긋나 무성 // 여러 파일(sms_send/dashboard/monthly_report)의 LIKE 문자열이 서로 어긋나 무성
// 장애가 나는 것을 막기 위해, 태그는 반드시 여기서만 정의하고 상수로 참조한다. // 장애가 나는 것을 막기 위해, 태그는 반드시 여기서만 정의하고 상수로 참조한다.
@ -96,6 +155,7 @@ const SMS_TAG_SENSOR_STUCK = '[센서고착]'; // 측정값 고착(live-bu
const SMS_TAG_ESCALATION = '[에스컬레이션]'; // 미복귀 경보 2차(야간) 통지 const SMS_TAG_ESCALATION = '[에스컬레이션]'; // 미복귀 경보 2차(야간) 통지
const SMS_TAG_LEAK_ALERT = '[누수감지]'; // 1번 모듈(누수) 누수 발생 const SMS_TAG_LEAK_ALERT = '[누수감지]'; // 1번 모듈(누수) 누수 발생
const SMS_TAG_LEAK_RECOVERY = '[누수복구]'; // 1번 모듈(누수) 누수 해소(복구) const SMS_TAG_LEAK_RECOVERY = '[누수복구]'; // 1번 모듈(누수) 누수 해소(복구)
const SMS_TAG_HIGH_PM = '[화재의심]'; // PMS7003 PM 급상승(연기 추정) — 화재 조기징후. 서버실/항온항습 화재 프록시
// 경보 종류(metric kind) → 태그 매핑(단일 출처). evaluate_metric_thresholds 의 // 경보 종류(metric kind) → 태그 매핑(단일 출처). evaluate_metric_thresholds 의
// breach 종류와 1:1 대응한다. // breach 종류와 1:1 대응한다.
@ -104,13 +164,16 @@ const SMS_METRIC_TAGS = [
'low_temp' => SMS_TAG_LOW_TEMP, 'low_temp' => SMS_TAG_LOW_TEMP,
'high_humidity' => SMS_TAG_HIGH_HUMIDITY, 'high_humidity' => SMS_TAG_HIGH_HUMIDITY,
'low_humidity' => SMS_TAG_LOW_HUMIDITY, 'low_humidity' => SMS_TAG_LOW_HUMIDITY,
// PMS7003 화재의심(PM): PM2.5·PM10 두 종류가 같은 태그([화재의심])로 매핑된다.
'high_pm25' => SMS_TAG_HIGH_PM,
'high_pm10' => SMS_TAG_HIGH_PM,
]; ];
/** /**
* sms_log 태그별 건수 집계 SELECT 절(단일 출처). 대시보드/월간보고서가 공유한다. * leak_sms_log 태그별 건수 집계 SELECT 절(단일 출처). 대시보드/월간보고서가 공유한다.
* 태그 문자열은 위 SMS_TAG_* 상수에서만 가져오므로, 태그를 바꿔도 모든 집계가 한 * 태그 문자열은 위 SMS_TAG_* 상수에서만 가져오므로, 태그를 바꿔도 모든 집계가 한
* 곳에서 따라온다(여러 파일의 LIKE 문자열이 어긋나 무성 장애 나는 것 방지). * 곳에서 따라온다(여러 파일의 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 * device_offline, device_online, leak_alert, leak_recovery, sms_fail
*/ */
function sms_summary_select_sql(): string { 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_LOW_TEMP . "%') AS low_temp,\n"
. " SUM(message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%') AS high_humidity,\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_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_RECOVERY . "%') AS recovery,\n"
. " SUM(message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%') AS device_offline,\n" . " SUM(message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%') AS device_offline,\n"
. " SUM(message LIKE '" . SMS_TAG_DEVICE_ONLINE . "%') AS device_online,\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분. // 동일 종류 경보 재발송 억제 쿨다운(초). 기본 30분.
define('METRIC_ALERT_COOLDOWN_SEC', (int)cfg('METRIC_ALERT_COOLDOWN_SEC', 1800)); 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) 보고를 // 누수(leak) 경보 재발송 억제 쿨다운(초). 1번 모듈은 누수 지속 중 주기(periodic) 보고를
// 계속 보내므로, 같은 누수 에피소드에서 [누수감지] SMS 가 반복 발송되지 않도록 한다. 기본 30분. // 계속 보내므로, 같은 누수 에피소드에서 [누수감지] SMS 가 반복 발송되지 않도록 한다. 기본 30분.
define('LEAK_ALERT_COOLDOWN_SEC', (int)cfg('LEAK_ALERT_COOLDOWN_SEC', 1800)); 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_MARGIN_C', (float)cfg('METRIC_DEWPOINT_MARGIN_C', 3.0));
define('METRIC_DEWPOINT_HYSTERESIS_C', (float)cfg('METRIC_DEWPOINT_HYSTERESIS_C', 1.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 잔액(잔여 건수) 사전경보 ───────────────────────────────────────────── // ─── SMS 잔액(잔여 건수) 사전경보 ─────────────────────────────────────────────
// Cafe24 응답의 잔여 건수가 이 값 이하로 내려가면 1회 '[SMS 잔액부족]' 경고를 보낸다 // Cafe24 응답의 잔여 건수가 이 값 이하로 내려가면 1회 '[SMS 잔액부족]' 경고를 보낸다
// (경보 채널이 잔액 소진으로 조용히 죽는 것을 사전 방지). 쿨다운으로 반복을 억제. // (경보 채널이 잔액 소진으로 조용히 죽는 것을 사전 방지). 쿨다운으로 반복을 억제.
@ -182,8 +304,14 @@ define('ESCALATE_AFTER_SEC', (int)cfg('ESCALATE_AFTER_SEC', 1800));
// 센서 고착(live-but-stuck) 검출: 최근 STUCK_DETECT_SAMPLES 측정의 표준편차가 // 센서 고착(live-but-stuck) 검출: 최근 STUCK_DETECT_SAMPLES 측정의 표준편차가
// STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장(같은 값 반복)을 의심해 알린다. // STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장(같은 값 반복)을 의심해 알린다.
// (하트비트는 '수신 끊김'만 본다 — I2C 는 살아있는데 값이 고착된 무성실패를 놓침.) // (하트비트는 '수신 끊김'만 본다 — 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_DETECT_EPSILON', (float)cfg('STUCK_DETECT_EPSILON', 0.01)); // 분해능 이하
define('STUCK_ALERT_COOLDOWN_SEC', (int)cfg('STUCK_ALERT_COOLDOWN_SEC', 21600)); // 6시간 define('STUCK_ALERT_COOLDOWN_SEC', (int)cfg('STUCK_ALERT_COOLDOWN_SEC', 21600)); // 6시간
@ -191,13 +319,149 @@ define('STUCK_ALERT_COOLDOWN_SEC', (int)cfg('STUCK_ALERT_COOLDOWN_SEC', 21600));
// 운영에서는 반드시 false. tests/chaos_drill.php 가 일시적으로 켜서 검증한다. // 운영에서는 반드시 false. tests/chaos_drill.php 가 일시적으로 켜서 검증한다.
define('SMS_FORCE_FAIL', (bool)cfg('SMS_FORCE_FAIL', false)); define('SMS_FORCE_FAIL', (bool)cfg('SMS_FORCE_FAIL', false));
// ─── Heartbeat (장비 오프라인 감지) ──────────────────────── // ─── Heartbeat (장비 오프라인 감지) — 센서별 타임아웃 ──────────────────────────
// SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300). 기본 // 온습도(SHT30) 보고 주기는 1시간(raspberry_pi4_sht30/config.py REPORT_INTERVAL=3600),
// 오프라인 판정은 약 4회 누락(20분)으로 둔다. config.local.php 로 조정 가능. // 누수 모듈 정상 보고 주기는 10분(raspberry_pi4/config.py REPORT_INTERVAL=600). 보고 주기가
define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200)); // 센서마다 크게 다르므로 오프라인 판정 타임아웃을 센서별로 해소한다(전역 단일값이면
// 온습도 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('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) 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" 도 허용)
// 유효 범위는 1..255 정수(api/sensor_data.php 의 sensor_id 검증과 동일). 범위 밖·
// 비숫자 항목은 버리고, 결과가 비면 기본 [1] 로 되돌린다(오설정으로 누수 카드
// 전체가 사라지는 것 방지).
$leak_sensor_ids = cfg('LEAK_SENSOR_IDS', [1]);
if (is_string($leak_sensor_ids)) {
$leak_sensor_ids = explode(',', $leak_sensor_ids);
}
$leak_sensor_ids = array_values(array_filter(
array_map('intval', (array)$leak_sensor_ids),
static fn($id) => $id >= 1 && $id <= 255
));
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_USER', cfg('ADMIN_USER', 'admin'));
define('ADMIN_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시 define('ADMIN_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시
@ -244,7 +508,7 @@ function json_response(array $data, int $http_code = 200): void {
// ─── 개인정보(수신번호) 마스킹 ───────────────────────────── // ─── 개인정보(수신번호) 마스킹 ─────────────────────────────
// 화면/보고서에 수신번호를 노출할 때 가운데를 가린다(010-****-1234). 발송 기록 // 화면/보고서에 수신번호를 노출할 때 가운데를 가린다(010-****-1234). 발송 기록
// 자체(sms_log)는 보존하되 표시 단계에서만 최소화한다(개인정보 최소표시 원칙). // 자체(leak_sms_log)는 보존하되 표시 단계에서만 최소화한다(개인정보 최소표시 원칙).
// 쉼표로 여러 번호가 들어오면 각각 마스킹한다. // 쉼표로 여러 번호가 들어오면 각각 마스킹한다.
function mask_phone(string $value): string { function mask_phone(string $value): string {
if (strpos($value, ',') !== false) { if (strpos($value, ',') !== false) {
@ -266,7 +530,7 @@ function mask_phone(string $value): string {
function latest_sms_balance(PDO $db): ?int { function latest_sms_balance(PDO $db): ?int {
try { try {
$rm = $db->query(" $rm = $db->query("
SELECT result_message FROM sms_log SELECT result_message FROM leak_sms_log
WHERE status = 'success' AND result_message LIKE '%잔여%' WHERE status = 'success' AND result_message LIKE '%잔여%'
ORDER BY id DESC LIMIT 1 ORDER BY id DESC LIMIT 1
")->fetchColumn(); ")->fetchColumn();
@ -287,7 +551,11 @@ function ingest_rejection_path(): string {
return __DIR__ . '/var/ingest_rejections.json'; 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+'); $fh = @fopen(ingest_rejection_path(), 'c+');
if ($fh === false) { if ($fh === false) {
return; // 카운터 실패가 수집 처리를 막아선 안 됨(best-effort) return; // 카운터 실패가 수집 처리를 막아선 안 됨(best-effort)
@ -336,6 +604,80 @@ function read_ingest_rejections(int $days = 1): array {
return $totals; 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) ─────── // ─── 서명 검증 (레거시: 본문 필드 기반, DEPRECATED) ───────
// 디코드된 데이터를 ksort 후 재직렬화하여 해시한다. 과거 RPi Python 클라이언트 // 디코드된 데이터를 ksort 후 재직렬화하여 해시한다. 과거 RPi Python 클라이언트
// 전용의 하위 호환 경로이며, 현재 운영 클라이언트(STM32 펌웨어)는 사용하지 않는다. // 전용의 하위 호환 경로이며, 현재 운영 클라이언트(STM32 펌웨어)는 사용하지 않는다.

View file

@ -22,21 +22,24 @@ require_once __DIR__ . '/sms_send.php';
function check_offline_sensors(): int { function check_offline_sensors(): int {
$db = get_db(); $db = get_db();
$stmt = $db->prepare(" // 전 센서를 읽어 센서별 타임아웃(heartbeat_timeout_for)으로 오프라인을 판정한다. 보고
SELECT * FROM sensor_status // 주기가 센서마다 달라(온습도 1시간 / 누수 10분) 전역 단일 타임아웃으로는 온습도 오탐
WHERE last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND) // 또는 누수 지연이 생긴다. silent_sec 로 무수신 경과를 함께 읽어 PHP 에서 비교한다.
AND offline_alerted = 0 $rows = $db->query("
"); SELECT sensor_id, sensor_name, device_id, device_location,
$stmt->execute([':timeout' => HEARTBEAT_TIMEOUT_SEC]); last_seen, offline_alerted,
$offline_sensors = $stmt->fetchAll(); TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS silent_sec
FROM leak_sensor_status
")->fetchAll();
// 각 센서를 SMS 발송 전에 "원자적으로 선점"한다(0→1 전이가 한 프로세스에서만 // 각 센서를 SMS 발송 전에 "원자적으로 선점"한다(0→1 전이가 한 프로세스에서만
// 성공). 여러 진입점(sensor_data 쓰로틀, 로그인 시 즉시 체크, 대시보드)이 // 성공). 여러 진입점(sensor_data 쓰로틀, 로그인 시 즉시 체크, 대시보드)이
// 동시에 실행돼도 오프라인 SMS 가 중복 발송되지 않도록 한다. // 동시에 실행돼도 오프라인 SMS 가 중복 발송되지 않도록 한다.
// last_seen 조건을 함께 둬, SELECT 와 UPDATE 사이에 장비가 복귀 보고를 하면 // last_seen 조건을 함께 둬, SELECT 와 UPDATE 사이에 장비가 복귀 보고를 하면
// (last_seen 갱신) 선점이 실패하여 오탐 오프라인 SMS 가 나가지 않도록 한다. // (last_seen 갱신) 선점이 실패하여 오탐 오프라인 SMS 가 나가지 않도록 한다.
// 타임아웃은 센서별로 바인딩한다.
$claim = $db->prepare(" $claim = $db->prepare("
UPDATE sensor_status SET offline_alerted = 1 UPDATE leak_sensor_status SET offline_alerted = 1
WHERE sensor_id = :sensor_id WHERE sensor_id = :sensor_id
AND offline_alerted = 0 AND offline_alerted = 0
AND last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND) AND last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND)
@ -44,10 +47,18 @@ function check_offline_sensors(): int {
$count = 0; $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([ $claim->execute([
':sensor_id' => $sensor['sensor_id'], ':sensor_id' => $sid,
':timeout' => HEARTBEAT_TIMEOUT_SEC, ':timeout' => $timeout,
]); ]);
if ($claim->rowCount() === 0) { if ($claim->rowCount() === 0) {
// 다른 동시 실행이 이미 선점했거나, 그 사이 장비가 복귀 보고를 했다. // 다른 동시 실행이 이미 선점했거나, 그 사이 장비가 복귀 보고를 했다.
@ -66,7 +77,7 @@ function check_offline_sensors(): int {
if (!$sent && SMS_RETRY_OFFLINE) { if (!$sent && SMS_RETRY_OFFLINE) {
// 일회성 오프라인 SMS 가 실패하면 선점(offline_alerted=1)을 되돌려, 다음 주기에 // 일회성 오프라인 SMS 가 실패하면 선점(offline_alerted=1)을 되돌려, 다음 주기에
// 재시도되게 한다(critical: 게이트웨이 일시오류로 장비 다운을 영영 못 알리던 갭). // 재시도되게 한다(critical: 게이트웨이 일시오류로 장비 다운을 영영 못 알리던 갭).
$db->prepare("UPDATE sensor_status SET offline_alerted = 0 WHERE sensor_id = :sid") $db->prepare("UPDATE leak_sensor_status SET offline_alerted = 0 WHERE sensor_id = :sid")
->execute([':sid' => $sensor['sensor_id']]); ->execute([':sid' => $sensor['sensor_id']]);
error_log("[Heartbeat] 오프라인 SMS 실패 - 센서{$sensor['sensor_id']} 재시도 예약"); error_log("[Heartbeat] 오프라인 SMS 실패 - 센서{$sensor['sensor_id']} 재시도 예약");
} }
@ -140,6 +151,12 @@ function check_offline_sensors_throttled(): void {
} catch (\Throwable $e) { } catch (\Throwable $e) {
error_log("[Heartbeat] 에스컬레이션/고착 검출 실패: " . $e->getMessage()); 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_send_daily_health_sms(get_db());
maybe_escalate_alerts(get_db()); maybe_escalate_alerts(get_db());
maybe_detect_stuck_sensors(get_db()); maybe_detect_stuck_sensors(get_db());
sweep_pending_leak_transitions(get_db());
} catch (\Throwable $e) { } catch (\Throwable $e) {
error_log("[Heartbeat] 헬스/에스컬레이션/고착 실패: " . $e->getMessage()); error_log("[Heartbeat] 헬스/에스컬레이션/고착/디바운스 실패: " . $e->getMessage());
} }
if (!$is_cli) { 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 charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그인 - 누수감지 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title> <title>로그인 - 누수감지 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title>
<link rel="stylesheet" href="assets/app.css">
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } /* 로그인 페이지 전용: 카드 화면 중앙 정렬 (그 외 크롬/폼 스타일은 app.css) */
body { font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #333; body { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: var(--sp-4); }
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; }
}
</style> </style>
</head> </head>
<body> <body>
<div class="login-box"> <main class="login-box">
<h1>누수감지 모니터링 <?= htmlspecialchars(APP_VERSION) ?></h1> <h1>누수감지 모니터링 <?= htmlspecialchars(APP_VERSION) ?></h1>
<p class="sub">대시보드에 접근하려면 로그인하세요.</p> <p class="sub">대시보드에 접근하려면 로그인하세요.</p>
<?php if ($expired): ?> <?php if ($expired): ?>
<div class="expired">세션이 만료되었습니다. 다시 로그인해주세요.</div> <div class="expired" role="status">세션이 만료되었습니다. 다시 로그인해주세요.</div>
<?php endif; ?> <?php endif; ?>
<?php if ($error): ?> <?php if ($error): ?>
<div class="error"><?= htmlspecialchars($error) ?></div> <div class="error" role="alert"><?= htmlspecialchars($error) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($configuration_error): ?> <?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; ?> <?php endif; ?>
<form method="POST" action=""> <form method="POST" action="">
@ -240,6 +205,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$is_locked && !$configuration_erro
</div> </div>
<button type="submit" class="btn" <?= ($is_locked || $configuration_error) ? 'disabled' : '' ?>>로그인</button> <button type="submit" class="btn" <?= ($is_locked || $configuration_error) ? 'disabled' : '' ?>>로그인</button>
</form> </form>
</div> </main>
</body> </body>
</html> </html>

View file

@ -1,10 +1,14 @@
<?php <?php
// ============================================================================= // =============================================================================
// monthly_report.php - 월간 운영 보고서 (SHT30 온습도 전용) // monthly_report.php - 월간 운영 보고서 (온습도 + 누수 통합)
// //
// 집계 범위: 온습도 임계 경보(고온/저온/고습/저습), 정상복귀, 장비 오프라인/복구, // 집계 범위: 온습도 임계 경보(고온/저온/고습/저습), 누수 감지/복구, 정상복귀,
// SMS 실패, 측정 건수, 평균/최저/최고 온도·습도. // 장비 오프라인/복구, SMS 실패, 측정 건수, 평균/최저/최고 온도·습도.
// 데이터 출처: sms_log(message 태그 prefix 기준), sensor_metric(온습도 측정 이력). // 데이터 출처: 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'; require_once __DIR__ . '/config.php';
@ -22,180 +26,11 @@ if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
} }
admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(대시보드 외 페이지에서도 강제) admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(대시보드 외 페이지에서도 강제)
function month_range(string $month): array { // ── 집계 데이터 계층(단일 출처) ──────────────────────────────────────────────
if (!preg_match('/^\d{4}-\d{2}$/', $month)) { // 헬퍼(month_range/round_or_null/fmt_num/csv_cell)와 $summary/$sensor_rows 및 인쇄
$month = date('Y-m'); // 뷰 전용 파생(일별 추이·누수 사고·verdict)을 이 include 가 산출한다. HTML 조회 뷰,
} // CSV 다운로드, PDF 인쇄 뷰가 동일 소스를 공유한다(집계 중복 0 — 회귀 방지).
$start = DateTime::createFromFormat('Y-m-d H:i:s', $month . '-01 00:00:00'); require_once __DIR__ . '/monthly_report_data.php';
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 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 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 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 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']];
});
// ── CSV 다운로드 ───────────────────────────────────────────────────────────── // ── CSV 다운로드 ─────────────────────────────────────────────────────────────
if (($_GET['format'] ?? '') === 'csv') { if (($_GET['format'] ?? '') === 'csv') {
@ -210,6 +45,8 @@ if (($_GET['format'] ?? '') === 'csv') {
fputcsv($out, ['low_temp_alerts', $summary['low_temp']]); fputcsv($out, ['low_temp_alerts', $summary['low_temp']]);
fputcsv($out, ['high_humidity_alerts', $summary['high_humidity']]); fputcsv($out, ['high_humidity_alerts', $summary['high_humidity']]);
fputcsv($out, ['low_humidity_alerts', $summary['low_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, ['recovery_sms', $summary['recovery']]);
fputcsv($out, ['device_offline_sms', $summary['device_offline']]); fputcsv($out, ['device_offline_sms', $summary['device_offline']]);
fputcsv($out, ['device_online_sms', $summary['device_online']]); fputcsv($out, ['device_online_sms', $summary['device_online']]);
@ -225,6 +62,7 @@ if (($_GET['format'] ?? '') === 'csv') {
fputcsv($out, [ fputcsv($out, [
'sensor_id', 'sensor_name', 'location', 'sensor_id', 'sensor_name', 'location',
'high_temp', 'low_temp', 'high_humidity', 'low_humidity', 'threshold_alerts', 'high_temp', 'low_temp', 'high_humidity', 'low_humidity', 'threshold_alerts',
'leak_alert', 'leak_recovery',
'recovery', 'device_offline', 'device_online', 'sms_fail', 'recovery', 'device_offline', 'device_online', 'sms_fail',
'metrics', 'avg_temp_c', 'min_temp_c', 'max_temp_c', 'metrics', 'avg_temp_c', 'min_temp_c', 'max_temp_c',
'avg_humidity_percent', 'min_humidity_percent', 'max_humidity_percent', 'avg_humidity_percent', 'min_humidity_percent', 'max_humidity_percent',
@ -234,6 +72,7 @@ if (($_GET['format'] ?? '') === 'csv') {
fputcsv($out, [ fputcsv($out, [
$s['sensor_id'], csv_cell($s['sensor_name']), csv_cell($s['device_location']), $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['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['recovery'], $s['device_offline'], $s['device_online'], $s['sms_fail'],
$s['metrics'], $s['metrics'],
$s['avg_temp'] ?? '', $s['min_temp'] ?? '', $s['max_temp'] ?? '', $s['avg_temp'] ?? '', $s['min_temp'] ?? '', $s['max_temp'] ?? '',
@ -244,6 +83,15 @@ if (($_GET['format'] ?? '') === 'csv') {
fclose($out); fclose($out);
exit; exit;
} }
// ── PDF 저장 전용 A4 문서 뷰 ─────────────────────────────────────────────────
// 다크 대시보드(app.css)를 그대로 인쇄하던 단순 프린트지를 대체한다. 화이트 A4 전문
// 보고서(표지·경영요약·지표·추이·지점별·누수사고·서명)를 렌더한다. 집계는 위 include 가
// 이미 산출했으므로 여기서 뷰만 그린다(CSV·HTML 조회 뷰는 불변 — 회귀 없음).
if (($_GET['format'] ?? '') === 'print') {
require __DIR__ . '/monthly_report_print.php';
exit;
}
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
@ -251,52 +99,37 @@ if (($_GET['format'] ?? '') === 'csv') {
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>월간 운영 보고서 - <?= htmlspecialchars($month) ?></title> <title>월간 운영 보고서 - <?= htmlspecialchars($month) ?></title>
<link rel="stylesheet" href="assets/app.css">
<style> <style>
* { box-sizing: border-box; } /* 월간 리포트 전용: 조회 툴바(공유 app.css 미포함). 값은 전부 토큰 — 생 hex 없음 */
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; } .toolbar {
header { background: #1a56db; color: #fff; padding: 16px 24px; display:flex; justify-content:space-between; align-items:center; gap:12px; } display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3);
header h1 { margin:0; font-size:1.15rem; } background: var(--surface); border: 1px solid var(--line); border-radius: var(--r);
header a { color:#fff; text-decoration:none; background:rgba(255,255,255,.15); padding:7px 12px; border-radius:6px; font-size:.85rem; } box-shadow: var(--shadow); padding: var(--sp-3) var(--sp-4); margin-bottom: var(--sp-5);
.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; }
} }
@media (max-width:760px) { .grid { grid-template-columns: repeat(2, minmax(0,1fr)); } header { flex-direction:column; align-items:flex-start; } } .toolbar label { display: flex; align-items: center; gap: var(--sp-2); }
@media (max-width:520px) { .toolbar input[type="month"] {
.container { margin:16px auto; padding:0 10px; } padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--r-sm);
header { padding:14px 16px; } font-family: inherit; font-size: .875rem; color: var(--ink); background: var(--surface);
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; }
} }
@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> </style>
</head> </head>
<body> <body>
<header> <header>
<h1><?= htmlspecialchars($month) ?> 월간 운영 보고서</h1> <h1><?= htmlspecialchars($month) ?> 월간 운영 보고서</h1>
<div> <div class="top-actions">
<a href="dashboard.php">대시보드</a> <a href="dashboard.php">대시보드</a>
</div> </div>
</header> </header>
@ -304,27 +137,29 @@ if (($_GET['format'] ?? '') === 'csv') {
<form class="toolbar" method="GET"> <form class="toolbar" method="GET">
<label>월 <input type="month" name="month" value="<?= htmlspecialchars($month) ?>"></label> <label>월 <input type="month" name="month" value="<?= htmlspecialchars($month) ?>"></label>
<button type="submit">조회</button> <button type="submit">조회</button>
<a class="btn" href="?month=<?= htmlspecialchars($month) ?>&format=csv">CSV 다운로드</a> <a class="tool-btn" href="?month=<?= htmlspecialchars($month) ?>&format=print" target="_blank" rel="noopener">PDF 보고서</a>
<button type="button" onclick="window.print()">인쇄/PDF</button> <a class="tool-btn" href="?month=<?= htmlspecialchars($month) ?>&format=csv">CSV 다운로드</a>
</form> </form>
<section class="card" style="margin-bottom:18px"> <section class="panel">
<span>보고서 생성</span> <p class="caption">보고서 생성</p>
<strong style="font-size:1rem"><?= htmlspecialchars($report_generated_at) ?></strong> <strong><?= htmlspecialchars($report_generated_at) ?></strong>
<p style="color:#64748b;font-size:.84rem;margin:8px 0 0">범위: <?= htmlspecialchars($report_scope) ?></p> <p class="caption">범위: <?= htmlspecialchars($report_scope) ?></p>
</section> </section>
<section class="grid"> <h2>월간 요약</h2>
<div class="card"><span>임계 경보(합계)</span><strong><?= $summary['threshold_alerts'] ?>건</strong></div> <section class="report-grid">
<div class="card"><span>고온/저온 경보</span><strong><?= $summary['high_temp'] ?>/<?= $summary['low_temp'] ?>건</strong></div> <div class="report-stat"><span>임계 경보(합계)</span><strong><?= $summary['threshold_alerts'] ?>건</strong></div>
<div class="card"><span>고습/저습 경보</span><strong><?= $summary['high_humidity'] ?>/<?= $summary['low_humidity'] ?>건</strong></div> <div class="report-stat"><span>고온/저온 경보</span><strong><?= $summary['high_temp'] ?>/<?= $summary['low_temp'] ?>건</strong></div>
<div class="card"><span>정상복귀</span><strong><?= $summary['recovery'] ?>건</strong></div> <div class="report-stat"><span>고습/저습 경보</span><strong><?= $summary['high_humidity'] ?>/<?= $summary['low_humidity'] ?>건</strong></div>
<div class="card"><span>장비 오프라인/복구</span><strong><?= $summary['device_offline'] ?>/<?= $summary['device_online'] ?>건</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="card"><span>SMS 실패</span><strong><?= $summary['sms_fail'] ?>건</strong></div> <div class="report-stat"><span>정상복귀</span><strong><?= $summary['recovery'] ?>건</strong></div>
<div class="card"><span>측정 건수</span><strong><?= $summary['metrics'] ?>건</strong></div> <div class="report-stat"><span>장비 오프라인/복구</span><strong><?= $summary['device_offline'] ?>/<?= $summary['device_online'] ?>건</strong></div>
<div class="card"><span>평균 온도/습도</span><strong><?= fmt_num($summary['avg_temp'], '℃') ?> / <?= fmt_num($summary['avg_humidity'], '%') ?></strong></div> <div class="report-stat"><span>SMS 실패</span><strong><?= $summary['sms_fail'] ?>건</strong></div>
<div class="card"><span>온도 최저/최고</span><strong><?= fmt_num($summary['min_temp'], '℃') ?> / <?= fmt_num($summary['max_temp'], '℃') ?></strong></div> <div class="report-stat"><span>측정 건수</span><strong><?= $summary['metrics'] ?>건</strong></div>
<div class="card"><span>습도 최저/최고</span><strong><?= fmt_num($summary['min_humidity'], '%') ?> / <?= fmt_num($summary['max_humidity'], '%') ?></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> </section>
<h2>센서별 요약</h2> <h2>센서별 요약</h2>
@ -332,7 +167,7 @@ if (($_GET['format'] ?? '') === 'csv') {
<table> <table>
<thead><tr> <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>정상복귀</th><th>오프라인/복구</th><th>SMS 실패</th> <th>정상복귀</th><th>오프라인/복구</th><th>SMS 실패</th>
<th>측정</th><th>평균 온도/습도</th><th>온도 최저/최고</th><th>습도 최저/최고</th> <th>측정</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['threshold_alerts'] ?></td>
<td><?= $s['high_temp'] ?>/<?= $s['low_temp'] ?></td> <td><?= $s['high_temp'] ?>/<?= $s['low_temp'] ?></td>
<td><?= $s['high_humidity'] ?>/<?= $s['low_humidity'] ?></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['recovery'] ?></td>
<td><?= $s['device_offline'] ?>/<?= $s['device_online'] ?></td> <td><?= $s['device_offline'] ?>/<?= $s['device_online'] ?></td>
<td><?= $s['sms_fail'] ?></td> <td><?= $s['sms_fail'] ?></td>
@ -356,7 +192,7 @@ if (($_GET['format'] ?? '') === 'csv') {
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($sensor_rows)): ?> <?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; ?> <?php endif; ?>
</tbody> </tbody>
</table> </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

@ -48,7 +48,7 @@ function collect_ops_checks(): array {
$db = get_db(); $db = get_db();
$checks[] = ops_check('서버', 'DB 연결', true, DB_NAME . ' 연결 성공', '', 'critical', 'setup_wizard.php 또는 security_evidence.php 화면'); $checks[] = ops_check('서버', 'DB 연결', true, DB_NAME . ' 연결 성공', '', 'critical', 'setup_wizard.php 또는 security_evidence.php 화면');
$required_tables = ['sensor_log', 'sensor_status', 'sensor_metric', 'sms_log']; $required_tables = ['leak_sensor_log', 'leak_sensor_status', 'leak_sensor_metric', 'leak_sms_log'];
foreach ($required_tables as $table) { foreach ($required_tables as $table) {
$exists = ops_table_exists($db, $table); $exists = ops_table_exists($db, $table);
$checks[] = ops_check( $checks[] = ops_check(
@ -63,30 +63,30 @@ function collect_ops_checks(): array {
} }
// 최근 온습도 측정 수신 여부(센서 동작/수집 확인) // 최근 온습도 측정 수신 여부(센서 동작/수집 확인)
if (ops_table_exists($db, 'sensor_metric')) { if (ops_table_exists($db, 'leak_sensor_metric')) {
$recent_metric_stmt = $db->query(" $recent_metric_stmt = $db->query("
SELECT COUNT(*) SELECT COUNT(*)
FROM sensor_metric FROM leak_sensor_metric
WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
"); ");
$recent_metric = (int)$recent_metric_stmt->fetchColumn(); $recent_metric = (int)$recent_metric_stmt->fetchColumn();
$checks[] = ops_check('센서', '최근 1시간 온습도 수신', $recent_metric > 0, "{$recent_metric}건", 'SHT30 기기 전원/네트워크/배선 및 sensor_data.php 수신 확인', 'warn', 'sensor_metric 최근 수신'); $checks[] = ops_check('센서', '최근 1시간 온습도 수신', $recent_metric > 0, "{$recent_metric}건", 'SHT30 기기 전원/네트워크/배선 및 sensor_data.php 수신 확인', 'warn', 'leak_sensor_metric 최근 수신');
} }
if (ops_table_exists($db, 'sms_log')) { if (ops_table_exists($db, 'leak_sms_log')) {
$recent_sms_fail_stmt = $db->query(" $recent_sms_fail_stmt = $db->query("
SELECT COUNT(*) SELECT COUNT(*)
FROM sms_log FROM leak_sms_log
WHERE status = 'fail' WHERE status = 'fail'
AND sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
"); ");
$recent_sms_fail = (int)$recent_sms_fail_stmt->fetchColumn(); $recent_sms_fail = (int)$recent_sms_fail_stmt->fetchColumn();
$checks[] = ops_check('SMS', '최근 30일 SMS 실패', $recent_sms_fail === 0, "{$recent_sms_fail}건", 'Cafe24 SMS 계정, 잔액, 발신번호, 수신자 확인', 'warn', 'sms_log 최근 실패 건수'); $checks[] = ops_check('SMS', '최근 30일 SMS 실패', $recent_sms_fail === 0, "{$recent_sms_fail}건", 'Cafe24 SMS 계정, 잔액, 발신번호, 수신자 확인', 'warn', 'leak_sms_log 최근 실패 건수');
// SMS 잔여 건수(최근 성공 발송 기준). 임계 이하이면 충전 필요(경보 채널 보호). // SMS 잔여 건수(최근 성공 발송 기준). 임계 이하이면 충전 필요(경보 채널 보호).
$sms_balance = latest_sms_balance($db); $sms_balance = latest_sms_balance($db);
if ($sms_balance !== null) { if ($sms_balance !== null) {
$checks[] = ops_check('SMS', 'SMS 잔여 건수', $sms_balance > SMS_BALANCE_WARN_THRESHOLD, "{$sms_balance}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건)", 'Cafe24 SMS 충전 — 잔액 소진 시 임계/오프라인 경보 발송이 중단됩니다', 'warn', 'sms_log 최근 발송 잔여'); $checks[] = ops_check('SMS', 'SMS 잔여 건수', $sms_balance > SMS_BALANCE_WARN_THRESHOLD, "{$sms_balance}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건)", 'Cafe24 SMS 충전 — 잔액 소진 시 임계/오프라인 경보 발송이 중단됩니다', 'warn', 'leak_sms_log 최근 발송 잔여');
} }
} }
} catch (Throwable $e) { } catch (Throwable $e) {
@ -94,7 +94,7 @@ function collect_ops_checks(): array {
} }
$default_recipients = count(SMS_RECIPIENTS) === 1 && preg_replace('/\D+/', '', SMS_RECIPIENTS[0]) === '01000000000'; $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('설정', '관리자 비밀번호', 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', 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 미설정 확인'); $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

@ -46,9 +46,9 @@ $summary = [
'sensor_log_retention_days' => SENSOR_LOG_RETENTION_DAYS, 'sensor_log_retention_days' => SENSOR_LOG_RETENTION_DAYS,
'sensor_metric_retention_days' => SENSOR_METRIC_RETENTION_DAYS, 'sensor_metric_retention_days' => SENSOR_METRIC_RETENTION_DAYS,
'admin_audit_retention_days' => ADMIN_AUDIT_RETENTION_DAYS, 'admin_audit_retention_days' => ADMIN_AUDIT_RETENTION_DAYS,
'sms_log' => ['matched' => 0, 'rows_deleted' => 0], 'leak_sms_log' => ['matched' => 0, 'rows_deleted' => 0],
'sensor_log' => ['matched' => 0, 'rows_deleted' => 0], 'leak_sensor_log' => ['matched' => 0, 'rows_deleted' => 0],
'sensor_metric' => ['matched' => 0, 'rows_deleted' => 0], 'leak_sensor_metric' => ['matched' => 0, 'rows_deleted' => 0],
'admin_audit' => ['kept_lines' => null, 'removed_lines' => null], 'admin_audit' => ['kept_lines' => null, 'removed_lines' => null],
]; ];
@ -62,16 +62,16 @@ try {
} }
$smsCutoff = retention_cutoff(SMS_LOG_RETENTION_DAYS); $smsCutoff = retention_cutoff(SMS_LOG_RETENTION_DAYS);
$summary['sms_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff]); $summary['leak_sms_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM leak_sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff]);
$summary['sms_log']['rows_deleted'] = retention_delete($db, "DELETE FROM sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff], $dryRun); $summary['leak_sms_log']['rows_deleted'] = retention_delete($db, "DELETE FROM leak_sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff], $dryRun);
$sensorCutoff = retention_cutoff(SENSOR_LOG_RETENTION_DAYS); $sensorCutoff = retention_cutoff(SENSOR_LOG_RETENTION_DAYS);
$summary['sensor_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff]); $summary['leak_sensor_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM leak_sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff]);
$summary['sensor_log']['rows_deleted'] = retention_delete($db, "DELETE FROM sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff], $dryRun); $summary['leak_sensor_log']['rows_deleted'] = retention_delete($db, "DELETE FROM leak_sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff], $dryRun);
$metricCutoff = retention_cutoff(SENSOR_METRIC_RETENTION_DAYS); $metricCutoff = retention_cutoff(SENSOR_METRIC_RETENTION_DAYS);
$summary['sensor_metric']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff]); $summary['leak_sensor_metric']['matched'] = retention_count($db, "SELECT COUNT(*) FROM leak_sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff]);
$summary['sensor_metric']['rows_deleted'] = retention_delete($db, "DELETE FROM sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff], $dryRun); $summary['leak_sensor_metric']['rows_deleted'] = retention_delete($db, "DELETE FROM leak_sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff], $dryRun);
$auditPath = admin_audit_log_path(); $auditPath = admin_audit_log_path();
if (is_file($auditPath)) { if (is_file($auditPath)) {
@ -113,9 +113,9 @@ if (is_file($auditPath)) {
admin_audit_log('retention_cleanup', 'system', true, [ admin_audit_log('retention_cleanup', 'system', true, [
'mode' => $summary['mode'], 'mode' => $summary['mode'],
'sms_matches' => $summary['sms_log']['matched'], 'sms_matches' => $summary['leak_sms_log']['matched'],
'sensor_matches' => $summary['sensor_log']['matched'], 'sensor_matches' => $summary['leak_sensor_log']['matched'],
'metric_matches' => $summary['sensor_metric']['matched'], 'metric_matches' => $summary['leak_sensor_metric']['matched'],
]); ]);
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');

View file

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

View file

@ -70,36 +70,25 @@ $token = $_GET['token'] ?? $_POST['token'] ?? '';
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>관리자 MFA 등록</title> <title>관리자 MFA 등록</title>
<link rel="stylesheet" href="assets/app.css">
<style> <style>
* { box-sizing: border-box; } /* 페이지 전용: 문서형 좁은 단 (app.css .container는 1320px로 넓음). 값은 토큰만. */
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; } .mfa-doc { max-width: 760px; margin: var(--sp-6) auto; padding: 0 var(--sp-4); }
main { max-width: 760px; margin: 32px auto; padding: 0 16px; } .mfa-doc .panel { margin-bottom: var(--sp-4); }
.panel { background: #fff; border-radius: 8px; padding: 22px; box-shadow: 0 1px 5px rgba(15,23,42,.12); margin-bottom: 16px; } .mfa-doc textarea { min-height: 82px; resize: vertical; }
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; }
</style> </style>
</head> </head>
<body> <body>
<main> <main class="mfa-doc">
<section class="panel"> <section class="panel">
<h1>Google Authenticator MFA 등록</h1> <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): ?> <?php if ($error): ?>
<div class="error"><?= htmlspecialchars($error) ?></div> <div class="error" role="alert"><?= htmlspecialchars($error) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($verified): ?> <?php if ($verified): ?>
<div class="ok">인증 앱 코드가 확인되었습니다. 아래 설정 줄을 `config.local.php`에 반영하세요.</div> <div class="banner-ok" role="status">인증 앱 코드가 확인되었습니다. 아래 설정 줄을 `config.local.php`에 반영하세요.</div>
<?php endif; ?> <?php endif; ?>
<h2>1. 인증 앱에 수동 등록</h2> <h2>1. 인증 앱에 수동 등록</h2>
@ -111,7 +100,7 @@ $token = $_GET['token'] ?? $_POST['token'] ?? '';
<div class="secret"><?= htmlspecialchars($secret) ?></div> <div class="secret"><?= htmlspecialchars($secret) ?></div>
<h2>2. 앱 링크 등록</h2> <h2>2. 앱 링크 등록</h2>
<p class="muted">모바일 브라우저에서 열면 인증 앱이 이 URI를 인식할 수 있습니다. 데스크톱에서는 수동 등록을 사용하세요.</p> <p class="caption">모바일 브라우저에서 열면 인증 앱이 이 URI를 인식할 수 있습니다. 데스크톱에서는 수동 등록을 사용하세요.</p>
<textarea readonly><?= htmlspecialchars($uri) ?></textarea> <textarea readonly><?= htmlspecialchars($uri) ?></textarea>
<p><a class="btn" href="<?= htmlspecialchars($uri) ?>">인증 앱으로 열기</a></p> <p><a class="btn" href="<?= htmlspecialchars($uri) ?>">인증 앱으로 열기</a></p>
@ -130,7 +119,7 @@ $token = $_GET['token'] ?? $_POST['token'] ?? '';
<?php if ($verified): ?> <?php if ($verified): ?>
<h2>4. config.local.php 반영</h2> <h2>4. config.local.php 반영</h2>
<textarea readonly>'ADMIN_TOTP_SECRET' => '<?= htmlspecialchars($secret) ?>',</textarea> <textarea readonly>'ADMIN_TOTP_SECRET' => '<?= htmlspecialchars($secret) ?>',</textarea>
<p class="muted">반영 후 `MFA_SETUP_TOKEN`은 빈 값으로 바꾸거나 삭제하세요.</p> <p class="caption">반영 후 `MFA_SETUP_TOKEN`은 빈 값으로 바꾸거나 삭제하세요.</p>
<?php endif; ?> <?php endif; ?>
</section> </section>
</main> </main>

View file

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

View file

@ -12,6 +12,10 @@
// ============================================================================= // =============================================================================
require_once __DIR__ . '/config.php'; 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) * 발신번호를 3분할 (sphone1, sphone2, sphone3)
@ -169,7 +173,7 @@ function send_sms_cafe24(string $receiver, string $message): array {
} }
/** /**
* 완성된 메시지를 모든 수신자(SMS_RECIPIENTS)에게 발송하고 sms_log 에 기록한다. * 완성된 메시지를 모든 수신자(SMS_RECIPIENTS)에게 발송하고 leak_sms_log 에 기록한다.
* *
* @param PDO $db * @param PDO $db
* @param array $sensor_data ['sensor_id','sensor_name','device_id', ...] * @param array $sensor_data ['sensor_id','sensor_name','device_id', ...]
@ -178,10 +182,12 @@ function send_sms_cafe24(string $receiver, string $message): array {
* @return bool 전원 발송 성공 여부 * @return bool 전원 발송 성공 여부
*/ */
function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message, string $logLabel, ?array $recipients = null): 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; $allSuccess = true;
$lastRemaining = null; // 게이트웨이가 알려준 최신 잔여 건수(잔액 사전경보용) $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) { foreach ($targets as $receiver) {
$receiverDigits = preg_replace('/\D+/', '', $receiver); $receiverDigits = preg_replace('/\D+/', '', $receiver);
@ -191,7 +197,7 @@ function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message
} }
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO sms_log INSERT INTO leak_sms_log
(sensor_id, sensor_name, device_id, message, receivers, (sensor_id, sensor_name, device_id, message, receivers,
status, result_code, result_message, sent_at) status, result_code, result_message, sent_at)
VALUES VALUES
@ -234,7 +240,7 @@ function maybe_warn_sms_balance(PDO $db, ?int $remaining): void {
} }
// 쿨다운: 최근 '[SMS 잔액부족]' 성공/실패 불문 발송이 있으면 생략(반복 폭주 방지). // 쿨다운: 최근 '[SMS 잔액부족]' 성공/실패 불문 발송이 있으면 생략(반복 폭주 방지).
$recent = $db->prepare(" $recent = $db->prepare("
SELECT sent_at FROM sms_log SELECT sent_at FROM leak_sms_log
WHERE message LIKE :tag WHERE message LIKE :tag
ORDER BY sent_at DESC ORDER BY sent_at DESC
LIMIT 1 LIMIT 1
@ -246,15 +252,14 @@ function maybe_warn_sms_balance(PDO $db, ?int $remaining): void {
} }
$message = SMS_TAG_BALANCE_LOW . "\n" $message = SMS_TAG_BALANCE_LOW . "\n"
. "SMS 잔여 {$remaining}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건 이하)\n" . "잔여 {$remaining}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건)\n"
. "충전하지 않으면 경보 발송이 중단됩니다.\n" . "충전 필요 · " . date('m-d H:i');
. "시각: " . date('Y-m-d H:i:s');
foreach (SMS_RECIPIENTS as $receiver) { foreach (SMS_RECIPIENTS as $receiver) {
$receiverDigits = preg_replace('/\D+/', '', $receiver); $receiverDigits = preg_replace('/\D+/', '', $receiver);
$result = send_sms_cafe24($receiverDigits, $message); $result = send_sms_cafe24($receiverDigits, $message);
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO sms_log INSERT INTO leak_sms_log
(sensor_id, sensor_name, device_id, message, receivers, (sensor_id, sensor_name, device_id, message, receivers,
status, result_code, result_message, sent_at) status, result_code, result_message, sent_at)
VALUES VALUES
@ -283,19 +288,7 @@ function metric_value_str(float $value): string {
return $s; return $s;
} }
/** // dew_point_c() 정의는 forecast.php 로 이동(단일 출처). 여기서는 require 로 로드해 사용.
* 이슬점(℃) — 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);
}
/** /**
* 결로마진(℃) = 현재온도 − 이슬점. 좁을수록 결로 위험. 측정 결측/산출불가면 null. * 결로마진(℃) = 현재온도 − 이슬점. 좁을수록 결로 위험. 측정 결측/산출불가면 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} * @return array{breaches: string[], status: string}
* breaches: 발생한 경보 종류 목록, status: sensor_metric 저장용 대표 상태 * 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 = []; $breaches = [];
if ($temp_c !== null) { if ($temp_c !== null) {
if ($temp_c >= METRIC_TEMP_HIGH_C) { if ($temp_c >= METRIC_TEMP_HIGH_C) {
@ -355,6 +358,13 @@ function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array {
$breaches[] = 'condensation_risk'; $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 [ return [
'breaches' => $breaches, 'breaches' => $breaches,
'status' => empty($breaches) ? 'normal' : $breaches[0], 'status' => empty($breaches) ? 'normal' : $breaches[0],
@ -379,7 +389,7 @@ function metric_is_recovered(?float $temp_c, ?float $rh): bool {
/** /**
* 센서가 현재 임계 경보 상태인지 판정한다(최근 metric SMS가 경보이고 그 뒤 복귀가 * 센서가 현재 임계 경보 상태인지 판정한다(최근 metric SMS가 경보이고 그 뒤 복귀가
* 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(sms_log 기준). * 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(leak_sms_log 기준).
*/ */
function metric_was_alerting(PDO $db, int $sensor_id): bool { function metric_was_alerting(PDO $db, int $sensor_id): bool {
// 태그는 config.php 상수(단일 출처)에서 가져와 바인딩한다. 문자열 하드코딩 금지. // 태그는 config.php 상수(단일 출처)에서 가져와 바인딩한다. 문자열 하드코딩 금지.
@ -397,7 +407,7 @@ function metric_was_alerting(PDO $db, int $sensor_id): bool {
$likes[] = "message LIKE {$key}"; $likes[] = "message LIKE {$key}";
$params[$key] = $tag . '%'; $params[$key] = $tag . '%';
} }
$sql = "SELECT message FROM sms_log $sql = "SELECT message FROM leak_sms_log
WHERE sensor_id = :sid WHERE sensor_id = :sid
AND status = 'success' AND status = 'success'
AND (" . implode(' OR ', $likes) . ") AND (" . implode(' OR ', $likes) . ")
@ -412,6 +422,82 @@ function metric_was_alerting(PDO $db, int $sensor_id): bool {
return !str_starts_with($msg, SMS_TAG_RECOVERY); 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 발송 (종류별 쿨다운 포함). * 온습도 임계 경보 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 array $sensor_data ['sensor_id','sensor_name','device_id','device_location']
* @param string $kind 'high_temp'|'low_temp'|'high_humidity'|'low_humidity' * @param string $kind 'high_temp'|'low_temp'|'high_humidity'|'low_humidity'|'high_pm25'|'high_pm10'
* @param float $value 측정값 (온도℃ 또는 습도%) * @param float $value 측정값 (온도℃ / 습도% / 미세먼지 µg/m³)
* @return bool 발송 시도 여부 (쿨다운/오류로 생략 시 false) * @return bool 발송 시도 여부 (쿨다운/오류로 생략 시 false)
*/ */
function send_metric_alert_sms(array $sensor_data, string $kind, float $value): bool { 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']; $sensor_id = (int)$sensor_data['sensor_id'];
// [태그, 지표명, 단위, 임계값, 방향문구] (태그는 config.php 단일 출처 상수) // [태그, 지표명, 단위, 임계값, 방향문구] (태그는 config.php 단일 출처 상수)
// 화재의심(high_pm25/high_pm10)은 PM2.5·PM10 이 같은 태그([화재의심])를 쓰되 지표명·
// 임계값으로 구분한다. 쿨다운은 태그 기준이므로 두 종류가 같은 쿨다운 창을 공유한다.
$labels = [ $labels = [
'high_temp' => [SMS_TAG_HIGH_TEMP, '온도', '℃', METRIC_TEMP_HIGH_C, '이상'], 'high_temp' => [SMS_TAG_HIGH_TEMP, '온도', '℃', METRIC_TEMP_HIGH_C, '이상'],
'low_temp' => [SMS_TAG_LOW_TEMP, '온도', '℃', METRIC_TEMP_LOW_C, '이하'], 'low_temp' => [SMS_TAG_LOW_TEMP, '온도', '℃', METRIC_TEMP_LOW_C, '이하'],
'high_humidity' => [SMS_TAG_HIGH_HUMIDITY, '습도', '%', METRIC_RH_HIGH, '이상'], 'high_humidity' => [SMS_TAG_HIGH_HUMIDITY, '습도', '%', METRIC_RH_HIGH, '이상'],
'low_humidity' => [SMS_TAG_LOW_HUMIDITY, '습도', '%', METRIC_RH_LOW, '이하'], 'low_humidity' => [SMS_TAG_LOW_HUMIDITY, '습도', '%', METRIC_RH_LOW, '이하'],
'condensation_risk' => [SMS_TAG_CONDENSATION, '결로마진', '℃', METRIC_DEWPOINT_MARGIN_C, '이하'], '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])) { if (!isset($labels[$kind])) {
error_log("[SMS] 알 수 없는 임계 경보 종류: {$kind}"); error_log("[SMS] 알 수 없는 임계 경보 종류: {$kind}");
@ -442,7 +532,7 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
[$tag, $metricName, $unit, $threshold, $dir] = $labels[$kind]; [$tag, $metricName, $unit, $threshold, $dir] = $labels[$kind];
// ── 동시 발송 직렬화 (중복 SMS 방지) ──────────────────────── // ── 동시 발송 직렬화 (중복 SMS 방지) ────────────────────────
// 쿨다운 검사→발송→sms_log 기록 사이에 다른 요청(서로 다른 측정의 동시 수신 등)이 // 쿨다운 검사→발송→leak_sms_log 기록 사이에 다른 요청(서로 다른 측정의 동시 수신 등)이
// 끼어들면 둘 다 쿨다운을 통과해 같은 경보를 중복 발송할 수 있다. (센서,종류) 단위 // 끼어들면 둘 다 쿨다운을 통과해 같은 경보를 중복 발송할 수 있다. (센서,종류) 단위
// MySQL 네임드 락으로 직렬화한다. 락이 잡혀 있으면(busy) 다른 프로세스가 같은 경보를 // MySQL 네임드 락으로 직렬화한다. 락이 잡혀 있으면(busy) 다른 프로세스가 같은 경보를
// 처리 중이므로 생략한다. GET_LOCK 미지원/오류(NULL)면 종전 동작(락 없이 진행)으로 // 처리 중이므로 생략한다. GET_LOCK 미지원/오류(NULL)면 종전 동작(락 없이 진행)으로
@ -466,35 +556,44 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
} }
try { try {
// ── 종류별 쿨다운 (sms_log 메시지 prefix 기준) ────────────── // ── 종류별 쿨다운 (에피소드 인지) ────────────────────────────────
// 이 종류 경보([고온경보] 등)와 정상복귀([정상복귀]) 중 최신 1건을 본다. 최신이
// 이 종류 경보이고(=아직 미복귀) 쿨다운 이내일 때만 생략한다. 최신이 정상복귀면
// 에피소드가 끝난 것이라 재발한 경보는 별개 에피소드 → 즉시 발송한다.
// (과거 버그: 종류 태그만 봐서, 복귀 직후 재발한 경보가 이전 경보의 쿨다운에
// 걸려 통째로 누락됐다.)
$recent = $db->prepare(" $recent = $db->prepare("
SELECT sent_at FROM sms_log SELECT message, sent_at FROM leak_sms_log
WHERE sensor_id = :sensor_id WHERE sensor_id = :sensor_id
AND status = 'success' AND status = 'success'
AND message LIKE :tag AND (message LIKE :alert OR message LIKE :recovery)
ORDER BY sent_at DESC ORDER BY sent_at DESC, id DESC
LIMIT 1 LIMIT 1
"); ");
$recent->execute([':sensor_id' => $sensor_id, ':tag' => $tag . '%']); $recent->execute([
$last_at = $recent->fetchColumn(); ':sensor_id' => $sensor_id,
if ($last_at !== false && (time() - strtotime($last_at)) < METRIC_ALERT_COOLDOWN_SEC) { ':alert' => $tag . '%',
$elapsed = time() - strtotime($last_at); ':recovery' => SMS_TAG_RECOVERY . '%',
error_log("[SMS] {$tag} 쿨다운 생략 - 센서{$sensor_id} ({$elapsed}s/" . METRIC_ALERT_COOLDOWN_SEC . "s)"); ]);
$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; return false;
} }
// ── 메시지 구성 ────────────────────────────────────────── // ── 메시지 구성 (단문 90B EUC-KR 이내) ─────────────────────
$dt = date('Y-m-d H:i:s'); $dt = date('m-d H:i');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}"; [$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$valStr = metric_value_str($value); $valStr = metric_value_str($value);
$thrStr = metric_value_str((float)$threshold); $thrStr = metric_value_str((float)$threshold);
$op = ($dir === '이하') ? '>' : '<'; // 정상 범위 방향(임계 미만/초과)
$message = "{$tag}\n" $message = "{$tag} {$loc}\n"
. "위치: {$loc}\n" . "{$metricName} {$valStr}{$unit} 정상{$op}{$thrStr}\n"
. "센서: {$name}\n" . "{$dt} 즉시확인";
. "{$metricName}: {$valStr}{$unit} (임계 {$thrStr}{$unit} {$dir})\n"
. "시각: {$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "임계경보 {$kind}"); return sms_dispatch_to_recipients($db, $sensor_data, $message, "임계경보 {$kind}");
} finally { } 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 { function send_metric_recovery_sms(array $sensor_data, float $temp_c, float $rh): bool {
$db = get_db(); $db = get_db();
$dt = date('Y-m-d H:i:s'); $dt = date('m-d H:i');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}"; [$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$message = SMS_TAG_RECOVERY . "\n" $message = SMS_TAG_RECOVERY . " {$loc}\n"
. "위치: {$loc}\n" . "온도 " . metric_value_str($temp_c) . "℃ 습도 " . metric_value_str($rh) . "%\n"
. "센서: {$name}\n" . "{$dt} 정상복귀";
. "온도 " . metric_value_str($temp_c) . "℃ / 습도 " . metric_value_str($rh) . "%\n"
. "시각: {$dt}\n"
. "온습도가 정상 범위로 복귀했습니다.";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "정상복귀"); return sms_dispatch_to_recipients($db, $sensor_data, $message, "정상복귀");
} }
@ -567,33 +662,41 @@ function send_leak_alert_sms(array $sensor_data): bool {
} }
try { try {
// ── 쿨다운 (sms_log 메시지 prefix 기준) ── // ── 쿨다운 (에피소드 인지) ──
// [누수감지]와 [누수복구] 중 최신 1건을 본다. 최신이 [누수감지](=아직 미복구)이고
// 쿨다운 이내일 때만 생략(지속 누수 중 주기보고 폭주 방지). 최신이 [누수복구]면
// 사고가 끝난 것이므로 재발한 누수는 별개 에피소드 → 즉시 발송한다.
// (과거 버그: [누수감지] 태그만 봐서, 복구 직후 재발한 누수가 이전 감지의 쿨다운에
// 걸려 통째로 누락됐다.)
$recent = $db->prepare(" $recent = $db->prepare("
SELECT sent_at FROM sms_log SELECT message, sent_at FROM leak_sms_log
WHERE sensor_id = :sensor_id WHERE sensor_id = :sensor_id
AND status = 'success' AND status = 'success'
AND message LIKE :tag AND (message LIKE :alert OR message LIKE :recovery)
ORDER BY sent_at DESC ORDER BY sent_at DESC, id DESC
LIMIT 1 LIMIT 1
"); ");
$recent->execute([':sensor_id' => $sensor_id, ':tag' => SMS_TAG_LEAK_ALERT . '%']); $recent->execute([
$last_at = $recent->fetchColumn(); ':sensor_id' => $sensor_id,
if ($last_at !== false && (time() - strtotime($last_at)) < LEAK_ALERT_COOLDOWN_SEC) { ':alert' => SMS_TAG_LEAK_ALERT . '%',
$elapsed = time() - strtotime($last_at); ':recovery' => SMS_TAG_LEAK_RECOVERY . '%',
error_log("[SMS] " . SMS_TAG_LEAK_ALERT . " 쿨다운 생략 - 센서{$sensor_id} ({$elapsed}s/" . LEAK_ALERT_COOLDOWN_SEC . "s)"); ]);
$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; return false;
} }
// ── 메시지 구성 ── // ── 메시지 구성 ──
$dt = date('Y-m-d H:i:s'); $dt = date('m-d H:i');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}"; [$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$message = SMS_TAG_LEAK_ALERT . "\n" $message = SMS_TAG_LEAK_ALERT . " {$loc}\n"
. "위치: {$loc}\n" . "누수 감지! 즉시 확인\n"
. "센서: {$name}\n" . "{$dt}";
. "누수가 감지되었습니다. 즉시 확인하세요!\n"
. "시각: {$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수감지"); return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수감지");
} finally { } finally {
@ -621,7 +724,7 @@ function send_leak_recovery_sms(array $sensor_data): bool {
// 직전 누수 상태 SMS([누수감지]/[누수복구]) 중 최신 1건을 본다. 최신이 [누수감지]일 // 직전 누수 상태 SMS([누수감지]/[누수복구]) 중 최신 1건을 본다. 최신이 [누수감지]일
// 때만(=아직 복구 통지 전) 복구 SMS 를 보낸다. // 때만(=아직 복구 통지 전) 복구 SMS 를 보낸다.
$stmt = $db->prepare(" $stmt = $db->prepare("
SELECT message FROM sms_log SELECT message FROM leak_sms_log
WHERE sensor_id = :sid WHERE sensor_id = :sid
AND status = 'success' AND status = 'success'
AND (message LIKE :alert OR message LIKE :recovery) AND (message LIKE :alert OR message LIKE :recovery)
@ -638,15 +741,12 @@ function send_leak_recovery_sms(array $sensor_data): bool {
return false; // 직전 누수 경보 없음(또는 이미 복구 통지함) → 발송 안 함 return false; // 직전 누수 경보 없음(또는 이미 복구 통지함) → 발송 안 함
} }
$dt = date('Y-m-d H:i:s'); $dt = date('m-d H:i');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}"; [$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$message = SMS_TAG_LEAK_RECOVERY . "\n" $message = SMS_TAG_LEAK_RECOVERY . " {$loc}\n"
. "위치: {$loc}\n" . "누수 해소(복구)\n"
. "센서: {$name}\n" . "{$dt}";
. "누수가 해소(복구)되었습니다.\n"
. "시각: {$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수복구"); 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 { function send_device_offline_sms(array $sensor_data): bool {
$db = get_db(); $db = get_db();
$dt = date('Y-m-d H:i:s'); $dt = date('m-d H:i');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}"; [$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$loc = $sensor_data['device_location'] ?? '알 수 없음'; $message = SMS_TAG_DEVICE_OFFLINE . " {$loc}\n"
$last = $sensor_data['last_seen'] ?? '알 수 없음'; . "통신 두절 · 장비 확인\n"
. "{$dt}";
$message = SMS_TAG_DEVICE_OFFLINE . "\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "마지막 통신: {$last}\n"
. "감지 시각: {$dt}\n"
. "장비 상태를 확인하세요!";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "오프라인"); 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 { function send_device_online_sms(array $sensor_data): bool {
$db = get_db(); $db = get_db();
$dt = date('Y-m-d H:i:s'); $dt = date('m-d H:i');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}"; [$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$message = SMS_TAG_DEVICE_ONLINE . "\n" $message = SMS_TAG_DEVICE_ONLINE . " {$loc}\n"
. "위치: {$loc}\n" . "통신 정상 복구\n"
. "센서: {$name}\n" . "{$dt}";
. "복구 시각: {$dt}\n"
. "통신이 정상 복구되었습니다.";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "장비복구"); return sms_dispatch_to_recipients($db, $sensor_data, $message, "장비복구");
} }
@ -702,14 +793,14 @@ function send_device_online_sms(array $sensor_data): bool {
* 예: "센서 2/2 정상, 최근수신 3분전, 24h경보 0건, 잔여 95건" * 예: "센서 2/2 정상, 최근수신 3분전, 24h경보 0건, 잔여 95건"
*/ */
function build_health_summary(PDO $db): string { function build_health_summary(PDO $db): string {
$online = (int)$db->query("SELECT COUNT(*) FROM sensor_status WHERE offline_alerted = 0")->fetchColumn(); $online = (int)$db->query("SELECT COUNT(*) FROM leak_sensor_status WHERE offline_alerted = 0")->fetchColumn();
$total = (int)$db->query("SELECT COUNT(*) FROM sensor_status")->fetchColumn(); $total = (int)$db->query("SELECT COUNT(*) FROM leak_sensor_status")->fetchColumn();
$lastSeen = $db->query("SELECT TIMESTAMPDIFF(MINUTE, MAX(last_seen), NOW()) FROM 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(" $alerts = (int)$db->query("
SELECT COUNT(*) FROM sms_log SELECT COUNT(*) FROM leak_sms_log
WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR) WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
AND status = 'success' AND status = 'success'
AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%' AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%'
@ -717,10 +808,10 @@ function build_health_summary(PDO $db): string {
OR message LIKE '" . SMS_TAG_CONDENSATION . "%' OR message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%') OR message LIKE '" . SMS_TAG_CONDENSATION . "%' OR message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%')
")->fetchColumn(); ")->fetchColumn();
$summary = "센서 {$online}/{$total} 정상, 최근수신 {$recvStr}, 24h경보 {$alerts}건"; $summary = "센서 {$online}/{$total} 최근{$recvStr} 24h경보{$alerts}건";
$bal = latest_sms_balance($db); $bal = latest_sms_balance($db);
if ($bal !== null) { if ($bal !== null) {
$summary .= ", 잔여 {$bal}건"; $summary .= " 잔여{$bal}";
} }
return $summary; return $summary;
} }
@ -735,7 +826,7 @@ function maybe_send_daily_health_sms(PDO $db): void {
return; return;
} }
$today = $db->prepare(" $today = $db->prepare("
SELECT 1 FROM sms_log SELECT 1 FROM leak_sms_log
WHERE message LIKE :tag AND DATE(sent_at) = CURDATE() WHERE message LIKE :tag AND DATE(sent_at) = CURDATE()
LIMIT 1 LIMIT 1
"); ");
@ -785,7 +876,7 @@ function maybe_send_daily_health_sms(PDO $db): void {
*/ */
function recent_tag_within(PDO $db, int $sensor_id, string $tag, int $seconds): bool { function recent_tag_within(PDO $db, int $sensor_id, string $tag, int $seconds): bool {
$stmt = $db->prepare(" $stmt = $db->prepare("
SELECT sent_at FROM sms_log SELECT sent_at FROM leak_sms_log
WHERE sensor_id = :sid AND message LIKE :tag WHERE sensor_id = :sid AND message LIKE :tag
ORDER BY sent_at DESC LIMIT 1 ORDER BY sent_at DESC LIMIT 1
"); ");
@ -828,7 +919,7 @@ function latest_alert_state_row(PDO $db, int $sensor_id): ?array {
} }
// id(AUTO_INCREMENT) 를 2차 정렬키로 둬, 같은 초에 경보·복귀가 기록돼도 최신 행이 // id(AUTO_INCREMENT) 를 2차 정렬키로 둬, 같은 초에 경보·복귀가 기록돼도 최신 행이
// 결정적으로 선택되게 한다(sent_at 은 1초 해상도). // 결정적으로 선택되게 한다(sent_at 은 1초 해상도).
$sql = "SELECT message, sent_at FROM sms_log $sql = "SELECT message, sent_at FROM leak_sms_log
WHERE sensor_id = :sid AND status = 'success' WHERE sensor_id = :sid AND status = 'success'
AND (" . implode(' OR ', $likes) . ") AND (" . implode(' OR ', $likes) . ")
ORDER BY sent_at DESC, id DESC LIMIT 1"; ORDER BY sent_at DESC, id DESC LIMIT 1";
@ -848,7 +939,7 @@ function maybe_escalate_alerts(PDO $db): void {
return; return;
} }
$alertTags = [SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP, SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY, SMS_TAG_CONDENSATION]; $alertTags = [SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP, SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY, SMS_TAG_CONDENSATION];
$sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM sensor_status")->fetchAll(); $sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM leak_sensor_status")->fetchAll();
foreach ($sensors as $s) { foreach ($sensors as $s) {
$sid = (int)$s['sensor_id']; $sid = (int)$s['sensor_id'];
$row = latest_alert_state_row($db, $sid); $row = latest_alert_state_row($db, $sid);
@ -874,13 +965,11 @@ function maybe_escalate_alerts(PDO $db): void {
continue; continue;
} }
$firstLine = strtok($msg, "\n"); $firstLine = strtok($msg, "\n");
$name = $s['sensor_name'] ?: "센서{$sid}"; [$loc, $name] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null);
$loc = $s['device_location'] ?: '알 수 없음';
$mins = (int)floor((time() - strtotime((string)$row['sent_at'])) / 60); $mins = (int)floor((time() - strtotime((string)$row['sent_at'])) / 60);
$message = SMS_TAG_ESCALATION . "\n" $message = SMS_TAG_ESCALATION . "\n"
. "위치: {$loc}\n센서: {$name}\n" . "{$firstLine} 미복귀 {$mins}분\n"
. "{$firstLine} 미복귀 {$mins}분 경과\n" . date('m-d H:i') . " 즉시확인";
. "1차 미대응 — 확인 바랍니다.\n시각: " . date('Y-m-d H:i:s');
$info = ['sensor_id' => $sid, 'sensor_name' => $name, 'device_id' => $s['device_id'], 'device_location' => $loc]; $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); sms_dispatch_to_recipients($db, $info, $message, "에스컬레이션", SMS_RECIPIENTS_TIER2);
error_log("[SMS] 에스컬레이션 발송 - 센서{$sid} ({$mins}분 미복귀)"); error_log("[SMS] 에스컬레이션 발송 - 센서{$sid} ({$mins}분 미복귀)");
@ -897,12 +986,12 @@ function maybe_detect_stuck_sensors(PDO $db): void {
return; return;
} }
$n = (int)STUCK_DETECT_SAMPLES; // 정수 상수 — LIMIT 에 안전하게 인라인 $n = (int)STUCK_DETECT_SAMPLES; // 정수 상수 — LIMIT 에 안전하게 인라인
$sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM sensor_status")->fetchAll(); $sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM leak_sensor_status")->fetchAll();
foreach ($sensors as $s) { foreach ($sensors as $s) {
$sid = (int)$s['sensor_id']; $sid = (int)$s['sensor_id'];
$stmt = $db->prepare(" $stmt = $db->prepare("
SELECT temperature_c, humidity_percent SELECT temperature_c, humidity_percent
FROM sensor_metric FROM leak_sensor_metric
WHERE sensor_id = :sid WHERE sensor_id = :sid
ORDER BY measured_at DESC ORDER BY measured_at DESC
LIMIT {$n} LIMIT {$n}
@ -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)) { if (recent_tag_within($db, $sid, SMS_TAG_SENSOR_STUCK, STUCK_ALERT_COOLDOWN_SEC)) {
continue; // 쿨다운 continue; // 쿨다운
} }
$name = $s['sensor_name'] ?: "센서{$sid}"; [$loc, $name] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null);
$loc = $s['device_location'] ?: '알 수 없음';
$tval = number_format((float)$rows[0]['temperature_c'], 2); $tval = number_format((float)$rows[0]['temperature_c'], 2);
$hval = number_format((float)$rows[0]['humidity_percent'], 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}회 측정값 고착(온도 {$tval}℃ / 습도 {$hval}%)\n"
. "센서/배선 점검 필요\n시각: " . date('Y-m-d H:i:s'); . "센서/배선 점검 필요\n시각: " . date('Y-m-d H:i:s');
$info = ['sensor_id' => $sid, 'sensor_name' => $name, 'device_id' => $s['device_id'], 'device_location' => $loc]; $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}회 평탄)"); 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,6 +1,18 @@
<?php <?php
// 모바일 접근 점검용 단순 핑. User-Agent 를 그대로 반사하면 반사형 XSS 가 되므로 // 모바일 접근 점검용 단순 핑. User-Agent 를 그대로 반사하면 반사형 XSS 가 되므로
// text/plain 으로 응답하고 출력값도 이스케이프한다(이중 방어). // text/plain 으로 응답하고 출력값도 이스케이프한다(이중 방어).
//
// 인증 가드(심층 방어): 1차 차단은 .htaccess FilesMatch(403)지만, .htaccess 가
// 무시되는 배포(AllowOverride None 등)에서도 비인증 노출되지 않도록 세션을 검사한다.
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
ini_set('session.cookie_samesite', 'Strict');
ini_set('session.use_strict_mode', 1);
session_start();
if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
http_response_code(403);
exit;
}
header('Content-Type: text/plain; charset=utf-8'); header('Content-Type: text/plain; charset=utf-8');
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
$ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';

View file

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

View file

@ -1,123 +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 로 읽어, A monitoring module that reads the e-con **5C-08 resistive leak detection sensor** via the
누수 발생 시 즉시 PHP API 서버에 보고(+SMS 발송 트리거)하고 **Pi Camera 로 현장 사진을 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 전용**으로 이식한 > This folder is a port of a module originally written for the **Raspberry Pi Zero 2 W**,
> 폴더입니다. GPIO(BCM) 번호 체계와 `rpicam`/`libcamera` 카메라 스택은 두 보드가 동일해 > 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 인터럽트 누수 감지, 주기 보고, 누수 시 촬영+업로드 | | `leak_sensor.py` | Main — GPIO interrupt leak detection, periodic reporting, capture+upload on leak |
| `config.py` | 비밀이 아닌 기본 설정(센서/임계/간격/카메라). `config_local` 에서 비밀값 로드 | | `config.py` | Non-secret default settings (sensor/threshold/interval/camera). Loads secrets from `config_local` |
| `config_local.example.py` | 운영·비밀값 템플릿 → `config_local.py` 로 복사해 사용(미커밋) | | `config_local.example.py` | Operational/secret value template → copy to `config_local.py` before use (not committed) |
| `requirements.txt` | Python 의존성(핀 고정) | | `requirements.txt` | Python dependencies (pinned) |
| `install.sh` | Pi 4 설치 스크립트(폐쇄망 오프라인 + 온라인 폴백) | | `install.sh` | Pi 4 install script (air-gapped offline + online fallback) |
| `vendor.sh` | 폐쇄망용 의존성 사전 다운로드(빌드 머신에서 실행) | | `vendor.sh` | Pre-download dependencies for air-gapped use (run on build machine) |
| `test_connection.py` | 서버 연결/서명 점검 | | `test_connection.py` | Server connection/signature check |
| `systemd/leak-sensor.service` | 부팅 자동 실행 서비스 유닛 | | `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** - **Raspberry Pi 4 Model B** (BCM2711) + Raspberry Pi OS **Bookworm 64-bit**
- e-con **5C-08** 누수감지센서(저항형, 고임피던스 ~1.5MΩ) - e-con **5C-08** leak detection sensor (resistive, high-impedance ~1.5MΩ)
- **외부 4.7MΩ 풀다운 저항** (필수 — 내부 풀저항은 끔) - **External 10MΩ pull-down resistor** (required — internal pull resistor is off)
- (선택) **Pi Camera** — Pi 4 = **15핀(1.0mm 피치) 표준 CSI**, Zero 2 W = **22핀(0.5mm 피치) CSI** - (Optional) **Pi Camera** — Pi 4 = **15-pin (1.0mm pitch) standard CSI**, Zero 2 W = **22-pin (0.5mm pitch) CSI**
→ 리본/카메라 보드 호환 안 됨 (Pi 4용 15핀 리본 또는 15↔22 변환 어댑터 필요) → Ribbon/camera boards are not compatible (a Pi 4 15-pin ribbon or a 15↔22 adapter is required)
- 전원: **USB-C 5V/3A** (카메라 사용 시 여유 전원 권장) - Power: **USB-C 5V/3A** (a power margin is recommended when using the camera)
- 네트워크: Pi 4 **Gigabit Ethernet**(유선) 또는 WiFi - 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 | | 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 번호로 항목을 추가합니다. > To add a sensor, add an entry by BCM number to `SENSORS` in `config.py`.
> (예: GPIO27=물리 13번, GPIO22=물리 15번) > (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) - `ACTIVE_LOW = False`: when dry GPIO=LOW(0), on leak GPIO=HIGH(1)
- `USE_INTERNAL_PULL = False`(**PUD_OFF 유지**): 내부 50kΩ 풀다운을 켜면 1.5MΩ 센서가 - `USE_INTERNAL_PULL = False` (**keep PUD_OFF**): if you turn on the internal 50kΩ pull-down,
GPIO 를 HIGH 로 끌어올리지 못해 **감지 실패**합니다. 외부 4.7MΩ 풀다운만 사용하세요. 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/` 서버는 SHT30 온습도 전용으로 전환**되었습니다 > ✅ **The `php/` server in this repo is exactly that server (single-server integration complete).**
> (`sql/migration_drop_leak.sql` 가 `leak_photo`/`leak_incident` 테이블과 `is_leak` 컬럼을 > Both module 1 (leak + camera) and module 2 (SHT30 temperature/humidity) report to the
> 제거하고 `event_type` 을 `startup|periodic` 로 축소). 따라서 현재 repo 서버는 leak 이벤트와 > **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`).
> >
> 이 Pi 4 모듈은 **leak 를 받는 기존 서버**(기존 `raspi_leck_detecter` 엔드포인트)로 보고하도록 > - **New install:** run only `sql/schema_sht30.sql` — the leak elements
> `config_local.py` 의 `API_URL` / `PHOTO_UPLOAD_URL` 을 설정해야 합니다. > (`leak_sensor_log.is_leak` / `leak_sensor_status.is_leak` columns, `leak_incident`, `leak_photo`,
> `leak_ingest_nonce` tables) are already included.
>
> 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 ```bash
cp config_local.example.py config_local.py 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). If `config_local.py` is missing, execution **stops immediately with a clear error** (fail closed).
`config_local.py` 와 `API_KEY` 는 **절대 커밋하지 마세요**(`.gitignore` 처리됨). **Never commit** `config_local.py` or `API_KEY` (they are handled by `.gitignore`).
### 4.2 온라인 설치 (인터넷 가능 환경) ### 4.2 Online install (environment with internet)
```bash ```bash
chmod +x install.sh chmod +x install.sh
./install.sh ./install.sh
``` ```
### 4.3 폐쇄망(air-gapped) 오프라인 설치 ### 4.3 Air-gapped offline install
인터넷이 되는 **동일 아키텍처(aarch64, 가급적 동일 Pi 4 + Bookworm 64-bit)** 머신에서 On a machine with internet of the **same architecture (aarch64, ideally the same Pi 4 +
의존성을 먼저 받아 번들을 만든 뒤, 타깃 Pi 4 로 옮겨 설치합니다. Bookworm 64-bit)**, first fetch the dependencies to build a bundle, then move it to the
target Pi 4 to install.
```bash ```bash
# (빌드 머신, 인터넷 O) wheelhouse/ + debs/ 생성 # (build machine, internet YES) create wheelhouse/ + debs/
./vendor.sh ./vendor.sh
# wheelhouse/ 와 debs/ 를 타깃 Pi 4 의 이 폴더로 복사한 뒤: # After copying wheelhouse/ and debs/ into this folder on the target Pi 4:
# (타깃 Pi 4, 인터넷 X) # (target Pi 4, internet NO)
./install.sh # debs/ · wheelhouse/ 가 있으면 자동으로 오프라인 설치 ./install.sh # if debs/ · wheelhouse/ exist, installs offline automatically
``` ```
> `install.sh` 는 `debs/` 가 있으면 `dpkg` 오프라인, `wheelhouse/` 가 있으면 > If `debs/` exists, `install.sh` installs offline with `dpkg`; if `wheelhouse/` exists, it
> `pip --no-index` 오프라인으로 설치합니다. 둘 다 없을 때만 인터넷 apt/pip 로 폴백합니다. > installs offline with `pip --no-index`. It falls back to internet apt/pip only when neither
> wheel/deb 는 아키텍처 종속이므로 x86_64 PC 에서 받은 것은 Pi 4 에서 동작하지 않습니다. > 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 ```bash
source venv/bin/activate source venv/bin/activate
python3 leak_sensor.py 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 ```bash
source venv/bin/activate source venv/bin/activate
@ -126,10 +138,10 @@ python3 test_connection.py
--- ---
## 6. 부팅 자동 실행 (systemd) ## 6. Auto-start on boot (systemd)
```bash ```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 cp systemd/leak-sensor.service /etc/systemd/system/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable leak-sensor sudo systemctl enable leak-sensor
@ -138,15 +150,19 @@ sudo systemctl status leak-sensor
journalctl -u leak-sensor -f journalctl -u leak-sensor -f
``` ```
`SupplementaryGroups=video` 가 카메라 접근에 필요합니다. `SupplementaryGroups=video` is required for camera access.
--- ---
## 7. 보안 메모 ## 7. Security notes
- 비밀값(`API_KEY`)·서버 URL·기기 정보는 `config_local.py`(미커밋)에만 둡니다. - Secret values (`API_KEY`), the server URL, and device info live only in `config_local.py`
커밋되는 파일에는 placeholder 만 존재합니다. (not committed). Committed files contain only placeholders.
- 보고 본문은 `API_KEY + 정렬된 JSON` 의 SHA-256 서명으로 위변조를 방지합니다 - The report body is protected against tampering with a **raw-body HMAC signature**:
(`leak_sensor.make_signature` ↔ 서버 검증 로직과 동일 포맷). `X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))`
- ⚠️ 이 서명은 **레거시 누수 서버와의 호환을 위해 의도적으로** `SHA-256(API_KEY + JSON)` 방식을 유지합니다. HMAC-SHA256 이 길이연장 공격에 면역이라 더 안전하지만, 전환하려면 **서버 검증부를 동시에 변경**해야 합니다. (`leak_sensor.sign_raw` ↔ server `config.php::verify_signature_raw`, same scheme as module 2).
- `config_local.py` 노출 시 즉시 `API_KEY` 를 교체(서버 동시 변경)하세요. - 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).

621
raspberry_pi4/WIRING.html Normal file
View file

@ -0,0 +1,621 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Leak Detection Node · Wiring &amp; Pinmap (Raspberry Pi 4)</title>
<style>
:root{
--bg:#eef1f5; --card:#ffffff; --ink:#212529; --muted:#6b7280; --line:#e5e8ec;
--sig:#2f9e44; --sig-d:#2b8a3e; --pwr:#e03131; --pwr-d:#c92a2a;
--gnd:#1971c2; --gnd-d:#1864ab; --exp:#e8590c; --exp-b:#ffa94d;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Malgun Gothic","Apple SD Gothic Neo","Noto Sans KR",sans-serif;
--mono:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;
}
*{box-sizing:border-box}
html,body{margin:0}
body{font-family:var(--sans);background:var(--bg);color:var(--ink);line-height:1.6;
-webkit-font-smoothing:antialiased;padding:24px 16px 60px}
.wrap{max-width:1000px;margin:0 auto}
svg text{font-family:var(--sans)}
/* Header */
.hero{background:linear-gradient(135deg,#0b2545 0%,#134074 55%,#1971c2 100%);
color:#fff;border-radius:16px;padding:26px 28px;box-shadow:0 8px 24px rgba(11,37,69,.18)}
.hero .eyebrow{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:#a5c4e6;font-weight:700;margin:0 0 6px}
.hero h1{margin:0 0 10px;font-size:26px;line-height:1.25;font-weight:800}
.hero p{margin:0 0 16px;font-size:15px;color:#dbe7f5;max-width:70ch}
.chips{display:flex;flex-wrap:wrap;gap:8px}
.chip{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.25);color:#fff;
border-radius:999px;padding:5px 12px;font-size:12.5px;font-weight:600;white-space:nowrap}
.chip.mono{font-family:var(--mono);letter-spacing:.02em}
/* Section cards */
section{background:var(--card);border:1px solid var(--line);border-radius:14px;
padding:22px 24px;margin-top:20px;box-shadow:0 2px 8px rgba(16,24,40,.05)}
h2{display:flex;align-items:center;gap:12px;margin:0 0 18px;font-size:19px;font-weight:800}
h2 .num{flex:none;width:30px;height:30px;border-radius:9px;background:#134074;color:#fff;
font-size:15px;display:flex;align-items:center;justify-content:center;font-weight:800}
h3{margin:22px 0 10px;font-size:15px;font-weight:700;color:#134074}
/* Pinmap block */
.pinwrap{display:grid;grid-template-columns:minmax(260px,1.15fr) 1fr;gap:24px;align-items:start}
.diagram{background:#fbfcfd;border:1px solid var(--line);border-radius:12px;padding:14px}
.legend{display:flex;flex-direction:column;gap:11px}
.legend .lg{display:flex;align-items:flex-start;gap:11px;font-size:13.5px}
.sw{flex:none;width:22px;height:22px;border-radius:6px;margin-top:1px}
.sw.sig{background:var(--sig);border:2px solid var(--sig-d)}
.sw.pwr{background:var(--pwr);border:2px solid var(--pwr-d)}
.sw.gnd{background:var(--gnd);border:2px solid var(--gnd-d)}
.sw.alt{background:#e7f5ff;border:2px dashed #4dabf7}
.sw.exp{background:#fff4e6;border:2px dashed var(--exp-b)}
.sw.off{background:#e9ecef;border:2px solid #ced4da}
.lg b{display:block;font-weight:700}
.lg span{color:var(--muted);font-size:12.5px}
.hint{margin-top:6px;font-size:12.5px;color:var(--muted);background:#f8f9fb;border:1px dashed #d5dbe3;
border-radius:8px;padding:9px 11px}
/* Table */
table{width:100%;border-collapse:collapse;font-size:14px}
caption{caption-side:bottom;text-align:left;color:var(--muted);font-size:12.5px;padding-top:10px}
th,td{padding:11px 12px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}
thead th{background:#f3f6fa;color:#134074;font-weight:700;font-size:13px;border-bottom:2px solid #d6e0ec}
td.mono,th.mono{font-family:var(--mono);white-space:nowrap}
.tag{display:inline-block;font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;line-height:1.7}
.tag.sig{background:#e7f6ec;color:var(--sig-d)}
.tag.pwr{background:#fdecec;color:var(--pwr-d)}
.tag.gnd{background:#e7f1fb;color:var(--gnd-d)}
.tag.exp{background:#fff1e3;color:var(--exp)}
tr.exprow td{background:#fffaf3}
/* Electrical notes */
.notes{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.note{border:1px solid var(--line);border-left:4px solid #1971c2;border-radius:10px;padding:13px 15px;background:#fbfcfd}
.note h4{margin:0 0 4px;font-size:14px}
.note p{margin:0;font-size:13.5px;color:#374151}
.note code,.inline-code{font-family:var(--mono);background:#eef2f7;padding:1px 6px;border-radius:5px;font-size:12.5px}
.crit{grid-column:1 / -1;border:2px solid var(--pwr);border-left-width:6px;background:#fff5f5;border-radius:10px;padding:15px 18px}
.crit .badge{display:inline-block;background:var(--pwr);color:#fff;font-weight:800;font-size:12px;
padding:3px 11px;border-radius:6px;letter-spacing:.04em;margin-bottom:8px}
.crit h4{margin:0 0 6px;font-size:16px;color:var(--pwr-d)}
.crit p{margin:0;font-size:14px;color:#5c1a1a}
.cam{margin-top:16px;border:1px solid var(--line);border-left:4px solid #7048e8;border-radius:10px;
padding:13px 16px;background:#fbfaff}
.cam h4{margin:0 0 6px;font-size:14px;color:#5f3dc4}
.cam p{margin:0;font-size:13.5px;color:#374151}
/* Checklist */
ol.check{counter-reset:c;list-style:none;margin:0;padding:0}
ol.check li{position:relative;padding:12px 14px 12px 80px;border-bottom:1px solid #f1f3f5;
line-height:1.5;font-size:14px}
ol.check li:last-child{border-bottom:none}
ol.check li::before{counter-increment:c;content:counter(c);position:absolute;left:12px;top:11px;
width:26px;height:26px;border-radius:50%;background:#e7f1fb;color:#1971c2;font-weight:800;
font-size:13px;display:flex;align-items:center;justify-content:center}
ol.check li::after{content:"";position:absolute;left:50px;top:13px;width:18px;height:18px;
border:2px solid #adb5bd;border-radius:4px}
ol.check li.key{background:#fff8f8}
ol.check li b{color:var(--pwr-d)}
@media (max-width:720px){
.pinwrap{grid-template-columns:1fr}
.notes{grid-template-columns:1fr}
.hero h1{font-size:22px}
}
@media print{
:root{--bg:#fff}
body{background:#fff;color:#000;padding:0;font-size:12px}
.wrap{max-width:100%}
.hero{background:#fff!important;color:#000;border:2px solid #134074;box-shadow:none}
.hero .eyebrow{color:#134074}.hero p{color:#333}
.chip{background:#fff;border:1px solid #999;color:#000}
section{box-shadow:none;border:1px solid #999;page-break-inside:avoid;break-inside:avoid}
h2 .num{background:#134074!important;-webkit-print-color-adjust:exact;print-color-adjust:exact}
svg{max-width:100%!important;height:auto}
table,tr,td,th,.note,.crit,.cam,ol.check li{page-break-inside:avoid;break-inside:avoid}
*{-webkit-print-color-adjust:exact;print-color-adjust:exact}
.diagram,.note,.crit,.cam{background:#fff}
}
</style>
</head>
<body>
<div class="wrap">
<!-- 1) HEADER -->
<header class="hero">
<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 · 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-pin header pinmap diagram -->
<section>
<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-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"/>
<rect x="70" y="255" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="317" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="379" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="441" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="503" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="565" width="460" height="31" fill="#f6f8fa"/>
<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">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>
<circle cx="330" cy="52" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="55.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">2</text>
<text x="220" y="87" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO2 (SDA1)</text>
<text x="360" y="87" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">5V</text>
<circle cx="252" cy="83" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="86.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">3</text>
<circle cx="330" cy="83" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="86.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">4</text>
<text x="220" y="118" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO3 (SCL1)</text>
<text x="360" y="118" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
<circle cx="252" cy="114" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="117.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">5</text>
<circle cx="330" cy="114" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="330" y="117.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">6</text>
<text x="220" y="149" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO4</text>
<text x="360" y="149" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO14 (TXD)</text>
<circle cx="252" cy="145" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="148.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">7</text>
<circle cx="330" cy="145" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="148.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">8</text>
<text x="220" y="180" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="700" fill="#123a63">GND</text>
<text x="360" y="180" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO15 (RXD)</text>
<circle cx="252" cy="176" r="12" fill="#1971c2" stroke="#1864ab" stroke-width="1.5"/><text x="252" y="179.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#ffffff">9</text>
<circle cx="330" cy="176" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="179.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">10</text>
<text x="220" y="211" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="700" fill="#1b4332">GPIO17</text>
<text x="360" y="211" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO18</text>
<circle cx="252" cy="207" r="12" fill="#2f9e44" stroke="#2b8a3e" stroke-width="1.5"/><text x="252" y="210.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#ffffff">11</text>
<circle cx="330" cy="207" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="210.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">12</text>
<text x="220" y="242" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#e8590c">GPIO27</text>
<text x="360" y="242" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
<circle cx="252" cy="238" r="12" fill="#fff4e6" stroke="#ffa94d" stroke-width="1.5" stroke-dasharray="3 2"/><text x="252" y="241.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#e8590c">13</text>
<circle cx="330" cy="238" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="330" y="241.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">14</text>
<text x="220" y="273" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#e8590c">GPIO22</text>
<text x="360" y="273" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO23</text>
<circle cx="252" cy="269" r="12" fill="#fff4e6" stroke="#ffa94d" stroke-width="1.5" stroke-dasharray="3 2"/><text x="252" y="272.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#e8590c">15</text>
<circle cx="330" cy="269" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="272.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">16</text>
<text x="220" y="304" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">3V3</text>
<text x="360" y="304" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO24</text>
<circle cx="252" cy="300" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="303.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">17</text>
<circle cx="330" cy="300" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="303.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">18</text>
<text x="220" y="335" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO10 (MOSI)</text>
<text x="360" y="335" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
<circle cx="252" cy="331" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="334.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">19</text>
<circle cx="330" cy="331" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="330" y="334.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">20</text>
<text x="220" y="366" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO9 (MISO)</text>
<text x="360" y="366" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO25</text>
<circle cx="252" cy="362" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="365.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">21</text>
<circle cx="330" cy="362" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="365.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">22</text>
<text x="220" y="397" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO11 (SCLK)</text>
<text x="360" y="397" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO8 (CE0)</text>
<circle cx="252" cy="393" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="396.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">23</text>
<circle cx="330" cy="393" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="396.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">24</text>
<text x="220" y="428" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
<text x="360" y="428" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO7 (CE1)</text>
<circle cx="252" cy="424" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="252" y="427.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">25</text>
<circle cx="330" cy="424" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="427.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">26</text>
<text x="220" y="459" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO0 (ID_SD)</text>
<text x="360" y="459" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO1 (ID_SC)</text>
<circle cx="252" cy="455" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="458.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">27</text>
<circle cx="330" cy="455" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="458.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">28</text>
<text x="220" y="490" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO5</text>
<text x="360" y="490" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
<circle cx="252" cy="486" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="489.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">29</text>
<circle cx="330" cy="486" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="330" y="489.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">30</text>
<text x="220" y="521" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO6</text>
<text x="360" y="521" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO12</text>
<circle cx="252" cy="517" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="520.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">31</text>
<circle cx="330" cy="517" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="520.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">32</text>
<text x="220" y="552" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO13</text>
<text x="360" y="552" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
<circle cx="252" cy="548" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="551.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">33</text>
<circle cx="330" cy="548" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="330" y="551.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">34</text>
<text x="220" y="583" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO19</text>
<text x="360" y="583" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO16</text>
<circle cx="252" cy="579" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="582.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">35</text>
<circle cx="330" cy="579" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="582.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">36</text>
<text x="220" y="614" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO26</text>
<text x="360" y="614" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO20</text>
<circle cx="252" cy="610" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="613.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">37</text>
<circle cx="330" cy="610" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="613.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">38</text>
<text x="220" y="645" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
<text x="360" y="645" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO21</text>
<circle cx="252" cy="641" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="252" y="644.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">39</text>
<circle cx="330" cy="641" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="644.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">40</text>
</svg>
</div>
<div class="legend">
<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 9) · 풀다운용<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 · 9 · 11</b>.</div>
</div>
</div>
</section>
<!-- 3) Pinmap table -->
<section>
<h2><span class="num">3</span>Pinmap Table</h2>
<table>
<thead>
<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">Signal</span> Sensor signal line</td>
<td class="mono">GPIO17</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">Power</span> Sensor power 3.3V</td>
<td class="mono">3V3</td>
<td class="mono">Pin 1</td>
<td>Sensor VCC supply. (Spare 3V3 = Pin 17)</td>
</tr>
<tr>
<td><span class="tag gnd">Ground</span> 풀다운 GND <span style="color:#868e96">(센서 선 아님)</span></td>
<td class="mono">GND</td>
<td class="mono">Pin 9</td>
<td>10MΩ 풀다운(GPIO17↔GND) 리턴용. Alternatives: any GND among Pin 6 / 14 / 20 / 25 / 30 / 34 / 39.</td>
</tr>
<tr class="exprow">
<td><span class="tag exp">Expansion</span> Additional sensor example</td>
<td class="mono">GPIO27</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">Expansion</span> Additional sensor example</td>
<td class="mono">GPIO22</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>Three pins used (Pin 1 · 9 · 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) Wiring schematic -->
<section>
<h2><span class="num">4</span>Wiring Schematic</h2>
<div class="diagram">
<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-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">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">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">Pin 9 · GND</text>
<rect x="182" y="264" width="10" height="12" rx="2" fill="#1971c2"/>
<!-- sensor block -->
<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">전극 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">전극 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 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 9 (풀다운용)</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="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="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"><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) Electrical design · cautions -->
<section>
<h2><span class="num">5</span>Electrical Design · Cautions</h2>
<div class="crit">
<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>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>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>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>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>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 (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) Installation checklist -->
<section>
<h2><span class="num">6</span>Installation Checklist</h2>
<ol class="check">
<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,73 +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 에서 로드한다. # Operational/secret values such as server URL / API_KEY / device identifier are loaded from config_local.py.
# config_local.py 는 .gitignore 로 커밋 제외 대상이다. # config_local.py is excluded from commits via .gitignore.
# 설정 방법: cp config_local.example.py config_local.py → 실제 값으로 채움 # Setup: cp config_local.example.py config_local.py → fill in with real values
# ============================================================================= # =============================================================================
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 센서 GPIO 설정 (BCM 번호 기준 — Pi 4 40핀 헤더와 동일) # Sensor GPIO settings (BCM numbering — same as Pi 4 40-pin header)
# GPIO17 = 40핀 헤더 물리 11번 핀 # GPIO17 = physical pin 11 on the 40-pin header
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
SENSORS = [ SENSORS = [
{"id": 1, "gpio": 17, "name": "1번 센서 (서버실)"}, {"id": 1, "gpio": 17, "name": "Sensor 1 (Server Room)"},
# 추가 센서가 있으면 아래에 추가 (BCM 번호 기준) # Add more sensors below if present (BCM numbering)
# {"id": 2, "gpio": 27, "name": "2번 센서 (기계실)"}, # 물리 13번 # {"id": 2, "gpio": 27, "name": "Sensor 2 (Machine Room)"}, # physical pin 13
# {"id": 3, "gpio": 22, "name": "3번 센서 (화장실)"}, # 물리 15번 # {"id": 3, "gpio": 22, "name": "Sensor 3 (Restroom)"}, # physical pin 15
] ]
# 센서 활성 레벨 설정 # Sensor active-level setting
# e-con 5C-08 저항형 센서 + 4.7MΩ 풀다운 사용 시: # When using the e-con 5C-08 resistive sensor + 10MΩ pull-down:
# 건조: GPIO = LOW(0) → ACTIVE_LOW = False # Dry: GPIO = LOW(0) → ACTIVE_LOW = False
# 누수: GPIO = HIGH(1) # Leak: GPIO = HIGH(1)
ACTIVE_LOW = False ACTIVE_LOW = False
# GPIO 내부 풀저항 설정 # GPIO internal pull-resistor setting
# 반드시 False (PUD_OFF) 유지! # Must stay False (PUD_OFF)!
# 내부 풀다운(50kΩ)을 켜면 누수 시에도 HIGH가 되지 않아 감지 실패함. # Enabling the internal pull-down (50kΩ) keeps the line from going HIGH even on a leak, causing detection failure.
# → 외부 4.7MΩ 풀다운 저항만 사용 # → Use only the external 10MΩ pull-down resistor
USE_INTERNAL_PULL = False # True = PUD_DOWN, False = PUD_OFF USE_INTERNAL_PULL = False # True = PUD_DOWN, False = PUD_OFF
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 주기 설정 # Interval settings
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
REPORT_INTERVAL = 3600 # 정상 상태 주기 보고 간격 (초) = 1시간 REPORT_INTERVAL = 600 # Normal-state periodic report interval (sec) = 10 min
LEAK_REPORT_INTERVAL = 10 # 누수 감지 시 재보고 간격 (초) # Must be shorter than the server's HEARTBEAT_TIMEOUT_SEC (default 1200 sec)
DEBOUNCE_MS = 500 # 바운싱 방지 시간 (ms) — 고임피던스라 넉넉히 설정 # 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_FILE = "/var/log/leak_sensor.log"
LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB
LOG_BACKUP_COUNT = 3 LOG_BACKUP_COUNT = 3
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 재시도 설정 # Retry settings
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
HTTP_TIMEOUT = 10 # HTTP 요청 타임아웃 (초) HTTP_TIMEOUT = 10 # HTTP request timeout (sec)
HTTP_RETRY_COUNT = 3 # 재시도 횟수 HTTP_RETRY_COUNT = 3 # Retry count
HTTP_RETRY_DELAY = 5 # 재시도 대기 (초) HTTP_RETRY_DELAY = 5 # Retry delay (sec)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 카메라 설정 # Camera settings
# Pi 4 = 15핀(1.0mm 피치) 표준 CSI, Zero 2 W = 22핀(0.5mm 피치) CSI → # Pi 4 = 15-pin (1.0mm pitch) standard CSI, Zero 2 W = 22-pin (0.5mm pitch) CSI →
# 리본/카메라 보드 호환 안 됨 (Pi 4용 15핀 리본 또는 15↔22 변환 어댑터 필요). # ribbon/camera board not compatible (needs a Pi 4 15-pin ribbon or a 15↔22 adapter).
# 촬영은 rpicam-still(Bookworm) / libcamera-still(Bullseye) 사용. # Capture uses rpicam-still (Bookworm) / libcamera-still (Bullseye).
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
CAMERA_ENABLED = True # False로 설정하면 촬영/업로드 건너뜀 CAMERA_ENABLED = True # Set to False to skip capture/upload
PHOTO_WIDTH = 640 # 촬영 해상도 (가로) PHOTO_WIDTH = 640 # Capture resolution (width)
PHOTO_HEIGHT = 480 # 촬영 해상도 (세로) PHOTO_HEIGHT = 480 # Capture resolution (height)
PHOTO_QUALITY = 60 # JPEG 품질 (1-100, 60 ≈ 50-80KB) PHOTO_QUALITY = 60 # JPEG quality (1-100, 60 ≈ 50-80KB)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 운영·비밀값 로드 (fail closed) # Load operational/secret values (fail closed)
# config_local.py 가 없으면 placeholder/하드코딩 비밀로 잘못 기동하는 대신 # If config_local.py is missing, stop immediately with a clear error
# 명확한 오류로 즉시 중단한다. # instead of wrongly starting with placeholder/hardcoded secrets.
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
try: 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_URL,
API_KEY, API_KEY,
PHOTO_UPLOAD_URL, PHOTO_UPLOAD_URL,
@ -76,7 +86,36 @@ try:
) )
except ImportError as e: except ImportError as e:
raise RuntimeError( raise RuntimeError(
"config_local.py 를 찾을 수 없습니다. " "config_local.py not found. "
"`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / " "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 ) from e
# ─────────────────────────────────────────────
# 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 — per-device sensor list override
except ImportError:
pass # use the default above if config_local has no SENSORS
# 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 must be a non-empty list (check config_local.py).")
_ids, _gpios = set(), set()
for _s in SENSORS:
if not (isinstance(_s, dict)
and isinstance(_s.get("id"), int) and 1 <= _s["id"] <= 255
and isinstance(_s.get("gpio"), int)
and isinstance(_s.get("name"), str) and _s["name"]):
raise RuntimeError(
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 duplicate id/gpio: {_s!r}")
_ids.add(_s["id"])
_gpios.add(_s["gpio"])
del _ids, _gpios

View file

@ -1,35 +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 # Usage: cp config_local.example.py config_local.py
# → 실제 값으로 채운 뒤 저장. config_local.py 는 절대 커밋하지 말 것 # → Fill in with real values, then save. Never commit config_local.py
# (.gitignore 로 제외되어 있음). # (excluded via .gitignore).
# #
# ⚠️ 서버 호환성 # ⚠️ Server compatibility
# 이 모듈(1번)은 2번 모듈(SHT30 온습도)과 "동일한" cafe24 서버 php/ 로 보고한다(통합). # This module (Module 1) reports to the "same" cafe24 server php/ as Module 2 (SHT30 temp/humidity) — integrated.
# 누수 이벤트(leak_alert / leak_recovery)와 사진 업로드는 서버가 raw-body 서명을 요구한다: # 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=요청본문 바이트)) # X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))
# 사진 업로드 canonical = device_id|sensor_id|log_id|timestamp (공백 없음) # photo upload canonical = device_id|sensor_id|log_id|timestamp (no spaces)
# → API_KEY 는 서버 config.local.php 의 API_KEY 와 "정확히 동일"해야 한다. # → API_KEY must be "exactly identical" to the API_KEY in the server's config.local.php.
# 서버에서 sql/migration_add_leak.sql 을 1회 실행하면(가산·멱등) 누수 수신이 활성화되고, # The server DB is installed from the single sql/schema_sht30.sql (includes leak components),
# 누수 상태·사진·온습도가 한 대시보드(dashboard.php)에 함께 표시된다. # and leak status, photos, and temp/humidity are shown together on one dashboard (dashboard.php).
# (구버전의 sha256(API_KEY + 정렬 JSON) body-field 서명은 폐기됨 — 서버가 거부한다.) # (The legacy sha256(API_KEY + sorted JSON) body-field signature is deprecated — the server rejects it.)
# ============================================================================= # =============================================================================
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 서버 API 엔드포인트 (통합 서버 = cafe24 php/, 2번 모듈과 동일 서버) # Server API endpoints (integrated server = cafe24 php/, same server as Module 2)
# INSTALL_PI_SERVER.md 기준 웹 루트: public_html/raspi_leck_detecter/ # Web root per INSTALL_PI_SERVER.md: public_html/raspi_leck_detecter/
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php" 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" 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" API_KEY = "CHANGE_ME"
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 기기 식별 (여러 대 운영 시 구분) # Device identification (distinguishes multiple units)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
DEVICE_ID = "rpi4-leak-01" DEVICE_ID = "rpi4-leak-01"
DEVICE_LOCATION = "서버실" DEVICE_LOCATION = "Server Room"
# ─────────────────────────────────────────────
# (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": "Sensor 3 (New Site)"},
# ]
# (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 #!/usr/bin/env bash
# ============================================================================= # =============================================================================
# 누수감지 센서 v2 (Raspberry Pi 4 Model B) 설치 스크립트 # Leak Detection Sensor v2 (Raspberry Pi 4 Model B) install script
# 대상 OS: Raspberry Pi OS Bookworm (64-bit, aarch64) # Target OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
# #
# 폐쇄망(air-gapped) 지원: # Air-gapped support:
# - debs/ 또는 wheelhouse/ 가 있으면 자동으로 "오프라인 모드"로 전환. # - 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:
# 필요하면 반드시 FLAG). 사전 다운로드는 vendor.sh 로 빌드 머신에서 수행. # if the network is needed, always FLAG it). Pre-download is done on the build
# - 환경변수 OFFLINE=1/0 으로 강제 지정 가능. # machine via vendor.sh.
# - 로그 파일 소유자: SERVICE_USER (기본 pi). # - Can be forced with the OFFLINE=1/0 environment variable.
# - Log file owner: SERVICE_USER (default pi).
# ============================================================================= # =============================================================================
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
@ -17,91 +18,101 @@ cd "$(dirname "$0")"
SERVICE_USER="${SERVICE_USER:-pi}" SERVICE_USER="${SERVICE_USER:-pi}"
echo "==========================================" echo "=========================================="
echo " 누수감지 센서 v2 (Pi 4) 설치" echo " Leak Detection Sensor v2 (Pi 4) install"
echo "==========================================" echo "=========================================="
# 0) 보드 모델 안내 (강제 아님) # 0) Board model notice (not enforced)
if [ -r /proc/device-tree/model ]; then if [ -r /proc/device-tree/model ]; then
MODEL="$(tr -d '\0' < /proc/device-tree/model)" MODEL="$(tr -d '\0' < /proc/device-tree/model)"
echo "[INFO] 보드: ${MODEL}" echo "[INFO] Board: ${MODEL}"
case "${MODEL}" in case "${MODEL}" in
*"Raspberry Pi 4"*) : ;; *"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 esac
fi fi
# 오프라인 모드 판정 # Determine offline mode
OFFLINE="${OFFLINE:-}" OFFLINE="${OFFLINE:-}"
if [ -z "${OFFLINE}" ]; then if [ -z "${OFFLINE}" ]; then
if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then
OFFLINE=1 OFFLINE=1
echo "[INFO] 오프라인 번들 감지 → 폐쇄망 오프라인 설치 모드 (네트워크 미사용)" echo "[INFO] Offline bundle detected -> air-gapped offline install mode (no network)"
else else
OFFLINE=0 OFFLINE=0
echo "[INFO] 오프라인 번들 없음 → 온라인 설치 모드 (인터넷 필요)" echo "[INFO] No offline bundle -> online install mode (internet required)"
fi fi
fi fi
# 1) 시스템 패키지: RPi.GPIO 대체(lgpio) + requests + venv + 카메라 앱 # 1) System packages: RPi.GPIO replacement (lgpio) + requests + venv + camera app
echo "[1/3] 시스템 패키지 설치..." echo "[1/3] Installing system packages..."
if [ "${OFFLINE}" = "1" ]; then if [ "${OFFLINE}" = "1" ]; then
if ! compgen -G "debs/*.deb" >/dev/null; 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 exit 1
fi fi
echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)" echo " Installing offline .deb (apt-get --no-download - automatic dependency ordering/resolution, no network)"
sudo apt-get install -y --no-download ./debs/*.deb # --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 else
echo " 온라인 apt 설치 (인터넷 필요)" echo " Online apt install (internet required)"
sudo apt-get update sudo apt-get update
if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; fi 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}" sudo apt-get install -y python3-rpi-lgpio python3-requests python3-venv "${CAM}"
fi fi
# 2) Python venv + requests # 2) Python venv + requests
echo "[2/3] Python 가상환경 설정..." echo "[2/3] Setting up Python virtual environment..."
if [ ! -d venv ]; then if [ ! -d venv ]; then
python3 -m venv --system-site-packages venv python3 -m venv --system-site-packages venv
echo " venv 생성 완료" echo " venv created"
else else
echo " venv 이미 존재 — 건너뜀" echo " venv already exists - skipping"
fi fi
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source venv/bin/activate source venv/bin/activate
if [ "${OFFLINE}" = "1" ]; then if [ "${OFFLINE}" = "1" ]; then
if [ ! -d wheelhouse ]; 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 exit 1
fi fi
echo " 오프라인 wheel 설치 (--no-index — 네트워크 미사용)" echo " Installing offline wheels (--no-index - no network)"
pip install --no-index --find-links=wheelhouse -r requirements.txt pip install --no-index --find-links=wheelhouse -r requirements.txt
else else
echo " 온라인 pip 설치 (인터넷 필요)" echo " Online pip install (internet required)"
pip install --upgrade pip pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
fi fi
echo " 패키지 설치 완료" echo " Package installation complete"
# 3) 로그 파일 (최소 권한: 소유자만 쓰기 — leak_sensor 가 단독 기록) # 3) Log file (least privilege: owner write only - leak_sensor is the sole writer)
echo "[3/3] 로그 파일 생성..." echo "[3/3] Creating log file..."
sudo mkdir -p /var/log sudo mkdir -p /var/log
sudo touch /var/log/leak_sensor.log sudo touch /var/log/leak_sensor.log
if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/leak_sensor.log; then 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 fi
sudo chmod 640 /var/log/leak_sensor.log sudo chmod 640 /var/log/leak_sensor.log
# 4) config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨) # 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 permissions set to 600 (owner ${SERVICE_USER})"
fi
# 4) config_local notice (fail closed - if missing, execution aborts immediately)
if [ ! -f config_local.py ]; then if [ ! -f config_local.py ]; then
echo "" 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" echo " cp config_local.example.py config_local.py"
fi fi
echo "" echo ""
echo "==========================================" echo "=========================================="
echo " 설치 완료! (OFFLINE=${OFFLINE})" echo " Installation complete! (OFFLINE=${OFFLINE})"
echo "==========================================" echo "=========================================="
echo " 실행: source venv/bin/activate && python3 leak_sensor.py" echo " Run: source venv/bin/activate && python3 leak_sensor.py"
echo " 서비스: README.md 의 systemd 섹션 참고" echo " Service: see the systemd section in README.md"
echo "" echo ""

View file

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

View file

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

View file

@ -1,16 +1,16 @@
# ============================================================================= # =============================================================================
# /etc/systemd/system/leak-sensor.service # /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 cp systemd/leak-sensor.service /etc/systemd/system/
# sudo systemctl daemon-reload # sudo systemctl daemon-reload
# sudo systemctl enable leak-sensor # sudo systemctl enable leak-sensor
# sudo systemctl start leak-sensor # sudo systemctl start leak-sensor
# sudo systemctl status leak-sensor # sudo systemctl status leak-sensor
# #
# ※ WorkingDirectory/ExecStart 경로는 실제 배치 경로에 맞게 수정. # NOTE: Adjust the WorkingDirectory/ExecStart paths to match the actual deployment path.
# 아래는 /home/pi/raspberry_pi4 에 배치한 경우 기준. # The values below assume deployment under /home/pi/raspberry_pi4.
# ============================================================================= # =============================================================================
[Unit] [Unit]
@ -22,7 +22,8 @@ Wants=network-online.target
Type=simple Type=simple
User=pi User=pi
Group=pi Group=pi
SupplementaryGroups=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 WorkingDirectory=/home/pi/raspberry_pi4
ExecStart=/home/pi/raspberry_pi4/venv/bin/python leak_sensor.py ExecStart=/home/pi/raspberry_pi4/venv/bin/python leak_sensor.py
Restart=always Restart=always
@ -31,13 +32,41 @@ StandardOutput=journal
StandardError=journal StandardError=journal
SyslogIdentifier=leak-sensor SyslogIdentifier=leak-sensor
# 환경 변수 # Environment variables
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
# 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 PrivateTmp=true
# 재시작 상한 (10분 내 5번 실패 시 중단) # -- 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
# Root filesystem read-only + only /var/log writable (including log rotation). /tmp is PrivateTmp.
ProtectSystem=strict
ReadWritePaths=/var/log
# /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
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictRealtime=true
# 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
# NOTE: PrivateDevices is not used -- it would hide /dev/gpiochip* and the camera node, breaking operation.
# Restart cap (stop after 5 failures within 10 minutes)
StartLimitBurst=5 StartLimitBurst=5
StartLimitInterval=600s StartLimitInterval=600s

View file

@ -1,24 +1,32 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""서버 연결 테스트 스크립트 — Pi 4 에서 실행. """Server connection test script — run on the Pi 4.
비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다 (config 경유).""" Secrets (API_URL/API_KEY) are loaded from config_local.py (via config)."""
import hashlib import hashlib
import hmac import hmac
import sys
import time import time
import subprocess import subprocess
from config import API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION from config import API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION, SENSORS
# API_URL 에서 베이스 경로 추출 (.../api/sensor_data.php → .../api) signed_ok = False # whether the signed POST returned 200 — setup.sh gates hardening on this exit code
# 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"]
# Extract the base path from API_URL (.../api/sensor_data.php → .../api)
API_BASE = API_URL.rsplit("/", 1)[0] API_BASE = API_URL.rsplit("/", 1)[0]
print("=" * 50) print("=" * 50)
print(" 서버 연결 테스트 (Pi 4)") print(" Server connection test (Pi 4)")
print("=" * 50) print("=" * 50)
print(f" API_BASE = {API_BASE}") print(f" API_BASE = {API_BASE}")
# ── 1단계: curl로 test.php 호출 ── # ── Step 1: call test.php with curl ──
print("\n[1/3] curl로 test.php 연결 확인...") print("\n[1/3] Checking test.php connectivity with curl...")
try: try:
result = subprocess.run( result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
@ -27,50 +35,51 @@ try:
capture_output=True, text=True, timeout=10 capture_output=True, text=True, timeout=10
) )
code = result.stdout.strip() code = result.stdout.strip()
print(f" HTTP 응답코드: {code}") print(f" HTTP response code: {code}")
if code == "200": if code == "200":
print(" → 성공! 서버 연결 정상") print(" → Success! Server connection OK")
elif code == "403": elif code == "403":
print(" → 403 Forbidden: 서버/호스팅 자체가 차단 중") print(" → 403 Forbidden: the server/host itself is blocking")
print(" → 호스팅 설정 또는 상위 .htaccess 확인 필요") print(" → Check the hosting settings or the upstream .htaccess")
else: else:
print(f" → 예상 외 응답: {code}") print(f" → Unexpected response: {code}")
except Exception as e: except Exception as e:
print(f" → curl 실패: {e}") print(f" → curl failed: {e}")
# ── 2단계: curl로 sensor_data.php 호출 ── # ── Step 2: call sensor_data.php with curl ──
print("\n[2/3] curl로 sensor_data.php 연결 확인...") print("\n[2/3] Checking sensor_data.php connectivity with curl...")
try: try:
result = subprocess.run( result = subprocess.run(
["curl", "-s", "-w", "\n%{http_code}", ["curl", "-s", "-w", "\n%{http_code}",
"-A", "LeakSensor/2.0", "-A", "LeakSensor/2.0",
"-X", "POST", API_URL, "-X", "POST", API_URL,
"-H", "Content-Type: application/json", "-H", "Content-Type: application/json",
"-d", '{"device_id":"test","sensor_id":1,"event_type":"startup","is_leak":0,"timestamp":1234567890}'], "-d", f'{{"device_id":"test","sensor_id":{TEST_SENSOR_ID},"event_type":"startup","is_leak":0,"timestamp":1234567890}}'],
capture_output=True, text=True, timeout=10 capture_output=True, text=True, timeout=10
) )
lines = result.stdout.strip().split("\n") lines = result.stdout.strip().split("\n")
code = lines[-1] if lines else "?" code = lines[-1] if lines else "?"
body = "\n".join(lines[:-1]) body = "\n".join(lines[:-1])
print(f" HTTP 응답코드: {code}") print(f" HTTP response code: {code}")
print(f" 응답 내용: {body[:200]}") print(f" Response body: {body[:200]}")
except Exception as e: except Exception as e:
print(f" → curl 실패: {e}") print(f" → curl failed: {e}")
# ── 3단계: Python requests로 서명 포함 호출 ── # ── Step 3: signed call via Python requests ──
print("\n[3/3] Python requests로 sensor_data.php 호출...") print("\n[3/3] Calling sensor_data.php with Python requests...")
try: try:
import requests import requests
# raw-body X-Signature HMAC (서버 verify_signature_raw 와 동일 — leak_sensor.leak_event_body # raw-body X-Signature HMAC (same as the server's verify_signature_raw — build the body
# 와 같은 고정 필드 순서로 본문 문자열을 만들고 그 바이트에 서명한 뒤 그대로 POST 한다). # string with the same fixed field order as leak_sensor.leak_event_body, sign those bytes,
# then POST them verbatim).
ts = int(time.time()) ts = int(time.time())
body = ( body = (
'{' '{'
f'"device_id":"{DEVICE_ID}",' f'"device_id":"{DEVICE_ID}",'
f'"device_location":"{DEVICE_LOCATION}",' f'"device_location":"{DEVICE_LOCATION}",'
f'"sensor_id":1,' f'"sensor_id":{TEST_SENSOR_ID},'
f'"sensor_name":"테스트 센서",' f'"sensor_name":"{TEST_SENSOR_NAME} (connection test)",'
f'"event_type":"startup",' f'"event_type":"startup",'
f'"is_leak":0,' f'"is_leak":0,'
f'"timestamp":{ts}' f'"timestamp":{ts}'
@ -91,22 +100,24 @@ try:
} }
) )
print(f" HTTP 응답코드: {resp.status_code}") print(f" HTTP response code: {resp.status_code}")
print(f" 응답 내용: {resp.text[:200]}") print(f" Response body: {resp.text[:200]}")
if resp.status_code == 200: if resp.status_code == 200:
print(" → 성공! 모든 연결 정상") signed_ok = True
print(" → Success! All connections OK")
elif resp.status_code == 403: 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', '?')}") print(f" → User-Agent: {resp.request.headers.get('User-Agent', '?')}")
else: else:
print(f" → 응답코드: {resp.status_code}") print(f" → Response code: {resp.status_code}")
except ImportError: 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: except Exception as e:
print(f" → 실패: {e}") print(f" → Failed: {e}")
print("\n" + "=" * 50) print("\n" + "=" * 50)
print(" 테스트 완료") print(" Test complete" + ("" if signed_ok else " — signed POST failed (check the log above)"))
print("=" * 50) print("=" * 50)
sys.exit(0 if signed_ok else 1)

View file

@ -1,14 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================= # =============================================================================
# vendor.sh - 폐쇄망 설치용 의존성 사전 다운로드 # vendor.sh - Pre-download dependencies for air-gapped installation
# 인터넷에 연결된 빌드/스테이징 머신에서 실행한다. # Run on a build/staging machine that has internet access.
# 산출물: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → 타깃 Pi 4 로 전송. # Output: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → transfer to target Pi 4.
# #
# wheel/deb 는 아키텍처에 종속된다. 기본값은 타깃(Raspberry Pi OS Bookworm 64-bit, # wheel/deb are architecture-dependent. The defaults are explicitly pinned to the target
# aarch64, Python 3.11)에 맞춰 명시 핀하므로 x86_64 빌드 머신에서 받아도 올바른 # (Raspberry Pi OS Bookworm 64-bit, aarch64, Python 3.11), so even downloading on an x86_64
# aarch64 wheel 을 확보한다(빌드호스트 arch 와 무관). 그래도 .deb 는 타깃 OS/arch # 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 (기본 manylinux2014_aarch64), TARGET_PYVER (기본 311), CAM # TARGET_PLATFORM (default manylinux2014_aarch64), TARGET_PYVER (default 311), CAM
# ============================================================================= # =============================================================================
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
@ -25,12 +25,12 @@ python3 -m pip download -r requirements.txt -d wheelhouse \
--python-version "${TARGET_PYVER}" \ --python-version "${TARGET_PYVER}" \
--implementation cp \ --implementation cp \
--abi "${TARGET_ABI}" \ --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: \ 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)" WHEEL_COUNT="$(ls -A wheelhouse 2>/dev/null | wc -l)"
echo " wheelhouse 파일 수: ${WHEEL_COUNT}" echo " wheelhouse file count: ${WHEEL_COUNT}"
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse 비어있음"; exit 1; } [ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse is empty"; exit 1; }
echo "[vendor] (2/2) apt .deb → debs/" echo "[vendor] (2/2) apt .deb → debs/"
mkdir -p debs mkdir -p debs
@ -38,21 +38,41 @@ CAM="${CAM:-}"
if [ -z "${CAM}" ]; then if [ -z "${CAM}" ]; then
if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; fi if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; fi
fi fi
BASE_PKGS="python3-rpi-lgpio python3-requests python3-venv ${CAM}" # 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 \ DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \ --no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
| awk '/^\w/{print $1}' | sort -u)" | awk '/^\w/{print $1}' | sort -u)"
( cd debs && apt-get download ${BASE_PKGS} ${DEPS} ) 2>/dev/null \ ( 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 MISS=0
for p in ${BASE_PKGS}; do for p in ${BASE_PKGS}; do
if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then
echo "[ERROR] 필수 패키지 .deb 누락: ${p}" echo "[ERROR] Missing required package .deb: ${p}"
MISS=1 MISS=1
fi fi
done done
[ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; } [ "${MISS}" = 0 ] || { echo "[ERROR] Missing required .deb — you must run vendor.sh on the target arch (aarch64 Bookworm)."; exit 1; }
echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)" echo " debs file count: $(ls debs/*.deb 2>/dev/null | wc -l) (all required packages confirmed)"
echo "[vendor] 완료. wheelhouse/ 와 debs/ 를 타깃 Pi 4 로 전송 후 install.sh 실행." # 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 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 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."

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."

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