feat: RPi4 누수+온습도 2-모듈 단일 서버 통합 + 보안 하드닝
2번 모듈 신규(raspberry_pi4_sht30/): SHT30 온습도 → cafe24, raw-body X-Signature HMAC, 폐쇄망 오프라인 설치(vendor/install), systemd, CRC/포맷 자가점검. firmware sht30_fw wire 포맷과 byte-parity 검증. 1번 모듈 통합(raspberry_pi4/): 누수+카메라를 동일 서버로. 레거시 sha256(key+json) body-sig → X-Signature HMAC 전환, 사진 canonical=device_id|sensor_id|log_id|timestamp. 서버 통합(php/): api/sensor_data.php 누수 이벤트 수신, api/photo_upload.php 신규(HMAC·finfo·서버생성 파일명·nonce 멱등·sensor 결속), photo.php 인증 사진서빙, dashboard.php 누수 패널+사진, sms_send.php 누수 SMS, config.php 태그/요약. DB: sql/migration_add_leak.sql(가산·멱등), schema_sht30.sql 통합 스키마. 보안 하드닝(외부 펜테스트 대비, 확정 CRITICAL/HIGH 0): cron_heartbeat api_key fail-closed, setup_mfa 부트스트랩 토큰 post-enrollment 잠금, var/ PHP 실행차단, HSTS. 문서/테스트: SYSTEM_OVERVIEW.md, security_hardening_test.php 갱신. 참고: 사용자 요청으로 세션 외 기존 미커밋 변경도 함께 포함. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
749b284941
commit
73e49fd528
80 changed files with 6732 additions and 391 deletions
178
CHANGELOG.md
178
CHANGELOG.md
|
|
@ -1,5 +1,183 @@
|
||||||
# 변경 이력
|
# 변경 이력
|
||||||
|
|
||||||
|
## Unreleased - 기능 추가 (고가치 M: 신뢰성·관측성)
|
||||||
|
|
||||||
|
경보가 조용히 죽지 않도록 보장 + 그것을 검증하는 신뢰성·관측성 묶음. 모두 cron/서버/
|
||||||
|
CLI 기반(새 상호작용 UI 없음), graceful degrade. (#5 임계·수신자 화면편집은 core config
|
||||||
|
로드를 DB 의존으로 바꿔 신뢰성 회귀 위험이 있어 별도 설계로 보류.)
|
||||||
|
|
||||||
|
### M#1 수집 거부 사유 카운터 (관측성)
|
||||||
|
- `config.php:record_ingest_rejection()/read_ingest_rejections()`(파일 flock 기반, DB 무관
|
||||||
|
→ 위조요청 폭주에도 안전). `api/sensor_data.php` 의 모든 거부 분기(403 서명/400 시각/
|
||||||
|
413 본문/503 키/필드/sensor_id)를 사유별 일자 집계하고, 대시보드 상단에 사유별
|
||||||
|
건수(서명불일치→키/펌웨어, 시각오차→SNTP/RTC 등 한국어 라벨)로 노출한다. "왜 데이터가
|
||||||
|
안 들어오는가"를 서버 로그 없이 분별.
|
||||||
|
|
||||||
|
### M#2 발송 실패 가시화 + 오프라인 재시도 (신뢰성)
|
||||||
|
- `dashboard.php`: 최근 1시간 내 경보/오프라인/에스컬레이션 SMS 가 실패하면 '경보 SMS
|
||||||
|
발송 실패 — 미수신 가능' 배너.
|
||||||
|
- `cron_heartbeat.php`: 일회성 오프라인 SMS 발송이 실패하면 `offline_alerted` 를 0 으로
|
||||||
|
되돌려 다음 주기에 재시도한다(`SMS_RETRY_OFFLINE`). 게이트웨이 일시오류로 장비 다운을
|
||||||
|
영영 못 알리던 갭을 닫음.
|
||||||
|
|
||||||
|
### M#3 미복귀 경보 에스컬레이션 (신뢰성, opt-in)
|
||||||
|
- `sms_send.php:maybe_escalate_alerts()`: 임계 경보가 `ESCALATE_AFTER_SEC`(기본 30분) 이상
|
||||||
|
미복귀로 지속되면 2차(야간) 수신자 `SMS_RECIPIENTS_TIER2` 에게 `[에스컬레이션]` 1회
|
||||||
|
통지(에피소드당 1회 — 최신 경보-상태가 경보일 때만). TIER2 가 비면 비활성. cron 호출.
|
||||||
|
- 대화형 ack 버튼 UI 는 보류(별도 POST 표면 — 후속).
|
||||||
|
|
||||||
|
### M#4 센서 고착(live-but-stuck) 검출 (자가신뢰성)
|
||||||
|
- `sms_send.php:maybe_detect_stuck_sensors()`: 최근 `STUCK_DETECT_SAMPLES`(6) 측정의 온도·
|
||||||
|
습도 표준편차가 둘 다 `STUCK_DETECT_EPSILON`(0.01, 분해능 이하) 미만이면 소자 고장(같은
|
||||||
|
값 반복)을 의심해 `[센서고착]` 1회(쿨다운). 하트비트(수신 끊김)로는 못 잡는 무성실패를
|
||||||
|
보완 — 단일 센서가 거짓말하면 감시 전체가 무력화되므로 핵심.
|
||||||
|
|
||||||
|
### M#6 카오스 드릴 (검증)
|
||||||
|
- `SMS_FORCE_FAIL` 게이트(`send_sms_cafe24` 가 실제 전송 없이 실패 반환)와
|
||||||
|
`tests/chaos_drill.php`(CLI) — 위 실패-의존 기능이 동작하는지 손시연 없이 검증.
|
||||||
|
라이브 종단 드릴 절차는 파일 헤더에 문서화.
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
- `tests/threshold_sms_test.php`: `stddev_pop` 단위 테스트(고착 판정).
|
||||||
|
- `tests/security_hardening_test.php`: M#1~#6 소스 회귀 고정.
|
||||||
|
- `run_tests.sh`: 드릴 포함.
|
||||||
|
|
||||||
|
### 보류(별도 설계 필요)
|
||||||
|
- 경보 ack 버튼 UI(대화형 POST), 임계·수신자 화면편집(#5, core config DB-override —
|
||||||
|
DB 장애 시 config 로드 실패 → 사이트 다운 위험), 풀 카오스 하네스(prod DB 주입).
|
||||||
|
|
||||||
|
## Unreleased - 기능 추가 (①시계동기 ②경보 생존성/결로/개인정보)
|
||||||
|
|
||||||
|
전수 아이디에이션에서 도출한 우선순위 1~2(①+②)를 구현했다. 외부 의존 0, 대부분
|
||||||
|
기존 파이프라인 재사용.
|
||||||
|
|
||||||
|
### ① 폐쇄망 시계동기 — 인터넷 NTP 의존 제거 (잠재 전면장애 해소)
|
||||||
|
- **`firmware/common/app_config.h`: `APP_SNTP_SERVER` 기본값을 `pool.ntp.org`(인터넷)에서
|
||||||
|
LAN NTP 자리표시자(`192.0.2.123`, RFC5737)로 교체**하고 폐쇄망 필수 설정임을 명시했다.
|
||||||
|
진짜 폐쇄망에서 인터넷 NTP 는 도달 불가 → SNTP 실패 → RTC 미설정 → `timestamp=0` →
|
||||||
|
TLS 차단 + 서버 신선도 위반 전량거부 = **보고 전면장애**였다(제약 위반 상태 해소).
|
||||||
|
- `api/sensor_data.php`: 200 응답에 `server_now`(서버 Unix 시각)를 추가했다. 펌웨어가
|
||||||
|
RTC 드리프트를 서버시각으로 미세보정(LSI 드리프트/간헐 NTP 대비)하는 **후속 펌웨어
|
||||||
|
작업의 입력**이다(서버측 선반영; 보안 영향 없음).
|
||||||
|
- TODO(운영): 배포 전 `APP_SNTP_SERVER` 를 실제 LAN NTP IP 로 설정. 최초 시각 확보는
|
||||||
|
LAN NTP(또는 배터리 RTC)가 필요하며, `server_now` 소비(RTC 보정)는 펌웨어 후속 과제.
|
||||||
|
|
||||||
|
### ②a SMS 잔액 사전경보
|
||||||
|
- `sms_send.php`: Cafe24 응답의 잔여 건수를 파싱(`send_sms_cafe24` 가 `remaining` 반환)하고,
|
||||||
|
임계(`SMS_BALANCE_WARN_THRESHOLD`, 기본 30) 이하이면 쿨다운(`SMS_BALANCE_WARN_COOLDOWN_SEC`)
|
||||||
|
적용 `[SMS 잔액부족]` 경고를 1회 발송한다(`maybe_warn_sms_balance`). 추가 외부호출 0.
|
||||||
|
- `config.php:latest_sms_balance()` + `ops_checks.php` 에 'SMS 잔여 건수' 점검 추가.
|
||||||
|
|
||||||
|
### ②b 일일 헬스(생존신호) SMS — 데드맨
|
||||||
|
- `sms_send.php:maybe_send_daily_health_sms()`: 하루 1통 `[정기점검]` 요약(정상 센서 수/
|
||||||
|
최근 수신/24h 경보/SMS 잔여)을 발송한다. `HEALTH_SMS_HOUR`(기본 9시) 이후 첫 호출에서,
|
||||||
|
날짜 dedup + 네임드 락으로 중복 없이 1회. `cron_heartbeat.php` 가 호출. '무소식=정상'
|
||||||
|
과 '무소식=경보경로 전체고장'의 모호성을 제거(관리자는 "안 오면 점검"만 기억).
|
||||||
|
|
||||||
|
### ②c 이슬점/결로 경보
|
||||||
|
- `sms_send.php`: Magnus 이슬점(`dew_point_c`)·결로마진(`condensation_margin_c`)을 추가하고,
|
||||||
|
`(현재온도−이슬점) ≤ METRIC_DEWPOINT_MARGIN_C`(기본 3℃)이면 새 종류 `condensation_risk`/
|
||||||
|
`[결로주의]` 경보를 발화한다. `evaluate_metric_thresholds`(말미 추가)·`metric_is_recovered`
|
||||||
|
(결로 해소 히스테리시스 포함)·`send_metric_alert_sms`(라벨)·`sensor_data.php`(표시값=결로마진)
|
||||||
|
에 통합. 온도·습도가 각각 정상이어도 잡히는 결합 위험(차가운 랙면 결로)을 단일 SHT30
|
||||||
|
산술만으로 포착 — 누수감지기 후신인 이 시스템의 본래 목적에 부합.
|
||||||
|
|
||||||
|
### ②d 수신번호 개인정보 마스킹
|
||||||
|
- `config.php:mask_phone()`(010-****-1234)을 `dashboard.php`·`setup_wizard.php` 의 수신번호
|
||||||
|
표시에 적용했다. 발송 기록(`sms_log`)은 보존하되 화면 표시만 최소화(개인정보 최소표시).
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
- `tests/threshold_sms_test.php`: 이슬점/결로마진/해소·`mask_phone` 경계 테스트(기존
|
||||||
|
임계 벡터는 결로 미발화 확인 — 회귀 없음).
|
||||||
|
- `tests/security_hardening_test.php`: ①+② 소스 회귀 고정(SNTP 비-인터넷, server_now,
|
||||||
|
잔액/헬스/결로/마스킹 상수·함수·호출).
|
||||||
|
|
||||||
|
## Unreleased - 개선 구현 (다중 에이전트 전수 점검 반영)
|
||||||
|
|
||||||
|
전체 서브시스템(펌웨어/PHP/DB/문서/빌드)을 다중 에이전트로 매핑·교차검증하여 도출한
|
||||||
|
개선 목록을 구현했다. **호환성 유지(가산/멱등)** 를 원칙으로 했으며, 새 DB 테이블은
|
||||||
|
없어도 동작하도록 graceful degrade 한다.
|
||||||
|
|
||||||
|
### 보안 / 신뢰성 (서버 PHP)
|
||||||
|
- **재전송(replay) 멱등 방어 추가.** `api/sensor_data.php` 가 인증·신선도를 통과한 요청
|
||||||
|
서명(X-Signature)을 `ingest_nonce` 테이블에 원자적으로 기록한다. 시계오차 창
|
||||||
|
(`INGEST_MAX_CLOCK_SKEW_SEC`) 안에서 같은 서명 본문이 다시 오면(펌웨어 재시도/캡처
|
||||||
|
재생) 중복 저장·중복 SMS 없이 **멱등 200** 으로 응답한다. 저장 실패 시 nonce 를
|
||||||
|
되돌려 재시도가 재처리되게 한다(유실 방지). 테이블이 없으면 자동 비활성
|
||||||
|
(`INGEST_NONCE_ENABLED`).
|
||||||
|
- **임계 경보 SMS 중복 발송 경합 제거.** `sms_send.php:send_metric_alert_sms()` 의
|
||||||
|
쿨다운 검사→발송→기록 구간을 `(센서,종류)` 단위 MySQL 네임드 락(`GET_LOCK`)으로
|
||||||
|
직렬화한다. 락 미지원 환경에서는 종전 동작으로 폴백한다(가용성 우선).
|
||||||
|
- **SMS 길이 캡.** Cafe24 단문('S', EUC-KR ≤90B) 한도를 넘는 메시지를 발송 전
|
||||||
|
멀티바이트 경계 보존하며 자른다(`sms_truncate_euckr`, `SMS_MAX_EUCKR_BYTES`). 위치/
|
||||||
|
센서명이 길 때 게이트웨이의 무성 절단/거부를 막는다.
|
||||||
|
- **경보 태그 단일 출처화.** `[고온경보]` 등 상태 태그를 `config.php` 의 `SMS_TAG_*`
|
||||||
|
상수와 `sms_summary_select_sql()` 헬퍼로 일원화하고, `sms_send/dashboard/monthly_report`
|
||||||
|
의 하드코딩 LIKE 리터럴을 제거했다. 태그를 한 곳만 바꿔도 쿨다운/복구/대시보드/월간
|
||||||
|
보고서가 일관 유지된다(문자열 어긋남 무성 장애 방지).
|
||||||
|
- **세션 절대 수명 캡 추가 + 모든 인증 페이지로 확대.** 유휴 타임아웃(`SESSION_TIMEOUT`)
|
||||||
|
만으로 연속 활동 세션이 무한정 유지되던 문제를 로그인 시각 기준 절대 수명
|
||||||
|
(`SESSION_ABSOLUTE_TIMEOUT`, 기본 12시간)으로 보완했다. 타임아웃 검사를
|
||||||
|
`admin_security.php:admin_enforce_session_timeout()` 공통 헬퍼로 일원화하고
|
||||||
|
`dashboard.php`·`monthly_report.php`·`security_evidence.php`·`setup_wizard.php` 전부에서
|
||||||
|
호출한다(이전엔 대시보드에서만 검사돼 다른 인증 페이지로 우회 가능했던 갭을 닫음).
|
||||||
|
- **반사형 XSS 차단.** `test_mobile.php` 가 User-Agent 를 그대로 반사하던 것을
|
||||||
|
`text/plain` + `htmlspecialchars` 로 막았다.
|
||||||
|
- **입력 검증.** `sensor_data.php` 가 `sensor_id` 범위(TINYINT 1..255)를 경계에서 검사해
|
||||||
|
500 대신 깔끔한 400 을 반환한다.
|
||||||
|
- **운영 자가진단 보강.** `ops_checks.php` 가 잔존 `MFA_SETUP_TOKEN` 과 활성화된 레거시
|
||||||
|
서명 폴백(`ALLOW_LEGACY_BODY_SIGNATURE`)을 경고한다. `config.local.example.php` 에
|
||||||
|
`ALLOW_LEGACY_BODY_SIGNATURE => false` 를 명시했다.
|
||||||
|
|
||||||
|
### DB
|
||||||
|
- `sql/schema_sht30.sql` · `sql/migration_drop_leak.sql`: 재전송 멱등용 `ingest_nonce`
|
||||||
|
테이블을 가산·멱등(`CREATE TABLE IF NOT EXISTS`)으로 추가했다.
|
||||||
|
|
||||||
|
### 펌웨어 / 빌드 (폐쇄망)
|
||||||
|
- **호스트 C 패리티 추가(미검증 갭 해소).** `firmware/test/host/sig_cli.c` 와
|
||||||
|
`parity_test.py` 가 펌웨어 실제 서명 구현(`common/sig.c`)을 호스트 컴파일·실행하여
|
||||||
|
Python 레퍼런스(`reference.py`)·서버(`verify_signature_raw`)와 **바이트 단위 일치**를
|
||||||
|
검증한다(C↔Python↔PHP 3자). 이전엔 C 가 어떤 테스트에서도 컴파일되지 않았다.
|
||||||
|
- **빌드 시 자리표시자 CA 차단.** `build.ps1` 이 `server_ca.c` 의 PLACEHOLDER 를
|
||||||
|
검출하면 빌드를 중단한다(부팅 시 `bsp_fatal` 로 정지하던 것을 빌드 단계에서 선차단;
|
||||||
|
`-AllowPlaceholderCA` 로 강행 가능).
|
||||||
|
- **벤더 핀 엄격 모드.** `vendor.sh`(`VENDOR_STRICT=1`)·`vendor.ps1`(`-Strict`)가 SHA-256
|
||||||
|
핀 미설정(TODO) 의존성이 있으면 실패한다(릴리스 재현성 강제).
|
||||||
|
- **stale 주석 정정.** `net.c`/`lwipopts.h` 의 RNG `TODO(hw)` 주석을 정정했다 —
|
||||||
|
HW RNG `bsp_rand32()` 는 `bsp.c` 에 이미 구현되어 weak 폴백을 덮어쓴다(예측성 완화는
|
||||||
|
이미 적용됨). `sig.h`/`secrets.h.example` 의 `sha256(key||body)` 표기를 HMAC 로 정정.
|
||||||
|
|
||||||
|
### 문서
|
||||||
|
- **서명 방식 문서 드리프트 일괄 정정(16개 파일).** 운영/보안 문서의
|
||||||
|
`sha256(API_KEY+body)`(키-접두) 표기를 실제 구현인
|
||||||
|
`HMAC-SHA256(키=API_KEY, 메시지=요청본문)` 으로 정정했다.
|
||||||
|
- `SECURITY_DOCUMENT_CONSISTENCY_AUDIT.md` 의 **거짓 일치 인증(false all-clear)** 을
|
||||||
|
바로잡았다(서명 행을 HMAC 로 갱신 + §2.1 추가).
|
||||||
|
- `out_of_range` 가 펌웨어 plausibility 전용이며 DB(`sensor_metric.metric_status`)에는
|
||||||
|
`normal/high_temp/low_temp/high_humidity/low_humidity` 만 저장됨을 명시
|
||||||
|
(`wiring_diagram.md`, `HARDWARE.md`, `PORTING_NOTES.md`, 펌웨어 `README.md`).
|
||||||
|
- `BUILD_OFFLINE.md` 의 비실존 스크립트/툴체인 이름 정정(`vendor_deps.* → vendor.*`,
|
||||||
|
`arm-none-eabi.cmake → arm-none-eabi-toolchain.cmake`). 웹루트 경로가 펌웨어
|
||||||
|
`app_config.h:APP_API_PATH` 와 일치해야 함을 교차참조.
|
||||||
|
- HWP 보안대책서 **과대 스코프** 경고를 명문화(온습도 전용; 화재/누수/정전/카메라
|
||||||
|
미구현). 원본 `.hwp` 는 운영자가 동일하게 정정 필요(`hwp_extracted_text.txt` 배너 +
|
||||||
|
`SECURITY_PLAN_HWP_REVIEW`/`PASS_READINESS`).
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
- `run_tests.sh`(전체 러너; php/python3/gcc 부재 시 SKIP 명시) 추가.
|
||||||
|
- `tests/threshold_sms_test.php`: `sms_truncate_euckr()` 경계 테스트(EUC-KR 멀티바이트).
|
||||||
|
- `tests/security_hardening_test.php`: 위 개선(nonce/락/SMS캡/태그상수/세션캡/XSS/ops)
|
||||||
|
소스 회귀 고정.
|
||||||
|
|
||||||
|
### 운영자 필수 조치 (코드로 끝낼 수 없는 항목)
|
||||||
|
- **(CRITICAL)** `firmware/certs/server_ca.c` 를 실제 Cafe24 루트 CA 로 교체(자리표시자
|
||||||
|
상태로는 부팅 불가; 이제 빌드 단계에서도 차단됨).
|
||||||
|
- **(HIGH)** 원본 `.hwp` 보안대책서를 온습도 전용 스코프로 정정 후 제출.
|
||||||
|
- `vendor.{sh,ps1}` 의 SHA-256 핀을 네트워크 빌드 머신에서 채우기(이후 `VENDOR_STRICT`
|
||||||
|
로 강제). 신규/전환 DB 에 `ingest_nonce` 생성(스키마/마이그레이션 재실행). 운영
|
||||||
|
`config.local.php` 에서 `MFA_SETUP_TOKEN` 비우기, `ALLOW_LEGACY_BODY_SIGNATURE=false`
|
||||||
|
확인.
|
||||||
|
|
||||||
## Unreleased - 보안 하드닝 (전체 코드리뷰 반영)
|
## Unreleased - 보안 하드닝 (전체 코드리뷰 반영)
|
||||||
|
|
||||||
### ⚠️ 호환성 주의 (BREAKING)
|
### ⚠️ 호환성 주의 (BREAKING)
|
||||||
|
|
|
||||||
74
SYSTEM_OVERVIEW.md
Normal file
74
SYSTEM_OVERVIEW.md
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# 시스템 전체 구성 — 하나의 모니터링 시스템 (Module 1 + Module 2 → cafe24 서버 → 대시보드)
|
||||||
|
|
||||||
|
서버실 모니터링은 **두 개의 Raspberry Pi 4 센서 모듈**이 각각 cafe24 서버로 측정/이벤트를
|
||||||
|
보고하고, 서버가 이를 저장·판정(SMS)·**대시보드 표시**하는 하나의 시스템입니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ 1번 모듈 (누수 + 카메라) │ GPIO 누수감지 + Pi Camera 촬영
|
||||||
|
│ raspberry_pi4/ │──┐ leak_alert / leak_recovery + 사진 업로드
|
||||||
|
│ Raspberry Pi 4 + Python │ │ X-Signature(HMAC-SHA256) + photo_upload.php
|
||||||
|
└─────────────────────────────┘ │
|
||||||
|
▼ HTTPS POST
|
||||||
|
┌─────────────────────────────┐ ┌──────────────────────────────┐
|
||||||
|
│ 2번 모듈 (온습도 SHT30) │ │ cafe24 서버 (php/) │
|
||||||
|
│ raspberry_pi4_sht30/ │─►│ api/sensor_data.php (수신) │
|
||||||
|
│ Raspberry Pi 4 + Python │ │ api/photo_upload.php (사진) │
|
||||||
|
└─────────────────────────────┘ │ → MySQL 저장 / 임계·누수 SMS │
|
||||||
|
HTTPS POST │ → dashboard.php (통합 대시보드)│
|
||||||
|
X-Signature(HMAC-SHA256) └──────────────────────────────┘
|
||||||
|
```
|
||||||
|
두 모듈 모두 동일한 raw-body `X-Signature`(HMAC-SHA256) 서명으로 **같은 서버**에 보고합니다(통합 완료).
|
||||||
|
|
||||||
|
## 모듈 매핑
|
||||||
|
|
||||||
|
| 구분 | 폴더 | 보드 | 센서 | sensor_id | 서버 보고 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **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`) |
|
||||||
|
|
||||||
|
두 모듈 모두:
|
||||||
|
- 비밀값은 `config_local.py`(미커밋)에만 — `cp config_local.example.py config_local.py` 후 채움.
|
||||||
|
- 폐쇄망(air-gapped) 설치 지원 — `vendor.sh`(빌드 머신) → 번들 전송 → `install.sh`(오프라인).
|
||||||
|
- `systemd` 유닛으로 부팅 자동 실행.
|
||||||
|
- "업로드 + config_local 채우기 + install.sh + systemd enable" 만 하면 운영 가능.
|
||||||
|
|
||||||
|
## 서버 (cafe24 `php/`)
|
||||||
|
|
||||||
|
- 웹 루트(예): `public_html/raspi_leck_detecter/` — `docs/INSTALL_PI_SERVER.md` 참고.
|
||||||
|
- DB 스키마: `sql/schema_sht30.sql` (신규) 또는 `sql/migration_drop_leak.sql` (전환).
|
||||||
|
- 대시보드: `dashboard.php` — `sensor_status`/`sensor_metric`/`sms_log` 표시, 기본 차트 sensor 2.
|
||||||
|
- 인증: `X-Signature = HMAC-SHA256(API_KEY, raw_body)`. 모든 모듈의 `API_KEY` = 서버 `config.local.php` 의 `API_KEY`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 1번 모듈(누수) 서버 통합 상태 — 단일 서버로 통합 완료 (Option 2 구현됨)
|
||||||
|
|
||||||
|
이전에 "운영 전 결정 필요"로 남겨 두었던 두 선택지 중 **2번(단일 서버로 통합)** 을 구현했습니다.
|
||||||
|
이제 **두 모듈 모두** 이 repo 의 `php/` 서버로 동일한 `X-Signature`(HMAC-SHA256) 서명으로 보고하고,
|
||||||
|
하나의 대시보드(`dashboard.php`)에서 **누수 상태/사진 + 온습도**를 함께 봅니다.
|
||||||
|
|
||||||
|
구현 내용:
|
||||||
|
|
||||||
|
- **서명 통일:** 1번 모듈을 raw-body `X-Signature` HMAC 으로 전환했습니다(2번 모듈과 동일 스킴).
|
||||||
|
본문 JSON 을 고정 필드 순서 문자열로 만들어 그 바이트에 서명하고 그대로 POST 합니다.
|
||||||
|
(구버전의 `sha256(API_KEY + 정렬 JSON)` body-field 서명은 폐기 — 서버 기본 설정이 거부.)
|
||||||
|
사진 업로드 서명 canonical = `device_id|sensor_id|log_id|timestamp` (공백 없음).
|
||||||
|
- **스키마(가산·멱등):** `sensor_log.is_leak` / `sensor_status.is_leak` 컬럼, `event_type` 의
|
||||||
|
`leak_alert`/`leak_recovery`, `leak_incident`(사고 추적) + `leak_photo`(사진 메타) 테이블을
|
||||||
|
다시 추가했습니다. `sensor_metric`(SHT30)은 그대로 보존합니다.
|
||||||
|
- **수신:** `api/sensor_data.php` 가 누수 이벤트를 받아 `sensor_log`/`sensor_status` 갱신,
|
||||||
|
`leak_incident` 개시/종료, `leak_alert` 응답에 `log_id` 반환. SHT30 측정 경로는 100% 유지.
|
||||||
|
- **사진:** 새 `api/photo_upload.php` (서명·신선도·MIME(finfo)·크기 상한·서버 생성 무작위 파일명).
|
||||||
|
- **SMS:** `sms_send.php` 에 `send_leak_alert_sms`(쿨다운) / `send_leak_recovery_sms`(경보 후에만).
|
||||||
|
|
||||||
|
### 운영 적용 절차 (기존 SHT30 서버에 누수 통합)
|
||||||
|
|
||||||
|
1. 기존 서버에서 **`sql/migration_add_leak.sql` 1회 실행**(가산·멱등 — 운영 데이터 보존, 재실행 안전).
|
||||||
|
신규(통합) 설치는 `sql/schema_sht30.sql` 하나만 실행하면 됩니다(이미 누수 요소 포함).
|
||||||
|
2. 아래 서버 파일을 (재)배포:
|
||||||
|
`api/sensor_data.php`, `api/photo_upload.php`, `sms_send.php`, `dashboard.php`, `config.php`,
|
||||||
|
`sql/migration_add_leak.sql`.
|
||||||
|
3. **쓰기 가능한 `php/var/photos/`** 디렉터리 확보(웹서버 사용자 쓰기 권한; 없으면 업로드 시 자동 생성 시도).
|
||||||
|
4. 1번 모듈 `raspberry_pi4/config_local.py` 의 `API_URL`/`PHOTO_UPLOAD_URL` 을 이 통합 서버로,
|
||||||
|
`API_KEY` 를 서버 `config.local.php` 의 `API_KEY` 와 동일하게 설정.
|
||||||
658
docs/INSTALL_GUIDE.html
Normal file
658
docs/INSTALL_GUIDE.html
Normal file
|
|
@ -0,0 +1,658 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>서버실 온습도(SHT30) 모니터링 v2606 — 설치 가이드 (Cafe24 → STM32)</title>
|
||||||
|
<style>
|
||||||
|
/* 폐쇄망 적합: 외부 CDN/폰트/스크립트 0. 인라인 스타일만 사용. */
|
||||||
|
:root{
|
||||||
|
--bg:#f1f5f9; --card:#fff; --ink:#0f172a; --muted:#64748b; --line:#e2e8f0;
|
||||||
|
--brand:#1a56db; --brandd:#1347b8; --ok:#166534; --okbg:#f0fdf4; --okln:#bbf7d0;
|
||||||
|
--warn:#92400e; --warnbg:#fffbeb; --warnln:#fde68a; --crit:#991b1b; --critbg:#fef2f2; --critln:#fecaca;
|
||||||
|
--mono:'D2Coding','Consolas','Courier New',monospace;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;}
|
||||||
|
body{margin:0;font-family:'Noto Sans KR','Malgun Gothic',system-ui,sans-serif;background:var(--bg);color:var(--ink);line-height:1.65;}
|
||||||
|
a{color:var(--brand);text-decoration:none;} a:hover{text-decoration:underline;}
|
||||||
|
header.top{background:linear-gradient(135deg,#1a56db,#0f766e);color:#fff;padding:34px 24px;}
|
||||||
|
header.top .wrap{max-width:980px;margin:0 auto;}
|
||||||
|
header.top h1{margin:0 0 6px;font-size:1.7rem;}
|
||||||
|
header.top p{margin:2px 0;color:rgba(255,255,255,.92);font-size:.95rem;}
|
||||||
|
.badges{margin-top:12px;display:flex;flex-wrap:wrap;gap:8px;}
|
||||||
|
.badge{background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.32);border-radius:999px;padding:3px 12px;font-size:.8rem;font-weight:600;}
|
||||||
|
.layout{max-width:980px;margin:0 auto;padding:24px 18px 80px;}
|
||||||
|
nav.toc{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px 20px;margin-bottom:24px;}
|
||||||
|
nav.toc h2{margin:0 0 10px;font-size:1rem;color:var(--brand);}
|
||||||
|
nav.toc ol{margin:0;padding-left:20px;columns:2;column-gap:28px;font-size:.9rem;}
|
||||||
|
@media(max-width:640px){nav.toc ol{columns:1;}}
|
||||||
|
nav.toc li{margin:3px 0;break-inside:avoid;}
|
||||||
|
section{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:24px 26px;margin-bottom:22px;box-shadow:0 1px 4px rgba(15,23,42,.05);}
|
||||||
|
section>h2{margin:0 0 4px;font-size:1.35rem;color:var(--brand);border-bottom:2px solid var(--line);padding-bottom:10px;}
|
||||||
|
.part-tag{display:inline-block;font-size:.78rem;font-weight:700;color:#fff;background:var(--brandd);border-radius:6px;padding:2px 10px;margin-bottom:8px;}
|
||||||
|
h3{margin:26px 0 8px;font-size:1.08rem;}
|
||||||
|
h4{margin:18px 0 6px;font-size:.98rem;color:#334155;}
|
||||||
|
p{margin:8px 0;}
|
||||||
|
code{font-family:var(--mono);background:#f1f5f9;border:1px solid var(--line);border-radius:4px;padding:1px 6px;font-size:.88em;color:#0b3b8c;}
|
||||||
|
pre{background:#0f172a;color:#e2e8f0;border-radius:10px;padding:16px 18px;overflow:auto;font-family:var(--mono);font-size:.85rem;line-height:1.55;}
|
||||||
|
pre code{background:none;border:none;color:inherit;padding:0;}
|
||||||
|
table{width:100%;border-collapse:collapse;margin:12px 0;font-size:.9rem;}
|
||||||
|
th,td{border:1px solid var(--line);padding:8px 10px;text-align:left;vertical-align:top;}
|
||||||
|
th{background:#f8fafc;font-weight:700;}
|
||||||
|
tbody tr:nth-child(even){background:#fafcff;}
|
||||||
|
.step{counter-increment:step;position:relative;padding-left:46px;margin:22px 0;}
|
||||||
|
.step::before{content:counter(step);position:absolute;left:0;top:-2px;width:32px;height:32px;border-radius:50%;background:var(--brand);color:#fff;font-weight:700;display:flex;align-items:center;justify-content:center;font-size:.95rem;}
|
||||||
|
.steps{counter-reset:step;}
|
||||||
|
.step h3{margin-top:0;}
|
||||||
|
/* 콜아웃 */
|
||||||
|
.note,.warn,.crit,.ok{border-radius:10px;padding:12px 16px;margin:14px 0;font-size:.9rem;border:1px solid;}
|
||||||
|
.note{background:#eff6ff;border-color:#bfdbfe;color:#1e3a8a;}
|
||||||
|
.warn{background:var(--warnbg);border-color:var(--warnln);color:var(--warn);}
|
||||||
|
.crit{background:var(--critbg);border-color:var(--critln);color:var(--crit);}
|
||||||
|
.ok{background:var(--okbg);border-color:var(--okln);color:var(--ok);}
|
||||||
|
.note b,.warn b,.crit b,.ok b{font-weight:800;}
|
||||||
|
/* 캡처 자리표시 */
|
||||||
|
figure{margin:18px 0;}
|
||||||
|
.shot{border:2px dashed #94a3b8;border-radius:12px;background:repeating-linear-gradient(45deg,#f8fafc,#f8fafc 12px,#f1f5f9 12px,#f1f5f9 24px);padding:26px 20px;text-align:center;}
|
||||||
|
.shot .cam{font-size:1.6rem;}
|
||||||
|
.shot .ph-title{font-weight:800;color:#334155;margin:6px 0 4px;font-size:.95rem;}
|
||||||
|
.shot .ph-what{color:#475569;font-size:.86rem;}
|
||||||
|
.shot .ph-what b{color:var(--brand);}
|
||||||
|
figure>figcaption{margin-top:8px;font-size:.82rem;color:var(--muted);text-align:center;font-style:italic;}
|
||||||
|
figure svg{max-width:100%;height:auto;display:block;margin:0 auto;background:#fff;border:1px solid var(--line);border-radius:12px;}
|
||||||
|
.kbd{font-family:var(--mono);background:#fff;border:1px solid #cbd5e1;border-bottom-width:2px;border-radius:5px;padding:1px 6px;font-size:.82em;}
|
||||||
|
hr{border:none;border-top:1px solid var(--line);margin:26px 0;}
|
||||||
|
.pin{font-family:var(--mono);font-weight:700;color:#0b3b8c;}
|
||||||
|
.small{font-size:.84rem;color:var(--muted);}
|
||||||
|
@media print{
|
||||||
|
body{background:#fff;} section{box-shadow:none;border-color:#cbd5e1;break-inside:avoid;}
|
||||||
|
header.top{background:#1a56db !important;-webkit-print-color-adjust:exact;print-color-adjust:exact;}
|
||||||
|
nav.toc{display:none;}
|
||||||
|
}
|
||||||
|
.cap{display:block;max-width:100%;height:auto;margin:6px auto 0;border:1px solid var(--line);border-radius:12px;box-shadow:0 1px 6px rgba(15,23,42,.08);}
|
||||||
|
.fname{font-family:var(--mono);font-size:.78rem;color:#475569;font-weight:600;}
|
||||||
|
.gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:16px;margin:14px 0;}
|
||||||
|
.gallery figure{margin:0;}
|
||||||
|
.gallery img{width:100%;height:auto;border:1px solid var(--line);border-radius:8px;background:#fff;}
|
||||||
|
.gallery figcaption{font-size:.78rem;color:var(--muted);margin-top:4px;text-align:center;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="top">
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>서버실 온습도(SHT30) 모니터링 시스템 v2606 — 설치 가이드</h1>
|
||||||
|
<p>Cafe24 PHP/MySQL 서버부터 STM32F407 MCU 펌웨어까지, 폐쇄망(air-gapped) 배포 절차</p>
|
||||||
|
<div class="badges">
|
||||||
|
<span class="badge">서버: Cafe24 PHP / MySQL</span>
|
||||||
|
<span class="badge">MCU: STM32F407VGT6 (sht30_fw)</span>
|
||||||
|
<span class="badge">센서: SHT30 I²C 0x44</span>
|
||||||
|
<span class="badge">전송: HMAC-SHA256 + TLS 1.2</span>
|
||||||
|
<span class="badge">폐쇄망 · 인터넷 없음</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<b>이 가이드의 이미지에 대하여.</b> 본 문서의 <b>아키텍처·배선 다이어그램은 코드/<code>HARDWARE.md</code> 기준으로 정확히 생성</b>한 SVG입니다.
|
||||||
|
Cafe24 관리자 콘솔·phpMyAdmin·플래싱 툴·실제 대시보드 화면처럼 <b>운영 환경에서만 캡처할 수 있는 화면은
|
||||||
|
“📷 캡처 위치” 자리표시</b>로 두었습니다. 각 자리표시의 캡션이 가리키는 화면을 직접 캡처해 그 자리에 넣으면 됩니다.
|
||||||
|
(저장소의 기존 캡처는 구버전 v2605 누수감지 UI라 현재 화면과 다릅니다 — <a href="#appendix">부록</a> 참조.)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="toc">
|
||||||
|
<h2>목차</h2>
|
||||||
|
<ol>
|
||||||
|
<li><a href="#overview">시스템 개요 & 아키텍처</a></li>
|
||||||
|
<li><a href="#prep">설치 전 준비물</a></li>
|
||||||
|
<li><a href="#A-files">A. Cafe24 — 파일 업로드</a></li>
|
||||||
|
<li><a href="#A-config">A. Cafe24 — config.local.php</a></li>
|
||||||
|
<li><a href="#A-db">A. Cafe24 — DB 설치(phpMyAdmin)</a></li>
|
||||||
|
<li><a href="#A-admin">A. Cafe24 — 권한 & 관리자/MFA</a></li>
|
||||||
|
<li><a href="#A-cron">A. Cafe24 — cron(오프라인 감지)</a></li>
|
||||||
|
<li><a href="#B-wiring">B. STM32 — 하드웨어 배선</a></li>
|
||||||
|
<li><a href="#B-build">B. STM32 — 폐쇄망 빌드</a></li>
|
||||||
|
<li><a href="#B-flash">B. STM32 — 플래시 & 콘솔 확인</a></li>
|
||||||
|
<li><a href="#verify">최종 검증(종단)</a></li>
|
||||||
|
<li><a href="#trouble">문제 해결</a></li>
|
||||||
|
<li><a href="#secrets">운영자 보관 값</a></li>
|
||||||
|
<li><a href="#appendix">부록 — 레거시 자산</a></li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ============================== 개요 ============================== -->
|
||||||
|
<section id="overview">
|
||||||
|
<h2>1. 시스템 개요 & 아키텍처</h2>
|
||||||
|
<p>단일 STM32F407 보드(<code>sht30_fw</code>, <code>sensor_id=2</code>)가 SHT30 센서를 I²C로 5분마다 읽어,
|
||||||
|
본문을 <b>HMAC-SHA256으로 서명</b>해 <b>TLS 1.2</b>로 Cafe24 PHP 서버에 POST합니다. 서버가 임계를 <b>재판정</b>하여
|
||||||
|
고온/저온/고습/저습·결로·오프라인 상황을 <b>SMS</b>로 알리고 <b>웹 대시보드/보고서</b>를 제공합니다.</p>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<svg viewBox="0 0 940 250" role="img" aria-label="시스템 아키텍처 다이어그램">
|
||||||
|
<defs>
|
||||||
|
<marker id="ah" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
|
||||||
|
<path d="M0,0 L8,3 L0,6 Z" fill="#334155"/>
|
||||||
|
</marker>
|
||||||
|
<style>
|
||||||
|
.b{fill:#fff;stroke:#1a56db;stroke-width:2;rx:10;}
|
||||||
|
.t{font-family:'Noto Sans KR',sans-serif;font-size:14px;fill:#0f172a;font-weight:700;text-anchor:middle;}
|
||||||
|
.s{font-family:'Noto Sans KR',sans-serif;font-size:11px;fill:#64748b;text-anchor:middle;}
|
||||||
|
.ln{stroke:#334155;stroke-width:2;marker-end:url(#ah);fill:none;}
|
||||||
|
.lbl{font-family:'Noto Sans KR',sans-serif;font-size:10.5px;fill:#0f766e;text-anchor:middle;font-weight:700;}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect x="20" y="92" width="120" height="66" rx="10" fill="#ecfeff" stroke="#0f766e" stroke-width="2"/>
|
||||||
|
<text class="t" x="80" y="120">SHT30</text><text class="s" x="80" y="140">온·습도 센서</text>
|
||||||
|
<rect x="200" y="80" width="170" height="90" rx="10" fill="#eff6ff" stroke="#1a56db" stroke-width="2"/>
|
||||||
|
<text class="t" x="285" y="112">STM32F407</text><text class="s" x="285" y="132">sht30_fw</text><text class="s" x="285" y="150">FreeRTOS+mbedTLS</text>
|
||||||
|
<rect x="430" y="80" width="180" height="90" rx="10" fill="#eff6ff" stroke="#1a56db" stroke-width="2"/>
|
||||||
|
<text class="t" x="520" y="108">Cafe24 PHP</text><text class="s" x="520" y="128">api/sensor_data.php</text><text class="s" x="520" y="146">임계 재판정 + 저장</text>
|
||||||
|
<rect x="670" y="40" width="160" height="60" rx="10" fill="#f0fdf4" stroke="#166534" stroke-width="2"/>
|
||||||
|
<text class="t" x="750" y="66">MySQL</text><text class="s" x="750" y="84">4 테이블</text>
|
||||||
|
<rect x="670" y="150" width="160" height="62" rx="10" fill="#fffbeb" stroke="#92400e" stroke-width="2"/>
|
||||||
|
<text class="t" x="750" y="176">SMS · 대시보드</text><text class="s" x="750" y="194">경보/보고서</text>
|
||||||
|
<line class="ln" x1="142" y1="125" x2="196" y2="125"/><text class="lbl" x="170" y="116">I²C 0x44</text>
|
||||||
|
<line class="ln" x1="372" y1="125" x2="426" y2="125"/><text class="lbl" x="399" y="112">HMAC</text><text class="lbl" x="399" y="146">TLS 1.2</text>
|
||||||
|
<line class="ln" x1="612" y1="110" x2="666" y2="80"/>
|
||||||
|
<line class="ln" x1="612" y1="140" x2="666" y2="172"/>
|
||||||
|
</svg>
|
||||||
|
<figcaption>그림 1. 전체 데이터 흐름 — SHT30 → STM32(서명/TLS) → Cafe24(재판정/저장) → MySQL + SMS/대시보드. (코드 기준 생성)</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>구분</th><th>설치 위치</th><th>역할</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Cafe24 서버</td><td><code>public_html/raspi_leck_detecter/</code></td><td>센서 API 수신, DB 저장, 임계 판정, SMS, 대시보드/보고서</td></tr>
|
||||||
|
<tr><td>Cafe24 MySQL</td><td>Cafe24 DB</td><td>센서 로그 · 온습도 측정값 · SMS 로그 · 장비 상태</td></tr>
|
||||||
|
<tr><td>STM32F407VGT6</td><td>펌웨어 플래시 (<code>sht30_fw</code>)</td><td>SHT30 I²C 측정, 5분 주기 보고, raw-body HMAC 서명</td></tr>
|
||||||
|
<tr><td>서버 cron</td><td>Cafe24 cron 또는 외부 스케줄러</td><td><code>cron_heartbeat.php</code> 주기 실행(오프라인 감지·헬스·에스컬레이션)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="warn"><b>웹 루트 경로 고정.</b> 폴더명 <code>raspi_leck_detecter</code> 는 레거시 이름이지만 펌웨어
|
||||||
|
<code>app_config.h</code> 의 <code>APP_API_PATH</code> 와 <b>반드시 일치</b>해야 합니다. 폴더명을 바꾸려면 펌웨어도 함께 바꿔 재빌드해야 합니다.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============================== 준비물 ============================== -->
|
||||||
|
<section id="prep">
|
||||||
|
<h2>2. 설치 전 준비물</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>서버측(Cafe24)</th><th>장치측(STM32)</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
• Cafe24 PHP 호스팅<br>
|
||||||
|
• Cafe24 MySQL DB(이름/계정/비밀번호)<br>
|
||||||
|
• Cafe24 SMS 서비스 계정 + secure key + 발신번호<br>
|
||||||
|
• 운영 담당자 SMS 수신번호<br>
|
||||||
|
• HTTPS 도메인/경로 + Cafe24 루트 CA
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
• STM32F407VGT6 보드(DISCOVERY 계열/커스텀 PCB)<br>
|
||||||
|
• SHT30 모듈(한진데이터 P4422-3, I²C)<br>
|
||||||
|
• LAN8720 Ethernet PHY(RMII) + LAN 케이블<br>
|
||||||
|
• I²C 풀업 4.7kΩ × 2 (모듈 내장 시 생략)<br>
|
||||||
|
• 외부 3.3V USB-UART 어댑터(USART3 콘솔)<br>
|
||||||
|
• 폐쇄망 빌드 머신(arm-none-eabi-gcc, CMake, Ninja, ST-Link 툴)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="note"><b>핵심 비밀값 한 쌍.</b> 서버 <code>config.local.php</code> 의 <code>API_KEY</code> 와 펌웨어
|
||||||
|
<code>secrets.h</code> 의 <code>APP_API_KEY</code> 는 <b>바이트 단위로 동일</b>해야 합니다. 서명
|
||||||
|
<code>X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문))</code> 검증의 기준입니다.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ====================================================================
|
||||||
|
PART A — CAFE24 서버
|
||||||
|
===================================================================== -->
|
||||||
|
<section id="A-files">
|
||||||
|
<span class="part-tag">PART A · Cafe24 서버</span>
|
||||||
|
<h2>3. 서버 파일 업로드</h2>
|
||||||
|
<p>Cafe24 FTP(또는 파일 관리자)로 웹 루트 아래에 PHP 트리를 업로드합니다.</p>
|
||||||
|
<pre><code>public_html/raspi_leck_detecter/
|
||||||
|
.htaccess config.php config.local.php ← 서버에서만 생성
|
||||||
|
config.local.example.php
|
||||||
|
login.php dashboard.php setup_wizard.php
|
||||||
|
setup_mfa.php admin_security.php ops_checks.php
|
||||||
|
sms_send.php cron_heartbeat.php monthly_report.php
|
||||||
|
security_evidence.php retention_cleanup.php setup_hash.php
|
||||||
|
blocked.php test_mobile.php
|
||||||
|
api/
|
||||||
|
sensor_data.php
|
||||||
|
var/
|
||||||
|
.gitkeep ← PHP가 쓰기 가능해야 함(상태/감사/카운터 파일)</code></pre>
|
||||||
|
<div class="crit"><b>업로드 금지.</b> <code>config.local.php</code> 와 펌웨어 <code>secrets.h</code> 는 비밀값이므로
|
||||||
|
저장소/공유에 올리지 않습니다. <code>php/var/</code> 는 <code>.htaccess</code> 로 외부 접근이 차단됩니다(확인 필수).</div>
|
||||||
|
<figure>
|
||||||
|
<img class="cap" src="screenshots/02-server-files-upload.png" alt="그림 2 캡처" onerror="this.style.display='none';this.nextElementSibling.hidden=false;">
|
||||||
|
<div class="shot" hidden>
|
||||||
|
<div class="cam">📷</div>
|
||||||
|
<div class="ph-title">캡처 위치 — 그림 2 <span class="fname">→ screenshots/02-server-files-upload.png</span></div>
|
||||||
|
<div class="ph-what">Cafe24 <b>FTP/파일 관리자</b>에서 <b><code>public_html/raspi_leck_detecter/</code> 업로드 완료된 파일 목록</b> 화면</div>
|
||||||
|
</div>
|
||||||
|
<figcaption>그림 2. 서버 파일 업로드 결과(웹 루트 트리)</figcaption>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="A-config">
|
||||||
|
<span class="part-tag">PART A · Cafe24 서버</span>
|
||||||
|
<h2>4. 운영 설정 — <code>config.local.php</code></h2>
|
||||||
|
<p><code>config.local.example.php</code> 를 복사해 <code>config.local.php</code> 를 만들고 실제 운영 값으로 교체합니다.</p>
|
||||||
|
<pre><code><?php
|
||||||
|
return [
|
||||||
|
'DB_HOST' => 'localhost',
|
||||||
|
'DB_PORT' => 3306,
|
||||||
|
'DB_NAME' => 'your_db_name',
|
||||||
|
'DB_USER' => 'your_db_user',
|
||||||
|
'DB_PASS' => 'your_db_password',
|
||||||
|
|
||||||
|
'API_KEY' => 'replace-with-long-random-secret', // 펌웨어 APP_API_KEY 와 동일!
|
||||||
|
'ALLOW_LEGACY_BODY_SIGNATURE' => false, // HMAC 전용(레거시 RPi 없으면 false)
|
||||||
|
|
||||||
|
'SMS_USER_ID' => 'your-cafe24-sms-user-id',
|
||||||
|
'SMS_SECURE' => 'your-cafe24-sms-secure-key',
|
||||||
|
'SMS_SENDER' => '01000000000',
|
||||||
|
'SMS_RECIPIENTS' => ['01000000000'],
|
||||||
|
'SMS_RECIPIENTS_TIER2' => [], // 미복귀 30분 시 2차(야간) 통지(opt-in)
|
||||||
|
|
||||||
|
'ADMIN_USER' => 'admin',
|
||||||
|
'ADMIN_PASSWORD_HASH' => '', // setup_hash.php 출력으로 채움(빈값=로그인 차단)
|
||||||
|
'ADMIN_TOTP_SECRET' => '', // setup_mfa.php 등록 후 채움(빈값=로그인 차단)
|
||||||
|
'MFA_SETUP_TOKEN' => 'replace-with-temporary-random-token', // 등록 후 비울 것
|
||||||
|
|
||||||
|
// 온습도 임계(서버 판정 → SMS). 기본값은 config.php 의 METRIC_*
|
||||||
|
'METRIC_TEMP_HIGH_C' => 30, 'METRIC_TEMP_LOW_C' => 10,
|
||||||
|
'METRIC_RH_HIGH' => 70, 'METRIC_RH_LOW' => 20,
|
||||||
|
];</code></pre>
|
||||||
|
<div class="warn"><b>fail-closed 기본값.</b> <code>ADMIN_PASSWORD_HASH</code> 또는 <code>ADMIN_TOTP_SECRET</code> 가
|
||||||
|
비어 있으면 관리자 로그인이 <b>차단</b>됩니다(보안). 4·6단계에서 채웁니다.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="A-db">
|
||||||
|
<span class="part-tag">PART A · Cafe24 서버</span>
|
||||||
|
<h2>5. 데이터베이스 설치 (phpMyAdmin)</h2>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step">
|
||||||
|
<h3>Cafe24 phpMyAdmin 접속 → 대상 DB 선택</h3>
|
||||||
|
<p>Cafe24 호스팅 관리 → MySQL → phpMyAdmin. 좌측에서 운영 DB(<code>DB_NAME</code>)를 클릭합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<h3>스키마 실행</h3>
|
||||||
|
<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>ingest_nonce</code>).</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>SQL 파일</th><th>적용 상황</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<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>
|
||||||
|
</table>
|
||||||
|
<div class="crit"><b>중복 실행 주의.</b> 이미 적용한 마이그레이션을 다시 실행하면 오류가 날 수 있습니다.
|
||||||
|
기존 설치는 <code>setup_wizard.php</code> 점검 결과를 먼저 확인하세요.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<figure>
|
||||||
|
<img class="cap" src="screenshots/03-db-phpmyadmin.png" alt="그림 3 캡처" onerror="this.style.display='none';this.nextElementSibling.hidden=false;">
|
||||||
|
<div class="shot" hidden>
|
||||||
|
<div class="cam">📷</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>
|
||||||
|
<figcaption>그림 3. DB 스키마 실행 결과 (sensor_log/sensor_status/sensor_metric/sms_log/ingest_nonce)</figcaption>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="A-admin">
|
||||||
|
<span class="part-tag">PART A · Cafe24 서버</span>
|
||||||
|
<h2>6. 권한 · 관리자 비밀번호 · MFA 등록</h2>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step">
|
||||||
|
<h3>상태 파일 폴더 쓰기 권한</h3>
|
||||||
|
<p><code>raspi_leck_detecter/var/</code> 가 PHP에서 쓰기 가능해야 합니다(브루트포스/하트비트/감사로그/거부 카운터 파일 저장).</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<h3>관리자 비밀번호 해시 생성(서버 CLI)</h3>
|
||||||
|
<pre><code>php setup_hash.php "새관리자비밀번호"</code></pre>
|
||||||
|
<p>출력된 bcrypt 해시를 <code>config.local.php</code> 의 <code>ADMIN_PASSWORD_HASH</code> 에 넣습니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<h3>MFA(TOTP) 등록</h3>
|
||||||
|
<p><code>MFA_SETUP_TOKEN</code> 을 임시 설정한 뒤 아래 주소로 접속해 인증 앱(Google/Microsoft Authenticator, Authy)에 등록합니다.</p>
|
||||||
|
<pre><code>https://your-domain.example/raspi_leck_detecter/setup_mfa.php?token=임시토큰</code></pre>
|
||||||
|
<p>화면의 Base32 키를 인증 앱에 등록 → 6자리 코드 검증 → 표시된 <code>ADMIN_TOTP_SECRET</code> 줄을
|
||||||
|
<code>config.local.php</code> 에 반영 → <b>등록 후 <code>MFA_SETUP_TOKEN</code> 을 빈 값으로</b> 바꿉니다.</p>
|
||||||
|
<figure>
|
||||||
|
<img class="cap" src="screenshots/04-mfa-setup.png" alt="그림 4 캡처" onerror="this.style.display='none';this.nextElementSibling.hidden=false;">
|
||||||
|
<div class="shot" hidden>
|
||||||
|
<div class="cam">📷</div>
|
||||||
|
<div class="ph-title">캡처 위치 — 그림 4 <span class="fname">→ screenshots/04-mfa-setup.png</span></div>
|
||||||
|
<div class="ph-what"><b><code>setup_mfa.php</code> 등록 화면</b>(Base32 수동 입력 키 + 6자리 코드 “확인됨” 상태). 비밀키 노출 주의 — 캡처 시 키 일부 가림 권장</div>
|
||||||
|
</div>
|
||||||
|
<figcaption>그림 4. 관리자 MFA(TOTP) 등록 화면</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<h3>설치 점검 화면 확인</h3>
|
||||||
|
<pre><code>https://your-domain.example/raspi_leck_detecter/setup_wizard.php</code></pre>
|
||||||
|
<p>확인 항목: DB 연결 · 필수 테이블 · API 키 교체 · 관리자 해시 · 관리자 TOTP · SMS 수신자 ·
|
||||||
|
<code>var/</code> 쓰기 권한 · <b>테스트 SMS 발송</b>.</p>
|
||||||
|
<figure>
|
||||||
|
<img class="cap" src="screenshots/05-setup-wizard.png" alt="그림 5 캡처" onerror="this.style.display='none';this.nextElementSibling.hidden=false;">
|
||||||
|
<div class="shot" hidden>
|
||||||
|
<div class="cam">📷</div>
|
||||||
|
<div class="ph-title">캡처 위치 — 그림 5 <span class="fname">→ screenshots/05-setup-wizard.png</span></div>
|
||||||
|
<div class="ph-what"><b><code>setup_wizard.php</code> 점검 결과</b>(모든 항목 ✅ + 테스트 SMS “발송 성공”). 수신번호는 마스킹되어 표시됨</div>
|
||||||
|
</div>
|
||||||
|
<figcaption>그림 5. 설치 점검(setup_wizard) 통과 화면</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="A-cron">
|
||||||
|
<span class="part-tag">PART A · Cafe24 서버</span>
|
||||||
|
<h2>7. cron 설정 — 오프라인 감지 · 헬스 · 에스컬레이션</h2>
|
||||||
|
<p><code>cron_heartbeat.php</code> 를 <b>1분 간격</b>으로 실행합니다(오프라인 감지 + 일일 헬스 SMS + 미복귀 에스컬레이션).</p>
|
||||||
|
<h4>방법 A — 서버 CLI cron</h4>
|
||||||
|
<pre><code>* * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php</code></pre>
|
||||||
|
<h4>방법 B — HTTP 호출(외부 스케줄러)</h4>
|
||||||
|
<pre><code>https://your-domain.example/raspi_leck_detecter/cron_heartbeat.php?key=<CRON_SECRET></code></pre>
|
||||||
|
<p class="small"><code>CRON_SECRET = HMAC-SHA256("cron", API_KEY)</code> 입니다(코드가 파생). 절대 경로는 Cafe24 파일 관리자/<code>phpinfo()</code>로 확인.
|
||||||
|
마지막 수신 후 <code>HEARTBEAT_TIMEOUT_SEC</code>(기본 1200초=20분) 초과 시 오프라인 판정.</p>
|
||||||
|
<div class="note"><b>cron이 없으면?</b> 다른 페이지(ingest/대시보드) 접속 시 5분 쓰로틀로 piggyback 실행됩니다. 단,
|
||||||
|
완전 유휴 상태에선 오프라인 감지가 늦어질 수 있으니 <b>cron 등록을 권장</b>합니다.</div>
|
||||||
|
<figure>
|
||||||
|
<img class="cap" src="screenshots/06-cron-register.png" alt="그림 6 캡처" onerror="this.style.display='none';this.nextElementSibling.hidden=false;">
|
||||||
|
<div class="shot" hidden>
|
||||||
|
<div class="cam">📷</div>
|
||||||
|
<div class="ph-title">캡처 위치 — 그림 6 <span class="fname">→ screenshots/06-cron-register.png</span></div>
|
||||||
|
<div class="ph-what">Cafe24 <b>크론탭(예약작업) 등록 화면</b> — <code>cron_heartbeat.php</code> 가 1분 주기로 등록된 목록</div>
|
||||||
|
</div>
|
||||||
|
<figcaption>그림 6. Cafe24 cron(예약작업) 등록</figcaption>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ====================================================================
|
||||||
|
PART B — STM32 MCU
|
||||||
|
===================================================================== -->
|
||||||
|
<section id="B-wiring">
|
||||||
|
<span class="part-tag">PART B · STM32 MCU</span>
|
||||||
|
<h2>8. 하드웨어 배선 (STM32F407VGT6)</h2>
|
||||||
|
<p>LQFP-100 기준. SHT30 I²C, LAN8720 RMII, USART3 콘솔, 상태 LED를 결선합니다.</p>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<svg viewBox="0 0 920 430" role="img" aria-label="STM32F407 SHT30 보드 배선도">
|
||||||
|
<defs><style>
|
||||||
|
.mcu{fill:#0f172a;}
|
||||||
|
.blk{fill:#fff;stroke-width:2;rx:10;}
|
||||||
|
.tt{font-family:'Noto Sans KR',sans-serif;font-size:13px;font-weight:700;fill:#0f172a;text-anchor:middle;}
|
||||||
|
.ss{font-family:'Noto Sans KR',sans-serif;font-size:10.5px;fill:#475569;text-anchor:middle;}
|
||||||
|
.pn{font-family:'Consolas',monospace;font-size:11px;font-weight:700;fill:#0b3b8c;}
|
||||||
|
.wt{font-family:'Consolas',monospace;font-size:10px;fill:#334155;}
|
||||||
|
.w{stroke-width:2.4;fill:none;}
|
||||||
|
</style></defs>
|
||||||
|
<!-- MCU -->
|
||||||
|
<rect x="360" y="120" width="200" height="190" rx="14" class="mcu"/>
|
||||||
|
<text x="460" y="150" text-anchor="middle" font-family="Noto Sans KR" font-size="15" font-weight="700" fill="#fff">STM32F407VGT6</text>
|
||||||
|
<text x="460" y="170" text-anchor="middle" font-family="Noto Sans KR" font-size="11" fill="#cbd5e1">168MHz · sht30_fw</text>
|
||||||
|
<text x="460" y="300" text-anchor="middle" font-family="Consolas" font-size="10" fill="#94a3b8">HSE 8MHz→PLL · IWDG 20s · LSE RTC</text>
|
||||||
|
<!-- SHT30 (left) -->
|
||||||
|
<rect x="40" y="150" width="150" height="120" class="blk" stroke="#0f766e"/>
|
||||||
|
<text class="tt" x="115" y="178">SHT30</text><text class="ss" x="115" y="196">P4422-3 · I²C 0x44</text>
|
||||||
|
<text class="pn" x="58" y="222">VDD</text><text class="pn" x="58" y="240">SCL</text><text class="pn" x="58" y="258">SDA</text><text class="pn" x="150" y="258">GND</text>
|
||||||
|
<!-- wires SHT30 -> MCU (PB6/PB7) -->
|
||||||
|
<path class="w" stroke="#f97316" d="M150,218 H 300 V 200 H 360"/><text class="wt" x="250" y="194">3.3V</text>
|
||||||
|
<path class="w" stroke="#22c55e" d="M150,236 H 320 V 225 H 360"/><text class="wt" x="300" y="219">PB6 SCL</text>
|
||||||
|
<path class="w" stroke="#3b82f6" d="M150,256 H 335 V 250 H 360"/><text class="wt" x="305" y="266">PB7 SDA</text>
|
||||||
|
<path class="w" stroke="#111827" d="M150,272 H 300 V 280 H 360"/><text class="wt" x="250" y="296">GND</text>
|
||||||
|
<!-- pullups -->
|
||||||
|
<text x="250" y="150" text-anchor="middle" font-family="Consolas" font-size="10" fill="#b45309">4.7kΩ↑3.3V (SCL/SDA)</text>
|
||||||
|
<!-- LAN8720 (top) -->
|
||||||
|
<rect x="610" y="40" width="270" height="92" class="blk" stroke="#1a56db"/>
|
||||||
|
<text class="tt" x="745" y="66">LAN8720 PHY (RMII · AF11)</text>
|
||||||
|
<text class="wt" x="745" y="86" text-anchor="middle">PA1 REF_CLK · PA2 MDIO · PC1 MDC · PA7 CRS_DV</text>
|
||||||
|
<text class="wt" x="745" y="102" text-anchor="middle">PC4/PC5 RXD0/1 · PB11 TXEN · PB12/PB13 TXD0/1</text>
|
||||||
|
<text class="ss" x="745" y="122">MDIO 1.5kΩ↑3.3V · 50MHz REF_CLK</text>
|
||||||
|
<path class="w" stroke="#1a56db" d="M610,110 H 540 V 120"/>
|
||||||
|
<!-- USART3 + LED (bottom) -->
|
||||||
|
<rect x="610" y="330" width="270" height="80" class="blk" stroke="#92400e"/>
|
||||||
|
<text class="tt" x="745" y="356">콘솔 & 상태 LED</text>
|
||||||
|
<text class="wt" x="745" y="376" text-anchor="middle">USART3: PD8 TX / PD9 RX (AF7, 115200 8N1)</text>
|
||||||
|
<text class="wt" x="745" y="392" text-anchor="middle">외부 3.3V USB-UART · 상태 LED = PD12</text>
|
||||||
|
<path class="w" stroke="#92400e" d="M610,360 H 540 V 300 H 560"/>
|
||||||
|
<!-- 범례 -->
|
||||||
|
<text x="40" y="300" font-family="Noto Sans KR" font-size="10.5" fill="#64748b">※ PB6/PB7(I²C)와 PB11/12/13(RMII)은 같은 PB포트지만 충돌 없음.</text>
|
||||||
|
<text x="40" y="316" font-family="Noto Sans KR" font-size="10.5" fill="#991b1b">※ PA2는 ETH_MDIO 전용 — USART2 사용 금지(콘솔은 USART3 PD8/PD9).</text>
|
||||||
|
</svg>
|
||||||
|
<figcaption>그림 7. STM32F407 + SHT30 + LAN8720 + USART3 콘솔 배선 (HARDWARE.md 핀맵 기준 생성)</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<h3>8.1 SHT30 I²C1</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>SHT30</th><th>STM32 핀</th><th>비고</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>VDD</td><td>3.3V</td><td rowspan="4">SCL/SDA 각각 <b>4.7kΩ → 3.3V</b> 풀업(모듈 내장 시 생략). 100kHz 권장. 명령 <code>0x2C06</code> → ~20ms → 6바이트 read</td></tr>
|
||||||
|
<tr><td>SCL</td><td class="pin">PB6</td></tr>
|
||||||
|
<tr><td>SDA</td><td class="pin">PB7</td></tr>
|
||||||
|
<tr><td>GND / ADDR</td><td>GND</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h3>8.2 LAN8720 RMII (AF11)</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>STM32 핀</th><th>RMII 신호</th><th>STM32 핀</th><th>RMII 신호</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="pin">PA1</td><td>ETH_REF_CLK (50MHz 입력)</td><td class="pin">PC4</td><td>ETH_RXD0</td></tr>
|
||||||
|
<tr><td class="pin">PA2</td><td>ETH_MDIO ⚠️ (USART2 금지)</td><td class="pin">PC5</td><td>ETH_RXD1</td></tr>
|
||||||
|
<tr><td class="pin">PC1</td><td>ETH_MDC</td><td class="pin">PB11</td><td>ETH_TX_EN</td></tr>
|
||||||
|
<tr><td class="pin">PA7</td><td>ETH_CRS_DV</td><td class="pin">PB12/PB13</td><td>ETH_TXD0 / TXD1</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h3>8.3 콘솔 · 상태 LED · 클럭</h3>
|
||||||
|
<ul>
|
||||||
|
<li>콘솔 UART: <span class="pin">PD8</span>(TX)/<span class="pin">PD9</span>(RX), USART3 AF7, <b>115200 8N1</b>, 외부 3.3V USB-UART 어댑터.</li>
|
||||||
|
<li>상태 LED: <span class="pin">PD12</span> — 부팅=점등 / 정상보고=느린 토글 / 망단절·TLS실패=빠른 점멸.</li>
|
||||||
|
<li>클럭: HSE 8MHz→168MHz, RMII REF_CLK 50MHz, RTC <b>LSE 32.768kHz</b>(권장; 정전 시 VBAT 시간 보존), IWDG=LSI 20s.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="B-build">
|
||||||
|
<span class="part-tag">PART B · STM32 MCU</span>
|
||||||
|
<h2>9. 폐쇄망 펌웨어 빌드</h2>
|
||||||
|
<div class="crit"><b>폐쇄망 원칙.</b> 다운로드는 <b>인터넷 가능한 빌드 머신에서만</b> 일어나고, 결과물 <code>.bin</code> 이 에어갭을
|
||||||
|
넘어 전달됩니다. 설치 호스트는 어떤 것도 직접 가져오지 않습니다.</div>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step">
|
||||||
|
<h3>의존성 벤더링(빌드 머신, 외부 다운로드는 여기서만)</h3>
|
||||||
|
<pre><code>bash firmware/scripts/vendor.sh # 또는 vendor.ps1
|
||||||
|
# 릴리스 빌드는 핀(SHA-256) 강제:
|
||||||
|
VENDOR_STRICT=1 bash firmware/scripts/vendor.sh</code></pre>
|
||||||
|
<p class="small">STM32CubeF4 HAL/CMSIS · FreeRTOS-Kernel · lwip(+sntp) · mbedtls 를 <code>firmware/third_party/</code> 에 고정 버전으로 배치. 빌드·런타임 외부 fetch 0건.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<h3>비밀값 — <code>secrets.h</code></h3>
|
||||||
|
<pre><code>cp firmware/common/secrets.h.example firmware/common/secrets.h
|
||||||
|
# APP_API_KEY 를 서버 config.local.php 의 API_KEY 와 바이트 단위로 동일하게 채운다.</code></pre>
|
||||||
|
<div class="warn"><code>secrets.h</code> 는 커밋 금지. 산출 <code>.bin</code> 에 키가 임베드되므로 <code>.bin</code> 자체를 비밀로 취급합니다.</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<h3>서버 루트 CA 임베드 — <code>certs/server_ca.c</code> <span style="color:var(--crit)">(필수)</span></h3>
|
||||||
|
<pre><code>openssl s_client -connect your-domain.example:443 -servername your-domain.example -showcerts </dev/null
|
||||||
|
# → 루트 CA 를 PEM 으로 저장 후 certs/server_ca.c 의 SERVER_CA_PEM 에 반영
|
||||||
|
# (SERVER_CA_PEM_LEN = sizeof(...) , 종결 NUL 포함)</code></pre>
|
||||||
|
<div class="crit"><b>자리표시자 CA로는 부팅 불가.</b> 기본 <code>server_ca.c</code> 는 PLACEHOLDER라 <code>tls_init()</code>가
|
||||||
|
실패해 부팅이 정지(fail-closed)합니다. <code>build.ps1</code> 이 PLACEHOLDER를 감지하면 빌드를 중단합니다 — 반드시 실제 CA로 교체하세요.</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<h3>설정값 확인 — <code>app_config.h</code></h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>설정</th><th>값</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>APP_API_HOST</code> / <code>APP_API_PATH</code></td><td>운영 도메인 / <code>/raspi_leck_detecter/api/sensor_data.php</code></td></tr>
|
||||||
|
<tr><td><code>APP_SNTP_SERVER</code> ⚠️</td><td><b>폐쇄망 LAN NTP 서버 IP</b>(기본 <code>192.0.2.123</code> 자리표시자 — 반드시 교체)</td></tr>
|
||||||
|
<tr><td><code>APP_SHT30_I2C_ADDR</code> / <code>APP_SHT30_REPORT_INTERVAL_SEC</code></td><td>0x44 / 300(5분)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="crit"><b>SNTP는 폐쇄망 전면장애 지점.</b> 인터넷 NTP(<code>pool.ntp.org</code>)는 폐쇄망에서 도달 불가 →
|
||||||
|
시간 미동기 → <code>timestamp=0</code> → TLS 차단 + 서버 전량 거부. <b>반드시 사내 LAN NTP IP</b>로 설정하세요.</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<h3>빌드 (cmake + ninja)</h3>
|
||||||
|
<pre><code>cmake -S firmware -B firmware/build -G Ninja \
|
||||||
|
-DCMAKE_TOOLCHAIN_FILE=firmware/cmake/arm-none-eabi-toolchain.cmake
|
||||||
|
cmake --build firmware/build --target sht30_fw # → sht30_fw.elf / .bin / .hex</code></pre>
|
||||||
|
<p class="small">(선택) 호스트 패리티 게이트로 C↔Python↔PHP 서명 일치 확인: <code>python3 firmware/test/host/parity_test.py</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<figure>
|
||||||
|
<img class="cap" src="screenshots/08-firmware-build.png" alt="그림 8 캡처" onerror="this.style.display='none';this.nextElementSibling.hidden=false;">
|
||||||
|
<div class="shot" hidden>
|
||||||
|
<div class="cam">📷</div>
|
||||||
|
<div class="ph-title">캡처 위치 — 그림 8 <span class="fname">→ screenshots/08-firmware-build.png</span></div>
|
||||||
|
<div class="ph-what">빌드 머신 터미널의 <b><code>cmake --build … --target sht30_fw</code> 성공 로그</b>(산출물 <code>sht30_fw.bin/.hex/.elf</code> + <code>arm-none-eabi-size</code> 출력)</div>
|
||||||
|
</div>
|
||||||
|
<figcaption>그림 8. 펌웨어 빌드 성공 로그</figcaption>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="B-flash">
|
||||||
|
<span class="part-tag">PART B · STM32 MCU</span>
|
||||||
|
<h2>10. 에어갭 전달 · 플래시 · 콘솔 확인</h2>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step">
|
||||||
|
<h3>무결성 해시 + 신뢰 매체 전달</h3>
|
||||||
|
<pre><code>sha256sum firmware/build/sht30_fw.bin > FW_HASHES.txt # 빌드 머신
|
||||||
|
# USB 등 신뢰 매체로 격리 호스트에 전달 후 재계산 대조</code></pre>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<h3>ST-Link 플래시 (격리 호스트)</h3>
|
||||||
|
<pre><code>pwsh firmware/scripts/flash.ps1 # 권장(자동 탐지)
|
||||||
|
# 대안:
|
||||||
|
st-flash write sht30_fw.bin 0x08000000
|
||||||
|
STM32_Programmer_CLI -c port=SWD -w sht30_fw.hex -v -rst</code></pre>
|
||||||
|
<figure>
|
||||||
|
<img class="cap" src="screenshots/09-flash.png" alt="그림 9 캡처" onerror="this.style.display='none';this.nextElementSibling.hidden=false;">
|
||||||
|
<div class="shot" hidden>
|
||||||
|
<div class="cam">📷</div>
|
||||||
|
<div class="ph-title">캡처 위치 — 그림 9 <span class="fname">→ screenshots/09-flash.png</span></div>
|
||||||
|
<div class="ph-what"><b>플래싱 툴 화면</b>(STM32CubeProgrammer 또는 <code>st-flash</code>의 “Flash written / verified” 결과)</div>
|
||||||
|
</div>
|
||||||
|
<figcaption>그림 9. STM32 펌웨어 플래시 완료</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<h3>콘솔 로그 확인 (USART3 PD8/PD9, 115200 8N1)</h3>
|
||||||
|
<p>외부 USB-UART 어댑터를 연결하고 부팅 시퀀스를 관찰합니다:</p>
|
||||||
|
<pre><code>bsp_init → 링크업 → DHCP(또는 static) → SNTP 시간 동기
|
||||||
|
→ TLS 핸드셰이크 → 첫 startup POST → 서버 HTTP 200</code></pre>
|
||||||
|
<p class="small">I²C1 0x44 응답 · 첫 온/습도 측정 · 상태 LED(PD12) 패턴을 함께 확인. 서명 불일치(403)면
|
||||||
|
<code>API_KEY</code>↔<code>APP_API_KEY</code>, TLS 실패면 <code>server_ca.c</code>/SNTP를 재점검.</p>
|
||||||
|
<figure>
|
||||||
|
<img class="cap" src="screenshots/10-serial-console.png" alt="그림 10 캡처" onerror="this.style.display='none';this.nextElementSibling.hidden=false;">
|
||||||
|
<div class="shot" hidden>
|
||||||
|
<div class="cam">📷</div>
|
||||||
|
<div class="ph-title">캡처 위치 — 그림 10 <span class="fname">→ screenshots/10-serial-console.png</span></div>
|
||||||
|
<div class="ph-what"><b>시리얼 터미널(USART3) 부팅 로그</b> — SNTP 동기 → TLS OK → 서버 200 응답 라인이 보이는 화면</div>
|
||||||
|
</div>
|
||||||
|
<figcaption>그림 10. STM32 콘솔 부팅 로그(정상 보고)</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============================== 검증 ============================== -->
|
||||||
|
<section id="verify">
|
||||||
|
<h2>11. 최종 검증 (종단)</h2>
|
||||||
|
<p>서버에서 아래 화면을 확인합니다.</p>
|
||||||
|
<pre><code>https://your-domain.example/raspi_leck_detecter/setup_wizard.php
|
||||||
|
https://your-domain.example/raspi_leck_detecter/dashboard.php
|
||||||
|
https://your-domain.example/raspi_leck_detecter/monthly_report.php
|
||||||
|
https://your-domain.example/raspi_leck_detecter/security_evidence.php</code></pre>
|
||||||
|
<div class="ok">
|
||||||
|
<b>정상 설치 기준</b>
|
||||||
|
<ul style="margin:6px 0 0;">
|
||||||
|
<li><code>setup_wizard.php</code> 필수 점검 항목 통과 + 테스트 SMS 수신</li>
|
||||||
|
<li><code>dashboard.php</code> 에 최신 온/습도와 센서 상태(<code>sensor_id=2</code>) 표시</li>
|
||||||
|
<li><code>sensor_metric</code> 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록</li>
|
||||||
|
<li>임계 초과 테스트 시 <code>metric_status</code> 경보 + SMS 수신 / 월간 보고서에 내역 조회</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<figure>
|
||||||
|
<img class="cap" src="screenshots/11-dashboard.png" alt="그림 11 캡처" onerror="this.style.display='none';this.nextElementSibling.hidden=false;">
|
||||||
|
<div class="shot" hidden>
|
||||||
|
<div class="cam">📷</div>
|
||||||
|
<div class="ph-title">캡처 위치 — 그림 11 <span class="fname">→ screenshots/11-dashboard.png</span></div>
|
||||||
|
<div class="ph-what"><b>운영 <code>dashboard.php</code> 화면</b>(상단 “정상 감시 중” + 2번 센서 SHT30 온/습도 카드 + 30일 요약). 데스크톱·모바일 2장 권장</div>
|
||||||
|
</div>
|
||||||
|
<figcaption>그림 11. 운영 대시보드(v2606 온습도) — 데스크톱 / 모바일</figcaption>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============================== 문제해결 ============================== -->
|
||||||
|
<section id="trouble">
|
||||||
|
<h2>12. 문제 해결</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>증상</th><th>확인 지점</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<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>로그인 불가</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>온습도 미표시</td><td>I²C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, <code>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>데이터 전혀 안 들어옴</td><td>대시보드 <b>수집 거부 사유 카운터</b>(서명불일치→키/펌웨어, 시각오차→SNTP/RTC)로 원인 분별</td></tr>
|
||||||
|
<tr><td>오프라인 알림 과다/지연</td><td><code>HEARTBEAT_TIMEOUT_SEC</code>(1200초), cron 주기</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============================== 보관값 ============================== -->
|
||||||
|
<section id="secrets">
|
||||||
|
<h2>13. 운영자가 별도 보관할 값</h2>
|
||||||
|
<div class="warn">아래 값은 저장소에 넣지 말고 운영자만 안전하게 보관합니다.</div>
|
||||||
|
<ul>
|
||||||
|
<li>Cafe24 DB 이름 / 계정 / 비밀번호</li>
|
||||||
|
<li><code>API_KEY</code> = 펌웨어 <code>APP_API_KEY</code></li>
|
||||||
|
<li>Cafe24 SMS user id / secure key / 발신번호 / 수신자 번호</li>
|
||||||
|
<li>관리자 계정·원문 비밀번호·<code>ADMIN_TOTP_SECRET</code></li>
|
||||||
|
<li>서버 업로드 경로, LAN NTP 서버 IP, 장비 ID(<code>stm32-sht30-01</code>)와 설치 위치</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============================== 부록 ============================== -->
|
||||||
|
<section id="appendix">
|
||||||
|
<h2>부록 — 저장소 내 기존 이미지 자산 (레거시 · 참고용)</h2>
|
||||||
|
<div class="warn"><b>주의:</b> 아래 캡처는 <b>구버전 v2605 누수감지(카메라/사진) UI</b> 또는 <b>Raspberry Pi</b> 기준이라
|
||||||
|
현재 v2606 STM32/온습도 시스템 화면과 <b>다릅니다</b>. 화면 구성 참고용으로만 사용하고, 위 본문의 “📷 캡처 위치”에는
|
||||||
|
<b>현재 운영 시스템에서 직접 캡처한 이미지</b>를 넣으세요.</div>
|
||||||
|
<p>본 HTML 파일은 <code>docs/</code> 폴더 기준 상대경로로 아래 자산을 참조할 수 있습니다(폴더 동반 시 표시됨):</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>경로</th><th>내용</th><th>상태</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>examples/captures/admin_dashboard_overview.png</code></td><td>관리자 대시보드 개요</td><td>⚠️ v2605 누수 UI</td></tr>
|
||||||
|
<tr><td><code>examples/captures/admin_dashboard_mobile.png</code></td><td>대시보드 모바일</td><td>⚠️ v2605</td></tr>
|
||||||
|
<tr><td><code>examples/captures/admin_dashboard_incident.png</code></td><td>경보/사고 카드</td><td>⚠️ v2605(사고/사진)</td></tr>
|
||||||
|
<tr><td><code>evidence/mfa_setup_page_capture.png</code></td><td>MFA 등록 페이지</td><td>참고(개념 동일)</td></tr>
|
||||||
|
<tr><td><code>examples/assets/sht30_sensor2_wiring.svg</code></td><td>배선도</td><td>⚠️ Raspberry Pi 기준</td></tr>
|
||||||
|
<tr><td><code>assets/security_plan/generated/security_plan_target_architecture.png</code></td><td>목표 아키텍처</td><td>보안계획 다이어그램</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="gallery">
|
||||||
|
<figure><img src="examples/captures/admin_dashboard_overview.png" alt="v2605 대시보드 개요"><figcaption>⚠️ v2605 대시보드 개요(누수 UI)</figcaption></figure>
|
||||||
|
<figure><img src="examples/captures/admin_dashboard_mobile.png" alt="v2605 모바일"><figcaption>⚠️ v2605 모바일</figcaption></figure>
|
||||||
|
<figure><img src="examples/captures/admin_dashboard_incident.png" alt="v2605 사고 카드"><figcaption>⚠️ v2605 사고/사진 카드</figcaption></figure>
|
||||||
|
<figure><img src="evidence/mfa_setup_page_capture.png" alt="MFA 등록 페이지"><figcaption>MFA 등록 페이지(참고·개념 동일)</figcaption></figure>
|
||||||
|
<figure><img src="examples/assets/sht30_sensor2_wiring.svg" alt="RPi 배선도"><figcaption>⚠️ 배선도(Raspberry Pi 기준)</figcaption></figure>
|
||||||
|
<figure><img src="assets/security_plan/generated/security_plan_target_architecture.png" alt="목표 아키텍처"><figcaption>목표 아키텍처(보안계획 다이어그램)</figcaption></figure>
|
||||||
|
</div>
|
||||||
|
<p class="small">현재 시스템의 정확한 아키텍처·배선은 본문 그림 1·7(생성 SVG)을 기준으로 하세요.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="small" style="text-align:center;margin-top:30px;">
|
||||||
|
서버실 온습도(SHT30) 모니터링 시스템 v2606 · 설치 가이드 · 폐쇄망(air-gapped) 배포 · 인쇄(<span class="kbd">Ctrl/Cmd</span>+<span class="kbd">P</span>) 지원
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
658
docs/INSTALL_GUIDE.standalone.html
Normal file
658
docs/INSTALL_GUIDE.standalone.html
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -93,7 +93,7 @@ return [
|
||||||
];
|
];
|
||||||
```
|
```
|
||||||
|
|
||||||
`API_KEY`는 STM32 펌웨어 `firmware/common/secrets.h`의 `APP_API_KEY`와 반드시 바이트 단위로 같아야 합니다(raw-body 서명 `X-Signature = sha256(API_KEY + 요청본문)`).
|
`API_KEY`는 STM32 펌웨어 `firmware/common/secrets.h`의 `APP_API_KEY`와 반드시 바이트 단위로 같아야 합니다(raw-body 서명 `X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`).
|
||||||
|
|
||||||
`ADMIN_TOTP_SECRET`은 Google Authenticator, Microsoft Authenticator, Authy 등 TOTP 인증 앱에 등록한 Base32 비밀키입니다. 이 값이 없으면 관리자 로그인을 허용하지 않습니다. 담당자 변경 시 관리자 비밀번호와 TOTP 비밀키를 모두 교체하고 변경 기록을 운영 인수인계 자료에 남깁니다.
|
`ADMIN_TOTP_SECRET`은 Google Authenticator, Microsoft Authenticator, Authy 등 TOTP 인증 앱에 등록한 Base32 비밀키입니다. 이 값이 없으면 관리자 로그인을 허용하지 않습니다. 담당자 변경 시 관리자 비밀번호와 TOTP 비밀키를 모두 교체하고 변경 기록을 운영 인수인계 자료에 남깁니다.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ Sensirion SHT30 온습도 센서, STM32F407VGT6(FreeRTOS) 펌웨어, Cafe24 PHP/
|
||||||
- 단일 보드 `sht30_fw` (`sensor_id=2`, `device_id=stm32-sht30-01`).
|
- 단일 보드 `sht30_fw` (`sensor_id=2`, `device_id=stm32-sht30-01`).
|
||||||
- SHT30 → STM32 I2C1(PB6 SCL / PB7 SDA, 주소 0x44, 3.3V/GND, 4.7kΩ 풀업). 상세 배선은 [wiring_diagram.md](./wiring_diagram.md) 및 [firmware/docs/HARDWARE.md](../firmware/docs/HARDWARE.md) §3 참조.
|
- SHT30 → STM32 I2C1(PB6 SCL / PB7 SDA, 주소 0x44, 3.3V/GND, 4.7kΩ 풀업). 상세 배선은 [wiring_diagram.md](./wiring_diagram.md) 및 [firmware/docs/HARDWARE.md](../firmware/docs/HARDWARE.md) §3 참조.
|
||||||
- 측정 주기: 5분(펌웨어 `APP_SHT30_REPORT_INTERVAL_SEC=300`). 최초 1회 startup, 이후 periodic.
|
- 측정 주기: 5분(펌웨어 `APP_SHT30_REPORT_INTERVAL_SEC=300`). 최초 1회 startup, 이후 periodic.
|
||||||
- 인증: 펌웨어가 `X-Signature: sha256(API_KEY + 요청본문)`(raw-body) 헤더로 서명하고 서버가 검증합니다. 관리자 로그인은 비밀번호 해시 + TOTP MFA + 감사로그로 보호합니다.
|
- 인증: 펌웨어가 `X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`(raw-body) 헤더로 서명하고 서버가 검증합니다. 관리자 로그인은 비밀번호 해시 + TOTP MFA + 감사로그로 보호합니다.
|
||||||
|
|
||||||
## 폴더 구조
|
## 폴더 구조
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
| 관리자 TOTP MFA | `login.php`, `setup_mfa.php`, `admin_security.php`, `ADMIN_TOTP_SECRET` | 반영 |
|
| 관리자 TOTP MFA | `login.php`, `setup_mfa.php`, `admin_security.php`, `ADMIN_TOTP_SECRET` | 반영 |
|
||||||
| MFA 최초 등록 토큰 | `setup_mfa.php`, `MFA_SETUP_TOKEN` | 반영 |
|
| MFA 최초 등록 토큰 | `setup_mfa.php`, `MFA_SETUP_TOKEN` | 반영 |
|
||||||
| 관리자 감사로그 | `admin_security.php`, `php/var/admin_audit.log` | 반영 |
|
| 관리자 감사로그 | `admin_security.php`, `php/var/admin_audit.log` | 반영 |
|
||||||
| raw-body API 서명 검증 | `config.php` `verify_signature_raw()`, 펌웨어 `sig.c`/`secrets.h` (`X-Signature`) | 반영 |
|
| 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`, `sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) | 반영 |
|
||||||
|
|
@ -21,6 +21,18 @@
|
||||||
| 백업·복구 증빙 | `scripts/backup_evidence.php` | 반영 |
|
| 백업·복구 증빙 | `scripts/backup_evidence.php` | 반영 |
|
||||||
| 보안 증적 보고서 | `security_evidence.php?format=md` | 반영 |
|
| 보안 증적 보고서 | `security_evidence.php?format=md` | 반영 |
|
||||||
|
|
||||||
|
### 2.1 서명 방식 정정 (이전 all-clear 정정)
|
||||||
|
|
||||||
|
실제 구현의 요청 서명은 **HMAC-SHA256** 이다.
|
||||||
|
|
||||||
|
- 서버: `config.php` `verify_signature_raw()` = `hash_hmac('sha256', $raw_body, API_KEY)`, `hash_equals()` 로 상수시간 비교.
|
||||||
|
- 펌웨어: `sig.c` `sig_raw_body()` = `HMAC-SHA256(key=API_KEY, msg=raw_body)`(소문자 hex), 호스트 레퍼런스 `reference.py` 동일.
|
||||||
|
- 헤더 표기: `X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body))`.
|
||||||
|
|
||||||
|
HMAC 구조를 채택한 이유는 키-접두 sha256(`sha256(API_KEY || body)`)의 **길이확장(length-extension) 공격**을 차단하기 위함이다.
|
||||||
|
|
||||||
|
이전 일부 문서(`README.md`, `INSTALL_PI_SERVER.md`, `SHT30_SENSOR2_SETUP.md`, `SOURCE_SECURITY_EVIDENCE.md`, `stm32f407_migration_plan.md`, `firmware/README.md`, `firmware/docs/PORTING_NOTES.md`, `firmware/docs/BUILD_OFFLINE.md`, `report_leak_detection_system.md`)는 서명을 키-접두 `sha256(API_KEY + body)` / `sha256(API_KEY || body)` 로 잘못 기술하고 있었다. **이 문서들은 본 정정으로 모두 HMAC-SHA256 표기로 수정되었으며, 코드와 일치한다.** (과거의 키-접두 sha256 기술은 실제 구현과 불일치하는 stale 문구였다.)
|
||||||
|
|
||||||
## 2. 수정한 기존 문서
|
## 2. 수정한 기존 문서
|
||||||
|
|
||||||
| 문서 | 반영 내용 |
|
| 문서 | 반영 내용 |
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,8 @@ HTTPS 적용 여부: 적용 / 미적용 / 확인 필요
|
||||||
DB 종류: Cafe24 MySQL
|
DB 종류: Cafe24 MySQL
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 참고: 위 `sht30_monitor/` 는 예시 폴더명입니다. 실제 서버 웹루트 경로는 펌웨어 `firmware/common/app_config.h` 의 `APP_API_PATH`(예: `/raspi_leck_detecter/api/sensor_data.php`)와 **반드시 일치**해야 합니다. 운영 경로를 정한 뒤 위 URL과 `APP_API_PATH` 를 같은 폴더명으로 맞추세요.
|
||||||
|
|
||||||
첨부하면 좋은 증적:
|
첨부하면 좋은 증적:
|
||||||
|
|
||||||
- Cafe24 파일 관리자에서 `sht30_monitor` 폴더가 보이는 화면
|
- Cafe24 파일 관리자에서 `sht30_monitor` 폴더가 보이는 화면
|
||||||
|
|
@ -258,6 +260,7 @@ DB 백업 주기: 매일 / 매주 / 매월
|
||||||
|
|
||||||
3. 서버 설치 경로:
|
3. 서버 설치 경로:
|
||||||
예) public_html/sht30_monitor/
|
예) public_html/sht30_monitor/
|
||||||
|
※ 실제 폴더명은 펌웨어 `app_config.h` 의 `APP_API_PATH` 와 동일해야 함
|
||||||
|
|
||||||
4. 단말 설치 위치:
|
4. 단말 설치 위치:
|
||||||
예) 서버실 서버랙 하단
|
예) 서버실 서버랙 하단
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
|
|
||||||
> ⚠️ HWP 바이너리는 직접 편집할 수 없습니다. 이 문서는 운영자가 HWP 본문에 반영하는 것을 전제로 한 **before → after 갱신 가이드**입니다. 인용한 라인 번호는 `docs/assets/security_plan/hwp_extracted_text.txt`(현재 HWP 추출본, 편집 금지) 기준이며 참고용입니다.
|
> ⚠️ HWP 바이너리는 직접 편집할 수 없습니다. 이 문서는 운영자가 HWP 본문에 반영하는 것을 전제로 한 **before → after 갱신 가이드**입니다. 인용한 라인 번호는 `docs/assets/security_plan/hwp_extracted_text.txt`(현재 HWP 추출본, 편집 금지) 기준이며 참고용입니다.
|
||||||
|
|
||||||
|
> 🔴 **제출 전 필수:** HWP 본문은 **SHT30 온습도 모니터링 전용**으로 범위를 좁힌 뒤 제출해야 합니다. 화재·누수·정전·카메라/현장 사진은 **미구현**이므로 사업개요·구축범위·구성도·물리/기술 보안대책에서 해당 서술을 모두 제거하거나 향후 확장으로 분리해야 합니다(아래 §2~§5 교체안 적용).
|
||||||
|
|
||||||
심사 대응 수준의 상세 보완안은 [SECURITY_PLAN_PASS_READINESS.md](SECURITY_PLAN_PASS_READINESS.md)를 함께 적용합니다.
|
심사 대응 수준의 상세 보완안은 [SECURITY_PLAN_PASS_READINESS.md](SECURITY_PLAN_PASS_READINESS.md)를 함께 적용합니다.
|
||||||
|
|
||||||
## 1. 요약 판단
|
## 1. 요약 판단
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
작성일: 2026-05-19
|
작성일: 2026-05-19
|
||||||
목적: 현재 HWP 보안대책서가 기관 보안성 검토 또는 국정원 검토 관점에서 설득력을 갖추도록, 수정해야 할 내용과 구체적 해결책을 제시한다.
|
목적: 현재 HWP 보안대책서가 기관 보안성 검토 또는 국정원 검토 관점에서 설득력을 갖추도록, 수정해야 할 내용과 구체적 해결책을 제시한다.
|
||||||
|
|
||||||
|
> 🔴 **제출 전 차단 항목:** 사업 범위를 **SHT30 온습도 모니터링 전용**(화재/누수/정전/카메라 미구현)으로 좁히기 전에는 제출하지 않는다. 상세는 §3.1 사업 범위 과대 기재 참조.
|
||||||
|
|
||||||
## 1. 결론
|
## 1. 결론
|
||||||
|
|
||||||
현재 HWP는 **완벽하지 않습니다.**
|
현재 HWP는 **완벽하지 않습니다.**
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ SHT30_SENSOR_ID=2
|
||||||
SHT30_I2C_ADDR=0x44
|
SHT30_I2C_ADDR=0x44
|
||||||
```
|
```
|
||||||
|
|
||||||
> 대안 구성에서도 `sensor_id=2`, raw-body 서명(`X-Signature = sha256(API_KEY + body)`), 5분 측정 주기는 동일합니다. 임계 판정과 SMS는 모두 서버에서 수행하므로 측정 송신부만 환경에 맞게 구성하면 됩니다.
|
> 대안 구성에서도 `sensor_id=2`, raw-body 서명(`X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`), 5분 측정 주기는 동일합니다. 임계 판정과 SMS는 모두 서버에서 수행하므로 측정 송신부만 환경에 맞게 구성하면 됩니다.
|
||||||
|
|
||||||
## 4. 대시보드 결과
|
## 4. 대시보드 결과
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,13 +59,13 @@ HWP 반영 문구:
|
||||||
|
|
||||||
HWP 반영 문구:
|
HWP 반영 문구:
|
||||||
|
|
||||||
> API 요청은 서버와 STM32 단말이 공유하는 API 키를 기반으로 SHA-256 서명을 생성·검증한다. 단말은 요청 본문 바이트 전체에 대해 `sha256(API_KEY || raw_body)`를 계산하여 `X-Signature` 헤더로 전송하고, 서버는 동일하게 계산한 값과 상수시간 비교한다. 서명이 일치하지 않으면 `403 인증 실패`로 거부하며 DB에 저장하지 않는다. 이를 통해 임의의 외부 요청이 허위 측정값을 위조해 등록하는 위험을 낮춘다.
|
> API 요청은 서버와 STM32 단말이 공유하는 API 키를 기반으로 HMAC-SHA256 서명을 생성·검증한다. 단말은 요청 본문 바이트 전체에 대해 `HMAC-SHA256(키=API_KEY, 메시지=raw_body)`를 계산하여 소문자 hex로 `X-Signature` 헤더로 전송하고, 서버는 동일하게 계산한 값과 상수시간 비교한다. HMAC을 사용하므로 길이확장(length-extension) 공격에 견고하다. 서명이 일치하지 않으면 `403 인증 실패`로 거부하며 DB에 저장하지 않는다. 이를 통해 임의의 외부 요청이 허위 측정값을 위조해 등록하는 위험을 낮춘다.
|
||||||
|
|
||||||
코드 근거:
|
코드 근거:
|
||||||
|
|
||||||
- `php/config.php`
|
- `php/config.php`
|
||||||
- `verify_signature_raw(string $raw_body): bool`
|
- `verify_signature_raw(string $raw_body): bool`
|
||||||
- `hash('sha256', API_KEY . $raw_body)` 계산 후 `X-Signature`(`HTTP_X_SIGNATURE`)와 비교
|
- `hash_hmac('sha256', $raw_body, API_KEY)` 계산 후 `X-Signature`(`HTTP_X_SIGNATURE`)와 비교
|
||||||
- `hash_equals()`로 상수시간 서명 비교
|
- `hash_equals()`로 상수시간 서명 비교
|
||||||
- 펌웨어 `firmware/common/sig.c`, `firmware/common/secrets.h`
|
- 펌웨어 `firmware/common/sig.c`, `firmware/common/secrets.h`
|
||||||
- `APP_API_KEY` 기반 raw-body 서명 생성
|
- `APP_API_KEY` 기반 raw-body 서명 생성
|
||||||
|
|
@ -237,11 +237,13 @@ HWP 반영 문구:
|
||||||
curl -I https://example.com/sht30_monitor/login.php
|
curl -I https://example.com/sht30_monitor/login.php
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 위 `sht30_monitor` 는 예시 웹루트 폴더명이며, 실제 경로는 펌웨어 `firmware/common/app_config.h` 의 `APP_API_PATH`(예: `/raspi_leck_detecter/...`)와 동일하게 맞춰야 한다.
|
||||||
|
|
||||||
## 3. 내가 만들어줄 수 있는 HWP 첨부 문구
|
## 3. 내가 만들어줄 수 있는 HWP 첨부 문구
|
||||||
|
|
||||||
아래 문구는 HWP의 “기술적 보안대책”에 그대로 넣을 수 있습니다.
|
아래 문구는 HWP의 “기술적 보안대책”에 그대로 넣을 수 있습니다.
|
||||||
|
|
||||||
> 본 시스템은 STM32 단말에서 Cafe24 PHP API로 HTTPS 기반 아웃바운드 요청만 수행하도록 구성한다. 센서 데이터 API는 공유 API 키 기반 raw-body SHA-256 서명(`X-Signature`)을 검증하며, 서명이 일치하지 않는 요청은 `403 인증 실패`로 거부한다. 센서 데이터 API는 POST JSON 요청과 필수 필드, 측정값 범위(온도 -40~125℃ / 습도 0~100%)를 검증한다. 온습도 임계 판정은 서버에서 운영 임계(`METRIC_*`)로 수행하고, 동일 종류 경보 30분 쿨다운과 복구 히스테리시스로 SMS 오발송을 방지한다. DB 저장은 PDO prepared statement를 사용하며, 운영 비밀값은 `config.local.php`와 펌웨어 `secrets.h`로 분리하여 소스코드에 직접 저장하지 않는다. 관리자 페이지는 비밀번호 해시, CSRF 토큰, 세션 쿠키 보안 속성, 로그인 실패 제한을 적용한다.
|
> 본 시스템은 STM32 단말에서 Cafe24 PHP API로 HTTPS 기반 아웃바운드 요청만 수행하도록 구성한다. 센서 데이터 API는 공유 API 키 기반 raw-body HMAC-SHA256 서명(`X-Signature`)을 검증하며, 서명이 일치하지 않는 요청은 `403 인증 실패`로 거부한다. 센서 데이터 API는 POST JSON 요청과 필수 필드, 측정값 범위(온도 -40~125℃ / 습도 0~100%)를 검증한다. 온습도 임계 판정은 서버에서 운영 임계(`METRIC_*`)로 수행하고, 동일 종류 경보 30분 쿨다운과 복구 히스테리시스로 SMS 오발송을 방지한다. DB 저장은 PDO prepared statement를 사용하며, 운영 비밀값은 `config.local.php`와 펌웨어 `secrets.h`로 분리하여 소스코드에 직접 저장하지 않는다. 관리자 페이지는 비밀번호 해시, CSRF 토큰, 세션 쿠키 보안 속성, 로그인 실패 제한을 적용한다.
|
||||||
|
|
||||||
추가 반영 문구:
|
추가 반영 문구:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
※ [구현 범위 정정] 본 시스템 구현 범위는 SHT30 온습도 모니터링 전용입니다(화재/누수/정전/카메라 미구현). 원본 .hwp 도 동일하게 정정 필요.
|
||||||
|
※ 아래 추출 본문은 원본 .hwp 그대로이며, 화재/누수/정전/현장 사진(카메라) 관련 서술은 실제 구현 범위가 아닙니다. 정정 지침은 docs/SECURITY_PLAN_HWP_REVIEW.md / docs/SECURITY_PLAN_PASS_READINESS.md 참조.
|
||||||
捤獥 汤捯 慤桥 漠杳 漠杳
|
捤獥 汤捯 慤桥 漠杳 漠杳
|
||||||
『IoT 기반 상시 모니터링 체계 구축』을 위한 보안대책서
|
『IoT 기반 상시 모니터링 체계 구축』을 위한 보안대책서
|
||||||
2026. 5.
|
2026. 5.
|
||||||
|
|
|
||||||
65
docs/build_standalone.py
Normal file
65
docs/build_standalone.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# =============================================================================
|
||||||
|
# build_standalone.py — INSTALL_GUIDE.html 의 모든 로컬 이미지를 base64 로 인라인하여
|
||||||
|
# 단일 자체완결 파일(INSTALL_GUIDE.standalone.html)을 만든다.
|
||||||
|
#
|
||||||
|
# 사용:
|
||||||
|
# 1) docs/screenshots/ 에 캡처를 README 의 파일명대로 저장
|
||||||
|
# 2) python3 docs/build_standalone.py
|
||||||
|
# 3) 산출물: docs/INSTALL_GUIDE.standalone.html (이미지까지 한 파일에 내장 — 폴더 없이 전달 가능)
|
||||||
|
#
|
||||||
|
# 파일이 아직 없는 캡처는 상대경로(src="screenshots/..")로 남겨 두므로, 본문의
|
||||||
|
# onerror 자리표시가 그대로 동작한다. 캡처를 채운 뒤 다시 실행하면 모두 내장된다.
|
||||||
|
# 외부(http/https)·이미 data: 인 src 는 건드리지 않는다(폐쇄망 유지).
|
||||||
|
# =============================================================================
|
||||||
|
import base64, os, re, sys
|
||||||
|
|
||||||
|
BASE = os.path.dirname(os.path.abspath(__file__)) # docs/
|
||||||
|
SRC = os.path.join(BASE, 'INSTALL_GUIDE.html')
|
||||||
|
OUT = os.path.join(BASE, 'INSTALL_GUIDE.standalone.html')
|
||||||
|
|
||||||
|
MIME = {'.png':'image/png', '.svg':'image/svg+xml', '.jpg':'image/jpeg',
|
||||||
|
'.jpeg':'image/jpeg', '.gif':'image/gif', '.bmp':'image/bmp', '.webp':'image/webp'}
|
||||||
|
|
||||||
|
def to_data_uri(path: str) -> str:
|
||||||
|
ext = os.path.splitext(path)[1].lower()
|
||||||
|
mime = MIME.get(ext, 'application/octet-stream')
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
b64 = base64.b64encode(f.read()).decode('ascii')
|
||||||
|
return f'data:{mime};base64,{b64}'
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not os.path.isfile(SRC):
|
||||||
|
print(f'ERROR: {SRC} 없음', file=sys.stderr); return 1
|
||||||
|
html = open(SRC, encoding='utf-8').read()
|
||||||
|
|
||||||
|
embedded, missing, skipped = [], [], []
|
||||||
|
for src in sorted(set(re.findall(r'src="([^"]+)"', html))):
|
||||||
|
if src.startswith(('data:', 'http://', 'https://')):
|
||||||
|
skipped.append(src); continue
|
||||||
|
fp = os.path.normpath(os.path.join(BASE, src))
|
||||||
|
if os.path.isfile(fp):
|
||||||
|
html = html.replace(f'src="{src}"', f'src="{to_data_uri(fp)}"')
|
||||||
|
embedded.append(src)
|
||||||
|
else:
|
||||||
|
missing.append(src)
|
||||||
|
|
||||||
|
open(OUT, 'w', encoding='utf-8').write(html)
|
||||||
|
|
||||||
|
print(f'내장(embedded) {len(embedded)}개:')
|
||||||
|
for e in embedded: print(' +', e)
|
||||||
|
if missing:
|
||||||
|
print(f'미존재 — 상대경로 유지(캡처 추가 후 재실행) {len(missing)}개:')
|
||||||
|
for m in missing: print(' -', m)
|
||||||
|
if skipped:
|
||||||
|
print(f'건너뜀(외부/이미 data:) {len(skipped)}개')
|
||||||
|
size = os.path.getsize(OUT)
|
||||||
|
print(f'\n산출물: {OUT} ({size/1024:.0f} KB)')
|
||||||
|
if not missing:
|
||||||
|
print('→ 모든 이미지 내장 완료. 이 파일 하나만 전달하면 됩니다(폴더 불필요).')
|
||||||
|
else:
|
||||||
|
print('→ 일부 캡처 미제공: 해당 자리는 자리표시로 표시됩니다. 캡처 추가 후 다시 실행하세요.')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
0
docs/screenshots/.gitkeep
Normal file
0
docs/screenshots/.gitkeep
Normal file
22
docs/screenshots/README.md
Normal file
22
docs/screenshots/README.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# 설치 가이드 캡처 이미지 폴더
|
||||||
|
|
||||||
|
`docs/INSTALL_GUIDE.html` 본문의 각 "📷 캡처 위치" 는 아래 파일명을 가진 `<img>` 로 연결되어 있습니다.
|
||||||
|
**운영 시스템에서 해당 화면을 캡처해 아래 파일명 그대로 이 폴더에 저장**하면, 가이드를 열었을 때
|
||||||
|
자리표시 대신 실제 이미지가 표시됩니다. (파일이 없으면 "여기에 넣으세요" 자리표시가 자동으로 보입니다.)
|
||||||
|
|
||||||
|
| 파일명 | 그림 | 캡처할 화면 |
|
||||||
|
|---|---|---|
|
||||||
|
| `02-server-files-upload.png` | 그림 2 | Cafe24 FTP/파일 관리자 — `public_html/raspi_leck_detecter/` 업로드된 파일 목록 |
|
||||||
|
| `03-db-phpmyadmin.png` | 그림 3 | phpMyAdmin SQL 탭에 `schema_sht30.sql` 실행 → 성공 + 테이블 생성 |
|
||||||
|
| `04-mfa-setup.png` | 그림 4 | `setup_mfa.php` 등록 화면(Base32 키 + 6자리 코드 확인). 비밀키 일부 가림 권장 |
|
||||||
|
| `05-setup-wizard.png` | 그림 5 | `setup_wizard.php` 점검 결과(모든 항목 ✅ + 테스트 SMS 성공) |
|
||||||
|
| `06-cron-register.png` | 그림 6 | Cafe24 크론탭(예약작업)에 `cron_heartbeat.php` 1분 주기 등록 |
|
||||||
|
| `08-firmware-build.png` | 그림 8 | 빌드 머신 터미널 — `cmake --build … --target sht30_fw` 성공 + size 출력 |
|
||||||
|
| `09-flash.png` | 그림 9 | 플래싱 툴(STM32CubeProgrammer/`st-flash`) "Flash written/verified" |
|
||||||
|
| `10-serial-console.png` | 그림 10 | 시리얼 터미널(USART3) 부팅 로그 — SNTP → TLS OK → 서버 200 |
|
||||||
|
| `11-dashboard.png` | 그림 11 | 운영 `dashboard.php`(상단 "정상 감시 중" + 2번 센서 SHT30 온/습도 카드) |
|
||||||
|
|
||||||
|
## 권장 사항
|
||||||
|
- 형식: PNG 권장(고정 파일명). 캡처에 **수신번호·TOTP 비밀키 등 비밀값이 보이면 가린 뒤** 저장하세요.
|
||||||
|
- 폭: 가로 1000~1400px 정도면 인쇄/화면 모두 적절합니다.
|
||||||
|
- 모바일 대시보드를 추가로 넣고 싶으면 본문 그림 11 옆에 `<img src="screenshots/11b-dashboard-mobile.png">` 를 직접 추가하면 됩니다.
|
||||||
|
|
@ -135,11 +135,11 @@ main()
|
||||||
|
|
||||||
### Phase 4 — 서명 (raw-body 방식, R1 해결로 단순화)
|
### Phase 4 — 서명 (raw-body 방식, R1 해결로 단순화)
|
||||||
서버가 **raw-body 서명**을 지원하도록 변경 완료 (PHP `verify_signature_raw`, 본 저장소 적용됨):
|
서버가 **raw-body 서명**을 지원하도록 변경 완료 (PHP `verify_signature_raw`, 본 저장소 적용됨):
|
||||||
- 서버 검증식: `sha256(API_KEY . 요청_본문_바이트_그대로)`, 값은 HTTP 헤더 **`X-Signature`**로 전송.
|
- 서버 검증식: `HMAC-SHA256(키=API_KEY, 메시지=요청_본문_바이트_그대로)`(소문자 hex), 값은 HTTP 헤더 **`X-Signature`**로 전송. HMAC을 사용하므로 길이확장 공격에 견고하다.
|
||||||
- 펌웨어는 **자신이 전송하는 본문 바이트 그대로**를 SHA-256으로 서명만 하면 된다.
|
- 펌웨어는 **자신이 전송하는 본문 바이트 그대로**를 API_KEY 로 HMAC-SHA256 서명만 하면 된다.
|
||||||
→ 키 정렬·부동소수 포맷·슬래시 이스케이프 **재현 불필요** (SHT30 float 문제 원천 제거).
|
→ 키 정렬·부동소수 포맷·슬래시 이스케이프 **재현 불필요** (SHT30 float 문제 원천 제거).
|
||||||
- 클라이언트 절차:
|
- 클라이언트 절차:
|
||||||
1. `body` 문자열 1회 생성(필드 순서 자유). 2. `sig = sha256_hex(API_KEY || body)`.
|
1. `body` 문자열 1회 생성(필드 순서 자유). 2. `sig = hmac_sha256_hex(key=API_KEY, msg=body)`.
|
||||||
3. `POST` 시 `Content-Type: application/json` + `X-Signature: <sig>` 헤더, 본문=`body`.
|
3. `POST` 시 `Content-Type: application/json` + `X-Signature: <sig>` 헤더, 본문=`body`.
|
||||||
4. 본문의 `signature` 필드는 불필요(있어도 서버가 무시).
|
4. 본문의 `signature` 필드는 불필요(있어도 서버가 무시).
|
||||||
- 서버는 `X-Signature` 헤더가 없으면 레거시 본문 필드 서명으로 폴백 → **RPi 무중단 공존**.
|
- 서버는 `X-Signature` 헤더가 없으면 레거시 본문 필드 서명으로 폴백 → **RPi 무중단 공존**.
|
||||||
|
|
@ -147,7 +147,7 @@ main()
|
||||||
|
|
||||||
```c
|
```c
|
||||||
// 펌웨어: 전송 바이트 그대로 서명
|
// 펌웨어: 전송 바이트 그대로 서명
|
||||||
sig_sha256_hex(body, sig_hex); // sha256(API_KEY || body)
|
sig_raw_body(API_KEY, body, sig_hex); // HMAC-SHA256(key=API_KEY, msg=body)
|
||||||
// HTTP 헤더에 "X-Signature: <sig_hex>" 추가, 본문은 body 그대로 전송
|
// HTTP 헤더에 "X-Signature: <sig_hex>" 추가, 본문은 body 그대로 전송
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -199,13 +199,13 @@ int build_body_sht30(char *out, size_t n, const Sht30Msg *m) {
|
||||||
m->metric_status, m->app_version, (long)m->timestamp);
|
m->metric_status, m->app_version, (long)m->timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sha256(API_KEY || body) → hex. body는 전송 바이트와 100% 동일해야 한다.
|
// HMAC-SHA256(key=API_KEY, msg=body) → hex. body는 전송 바이트와 100% 동일해야 한다.
|
||||||
void sig_sha256_hex(const char *body, char hex[65]) {
|
// HMAC 구조이므로 키-접두 sha256과 달리 길이확장 공격에 견고하다.
|
||||||
uint8_t d[32]; mbedtls_sha256_context c;
|
void sig_raw_body(const char *key, const char *body, char hex[65]) {
|
||||||
mbedtls_sha256_init(&c); mbedtls_sha256_starts(&c, 0);
|
uint8_t d[32];
|
||||||
mbedtls_sha256_update(&c, (const uint8_t*)API_KEY, strlen(API_KEY));
|
const mbedtls_md_info_t *md = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
|
||||||
mbedtls_sha256_update(&c, (const uint8_t*)body, strlen(body));
|
mbedtls_md_hmac(md, (const uint8_t*)key, strlen(key),
|
||||||
mbedtls_sha256_finish(&c, d); mbedtls_sha256_free(&c);
|
(const uint8_t*)body, strlen(body), d);
|
||||||
for (int i=0;i<32;i++) sprintf(hex+i*2, "%02x", d[i]);
|
for (int i=0;i<32;i++) sprintf(hex+i*2, "%02x", d[i]);
|
||||||
}
|
}
|
||||||
// POST 시 헤더에 "X-Signature: <hex>" 추가, 본문은 위 body 그대로 전송.
|
// POST 시 헤더에 "X-Signature: <hex>" 추가, 본문은 위 body 그대로 전송.
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,9 @@ SHT30 모듈은 3.3V 단일 전원으로 동작합니다. SCL/SDA는 오픈드
|
||||||
- 첫 측정값(온도/습도) 및 startup 보고
|
- 첫 측정값(온도/습도) 및 startup 보고
|
||||||
- 네트워크/SNTP/TLS 핸드셰이크 → 서버 200 응답
|
- 네트워크/SNTP/TLS 핸드셰이크 → 서버 200 응답
|
||||||
|
|
||||||
정상 측정 범위(벗어나면 `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` 는 저장되지 않습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
└──────────────┘ │ Task_Watchdog: IWDG refresh │ │ 스위치/공유기 │
|
└──────────────┘ │ Task_Watchdog: IWDG refresh │ │ 스위치/공유기 │
|
||||||
└────────────────────────────┘ └──────┬───────┘
|
└────────────────────────────┘ └──────┬───────┘
|
||||||
│
|
│
|
||||||
lowercase_hex(sha256(API_KEY || raw_body)) X-Signature 헤더 │ HTTPS/TLS1.2
|
lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)) X-Signature 헤더 │ HTTPS/TLS1.2
|
||||||
▼
|
▼
|
||||||
┌────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────┐
|
||||||
│ Cafe24 호스팅 (변경 없음) │
|
│ Cafe24 호스팅 (변경 없음) │
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
```
|
```
|
||||||
|
|
||||||
- 펌웨어는 매 보고마다 `connect → TLS 핸드셰이크 → POST → close` 를 수행한다(메모리 절약, 보고 주기가 길어 성능 무관).
|
- 펌웨어는 매 보고마다 `connect → TLS 핸드셰이크 → POST → close` 를 수행한다(메모리 절약, 보고 주기가 길어 성능 무관).
|
||||||
- 서명은 본문 바이트 그대로 `sha256(API_KEY || body)` 를 계산해 **`X-Signature`** 헤더로 보낸다. 본문의 키 정렬/float 포맷 재현이 불필요하다(서버 `verify_signature_raw`).
|
- 서명은 본문 바이트 그대로 `HMAC-SHA256(key=API_KEY, msg=body)`(소문자 hex) 를 계산해 **`X-Signature`** 헤더로 보낸다. 본문의 키 정렬/float 포맷 재현이 불필요하다(서버 `verify_signature_raw`). HMAC 구조라 길이확장 공격에 견고하다.
|
||||||
- **임계(고온/저온/고습/저습) 판정과 SMS 발송은 서버(PHP)에서 수행**한다. 펌웨어는 온습도 원값과 plausibility 상태(`metric_status`)만 보고하고, 경보 임계는 서버 `config.php` 의 `METRIC_*` 상수가 결정한다(폐쇄망 재플래시 비용을 피하기 위함).
|
- **임계(고온/저온/고습/저습) 판정과 SMS 발송은 서버(PHP)에서 수행**한다. 펌웨어는 온습도 원값과 plausibility 상태(`metric_status`)만 보고하고, 경보 임계는 서버 `config.php` 의 `METRIC_*` 상수가 결정한다(폐쇄망 재플래시 비용을 피하기 위함).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
| 프로세스 관리 | systemd 재시작 | IWDG 워치독 + 재연결 | 변경(이식) |
|
| 프로세스 관리 | systemd 재시작 | IWDG 워치독 + 재연결 | 변경(이식) |
|
||||||
| 로깅 | 로테이팅 파일 로그 | UART 콘솔 | 변경(축소) |
|
| 로깅 | 로테이팅 파일 로그 | UART 콘솔 | 변경(축소) |
|
||||||
|
|
||||||
> **임계 판정의 위치:** 펌웨어는 `temperature_c`/`humidity_percent` 원값과 `metric_status`(센서 물리/타당성 범위 기준 `normal`/`out_of_range`)만 보고한다. 고온/저온/고습/저습 경보와 SMS 트리거는 **서버 `config.php` 의 `METRIC_*` 임계**가 담당한다. 폐쇄망에서 임계값을 조정할 때 펌웨어 재플래시가 불필요하도록 의도된 분리다.
|
> **임계 판정의 위치:** 펌웨어는 `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에 저장되지 않음).
|
||||||
|
|
||||||
자세한 함수 단위 매핑은 [`docs/PORTING_NOTES.md`](docs/PORTING_NOTES.md) 참고.
|
자세한 함수 단위 매핑은 [`docs/PORTING_NOTES.md`](docs/PORTING_NOTES.md) 참고.
|
||||||
|
|
||||||
|
|
@ -95,7 +95,7 @@ cp firmware/common/secrets.h.example firmware/common/secrets.h
|
||||||
|
|
||||||
# 2) 빌드 (sht30_fw 단일 타깃)
|
# 2) 빌드 (sht30_fw 단일 타깃)
|
||||||
cmake -S firmware -B firmware/build -G Ninja \
|
cmake -S firmware -B firmware/build -G Ninja \
|
||||||
-DCMAKE_TOOLCHAIN_FILE=firmware/cmake/arm-none-eabi.cmake
|
-DCMAKE_TOOLCHAIN_FILE=firmware/cmake/arm-none-eabi-toolchain.cmake
|
||||||
cmake --build firmware/build --target sht30_fw # → sht30_fw.bin/.hex/.elf
|
cmake --build firmware/build --target sht30_fw # → sht30_fw.bin/.hex/.elf
|
||||||
|
|
||||||
# 3) 플래시 (ST-Link) — 보드 파라미터 없이 sht30_fw 고정
|
# 3) 플래시 (ST-Link) — 보드 파라미터 없이 sht30_fw 고정
|
||||||
|
|
@ -119,7 +119,7 @@ firmware/
|
||||||
├─ README.md ← (이 문서)
|
├─ README.md ← (이 문서)
|
||||||
├─ CMakeLists.txt 빌드 루트: sht30_fw 타깃, third_party 통합
|
├─ CMakeLists.txt 빌드 루트: sht30_fw 타깃, third_party 통합
|
||||||
├─ cmake/
|
├─ cmake/
|
||||||
│ └─ arm-none-eabi.cmake 크로스 툴체인 파일 (gcc 15.2)
|
│ └─ arm-none-eabi-toolchain.cmake 크로스 툴체인 파일 (gcc 15.2)
|
||||||
├─ scripts/
|
├─ scripts/
|
||||||
│ ├─ vendor.* 빌드 머신에서 고정 버전 의존성 벤더링(폐쇄망 준비)
|
│ ├─ vendor.* 빌드 머신에서 고정 버전 의존성 벤더링(폐쇄망 준비)
|
||||||
│ ├─ build.ps1 cmake configure + sht30_fw 빌드
|
│ ├─ build.ps1 cmake configure + sht30_fw 빌드
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,15 @@
|
||||||
#define APP_HTTP_RETRY_DELAY_MS 5000u /* 재시도 간 대기 */
|
#define APP_HTTP_RETRY_DELAY_MS 5000u /* 재시도 간 대기 */
|
||||||
|
|
||||||
/* ── 시간 동기 (SNTP) — TLS 인증서 유효기간/timestamp 필드에 필수 ────────── */
|
/* ── 시간 동기 (SNTP) — TLS 인증서 유효기간/timestamp 필드에 필수 ────────── */
|
||||||
#define APP_SNTP_SERVER "pool.ntp.org"
|
/* !! 폐쇄망(air-gapped) 필수 설정 !!
|
||||||
|
* APP_SNTP_SERVER 는 *폐쇄망 내부 LAN NTP 서버*의 IP/호스트로 반드시 교체할 것.
|
||||||
|
* 인터넷 NTP(예: pool.ntp.org)는 폐쇄망에서 도달 불가 → SNTP 실패 → RTC 미설정 →
|
||||||
|
* timestamp=0 → TLS 연결 차단 + 서버가 신선도 위반으로 전량 거부 = 보고 전면 장애.
|
||||||
|
* (DNS 가 없을 수 있으므로 호스트명보다 LAN IP 권장. 아래는 RFC5737 TEST-NET 자리표시자
|
||||||
|
* 로, 그대로 두면 의도적으로 동기 실패하여 미설정을 드러낸다.)
|
||||||
|
* 보조: 서버 ingest 200 응답의 server_now 로 RTC 드리프트를 미세보정하는 후속 작업
|
||||||
|
* 예정(LSI 드리프트/간헐 NTP 대비). 최초 시각 확보는 LAN NTP(또는 배터리 RTC)가 필요. */
|
||||||
|
#define APP_SNTP_SERVER "192.0.2.123" /* TODO(deploy): 폐쇄망 LAN NTP IP 로 교체 */
|
||||||
#define APP_SNTP_RETRY_DELAY_MS 15000u
|
#define APP_SNTP_RETRY_DELAY_MS 15000u
|
||||||
|
|
||||||
/* ── 네트워크 주소 지정 ─────────────────────────────────────────────────── */
|
/* ── 네트워크 주소 지정 ─────────────────────────────────────────────────── */
|
||||||
|
|
|
||||||
|
|
@ -236,15 +236,15 @@ void net_ip_str(char *buf, uint32_t buflen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── LWIP_RAND() 백엔드 ─────────────────────────────────────────────────────
|
/* ── LWIP_RAND() 백엔드 (약한 폴백) ─────────────────────────────────────────
|
||||||
* lwipopts.h 의 LWIP_RAND() 가 bsp_rand32() 를 호출한다. 하드웨어 RNG 가
|
* lwipopts.h 의 LWIP_RAND() 가 bsp_rand32() 를 호출한다. 운영 펌웨어(sht30_fw)는
|
||||||
* 정식 구현(rng.c/bsp.c)될 때까지의 약한 대체 구현. RNG 페리페럴이 준비되면
|
* bsp.c 의 *강한* 하드웨어 RNG 구현(bsp_rand32, RNG->DR)이 이 weak 심볼을 덮어쓰므로
|
||||||
* 그쪽 strong 심볼이 우선하도록 weak 로 둔다.
|
* 실제로는 HW RNG 가 사용된다(TCP ISN / DHCP·DNS 트랜잭션 ID 예측성 완화). 이 weak
|
||||||
* TODO(hw): bsp/rng 모듈에서 HW RNG(RNG->DR) 기반 강한 bsp_rand32() 제공. */
|
* 구현은 bsp.c 를 링크하지 않는 빌드(예: 호스트 패리티)에서만 쓰이는 폴백이다. */
|
||||||
__attribute__((weak)) uint32_t bsp_rand32(void)
|
__attribute__((weak)) uint32_t bsp_rand32(void)
|
||||||
{
|
{
|
||||||
/* xorshift32 + FreeRTOS 틱 시드(예측 가능 — TLS/DHCP 트랜잭션 ID 보안엔 부적합).
|
/* xorshift32 + FreeRTOS 틱 시드(예측 가능 — TLS/DHCP 트랜잭션 ID 보안엔 부적합).
|
||||||
* 운영에서는 반드시 HW RNG 구현으로 대체. */
|
* 운영 빌드에서는 bsp.c 의 강한 HW RNG 구현이 이 폴백을 대체한다. */
|
||||||
static uint32_t s = 0x1234abcdU;
|
static uint32_t s = 0x1234abcdU;
|
||||||
s ^= (uint32_t)xTaskGetTickCount();
|
s ^= (uint32_t)xTaskGetTickCount();
|
||||||
s ^= s << 13;
|
s ^= s << 13;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
* secrets.h 는 저장소에 커밋하지 않는다(.gitignore 등록).
|
* secrets.h 는 저장소에 커밋하지 않는다(.gitignore 등록).
|
||||||
*
|
*
|
||||||
* APP_API_KEY 는 서버 php/config.php 의 API_KEY 와 반드시 동일해야 한다.
|
* APP_API_KEY 는 서버 php/config.php 의 API_KEY 와 반드시 동일해야 한다.
|
||||||
* (raw-body 서명: X-Signature = sha256(APP_API_KEY || 본문) )
|
* (raw-body 서명: X-Signature = lowercase_hex(HMAC-SHA256(키=APP_API_KEY, 메시지=본문 바이트)) )
|
||||||
* ===========================================================================*/
|
* ===========================================================================*/
|
||||||
#ifndef SECRETS_H
|
#ifndef SECRETS_H
|
||||||
#define SECRETS_H
|
#define SECRETS_H
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
#define SIG_HEX_LEN 64 /* SHA-256 hex 길이 (NUL 제외) */
|
#define SIG_HEX_LEN 64 /* SHA-256 hex 길이 (NUL 제외) */
|
||||||
#define SIG_HEX_BUFSZ (SIG_HEX_LEN + 1)
|
#define SIG_HEX_BUFSZ (SIG_HEX_LEN + 1)
|
||||||
|
|
||||||
/* out_hex 에 sha256(key || body) 의 소문자 hex(+NUL)를 기록한다.
|
/* out_hex 에 HMAC-SHA256(key, body) 의 소문자 hex(+NUL)를 기록한다.
|
||||||
* out_hex 는 최소 SIG_HEX_BUFSZ 바이트.
|
* out_hex 는 최소 SIG_HEX_BUFSZ 바이트.
|
||||||
* key 는 NUL 종료 문자열, body 는 임의 바이트열(body_len 길이). */
|
* key 는 NUL 종료 문자열, body 는 임의 바이트열(body_len 길이). */
|
||||||
void sig_raw_body(const char *key, const char *body, size_t body_len,
|
void sig_raw_body(const char *key, const char *body, size_t body_len,
|
||||||
|
|
|
||||||
|
|
@ -198,9 +198,9 @@ extern void timesync_apply_epoch(uint32_t unix_seconds);
|
||||||
#define LWIP_NETIF_TX_SINGLE_PBUF 1 /* 송신 시 단일 pbuf 선호(HAL_ETH 단순화) */
|
#define LWIP_NETIF_TX_SINGLE_PBUF 1 /* 송신 시 단일 pbuf 선호(HAL_ETH 단순화) */
|
||||||
#define LWIP_RAND() ((u32_t)bsp_rand32())
|
#define LWIP_RAND() ((u32_t)bsp_rand32())
|
||||||
|
|
||||||
/* LWIP_RAND 용: 하드웨어 RNG 기반 32비트 난수(bsp/별도 모듈 제공).
|
/* LWIP_RAND 용: 하드웨어 RNG 기반 32비트 난수.
|
||||||
* TODO(hw): bsp.c 또는 rng.c 에서 bsp_rand32() 를 RNG 페리페럴로 구현.
|
* 운영 펌웨어(sht30_fw)는 bsp.c 가 RNG 페리페럴(RNG->DR) 기반 강한 bsp_rand32() 를
|
||||||
* (현재 net.c 에 약한 대체 구현이 있을 수 있음) */
|
* 제공하며, net.c 의 weak 폴백을 덮어쓴다(bsp.c 미링크 빌드에서만 weak 폴백 사용). */
|
||||||
uint32_t bsp_rand32(void);
|
uint32_t bsp_rand32(void);
|
||||||
|
|
||||||
#endif /* LWIPOPTS_H */
|
#endif /* LWIPOPTS_H */
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,10 @@ firmware/third_party/
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 빌드 머신(인터넷 가능)에서 1회 실행. 고정 버전을 받아 third_party/ 로 정리한다.
|
# 빌드 머신(인터넷 가능)에서 1회 실행. 고정 버전을 받아 third_party/ 로 정리한다.
|
||||||
bash firmware/scripts/vendor_deps.sh # (또는 vendor_deps.ps1)
|
bash firmware/scripts/vendor.sh # (또는 vendor.ps1)
|
||||||
```
|
```
|
||||||
|
|
||||||
> `TODO(hw):` `firmware/scripts/vendor_deps.{sh,ps1}` 는 별도 작업으로 작성/제공된다. 권장 고정 버전(예시):
|
> `TODO(hw):` `firmware/scripts/vendor.{sh,ps1}` 는 별도 작업으로 작성/제공된다. 권장 고정 버전(예시):
|
||||||
> STM32CubeF4 `v1.28.x`, FreeRTOS-Kernel `V11.x`, lwip `STABLE-2.2.x`, mbedtls `v3.6.x` (LTS).
|
> STM32CubeF4 `v1.28.x`, FreeRTOS-Kernel `V11.x`, lwip `STABLE-2.2.x`, mbedtls `v3.6.x` (LTS).
|
||||||
> 스크립트 미제공 시: 각 릴리스 tarball 을 수동으로 받아 위 디렉터리 구조로 풀고, **버전을 기록**한다.
|
> 스크립트 미제공 시: 각 릴리스 tarball 을 수동으로 받아 위 디렉터리 구조로 풀고, **버전을 기록**한다.
|
||||||
|
|
||||||
|
|
@ -76,7 +76,7 @@ cp firmware/common/secrets.h.example firmware/common/secrets.h
|
||||||
```
|
```
|
||||||
|
|
||||||
`secrets.h` 의 `APP_API_KEY` 를 **서버 `php/config.php` 의 `API_KEY` 와 바이트 단위로 동일**하게 채운다.
|
`secrets.h` 의 `APP_API_KEY` 를 **서버 `php/config.php` 의 `API_KEY` 와 바이트 단위로 동일**하게 채운다.
|
||||||
(raw-body 서명: `X-Signature = sha256(APP_API_KEY || body)`.)
|
(raw-body 서명: `X-Signature = lowercase_hex(HMAC-SHA256(key=APP_API_KEY, msg=body))`.)
|
||||||
|
|
||||||
> - `secrets.h` 는 **저장소에 커밋하지 않는다**(`.gitignore` 등록). 산출 `.bin` 안에는 키가 임베드되므로
|
> - `secrets.h` 는 **저장소에 커밋하지 않는다**(`.gitignore` 등록). 산출 `.bin` 안에는 키가 임베드되므로
|
||||||
> `.bin` 자체를 비밀로 취급한다(공통 보안 규칙: 산출물에 비밀이 새지 않도록 유통 통제).
|
> `.bin` 자체를 비밀로 취급한다(공통 보안 규칙: 산출물에 비밀이 새지 않도록 유통 통제).
|
||||||
|
|
@ -126,7 +126,7 @@ const unsigned int SERVER_CA_PEM_LEN = sizeof(SERVER_CA_PEM);
|
||||||
```bash
|
```bash
|
||||||
# 구성 (크로스 툴체인 파일 지정)
|
# 구성 (크로스 툴체인 파일 지정)
|
||||||
cmake -S firmware -B firmware/build -G Ninja \
|
cmake -S firmware -B firmware/build -G Ninja \
|
||||||
-DCMAKE_TOOLCHAIN_FILE=firmware/cmake/arm-none-eabi.cmake
|
-DCMAKE_TOOLCHAIN_FILE=firmware/cmake/arm-none-eabi-toolchain.cmake
|
||||||
|
|
||||||
# SHT30 보드 산출물 빌드 (보드 매크로 -DBOARD_SHT30 은 타깃이 자동 주입)
|
# SHT30 보드 산출물 빌드 (보드 매크로 -DBOARD_SHT30 은 타깃이 자동 주입)
|
||||||
cmake --build firmware/build --target sht30_fw # → sht30_fw.elf / .bin / .hex
|
cmake --build firmware/build --target sht30_fw # → sht30_fw.elf / .bin / .hex
|
||||||
|
|
@ -148,14 +148,14 @@ arm-none-eabi-size firmware/build/sht30_fw.elf # Flash(1MB)/RAM(192KB) 예
|
||||||
```
|
```
|
||||||
|
|
||||||
> 빌드 중 인터넷 접근이 발생하면 **벤더링 누락**이다 — `third_party/` 를 보강하고 외부 fetch 0건을 보장한다.
|
> 빌드 중 인터넷 접근이 발생하면 **벤더링 누락**이다 — `third_party/` 를 보강하고 외부 fetch 0건을 보장한다.
|
||||||
> `TODO(hw):` `firmware/cmake/arm-none-eabi.cmake` / `*.ld`(CCM 스택 배치) 는 별도 작업으로 제공된다.
|
> `TODO(hw):` `firmware/cmake/arm-none-eabi-toolchain.cmake` / `*.ld`(CCM 스택 배치) 는 별도 작업으로 제공된다.
|
||||||
|
|
||||||
### 4.1 (선택) 호스트 패리티 게이트 — 서버 기대값 대조
|
### 4.1 (선택) 호스트 패리티 게이트 — 서버 기대값 대조
|
||||||
|
|
||||||
`.bin` 을 만들기 전, **이식성 순수 로직**(서명/JSON/CRC/변환)을 호스트에서 PHP 기대값과 대조한다.
|
`.bin` 을 만들기 전, **이식성 순수 로직**(서명/JSON/CRC/변환)을 호스트에서 PHP 기대값과 대조한다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# firmware/test/host : sha256(API_KEY||body) 와 PHP verify_signature_raw 결과 일치 확인
|
# firmware/test/host : HMAC-SHA256(key=API_KEY, msg=body) 와 PHP verify_signature_raw 결과 일치 확인
|
||||||
python3 firmware/test/host/parity_test.py
|
python3 firmware/test/host/parity_test.py
|
||||||
php firmware/test/host/php_verify.php # 동일 본문에 대한 서버측 서명 기대값
|
php firmware/test/host/php_verify.php # 동일 본문에 대한 서버측 서명 기대값
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -98,8 +98,9 @@ Sensirion SHT30 (한진데이터 P4422-3 모듈), I2C. RPi `sht30_monitor.py`
|
||||||
- 7-bit 주소: **0x44** (`APP_SHT30_I2C_ADDR`). HAL 호출 시 `0x44<<1`.
|
- 7-bit 주소: **0x44** (`APP_SHT30_I2C_ADDR`). HAL 호출 시 `0x44<<1`.
|
||||||
- 명령: `0x2C06` (high-repeatability, clock-stretch off) → ~20ms 대기 → 6바이트 read.
|
- 명령: `0x2C06` (high-repeatability, clock-stretch off) → ~20ms 대기 → 6바이트 read.
|
||||||
- 풀업: 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에 남지 않음).
|
||||||
|
|
||||||
```
|
```
|
||||||
SHT30 모듈 STM32
|
SHT30 모듈 STM32
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ SHT30 시나리오: `startup` → (300s 마다) `periodic` 측정 보고. 서버
|
||||||
| **TLS 해시/암호** | OS OpenSSL | **소프트웨어**(SHA-256/AES) | F407 에 HW 해시 가속기 없음. RSA-2048 핸드셰이크 ~1–2s 허용 |
|
| **TLS 해시/암호** | OS OpenSSL | **소프트웨어**(SHA-256/AES) | F407 에 HW 해시 가속기 없음. RSA-2048 핸드셰이크 ~1–2s 허용 |
|
||||||
| **동시성** | Python threads + Lock | FreeRTOS tasks/queue/timer (네이티브 API) | RTOS. 태스크 스택은 CCM(64KB) 배치 |
|
| **동시성** | Python threads + Lock | FreeRTOS tasks/queue/timer (네이티브 API) | RTOS. 태스크 스택은 CCM(64KB) 배치 |
|
||||||
| **시간원** | OS NTP | SNTP→RTC (TLS 전에 수행) | UDP SNTP 는 TLS 불필요 → 닭-달걀 회피 |
|
| **시간원** | OS NTP | SNTP→RTC (TLS 전에 수행) | UDP SNTP 는 TLS 불필요 → 닭-달걀 회피 |
|
||||||
| **서명 방식** | `sha256(API_KEY + json.dumps(sort_keys, ensure_ascii=False, separators=(',',':')))` (본문에 `signature` 필드) | `sha256(API_KEY ‖ raw_body)` → **`X-Signature` 헤더** | **(중요)** 키 정렬/float 포맷/슬래시 이스케이프 재현 불필요. SHT30 float 서명 패리티 문제 원천 제거(R1). 서버는 `verify_signature_raw` + 레거시 폴백 이중 모드 |
|
| **서명 방식** | `sha256(API_KEY + json.dumps(sort_keys, ensure_ascii=False, separators=(',',':')))` (본문에 `signature` 필드) | `HMAC-SHA256(key=API_KEY, msg=raw_body)`(소문자 hex) → **`X-Signature` 헤더** | **(중요)** 키 정렬/float 포맷/슬래시 이스케이프 재현 불필요. SHT30 float 서명 패리티 문제 원천 제거(R1). HMAC 구조라 길이확장 공격에 견고. 서버는 `verify_signature_raw` + 레거시 폴백 이중 모드 |
|
||||||
| **본문 필드(wire)** | `is_leak` 포함(누수 시스템 잔재) | **`is_leak` 필드 제거** | 단일 SHT30 보드에 무의미. `jb_sht30_event` 가 더 이상 직렬화하지 않으며, 서버 `sensor_data.php` 도 `is_leak` 처리 제거 |
|
| **본문 필드(wire)** | `is_leak` 포함(누수 시스템 잔재) | **`is_leak` 필드 제거** | 단일 SHT30 보드에 무의미. `jb_sht30_event` 가 더 이상 직렬화하지 않으며, 서버 `sensor_data.php` 도 `is_leak` 처리 제거 |
|
||||||
| **임계 판정/SMS** | (서버측) | **서버 `config.php` METRIC_* 에서 판정** | 폐쇄망 재플래시 비용 회피. 펌웨어는 원값 + `metric_status` 만 보고(§4) |
|
| **임계 판정/SMS** | (서버측) | **서버 `config.php` METRIC_* 에서 판정** | 폐쇄망 재플래시 비용 회피. 펌웨어는 원값 + `metric_status` 만 보고(§4) |
|
||||||
| **설정** | env / `config.py` | 컴파일 타임 `app_config.h`/`board_config.h` + `secrets.h` | 런타임 env 없음(옵션: flash 설정/UART 명령 미구현) |
|
| **설정** | env / `config.py` | 컴파일 타임 `app_config.h`/`board_config.h` + `secrets.h` | 런타임 env 없음(옵션: flash 설정/UART 명령 미구현) |
|
||||||
|
|
@ -99,7 +99,7 @@ RPi 의 `make_signature()` 는 본문을 **정규화(sort_keys, no-space, non-as
|
||||||
|
|
||||||
```c
|
```c
|
||||||
/* 펌웨어 절차 (httpapi.c::api_post_once 가 내부 수행) */
|
/* 펌웨어 절차 (httpapi.c::api_post_once 가 내부 수행) */
|
||||||
sig_raw_body(APP_API_KEY, body, strlen(body), sig_hex); /* sha256(key || body) */
|
sig_raw_body(APP_API_KEY, body, strlen(body), sig_hex); /* HMAC-SHA256(key=APP_API_KEY, msg=body) */
|
||||||
/* HTTP 헤더에 "X-Signature: <sig_hex>", 본문은 body 바이트 그대로 전송 */
|
/* HTTP 헤더에 "X-Signature: <sig_hex>", 본문은 body 바이트 그대로 전송 */
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -119,6 +119,8 @@ SHT30 측정에서 SMS 발송까지의 책임 분담:
|
||||||
1. **펌웨어(측정·보고)**: 300s 마다 SHT30 을 읽어 `temperature_c`/`humidity_percent` **원값**(소수 2자리)을 계산하고,
|
1. **펌웨어(측정·보고)**: 300s 마다 SHT30 을 읽어 `temperature_c`/`humidity_percent` **원값**(소수 2자리)을 계산하고,
|
||||||
센서 물리/타당성 범위(`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` 는 **펌웨어 전용 타당성 분류**다. 서버는 이 값을 무시하고 운영 임계로 재판정하므로
|
||||||
|
DB `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 게이트웨이로 문자**를 발송한다.
|
||||||
|
|
||||||
|
|
@ -155,7 +157,7 @@ SHT30 측정에서 SMS 발송까지의 책임 분담:
|
||||||
|
|
||||||
## 6. 검증 게이트 (이식 정확성 확인)
|
## 6. 검증 게이트 (이식 정확성 확인)
|
||||||
|
|
||||||
- **호스트 패리티**: `firmware/test/host/parity_test.py` + `php_verify.php` 로 `sha256(API_KEY‖body)` 가 서버
|
- **호스트 패리티**: `firmware/test/host/parity_test.py` + `php_verify.php` 로 `HMAC-SHA256(key=API_KEY, msg=body)` 가 서버
|
||||||
`verify_signature_raw` 기대값과 일치하는지 대조(순수 로직: 서명/JSON/CRC/변환은 호스트·ARM 동일 다이제스트).
|
`verify_signature_raw` 기대값과 일치하는지 대조(순수 로직: 서명/JSON/CRC/변환은 호스트·ARM 동일 다이제스트).
|
||||||
- **E2E**: 스테이징 PHP 로 `startup/periodic`(SHT30) 200 수신, `temperature_c`/`humidity_percent`/`metric_status` 적재 +
|
- **E2E**: 스테이징 PHP 로 `startup/periodic`(SHT30) 200 수신, `temperature_c`/`humidity_percent`/`metric_status` 적재 +
|
||||||
서버 임계 초과 시 SMS 트리거 확인.
|
서버 임계 초과 시 SMS 트리거 확인.
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,10 @@ param(
|
||||||
|
|
||||||
[switch]$Clean,
|
[switch]$Clean,
|
||||||
|
|
||||||
[string]$ToolchainPrefix
|
[string]$ToolchainPrefix,
|
||||||
|
|
||||||
|
# 자리표시자(PLACEHOLDER) 서버 CA 상태로도 빌드를 강행한다(테스트 전용).
|
||||||
|
[switch]$AllowPlaceholderCA
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
@ -78,6 +81,24 @@ networked 빌드 머신에서 먼저 실행하세요:
|
||||||
"@
|
"@
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── 배포 가드: 자리표시자(PLACEHOLDER) 서버 CA 검출 ──────────────────────────
|
||||||
|
# server_ca.c 가 자리표시자면 부팅 시 tls_init() 의 X.509 파싱이 실패해 bsp_fatal()
|
||||||
|
# 로 안전 정지한다(데이터 유출은 없으나 동작 불가). "조용한 부팅 실패"를 피하려고
|
||||||
|
# 빌드 단계에서 미리 차단한다. 실제 Cafe24 루트 CA 로 교체해야 한다(certs/README.md).
|
||||||
|
$ServerCa = Join-Path $FwRoot 'certs/server_ca.c'
|
||||||
|
if ((Test-Path $ServerCa) -and (Select-String -Path $ServerCa -SimpleMatch 'PLACEHOLDER' -Quiet)) {
|
||||||
|
if ($AllowPlaceholderCA) {
|
||||||
|
Write-Warning "server_ca.c 가 자리표시자(PLACEHOLDER) 입니다 — 이 펌웨어는 부팅 시 TLS 초기화 실패로 정지합니다(-AllowPlaceholderCA 로 강행)."
|
||||||
|
} else {
|
||||||
|
throw @"
|
||||||
|
server_ca.c 가 자리표시자(PLACEHOLDER) 상태입니다 — 실제 Cafe24 루트 CA 로 교체해야 합니다.
|
||||||
|
이 상태로 빌드한 펌웨어는 부팅 시 tls_init() 이 bsp_fatal() 로 정지합니다(동작 불가).
|
||||||
|
교체: firmware/certs/README.md 및 certs/gen_ca_header.ps1 참고.
|
||||||
|
(테스트 목적 강행: build.ps1 ... -AllowPlaceholderCA)
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# ── 클린 빌드 ────────────────────────────────────────────────────────────────
|
# ── 클린 빌드 ────────────────────────────────────────────────────────────────
|
||||||
if ($Clean -and (Test-Path $BuildDir)) {
|
if ($Clean -and (Test-Path $BuildDir)) {
|
||||||
Write-Host " (clean) 기존 빌드 디렉터리 삭제: $BuildDir" -ForegroundColor Yellow
|
Write-Host " (clean) 기존 빌드 디렉터리 삭제: $BuildDir" -ForegroundColor Yellow
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,9 @@
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[switch]$Force,
|
[switch]$Force,
|
||||||
[switch]$KeepArchives
|
[switch]$KeepArchives,
|
||||||
|
# 엄격 핀 모드: SHA-256 이 'TODO'(미설정)인 의존성이 있으면 실패(릴리스 빌드용).
|
||||||
|
[switch]$Strict
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
@ -136,6 +138,9 @@ foreach ($dep in $Deps) {
|
||||||
# ── 무결성 검증(SHA-256) ────────────────────────────────────────────────
|
# ── 무결성 검증(SHA-256) ────────────────────────────────────────────────
|
||||||
$actual = Get-FileSha256 $archive
|
$actual = Get-FileSha256 $archive
|
||||||
if ($dep.Sha256 -eq 'TODO' -or [string]::IsNullOrWhiteSpace($dep.Sha256)) {
|
if ($dep.Sha256 -eq 'TODO' -or [string]::IsNullOrWhiteSpace($dep.Sha256)) {
|
||||||
|
if ($Strict) {
|
||||||
|
throw "Strict 모드: $name 의 SHA-256 핀이 미설정(TODO) 입니다. 계산값을 `$Deps 에 기록 후 재실행하세요(재현성 고정): $actual"
|
||||||
|
}
|
||||||
Write-Host " [경고] SHA-256 미설정 — 검증 생략. 계산값을 기록하세요:" -ForegroundColor Yellow
|
Write-Host " [경고] SHA-256 미설정 — 검증 생략. 계산값을 기록하세요:" -ForegroundColor Yellow
|
||||||
Write-Host " $name : $actual" -ForegroundColor Yellow
|
Write-Host " $name : $actual" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
# firmware/scripts/vendor.sh # 표준 벤더링
|
# firmware/scripts/vendor.sh # 표준 벤더링
|
||||||
# FORCE=1 firmware/scripts/vendor.sh # 기존 트리 덮어쓰기(재벤더링)
|
# FORCE=1 firmware/scripts/vendor.sh # 기존 트리 덮어쓰기(재벤더링)
|
||||||
# KEEP_ARCHIVES=1 firmware/scripts/vendor.sh # 압축본 보존(_archives)
|
# KEEP_ARCHIVES=1 firmware/scripts/vendor.sh # 압축본 보존(_archives)
|
||||||
|
# VENDOR_STRICT=1 firmware/scripts/vendor.sh # 핀(SHA-256) 미설정 시 실패(릴리스용)
|
||||||
#
|
#
|
||||||
# 요구 도구: curl(또는 wget), sha256sum(또는 shasum), unzip, tar.
|
# 요구 도구: curl(또는 wget), sha256sum(또는 shasum), unzip, tar.
|
||||||
#
|
#
|
||||||
|
|
@ -28,6 +29,9 @@ ARCHIVE_DIR="${THIRD_PARTY}/_archives"
|
||||||
|
|
||||||
FORCE="${FORCE:-0}"
|
FORCE="${FORCE:-0}"
|
||||||
KEEP_ARCHIVES="${KEEP_ARCHIVES:-0}"
|
KEEP_ARCHIVES="${KEEP_ARCHIVES:-0}"
|
||||||
|
# 엄격 핀 모드: SHA-256 이 'TODO'(미설정)인 의존성이 있으면 즉시 실패한다. 릴리스
|
||||||
|
# 빌드는 VENDOR_STRICT=1 로 실행해 모든 핀이 채워졌는지(재현성 고정) 강제한다.
|
||||||
|
VENDOR_STRICT="${VENDOR_STRICT:-0}"
|
||||||
|
|
||||||
mkdir -p "${THIRD_PARTY}"
|
mkdir -p "${THIRD_PARTY}"
|
||||||
[ "${KEEP_ARCHIVES}" = "1" ] && mkdir -p "${ARCHIVE_DIR}"
|
[ "${KEEP_ARCHIVES}" = "1" ] && mkdir -p "${ARCHIVE_DIR}"
|
||||||
|
|
@ -108,6 +112,12 @@ for entry in "${DEPS[@]}"; do
|
||||||
# ── 무결성 검증 ────────────────────────────────────────────────────────────
|
# ── 무결성 검증 ────────────────────────────────────────────────────────────
|
||||||
actual="$(sha256_of "${archive}")"
|
actual="$(sha256_of "${archive}")"
|
||||||
if [ "${sha256}" = "TODO" ] || [ -z "${sha256}" ]; then
|
if [ "${sha256}" = "TODO" ] || [ -z "${sha256}" ]; then
|
||||||
|
if [ "${VENDOR_STRICT}" = "1" ]; then
|
||||||
|
echo " ERROR: VENDOR_STRICT=1 인데 ${name} 의 SHA-256 핀이 미설정(TODO)입니다." >&2
|
||||||
|
echo " 아래 계산값을 DEPS 에 기록한 뒤 다시 실행하세요(재현성 고정):" >&2
|
||||||
|
echo " ${name} : ${actual}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo " [경고] SHA-256 미설정 — 검증 생략. 계산값을 기록하세요:"
|
echo " [경고] SHA-256 미설정 — 검증 생략. 계산값을 기록하세요:"
|
||||||
echo " ${name} : ${actual}"
|
echo " ${name} : ${actual}"
|
||||||
elif [ "${actual}" != "$(echo "${sha256}" | tr 'A-Z' 'a-z')" ]; then
|
elif [ "${actual}" != "$(echo "${sha256}" | tr 'A-Z' 'a-z')" ]; then
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,54 @@ parity_test.py - 펌웨어 wire 계약을 서버(PHP)와 교차검증한다.
|
||||||
3. 본문/서명 변조 시 서버가 거부한다.
|
3. 본문/서명 변조 시 서버가 거부한다.
|
||||||
4. SHT3x CRC8 및 변환식이 데이터시트 벡터와 일치한다.
|
4. SHT3x CRC8 및 변환식이 데이터시트 벡터와 일치한다.
|
||||||
5. fixed2() 소수 포맷이 기대 문자열과 일치한다(펌웨어 jb_fixed2 와 동일).
|
5. fixed2() 소수 포맷이 기대 문자열과 일치한다(펌웨어 jb_fixed2 와 동일).
|
||||||
|
6. (gcc/clang 가용 시) 펌웨어 실제 C 서명(common/sig.c)을 호스트 컴파일·실행해
|
||||||
|
Python 레퍼런스 및 서버 검증과 바이트 단위로 일치하는지 확인한다(C↔Python↔PHP).
|
||||||
|
|
||||||
C 펌웨어(jsonbody.c/sig.c/sht30_convert.c)는 reference.py 와 동일 알고리즘으로
|
reference.py 는 펌웨어 jsonbody.c/sht30_convert.c 와 동일 알고리즘의 Python 미러이고,
|
||||||
작성되어 있어, 본 테스트 통과는 펌웨어 wire 계약이 서버와 호환됨을 의미한다.
|
서명은 항목 6 에서 펌웨어 sig.c 를 실제로 컴파일해 직접 대조하므로, 본 테스트 통과는
|
||||||
|
펌웨어 wire 계약이 서버와 호환됨을 의미한다.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import reference as ref
|
import reference as ref
|
||||||
|
|
||||||
HERE = Path(__file__).resolve().parent
|
HERE = Path(__file__).resolve().parent
|
||||||
PHP_VERIFY = HERE / "php_verify.php"
|
PHP_VERIFY = HERE / "php_verify.php"
|
||||||
|
COMMON = HERE.parent.parent / "common" # firmware/common
|
||||||
|
SIG_CLI_SRC = HERE / "sig_cli.c"
|
||||||
|
|
||||||
|
|
||||||
|
def build_c_signer():
|
||||||
|
"""펌웨어 실제 구현 common/{sig.c,sha256_sw.c,hexutil.c} + sig_cli.c 를 호스트 컴파일.
|
||||||
|
성공 시 실행 파일 경로 문자열, 컴파일러가 없으면 None(스킵). 이 빌드가 C↔Python↔PHP
|
||||||
|
3자 패리티의 핵심 — C 가 실제로 컴파일·실행되어 같은 서명을 내는지 확인한다."""
|
||||||
|
cc = os.environ.get("CC") or shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
|
||||||
|
if not cc:
|
||||||
|
return None
|
||||||
|
out = Path(tempfile.gettempdir()) / "sht30_sig_cli.bin"
|
||||||
|
srcs = [str(SIG_CLI_SRC), str(COMMON / "sig.c"),
|
||||||
|
str(COMMON / "sha256_sw.c"), str(COMMON / "hexutil.c")]
|
||||||
|
cmd = [cc, "-O2", "-std=c11", "-Wall", f"-I{COMMON}", "-o", str(out), *srcs]
|
||||||
|
proc = subprocess.run(cmd, capture_output=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
sys.stderr.write("C signer build failed:\n" + proc.stderr.decode("utf-8", "replace") + "\n")
|
||||||
|
return None
|
||||||
|
return str(out)
|
||||||
|
|
||||||
|
|
||||||
|
def c_sign(binary: str, api_key: str, body: str) -> str:
|
||||||
|
"""호스트에서 컴파일한 펌웨어 sig_raw_body 로 본문을 서명(lowercase hex)."""
|
||||||
|
out = subprocess.run([binary, api_key], input=body.encode("utf-8"),
|
||||||
|
capture_output=True, check=True)
|
||||||
|
return out.stdout.decode("utf-8").strip()
|
||||||
|
|
||||||
failures: list[str] = []
|
failures: list[str] = []
|
||||||
|
|
||||||
|
|
@ -76,6 +109,12 @@ def main() -> int:
|
||||||
api_key = php_api_key()
|
api_key = php_api_key()
|
||||||
check(api_key != "", "PHP API_KEY should be readable")
|
check(api_key != "", "PHP API_KEY should be readable")
|
||||||
|
|
||||||
|
# ── C 서명기 빌드 (펌웨어 sig.c 를 호스트 컴파일). 컴파일러 없으면 스킵 ───────
|
||||||
|
c_signer = build_c_signer()
|
||||||
|
if c_signer is None:
|
||||||
|
sys.stderr.write("[parity] NOTE: C 컴파일러 없음 — C↔Python↔PHP 패리티 스킵"
|
||||||
|
" (Python↔PHP 패리티만 검증). CC 설정 또는 gcc/clang 설치 시 활성화.\n")
|
||||||
|
|
||||||
# ── 1~3. SHT30 이벤트 (startup/periodic, 정상/경계/음수/정수값 float) ─────
|
# ── 1~3. SHT30 이벤트 (startup/periodic, 정상/경계/음수/정수값 float) ─────
|
||||||
sht_cases = [
|
sht_cases = [
|
||||||
("startup", 24.0, 48.5, "normal"),
|
("startup", 24.0, 48.5, "normal"),
|
||||||
|
|
@ -99,6 +138,16 @@ def main() -> int:
|
||||||
)
|
)
|
||||||
assert_server_accepts(body, api_key, f"sht30/{ev}/{t}/{h}")
|
assert_server_accepts(body, api_key, f"sht30/{ev}/{t}/{h}")
|
||||||
|
|
||||||
|
# ── 6. C↔Python↔PHP 3자 서명 패리티 (gcc 가용 시) ───────────────────
|
||||||
|
if c_signer is not None:
|
||||||
|
py_sig = ref.sign_raw(api_key, body)
|
||||||
|
cc_sig = c_sign(c_signer, api_key, body)
|
||||||
|
check(cc_sig == py_sig,
|
||||||
|
f"sht30/{ev}/{t}/{h}: C 서명이 Python 레퍼런스와 일치해야 함"
|
||||||
|
f" (C={cc_sig[:12]}.. py={py_sig[:12]}..)")
|
||||||
|
check(php_verify(body, cc_sig) == "OK",
|
||||||
|
f"sht30/{ev}/{t}/{h}: C 서명이 서버 verify_signature_raw 를 통과해야 함")
|
||||||
|
|
||||||
# ── 4. SHT3x CRC8 / 변환 데이터시트 벡터 ─────────────────────────────────
|
# ── 4. SHT3x CRC8 / 변환 데이터시트 벡터 ─────────────────────────────────
|
||||||
# 데이터시트: CRC8(0xBE,0xEF) = 0x92
|
# 데이터시트: CRC8(0xBE,0xEF) = 0x92
|
||||||
check(ref.sht30_crc8(bytes([0xBE, 0xEF])) == 0x92, "SHT3x CRC8 datasheet vector (0xBEEF -> 0x92)")
|
check(ref.sht30_crc8(bytes([0xBE, 0xEF])) == 0x92, "SHT3x CRC8 datasheet vector (0xBEEF -> 0x92)")
|
||||||
|
|
|
||||||
50
firmware/test/host/sig_cli.c
Normal file
50
firmware/test/host/sig_cli.c
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/* =============================================================================
|
||||||
|
* sig_cli.c - 호스트 C 패리티 드라이버
|
||||||
|
*
|
||||||
|
* 펌웨어의 실제 서명 구현(common/sig.c: sig_raw_body)을 호스트에서 그대로 컴파일·
|
||||||
|
* 실행하여, Python 레퍼런스(reference.py: sign_raw)와 서버(php verify_signature_raw)
|
||||||
|
* 와 *바이트 단위로* 일치하는지 검증한다. 이렇게 해야 "C 는 컴파일·실행되지 않아
|
||||||
|
* 손으로만 맞춘" 갭이 닫힌다(C↔Python↔PHP 3자 패리티).
|
||||||
|
*
|
||||||
|
* 사용: sig_cli <api_key> (본문은 stdin, 바이너리 그대로)
|
||||||
|
* 출력: lowercase_hex(HMAC-SHA256(key=api_key, msg=body)) (개행 없음)
|
||||||
|
*
|
||||||
|
* parity_test.py 가 common/{sig.c,sha256_sw.c,hexutil.c} 와 함께 컴파일한다.
|
||||||
|
* ===========================================================================*/
|
||||||
|
#include "sig.h"
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
int main(int argc, char **argv)
|
||||||
|
{
|
||||||
|
if (argc < 2) {
|
||||||
|
fprintf(stderr, "usage: %s <api_key> (body on stdin)\n", argv[0]);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
const char *key = argv[1];
|
||||||
|
|
||||||
|
/* stdin 전체를 바이너리 그대로 읽는다(서명은 본문 바이트에 대해 계산되므로
|
||||||
|
* 어떤 변환도 하지 않는다). */
|
||||||
|
size_t cap = 4096, len = 0;
|
||||||
|
char *body = (char *)malloc(cap);
|
||||||
|
if (body == NULL) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
int c;
|
||||||
|
while ((c = getchar()) != EOF) {
|
||||||
|
if (len + 1 > cap) {
|
||||||
|
cap *= 2;
|
||||||
|
char *n = (char *)realloc(body, cap);
|
||||||
|
if (n == NULL) { free(body); return 3; }
|
||||||
|
body = n;
|
||||||
|
}
|
||||||
|
body[len++] = (char)c;
|
||||||
|
}
|
||||||
|
|
||||||
|
char out_hex[SIG_HEX_BUFSZ];
|
||||||
|
sig_raw_body(key, body, len, out_hex);
|
||||||
|
fputs(out_hex, stdout);
|
||||||
|
|
||||||
|
free(body);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
@ -38,8 +38,11 @@
|
||||||
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"
|
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
# TODO(follow-up): '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'"
|
||||||
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||||
|
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
|
||||||
Header always unset X-Powered-By
|
Header always unset X-Powered-By
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,3 +143,23 @@ function admin_audit_log_path(): string {
|
||||||
function admin_mfa_configured(): bool {
|
function admin_mfa_configured(): bool {
|
||||||
return defined('ADMIN_TOTP_SECRET') && ADMIN_TOTP_SECRET !== '';
|
return defined('ADMIN_TOTP_SECRET') && ADMIN_TOTP_SECRET !== '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 인증 페이지 공통: 세션 유휴(idle) + 절대(absolute) 수명 타임아웃을 강제한다.
|
||||||
|
// 모든 인증 페이지가 logged_in 확인 직후 호출해야 한다(대시보드 외 페이지에서도
|
||||||
|
// 절대 수명이 보장되도록 — 한 페이지에서만 검사하면 다른 페이지로 우회 가능).
|
||||||
|
// 만료 시 세션을 파기하고 login.php?expired 로 보낸 뒤 종료한다.
|
||||||
|
// - 유휴(idle): 마지막 활동(last_activity)에서 SESSION_TIMEOUT 초과
|
||||||
|
// - 절대(absolute): 로그인 시각(login_time, 갱신하지 않음)에서 SESSION_ABSOLUTE_TIMEOUT 초과
|
||||||
|
// 호출 전 config.php(SESSION_TIMEOUT/SESSION_ABSOLUTE_TIMEOUT) 와 session_start() 필요.
|
||||||
|
function admin_enforce_session_timeout(): void {
|
||||||
|
$idle_at = $_SESSION['last_activity'] ?? ($_SESSION['login_time'] ?? null);
|
||||||
|
$login_at = $_SESSION['login_time'] ?? null;
|
||||||
|
$idle_expired = isset($idle_at) && (time() - $idle_at) > SESSION_TIMEOUT;
|
||||||
|
$absolute_expired = isset($login_at) && (time() - $login_at) > SESSION_ABSOLUTE_TIMEOUT;
|
||||||
|
if ($idle_expired || $absolute_expired) {
|
||||||
|
session_destroy();
|
||||||
|
header('Location: login.php?expired');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$_SESSION['last_activity'] = time();
|
||||||
|
}
|
||||||
|
|
|
||||||
356
php/api/photo_upload.php
Normal file
356
php/api/photo_upload.php
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
<?php
|
||||||
|
// =============================================================================
|
||||||
|
// api/photo_upload.php - 1번 모듈(누수+카메라) 누수 사진 업로드 수신 API
|
||||||
|
//
|
||||||
|
// [흐름] 1번 모듈은 누수 감지 시 api/sensor_data.php 에 leak_alert 를 보내 log_id 를
|
||||||
|
// 받은 뒤, Pi Camera 로 촬영한 JPEG 을 이 엔드포인트에 multipart 로 업로드한다.
|
||||||
|
//
|
||||||
|
// [요청 형식] POST multipart/form-data
|
||||||
|
// form fields: device_id, sensor_id, log_id, timestamp
|
||||||
|
// file field: photo (단일, image/jpeg)
|
||||||
|
// header : X-Signature
|
||||||
|
//
|
||||||
|
// [인증] X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=canonical))
|
||||||
|
// canonical = device_id . "|" . sensor_id . "|" . log_id . "|" . timestamp (공백 없음)
|
||||||
|
// 클라이언트(raspberry_pi4/leak_sensor.py upload_photo)와 바이트 동일해야 한다.
|
||||||
|
// - timestamp 신선도(INGEST_MAX_CLOCK_SKEW_SEC)로 재전송(replay) 방어.
|
||||||
|
// - hash_equals 로 타이밍 안전 비교, 불일치 시 403.
|
||||||
|
// - 기본 API_KEY(미설정)면 수신 거부(fail-closed).
|
||||||
|
//
|
||||||
|
// [파일 보안]
|
||||||
|
// - 단일 file field "photo" 만 허용, 크기 상한(PHOTO_MAX_BYTES) 강제.
|
||||||
|
// - MIME 은 클라이언트 신고가 아니라 임시파일을 finfo 로 직접 검사(image/jpeg 만 허용).
|
||||||
|
// - 저장 파일명은 서버가 생성하는 무작위 이름(클라이언트 파일명/경로 미사용 → 경로주입 차단).
|
||||||
|
// - 저장 위치는 php/var/photos/ (없으면 생성). leak_photo 에 메타데이터만 기록.
|
||||||
|
//
|
||||||
|
// [응답] 200 OK application/json {"status":"ok","photo_id":..,"filename":".."}
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
header('Access-Control-Allow-Origin: https://pores.kr');
|
||||||
|
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, X-Device-Id, X-Signature');
|
||||||
|
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Method Not Allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
|
|
||||||
|
// 누수 사진 1장 최대 크기(바이트). 기본 2MB. 인증 전 대용량 업로드 증폭형 DoS 방지.
|
||||||
|
if (!defined('PHOTO_MAX_BYTES')) {
|
||||||
|
define('PHOTO_MAX_BYTES', 2 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
// multipart 헤더/필드(boundary, device_id, sensor_id, log_id, timestamp 등) 오버헤드 여유.
|
||||||
|
// Content-Length 사전 거부에서 사진 본문 외 부수 바이트를 감안한다.
|
||||||
|
if (!defined('PHOTO_MULTIPART_OVERHEAD_BYTES')) {
|
||||||
|
define('PHOTO_MULTIPART_OVERHEAD_BYTES', 8192);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 인증 전 사전 거부: Content-Length 가 상한+오버헤드를 넘으면 즉시 413 ──
|
||||||
|
// 서명 검증/파일 수신 등 무거운 처리에 들어가기 전에 차단(증폭형 DoS 방어).
|
||||||
|
$declared_len = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
|
||||||
|
if ($declared_len > PHOTO_MAX_BYTES + PHOTO_MULTIPART_OVERHEAD_BYTES) {
|
||||||
|
error_log("[PHOTO] Content-Length 과대({$declared_len}B) from " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
|
||||||
|
record_ingest_rejection('photo_too_large');
|
||||||
|
json_response(['status' => 'error', 'message' => '사진 크기 오류(최대 ' . PHOTO_MAX_BYTES . 'B)'], 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 운영 비밀값 강제: 기본 API_KEY 로는 수신 거부(fail-closed) ──
|
||||||
|
if (api_key_is_default()) {
|
||||||
|
error_log("[PHOTO] 거부: API_KEY 가 기본값입니다. config.local.php 에 운영 키를 설정하세요.");
|
||||||
|
record_ingest_rejection('key_default');
|
||||||
|
json_response(['status' => 'error', 'message' => '서버 구성 오류'], 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 입력 문자열 정제: 제어문자 제거(로그/경로 주입 방지). 서명 검증은 정제 전 원본으로 한다.
|
||||||
|
function photo_sanitize_str(string $s): string {
|
||||||
|
return preg_replace('/[[:cntrl:]]+/', '', $s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 존재 확인(미통합 배포 graceful degrade). sensor_data.php api_table_exists 와 동일.
|
||||||
|
function photo_table_exists(PDO $db, string $table): bool {
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = :table
|
||||||
|
");
|
||||||
|
$stmt->execute([':table' => $table]);
|
||||||
|
return (int)$stmt->fetchColumn() > 0;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("[PHOTO] table check failed: {$table} " . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 재전송(replay) 멱등 선점/해제 ──────────────────────────────
|
||||||
|
// sensor_data.php 의 ingest_nonce_claim/ingest_nonce_release 와 동일하게 동작한다.
|
||||||
|
// (photo_upload.php 는 sensor_data.php 를 include 하지 않으므로 여기서 동일 헬퍼를 둔다.)
|
||||||
|
// 'claimed'/'duplicate'/'unavailable'
|
||||||
|
function photo_nonce_claim(PDO $db, string $sig): string {
|
||||||
|
if (!INGEST_NONCE_ENABLED || $sig === '') {
|
||||||
|
return 'unavailable';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$ins = $db->prepare("INSERT INTO ingest_nonce (signature, seen_at) VALUES (:sig, NOW())");
|
||||||
|
$ins->execute([':sig' => substr($sig, 0, 128)]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$code = $e->getCode();
|
||||||
|
if ($code === '23000') {
|
||||||
|
return 'duplicate'; // PK 충돌 = 동일 서명 재수신
|
||||||
|
}
|
||||||
|
if ($code !== '42S02') { // 42S02 = 테이블 없음(기존 배포) → 조용히 비활성
|
||||||
|
error_log("[PHOTO] nonce claim 오류({$code}): " . $e->getMessage());
|
||||||
|
}
|
||||||
|
return 'unavailable';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2);
|
||||||
|
$db->exec("DELETE FROM ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
/* 정리는 실패해도 무방 */
|
||||||
|
}
|
||||||
|
return 'claimed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function photo_nonce_release(PDO $db, string $sig): void {
|
||||||
|
if ($sig === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$db->prepare("DELETE FROM ingest_nonce WHERE signature = :sig")
|
||||||
|
->execute([':sig' => substr($sig, 0, 128)]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("[PHOTO] nonce release 오류: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 var/photos/ 디렉터리에 보호 파일(.htaccess + index.html)을 best-effort 로 기록한다.
|
||||||
|
// 첫 업로드 직후에도 웹 직접 접근이 차단된 상태가 되도록(mkdir 시점 자가보호).
|
||||||
|
function photo_write_dir_guards(string $dir): void {
|
||||||
|
$htaccess = $dir . '/.htaccess';
|
||||||
|
if (!is_file($htaccess)) {
|
||||||
|
@file_put_contents(
|
||||||
|
$htaccess,
|
||||||
|
"<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n"
|
||||||
|
. "<IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n</IfModule>\n"
|
||||||
|
. "<IfModule mod_autoindex.c>\n Options -Indexes\n</IfModule>\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$index = $dir . '/index.html';
|
||||||
|
if (!is_file($index)) {
|
||||||
|
@file_put_contents($index, "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>403</title></head><body></body></html>\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 입력 추출 (multipart form fields) ──
|
||||||
|
$device_id_raw = (string)($_POST['device_id'] ?? '');
|
||||||
|
$sensor_id_raw = (string)($_POST['sensor_id'] ?? '');
|
||||||
|
$log_id_raw = (string)($_POST['log_id'] ?? '');
|
||||||
|
$timestamp_raw = (string)($_POST['timestamp'] ?? '');
|
||||||
|
|
||||||
|
if ($device_id_raw === '' || $sensor_id_raw === '' || $log_id_raw === '' || $timestamp_raw === '') {
|
||||||
|
record_ingest_rejection('missing_field');
|
||||||
|
json_response(['status' => 'error', 'message' => '필드 누락'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 서명 검증 (canonical = device_id|sensor_id|log_id|timestamp, 공백 없음) ──
|
||||||
|
// 서명 입력은 클라이언트가 보낸 값을 "그대로" 이어 붙인다(클라이언트도 동일 문자열로 서명).
|
||||||
|
$received_sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
|
||||||
|
$canonical = $device_id_raw . '|' . $sensor_id_raw . '|' . $log_id_raw . '|' . $timestamp_raw;
|
||||||
|
$expected_sig = hash_hmac('sha256', $canonical, API_KEY);
|
||||||
|
if ($received_sig === '' || !hash_equals($expected_sig, $received_sig)) {
|
||||||
|
error_log("[PHOTO] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
|
||||||
|
record_ingest_rejection('signature');
|
||||||
|
json_response(['status' => 'error', 'message' => '인증 실패'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 재전송(replay) 방어: timestamp 신선도 ──
|
||||||
|
$ts = (int)$timestamp_raw;
|
||||||
|
$now = time();
|
||||||
|
if ($ts <= 0 || abs($now - $ts) > INGEST_MAX_CLOCK_SKEW_SEC) {
|
||||||
|
error_log("[PHOTO] timestamp 신선도 위반 (ts={$ts}, now={$now}) from " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
|
||||||
|
record_ingest_rejection('timestamp');
|
||||||
|
json_response(['status' => 'error', 'message' => '요청 시각이 허용 범위를 벗어났습니다'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 값 검증 ──
|
||||||
|
$sensor_id = (int)$sensor_id_raw;
|
||||||
|
$log_id = (int)$log_id_raw;
|
||||||
|
if ($sensor_id < 1 || $sensor_id > 255) {
|
||||||
|
record_ingest_rejection('sensor_id_range');
|
||||||
|
json_response(['status' => 'error', 'message' => 'sensor_id 범위 오류(1..255)'], 400);
|
||||||
|
}
|
||||||
|
if ($log_id < 1) {
|
||||||
|
record_ingest_rejection('log_id_range');
|
||||||
|
json_response(['status' => 'error', 'message' => 'log_id 오류'], 400);
|
||||||
|
}
|
||||||
|
$device_id = photo_sanitize_str(substr(trim($device_id_raw), 0, 64));
|
||||||
|
|
||||||
|
// ── 업로드 파일 검증 ──
|
||||||
|
if (!isset($_FILES['photo']) || !is_array($_FILES['photo'])) {
|
||||||
|
record_ingest_rejection('no_file');
|
||||||
|
json_response(['status' => 'error', 'message' => 'photo 파일 누락'], 400);
|
||||||
|
}
|
||||||
|
$file = $_FILES['photo'];
|
||||||
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
|
record_ingest_rejection('upload_error');
|
||||||
|
json_response(['status' => 'error', 'message' => '업로드 오류'], 400);
|
||||||
|
}
|
||||||
|
$tmp_path = (string)($file['tmp_name'] ?? '');
|
||||||
|
if ($tmp_path === '' || !is_uploaded_file($tmp_path)) {
|
||||||
|
// is_uploaded_file: HTTP 업로드로 들어온 임시파일만 허용(임의 경로 주입 차단).
|
||||||
|
record_ingest_rejection('not_uploaded');
|
||||||
|
json_response(['status' => 'error', 'message' => '잘못된 업로드'], 400);
|
||||||
|
}
|
||||||
|
$size = (int)($file['size'] ?? 0);
|
||||||
|
if ($size <= 0 || $size > PHOTO_MAX_BYTES) {
|
||||||
|
record_ingest_rejection('photo_too_large');
|
||||||
|
json_response(['status' => 'error', 'message' => '사진 크기 오류(최대 ' . PHOTO_MAX_BYTES . 'B)'], 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIME: 클라이언트 신고를 믿지 않고 임시파일을 finfo 로 직접 판별. image/jpeg 만 허용.
|
||||||
|
$mime = '';
|
||||||
|
if (class_exists('finfo')) {
|
||||||
|
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$mime = (string)$finfo->file($tmp_path);
|
||||||
|
} elseif (function_exists('mime_content_type')) {
|
||||||
|
$mime = (string)mime_content_type($tmp_path);
|
||||||
|
}
|
||||||
|
if ($mime !== 'image/jpeg') {
|
||||||
|
error_log("[PHOTO] MIME 거부: {$mime} from " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
|
||||||
|
record_ingest_rejection('photo_mime');
|
||||||
|
json_response(['status' => 'error', 'message' => 'JPEG 이미지만 허용됩니다'], 415);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 실제 파일 크기 재검증 (클라이언트 신고 $_FILES['size'] 만 믿지 않음) ──
|
||||||
|
// finfo 와 마찬가지로 임시파일을 직접 측정해 상한을 다시 확인한다.
|
||||||
|
$real_size = @filesize($tmp_path);
|
||||||
|
if ($real_size === false || $real_size <= 0 || $real_size > PHOTO_MAX_BYTES) {
|
||||||
|
record_ingest_rejection('photo_too_large');
|
||||||
|
json_response(['status' => 'error', 'message' => '사진 크기 오류(최대 ' . PHOTO_MAX_BYTES . 'B)'], 413);
|
||||||
|
}
|
||||||
|
$size = (int)$real_size; // 저장 메타에는 실측 크기를 기록
|
||||||
|
|
||||||
|
// ── log_id 존재/일관성 확인 + 저장 ──
|
||||||
|
$nonce_state = 'unavailable';
|
||||||
|
$photo_id = null;
|
||||||
|
$filename = '';
|
||||||
|
try {
|
||||||
|
$db = get_db();
|
||||||
|
|
||||||
|
// log_id 가 sensor_log 에 실제로 존재하는지 + 어느 센서/디바이스의 로그인지 확인한다
|
||||||
|
// (임의 log_id 로 사진 누적 방지, 교차 센서 결속). 권위값은 로그 행의 sensor_id/device_id.
|
||||||
|
$chk = $db->prepare("SELECT sensor_id, device_id FROM sensor_log WHERE id = :id");
|
||||||
|
$chk->execute([':id' => $log_id]);
|
||||||
|
$log_row = $chk->fetch();
|
||||||
|
if (!$log_row) {
|
||||||
|
record_ingest_rejection('log_id_unknown');
|
||||||
|
json_response(['status' => 'error', 'message' => 'log_id 를 찾을 수 없습니다'], 400);
|
||||||
|
}
|
||||||
|
// 교차 센서 업로드 차단: 요청의 sensor_id 가 로그 행의 센서와 다르면 거부.
|
||||||
|
$log_sensor_id = (int)$log_row['sensor_id'];
|
||||||
|
if ($log_sensor_id !== $sensor_id) {
|
||||||
|
error_log("[PHOTO] 교차 센서 거부: req sensor_id={$sensor_id} vs log sensor_id={$log_sensor_id} (log_id={$log_id})");
|
||||||
|
record_ingest_rejection('sensor_mismatch');
|
||||||
|
json_response(['status' => 'error', 'message' => 'log_id 와 sensor_id 가 일치하지 않습니다'], 403);
|
||||||
|
}
|
||||||
|
// 저장에는 로그 행의 권위값을 사용(클라이언트 신고가 아니라 서버가 아는 값).
|
||||||
|
$store_sensor_id = $log_sensor_id;
|
||||||
|
$store_device_id = photo_sanitize_str(substr((string)$log_row['device_id'], 0, 64));
|
||||||
|
|
||||||
|
// ── 재전송(replay) 멱등 선점 (sensor_data.php 와 동일) ──
|
||||||
|
// 같은 X-Signature 가 시계오차 창 안에서 다시 오면(클라이언트 재시도/캡처 재생)
|
||||||
|
// 두 번째 파일/행을 만들지 않고 멱등 200 으로 종료한다.
|
||||||
|
$nonce_state = photo_nonce_claim($db, $received_sig);
|
||||||
|
if ($nonce_state === 'duplicate') {
|
||||||
|
json_response([
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => '이미 처리된 요청(멱등)',
|
||||||
|
'duplicate' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 벨트앤서스펜더: 같은 log_id 에 이미 사진이 있으면 중복으로 보고 건너뛴다
|
||||||
|
// (정책: log_id 당 사진 1장). nonce 가 비활성/정리된 경우의 추가 방어.
|
||||||
|
if (photo_table_exists($db, 'leak_photo')) {
|
||||||
|
$dup = $db->prepare("SELECT id, filename FROM leak_photo WHERE log_id = :log_id ORDER BY id ASC LIMIT 1");
|
||||||
|
$dup->execute([':log_id' => $log_id]);
|
||||||
|
$dup_row = $dup->fetch();
|
||||||
|
if ($dup_row) {
|
||||||
|
json_response([
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => '이미 사진이 저장된 log_id(멱등)',
|
||||||
|
'duplicate' => true,
|
||||||
|
'photo_id' => (int)$dup_row['id'],
|
||||||
|
'filename' => (string)$dup_row['filename'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 저장 디렉터리 (없으면 생성). 웹에서 직접 접근되지 않도록 var/ 하위에 둔다.
|
||||||
|
$photo_dir = dirname(__DIR__) . '/var/photos';
|
||||||
|
if (!is_dir($photo_dir)) {
|
||||||
|
if (!@mkdir($photo_dir, 0750, true) && !is_dir($photo_dir)) {
|
||||||
|
error_log("[PHOTO] 디렉터리 생성 실패: {$photo_dir}");
|
||||||
|
photo_nonce_release($db, $received_sig);
|
||||||
|
json_response(['status' => 'error', 'message' => '저장소 오류'], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 새로 만든(또는 기존) 디렉터리에 보호 파일을 best-effort 로 기록(웹 직접 접근 차단).
|
||||||
|
photo_write_dir_guards($photo_dir);
|
||||||
|
|
||||||
|
// 서버 생성 무작위 파일명(클라이언트 파일명/경로 미사용 → 경로주입 원천 차단).
|
||||||
|
$filename = sprintf('leak_%d_%d_%s.jpg', $store_sensor_id, $log_id, bin2hex(random_bytes(8)));
|
||||||
|
$dest = $photo_dir . '/' . $filename;
|
||||||
|
|
||||||
|
if (!move_uploaded_file($tmp_path, $dest)) {
|
||||||
|
error_log("[PHOTO] move_uploaded_file 실패: {$dest}");
|
||||||
|
photo_nonce_release($db, $received_sig);
|
||||||
|
json_response(['status' => 'error', 'message' => '사진 저장 실패'], 500);
|
||||||
|
}
|
||||||
|
@chmod($dest, 0640);
|
||||||
|
|
||||||
|
// leak_photo 메타데이터 기록 (파라미터 바인딩, 권위 sensor_id/device_id 사용).
|
||||||
|
$captured_at = date('Y-m-d H:i:s', $ts);
|
||||||
|
$ins = $db->prepare("
|
||||||
|
INSERT INTO leak_photo
|
||||||
|
(log_id, sensor_id, device_id, filename, mime, bytes, captured_at, created_at)
|
||||||
|
VALUES
|
||||||
|
(:log_id, :sensor_id, :device_id, :filename, :mime, :bytes, :captured_at, NOW())
|
||||||
|
");
|
||||||
|
$ins->execute([
|
||||||
|
':log_id' => $log_id,
|
||||||
|
':sensor_id' => $store_sensor_id,
|
||||||
|
':device_id' => $store_device_id,
|
||||||
|
':filename' => $filename,
|
||||||
|
':mime' => $mime,
|
||||||
|
':bytes' => $size,
|
||||||
|
':captured_at' => $captured_at,
|
||||||
|
]);
|
||||||
|
$photo_id = (int)$db->lastInsertId();
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log("[PHOTO] DB 오류: " . $e->getMessage());
|
||||||
|
// 저장 실패 시 선점한 nonce 를 되돌려, 디바이스 재시도가 재처리되도록 한다(유실 방지).
|
||||||
|
if (isset($db) && $nonce_state === 'claimed') {
|
||||||
|
photo_nonce_release($db, $received_sig);
|
||||||
|
}
|
||||||
|
json_response(['status' => 'error', 'message' => 'DB 저장 실패'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response([
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => '사진 저장 완료',
|
||||||
|
'photo_id' => $photo_id,
|
||||||
|
'filename' => $filename,
|
||||||
|
]);
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// api/sensor_data.php - STM32 SHT30 보드로부터 온습도 측정 데이터를 받는 API
|
// api/sensor_data.php - 센서 보드로부터 측정/이벤트 데이터를 받는 통합 수집 API
|
||||||
|
// (2번 모듈=SHT30 온습도 sensor_id=2, 1번 모듈=누수+카메라 sensor_id=1)
|
||||||
//
|
//
|
||||||
// [요청 형식] POST application/json
|
// [요청 형식 — 온습도(SHT30, 2번 모듈)] POST application/json
|
||||||
// {
|
// {
|
||||||
// "device_id": "stm32-sht30-01",
|
// "device_id": "stm32-sht30-01",
|
||||||
// "device_location": "서버실",
|
// "device_location": "서버실",
|
||||||
|
|
@ -17,6 +18,19 @@
|
||||||
// "app_version": "v2606-sht30"
|
// "app_version": "v2606-sht30"
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
|
// [요청 형식 — 누수(1번 모듈)] POST application/json
|
||||||
|
// {
|
||||||
|
// "device_id": "rpi4-leak-01",
|
||||||
|
// "device_location": "서버실",
|
||||||
|
// "sensor_id": 1,
|
||||||
|
// "sensor_name": "1번 센서 (누수)",
|
||||||
|
// "event_type": "leak_alert" | "leak_recovery" | "periodic" | "startup",
|
||||||
|
// "is_leak": 1, // 누수 여부(0/1)
|
||||||
|
// "timestamp": 1700000000
|
||||||
|
// }
|
||||||
|
// - leak_alert 응답에 log_id 를 실어 보내면, 1번 모듈이 그 log_id 로 사진을
|
||||||
|
// api/photo_upload.php 에 업로드한다.
|
||||||
|
//
|
||||||
// [인증 방식 2가지]
|
// [인증 방식 2가지]
|
||||||
// 1) 신규(STM32 펌웨어): HTTP 헤더 X-Signature = HMAC-SHA256(key=API_KEY, msg=raw_body)
|
// 1) 신규(STM32 펌웨어): HTTP 헤더 X-Signature = HMAC-SHA256(key=API_KEY, msg=raw_body)
|
||||||
// - 본문 바이트 그대로 서명 → JSON 재직렬화/부동소수 포맷 호환 문제 없음
|
// - 본문 바이트 그대로 서명 → JSON 재직렬화/부동소수 포맷 호환 문제 없음
|
||||||
|
|
@ -58,6 +72,7 @@ require_once dirname(__DIR__) . '/cron_heartbeat.php';
|
||||||
// 수신되는 것을 차단한다. (security: 시작 시 비밀값 존재 검증)
|
// 수신되는 것을 차단한다. (security: 시작 시 비밀값 존재 검증)
|
||||||
if (api_key_is_default()) {
|
if (api_key_is_default()) {
|
||||||
error_log("[API] 거부: API_KEY 가 기본값입니다. config.local.php 에 운영 키를 설정하세요.");
|
error_log("[API] 거부: API_KEY 가 기본값입니다. config.local.php 에 운영 키를 설정하세요.");
|
||||||
|
record_ingest_rejection('key_default');
|
||||||
json_response(['status' => 'error', 'message' => '서버 구성 오류'], 503);
|
json_response(['status' => 'error', 'message' => '서버 구성 오류'], 503);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,6 +98,53 @@ function api_table_exists(PDO $db, string $table): bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 재전송(replay) 멱등 선점 ──────────────────────────────────
|
||||||
|
// 인증·신선도(timestamp)를 통과한 요청의 서명을 nonce 로 원자적 기록한다. 시계오차
|
||||||
|
// 창 안에서 같은 서명 본문이 다시 오면(펌웨어 재시도/캡처 재생) 한 번만 반영된다.
|
||||||
|
// 'claimed' : 이번에 선점 → 계속 처리(저장/발송)
|
||||||
|
// 'duplicate' : 이미 처리됨 → 멱등 200 으로 종료(재저장/재발송 없음)
|
||||||
|
// 'unavailable' : nonce 비활성/테이블 없음/오류 → 종전대로 진행(graceful degrade)
|
||||||
|
function ingest_nonce_claim(PDO $db, string $sig): string {
|
||||||
|
if (!INGEST_NONCE_ENABLED || $sig === '') {
|
||||||
|
return 'unavailable';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$ins = $db->prepare("INSERT INTO ingest_nonce (signature, seen_at) VALUES (:sig, NOW())");
|
||||||
|
$ins->execute([':sig' => substr($sig, 0, 128)]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$code = $e->getCode();
|
||||||
|
if ($code === '23000') {
|
||||||
|
return 'duplicate'; // PK 충돌 = 동일 서명 재수신
|
||||||
|
}
|
||||||
|
if ($code !== '42S02') { // 42S02 = 테이블 없음(기존 배포) → 조용히 비활성
|
||||||
|
error_log("[API] nonce claim 오류({$code}): " . $e->getMessage());
|
||||||
|
}
|
||||||
|
return 'unavailable';
|
||||||
|
}
|
||||||
|
// 선점 성공 후, 신선도 창의 2배보다 오래된 nonce 정리(베스트 에포트, 인덱스로 가벼움).
|
||||||
|
try {
|
||||||
|
$ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2);
|
||||||
|
$db->exec("DELETE FROM ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
/* 정리는 실패해도 무방 */
|
||||||
|
}
|
||||||
|
return 'claimed';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 처리 중 저장이 실패하면(예: DB 오류) 선점한 nonce 를 되돌려, 디바이스 재시도가
|
||||||
|
// 다시 처리될 수 있게 한다(데이터 유실 방지).
|
||||||
|
function ingest_nonce_release(PDO $db, string $sig): void {
|
||||||
|
if ($sig === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$db->prepare("DELETE FROM ingest_nonce WHERE signature = :sig")
|
||||||
|
->execute([':sig' => substr($sig, 0, 128)]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("[API] nonce release 오류: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 요청 파싱 ─────────────────────────────────────────────
|
// ── 요청 파싱 ─────────────────────────────────────────────
|
||||||
$raw = file_get_contents('php://input');
|
$raw = file_get_contents('php://input');
|
||||||
|
|
||||||
|
|
@ -90,12 +152,14 @@ $raw = file_get_contents('php://input');
|
||||||
if (strlen($raw) > INGEST_MAX_BODY_BYTES) {
|
if (strlen($raw) > INGEST_MAX_BODY_BYTES) {
|
||||||
error_log("[API] 본문 과대(" . strlen($raw) . "B > " . INGEST_MAX_BODY_BYTES . "B) from "
|
error_log("[API] 본문 과대(" . strlen($raw) . "B > " . INGEST_MAX_BODY_BYTES . "B) from "
|
||||||
. ($_SERVER['REMOTE_ADDR'] ?? '?'));
|
. ($_SERVER['REMOTE_ADDR'] ?? '?'));
|
||||||
|
record_ingest_rejection('body_too_large');
|
||||||
json_response(['status' => 'error', 'message' => '본문이 너무 큽니다'], 413);
|
json_response(['status' => 'error', 'message' => '본문이 너무 큽니다'], 413);
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = json_decode($raw, true);
|
$data = json_decode($raw, true);
|
||||||
|
|
||||||
if (!$data) {
|
if (!$data) {
|
||||||
|
record_ingest_rejection('invalid_json');
|
||||||
json_response(['status' => 'error', 'message' => '잘못된 JSON 형식'], 400);
|
json_response(['status' => 'error', 'message' => '잘못된 JSON 형식'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,6 +180,7 @@ 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');
|
||||||
json_response(['status' => 'error', 'message' => '인증 실패'], 403);
|
json_response(['status' => 'error', 'message' => '인증 실패'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,6 +188,7 @@ if (!$sig_ok) {
|
||||||
$required = ['device_id', 'sensor_id', 'event_type', 'timestamp'];
|
$required = ['device_id', 'sensor_id', 'event_type', 'timestamp'];
|
||||||
foreach ($required as $field) {
|
foreach ($required as $field) {
|
||||||
if (!isset($data[$field])) {
|
if (!isset($data[$field])) {
|
||||||
|
record_ingest_rejection('missing_field');
|
||||||
json_response(['status' => 'error', 'message' => "필드 누락: {$field}"], 400);
|
json_response(['status' => 'error', 'message' => "필드 누락: {$field}"], 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -131,9 +197,25 @@ foreach ($required as $field) {
|
||||||
$device_id = api_sanitize_str(substr(trim($data['device_id']), 0, 64));
|
$device_id = api_sanitize_str(substr(trim($data['device_id']), 0, 64));
|
||||||
$device_location = api_sanitize_str(substr(trim($data['device_location'] ?? ''), 0, 100));
|
$device_location = api_sanitize_str(substr(trim($data['device_location'] ?? ''), 0, 100));
|
||||||
$sensor_id = (int)$data['sensor_id'];
|
$sensor_id = (int)$data['sensor_id'];
|
||||||
|
// sensor_id 범위 검증(스키마 TINYINT UNSIGNED: 1..255). 범위를 벗어나면 DB INSERT
|
||||||
|
// 단계에서 500(DB 저장 실패)이 되므로, 경계에서 깔끔하게 400 으로 거른다.
|
||||||
|
if ($sensor_id < 1 || $sensor_id > 255) {
|
||||||
|
record_ingest_rejection('sensor_id_range');
|
||||||
|
json_response(['status' => 'error', 'message' => 'sensor_id 범위 오류(1..255)'], 400);
|
||||||
|
}
|
||||||
$sensor_name = api_sanitize_str(substr(trim($data['sensor_name'] ?? "센서{$sensor_id}"), 0, 100));
|
$sensor_name = api_sanitize_str(substr(trim($data['sensor_name'] ?? "센서{$sensor_id}"), 0, 100));
|
||||||
$event_type = in_array($data['event_type'], ['startup', 'periodic'], true)
|
// 통합 수집: 온습도(startup/periodic) + 누수(leak_alert/leak_recovery) 이벤트 허용.
|
||||||
|
$event_type = in_array($data['event_type'], ['startup', 'periodic', 'leak_alert', 'leak_recovery'], true)
|
||||||
? $data['event_type'] : 'periodic';
|
? $data['event_type'] : 'periodic';
|
||||||
|
// 누수 여부(0/1). 1번 모듈만 보낸다. leak_alert 는 항상 누수=1, leak_recovery 는 0 으로
|
||||||
|
// 강제해 이벤트와 상태의 일관성을 보장한다(필드 누락/오값 방어).
|
||||||
|
$is_leak = !empty($data['is_leak']) ? 1 : 0;
|
||||||
|
if ($event_type === 'leak_alert') {
|
||||||
|
$is_leak = 1;
|
||||||
|
} elseif ($event_type === 'leak_recovery') {
|
||||||
|
$is_leak = 0;
|
||||||
|
}
|
||||||
|
$is_leak_event = ($event_type === 'leak_alert' || $event_type === 'leak_recovery');
|
||||||
$ts = (int)$data['timestamp'];
|
$ts = (int)$data['timestamp'];
|
||||||
|
|
||||||
// ── 재전송(replay) 방어: timestamp 신선도 검증 ────────────
|
// ── 재전송(replay) 방어: timestamp 신선도 검증 ────────────
|
||||||
|
|
@ -143,6 +225,7 @@ $now = time();
|
||||||
if ($ts <= 0 || abs($now - $ts) > INGEST_MAX_CLOCK_SKEW_SEC) {
|
if ($ts <= 0 || abs($now - $ts) > INGEST_MAX_CLOCK_SKEW_SEC) {
|
||||||
error_log("[API] timestamp 신선도 위반 (ts={$ts}, now={$now}, max=" . INGEST_MAX_CLOCK_SKEW_SEC . "s) from "
|
error_log("[API] timestamp 신선도 위반 (ts={$ts}, now={$now}, max=" . INGEST_MAX_CLOCK_SKEW_SEC . "s) from "
|
||||||
. ($_SERVER['REMOTE_ADDR'] ?? '?'));
|
. ($_SERVER['REMOTE_ADDR'] ?? '?'));
|
||||||
|
record_ingest_rejection('timestamp');
|
||||||
json_response(['status' => 'error', 'message' => '요청 시각이 허용 범위를 벗어났습니다'], 400);
|
json_response(['status' => 'error', 'message' => '요청 시각이 허용 범위를 벗어났습니다'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,22 +255,37 @@ $metric_status = $metric_eval['status'];
|
||||||
$metric_saved = false;
|
$metric_saved = false;
|
||||||
|
|
||||||
// ── DB 저장 ───────────────────────────────────────────────
|
// ── DB 저장 ───────────────────────────────────────────────
|
||||||
// 주기(periodic) 측정 보고는 sensor_log 에 남기지 않고 sensor_metric/상태만 갱신
|
// 주기(periodic) 온습도 측정 보고는 sensor_log 에 남기지 않고 sensor_metric/상태만 갱신
|
||||||
// (대시보드 로그 정리). startup 은 1회 sensor_log 기록.
|
// (대시보드 로그 정리). startup 은 1회 sensor_log 기록.
|
||||||
|
// 누수 이벤트(leak_alert/leak_recovery)는 항상 sensor_log 에 기록한다
|
||||||
|
// (leak_alert 는 log_id 가 사진 업로드에 필요하고, 누수 이력 추적 대상).
|
||||||
$skip_log = ($event_type === 'periodic' && $has_metric_payload);
|
$skip_log = ($event_type === 'periodic' && $has_metric_payload);
|
||||||
$log_id = null;
|
$log_id = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db = get_db();
|
$db = get_db();
|
||||||
|
|
||||||
|
// ── 재전송(replay) 멱등 선점 ──
|
||||||
|
// 인증·신선도를 통과한 서명을 nonce 로 원자적 기록한다. 같은 서명 본문이 시계오차
|
||||||
|
// 창 안에서 다시 오면(펌웨어 재시도/캡처 재생) 중복 저장·중복 SMS 없이 멱등 200.
|
||||||
|
$req_sig = $has_raw_sig ? (string)$_SERVER['HTTP_X_SIGNATURE'] : (string)($data['signature'] ?? '');
|
||||||
|
$nonce_state = ingest_nonce_claim($db, $req_sig);
|
||||||
|
if ($nonce_state === 'duplicate') {
|
||||||
|
json_response([
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => '이미 처리된 요청(멱등)',
|
||||||
|
'duplicate' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if (!$skip_log) {
|
if (!$skip_log) {
|
||||||
$stmt = $db->prepare("
|
$stmt = $db->prepare("
|
||||||
INSERT INTO sensor_log
|
INSERT INTO sensor_log
|
||||||
(device_id, device_location, sensor_id, sensor_name,
|
(device_id, device_location, sensor_id, sensor_name,
|
||||||
event_type, sensor_time, created_at)
|
event_type, is_leak, sensor_time, created_at)
|
||||||
VALUES
|
VALUES
|
||||||
(:device_id, :device_location, :sensor_id, :sensor_name,
|
(:device_id, :device_location, :sensor_id, :sensor_name,
|
||||||
:event_type, :sensor_time, NOW())
|
:event_type, :is_leak, :sensor_time, NOW())
|
||||||
");
|
");
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
':device_id' => $device_id,
|
':device_id' => $device_id,
|
||||||
|
|
@ -195,6 +293,7 @@ try {
|
||||||
':sensor_id' => $sensor_id,
|
':sensor_id' => $sensor_id,
|
||||||
':sensor_name' => $sensor_name,
|
':sensor_name' => $sensor_name,
|
||||||
':event_type' => $event_type,
|
':event_type' => $event_type,
|
||||||
|
':is_leak' => $is_leak,
|
||||||
':sensor_time' => $sensor_dt,
|
':sensor_time' => $sensor_dt,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -211,18 +310,22 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 최신 센서 상태 업서트 (항상 갱신, 보고 = 온라인) ──
|
// ── 최신 센서 상태 업서트 (항상 갱신, 보고 = 온라인) ──
|
||||||
|
// is_leak 은 누수 이벤트(leak_alert/leak_recovery)일 때만 갱신한다. 온습도 보고
|
||||||
|
// (startup/periodic)는 누수 상태를 건드리지 않도록 기존 값을 보존한다(통합 서버에서
|
||||||
|
// 2번 모듈 보고가 1번 모듈 누수 상태를 덮어쓰지 않게).
|
||||||
$stmt2 = $db->prepare("
|
$stmt2 = $db->prepare("
|
||||||
INSERT INTO sensor_status
|
INSERT INTO sensor_status
|
||||||
(sensor_id, device_id, sensor_name, device_location,
|
(sensor_id, device_id, sensor_name, device_location,
|
||||||
last_event_type, last_seen, offline_alerted)
|
last_event_type, is_leak, last_seen, offline_alerted)
|
||||||
VALUES
|
VALUES
|
||||||
(:sensor_id, :device_id, :sensor_name, :device_location,
|
(:sensor_id, :device_id, :sensor_name, :device_location,
|
||||||
:event_type, NOW(), 0)
|
:event_type, :is_leak_ins, NOW(), 0)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
device_id = VALUES(device_id),
|
device_id = VALUES(device_id),
|
||||||
sensor_name = VALUES(sensor_name),
|
sensor_name = VALUES(sensor_name),
|
||||||
device_location = VALUES(device_location),
|
device_location = VALUES(device_location),
|
||||||
last_event_type = VALUES(last_event_type),
|
last_event_type = VALUES(last_event_type),
|
||||||
|
is_leak = IF(:is_leak_event, VALUES(is_leak), is_leak),
|
||||||
last_seen = NOW(),
|
last_seen = NOW(),
|
||||||
offline_alerted = 0
|
offline_alerted = 0
|
||||||
");
|
");
|
||||||
|
|
@ -232,6 +335,8 @@ try {
|
||||||
':sensor_name' => $sensor_name,
|
':sensor_name' => $sensor_name,
|
||||||
':device_location' => $device_location,
|
':device_location' => $device_location,
|
||||||
':event_type' => $event_type,
|
':event_type' => $event_type,
|
||||||
|
':is_leak_ins' => $is_leak,
|
||||||
|
':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, 'sensor_metric')) {
|
||||||
|
|
@ -259,6 +364,52 @@ try {
|
||||||
$metric_saved = true;
|
$metric_saved = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 누수 사고(에피소드) 추적 (leak_incident) ──────────
|
||||||
|
// leak_alert: 미복구 사고가 없으면 새 사고를 연다(중복 leak_alert 는 새로 열지 않음).
|
||||||
|
// leak_recovery: 같은 센서의 가장 최근 미복구 사고를 닫는다(recovered_at/복구 log_id 채움).
|
||||||
|
// leak_incident 테이블이 없으면(누수 미통합 서버) graceful 하게 건너뛴다.
|
||||||
|
if ($is_leak_event && api_table_exists($db, 'leak_incident')) {
|
||||||
|
if ($event_type === 'leak_alert') {
|
||||||
|
$open = $db->prepare("
|
||||||
|
SELECT id FROM leak_incident
|
||||||
|
WHERE sensor_id = :sensor_id AND recovered_at IS NULL
|
||||||
|
ORDER BY started_at DESC, id DESC LIMIT 1
|
||||||
|
");
|
||||||
|
$open->execute([':sensor_id' => $sensor_id]);
|
||||||
|
if ($open->fetchColumn() === false) {
|
||||||
|
$ins_inc = $db->prepare("
|
||||||
|
INSERT INTO leak_incident
|
||||||
|
(sensor_id, device_id, sensor_name, device_location,
|
||||||
|
started_at, log_id_alert, created_at)
|
||||||
|
VALUES
|
||||||
|
(:sensor_id, :device_id, :sensor_name, :device_location,
|
||||||
|
:started_at, :log_id_alert, NOW())
|
||||||
|
");
|
||||||
|
$ins_inc->execute([
|
||||||
|
':sensor_id' => $sensor_id,
|
||||||
|
':device_id' => $device_id,
|
||||||
|
':sensor_name' => $sensor_name,
|
||||||
|
':device_location' => $device_location,
|
||||||
|
':started_at' => $sensor_dt,
|
||||||
|
':log_id_alert' => $log_id !== null ? (int)$log_id : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else { // leak_recovery
|
||||||
|
$close = $db->prepare("
|
||||||
|
UPDATE leak_incident
|
||||||
|
SET recovered_at = :recovered_at, log_id_recovery = :log_id_recovery
|
||||||
|
WHERE sensor_id = :sensor_id AND recovered_at IS NULL
|
||||||
|
ORDER BY started_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
");
|
||||||
|
$close->execute([
|
||||||
|
':recovered_at' => $sensor_dt,
|
||||||
|
':log_id_recovery' => $log_id !== null ? (int)$log_id : null,
|
||||||
|
':sensor_id' => $sensor_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 오프라인→온라인 복구 SMS 발송 ─────────────────────
|
// ── 오프라인→온라인 복구 SMS 발송 ─────────────────────
|
||||||
if ($was_offline) {
|
if ($was_offline) {
|
||||||
$online_info = [
|
$online_info = [
|
||||||
|
|
@ -273,6 +424,10 @@ try {
|
||||||
|
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
error_log("[API] DB 오류: " . $e->getMessage());
|
error_log("[API] DB 오류: " . $e->getMessage());
|
||||||
|
// 저장 실패 시 선점한 nonce 를 되돌려, 디바이스 재시도가 재처리되도록 한다(유실 방지).
|
||||||
|
if (isset($nonce_state, $req_sig) && $nonce_state === 'claimed') {
|
||||||
|
ingest_nonce_release($db, $req_sig);
|
||||||
|
}
|
||||||
json_response(['status' => 'error', 'message' => 'DB 저장 실패'], 500);
|
json_response(['status' => 'error', 'message' => 'DB 저장 실패'], 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,8 +444,13 @@ if ($has_metric_payload) {
|
||||||
if (!empty($metric_eval['breaches'])) {
|
if (!empty($metric_eval['breaches'])) {
|
||||||
// 임계 초과: 종류별로 SMS 발송(각자 쿨다운 적용)
|
// 임계 초과: 종류별로 SMS 발송(각자 쿨다운 적용)
|
||||||
foreach ($metric_eval['breaches'] as $kind) {
|
foreach ($metric_eval['breaches'] as $kind) {
|
||||||
$value = in_array($kind, ['high_temp', 'low_temp'], true)
|
if ($kind === 'condensation_risk') {
|
||||||
? $temperature_c : $humidity_percent;
|
$value = condensation_margin_c($temperature_c, $humidity_percent); // 표시값=결로마진(℃)
|
||||||
|
} elseif (in_array($kind, ['high_temp', 'low_temp'], true)) {
|
||||||
|
$value = $temperature_c;
|
||||||
|
} else {
|
||||||
|
$value = $humidity_percent;
|
||||||
|
}
|
||||||
if ($value !== null && send_metric_alert_sms($sensor_info, $kind, (float)$value)) {
|
if ($value !== null && send_metric_alert_sms($sensor_info, $kind, (float)$value)) {
|
||||||
$sms_sent = true;
|
$sms_sent = true;
|
||||||
}
|
}
|
||||||
|
|
@ -305,6 +465,22 @@ if ($has_metric_payload) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 누수 경보 / 복구 SMS (1번 모듈) ────────────────────────
|
||||||
|
// leak_alert: [누수감지] SMS(쿨다운). 누수 지속 중 주기 보고가 와도 쿨다운으로 1회.
|
||||||
|
// leak_recovery: 직전에 누수 경보가 있었을 때만 [누수복구] SMS 발송.
|
||||||
|
// sms_send.php 에 헬퍼가 없거나(미통합 배포) 함수 미정의면 graceful 하게 건너뛴다.
|
||||||
|
if ($is_leak_event) {
|
||||||
|
if ($event_type === 'leak_alert' && function_exists('send_leak_alert_sms')) {
|
||||||
|
if (send_leak_alert_sms($sensor_info)) {
|
||||||
|
$sms_sent = true;
|
||||||
|
}
|
||||||
|
} elseif ($event_type === 'leak_recovery' && function_exists('send_leak_recovery_sms')) {
|
||||||
|
if (send_leak_recovery_sms($sensor_info)) {
|
||||||
|
$sms_sent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Heartbeat: 다른 센서 오프라인 여부 체크 (5분 쓰로틀) ──
|
// ── Heartbeat: 다른 센서 오프라인 여부 체크 (5분 쓰로틀) ──
|
||||||
check_offline_sensors_throttled();
|
check_offline_sensors_throttled();
|
||||||
|
|
||||||
|
|
@ -316,4 +492,8 @@ json_response([
|
||||||
'metric_saved' => $metric_saved,
|
'metric_saved' => $metric_saved,
|
||||||
'metric_status' => $metric_status,
|
'metric_status' => $metric_status,
|
||||||
'sms_sent' => $sms_sent,
|
'sms_sent' => $sms_sent,
|
||||||
|
// 서버 현재 시각(Unix). 펌웨어가 RTC 드리프트 보정/시계 헬스 점검에 사용할 수 있도록
|
||||||
|
// 응답에 싣는다(폐쇄망에서 SNTP 가 간헐적일 때 서버시각으로 RTC 를 미세보정하는 후속
|
||||||
|
// 펌웨어 작업의 입력). 보안 영향 없음(인증된 200 응답에만 포함).
|
||||||
|
'server_now' => $now,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ return [
|
||||||
|
|
||||||
'API_KEY' => 'replace-with-long-random-secret',
|
'API_KEY' => 'replace-with-long-random-secret',
|
||||||
|
|
||||||
|
// 요청 서명은 HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트)이며 X-Signature 헤더로
|
||||||
|
// 전송된다. 레거시 키-접두 sha256 폴백은 기본 비활성이고, 운영(STM32 HMAC 전용)에서는
|
||||||
|
// 반드시 false 로 둔다. 과거 RPi(레거시) 클라이언트가 아직 남아 있을 때만 true 로 켠다.
|
||||||
|
'ALLOW_LEGACY_BODY_SIGNATURE' => false,
|
||||||
|
|
||||||
'SMS_USER_ID' => 'your-cafe24-sms-user-id',
|
'SMS_USER_ID' => 'your-cafe24-sms-user-id',
|
||||||
'SMS_SECURE' => 'your-cafe24-sms-secure-key',
|
'SMS_SECURE' => 'your-cafe24-sms-secure-key',
|
||||||
'SMS_SENDER' => '01000000000',
|
'SMS_SENDER' => '01000000000',
|
||||||
|
|
@ -43,6 +48,13 @@ return [
|
||||||
'METRIC_RH_LOW' => 20, // 저습 임계(%)
|
'METRIC_RH_LOW' => 20, // 저습 임계(%)
|
||||||
'METRIC_ALERT_COOLDOWN_SEC' => 1800, // 동일 종류 경보 재발송 억제(초, 기본 30분)
|
'METRIC_ALERT_COOLDOWN_SEC' => 1800, // 동일 종류 경보 재발송 억제(초, 기본 30분)
|
||||||
|
|
||||||
|
// 미복귀 경보 2차(야간) 에스컬레이션 수신자. 비우면 비활성(opt-in).
|
||||||
|
'SMS_RECIPIENTS_TIER2' => [],
|
||||||
|
'ESCALATE_AFTER_SEC' => 1800, // 경보 미복귀 30분 후 2차 통지
|
||||||
|
// 아래는 기본값으로 동작(필요 시에만 override):
|
||||||
|
// METRIC_DEWPOINT_MARGIN_C(3℃ 결로), SMS_BALANCE_WARN_THRESHOLD(30건),
|
||||||
|
// HEALTH_SMS_HOUR(9시), STUCK_DETECT_SAMPLES(6), SMS_RETRY_OFFLINE(true)
|
||||||
|
|
||||||
'SMS_LOG_RETENTION_DAYS' => 365,
|
'SMS_LOG_RETENTION_DAYS' => 365,
|
||||||
'SENSOR_LOG_RETENTION_DAYS' => 365,
|
'SENSOR_LOG_RETENTION_DAYS' => 365,
|
||||||
'SENSOR_METRIC_RETENTION_DAYS' => 365,
|
'SENSOR_METRIC_RETENTION_DAYS' => 365,
|
||||||
|
|
|
||||||
210
php/config.php
210
php/config.php
|
|
@ -48,6 +48,12 @@ define('INGEST_MAX_CLOCK_SKEW_SEC', (int)cfg('INGEST_MAX_CLOCK_SKEW_SEC', 600));
|
||||||
// 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 를 차단한다.
|
// 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 를 차단한다.
|
||||||
define('INGEST_MAX_BODY_BYTES', (int)cfg('INGEST_MAX_BODY_BYTES', 8192));
|
define('INGEST_MAX_BODY_BYTES', (int)cfg('INGEST_MAX_BODY_BYTES', 8192));
|
||||||
|
|
||||||
|
// 재전송(replay) 멱등 방어. 서명(X-Signature)을 nonce 로 기록해, 시계오차 창
|
||||||
|
// (INGEST_MAX_CLOCK_SKEW_SEC) 안에서 동일 서명 본문이 재수신되면 멱등 처리(재저장/
|
||||||
|
// 재발송 생략)한다. 펌웨어 재시도(같은 본문)·캡처 재생 모두 한 번만 반영된다.
|
||||||
|
// ingest_nonce 테이블이 없는 기존 배포에서는 자동으로 비활성(graceful degrade).
|
||||||
|
define('INGEST_NONCE_ENABLED', (bool)cfg('INGEST_NONCE_ENABLED', true));
|
||||||
|
|
||||||
// 레거시 본문 필드 서명(verify_signature: 키-접두 sha256) 수용 여부. 기본 비활성.
|
// 레거시 본문 필드 서명(verify_signature: 키-접두 sha256) 수용 여부. 기본 비활성.
|
||||||
// 운영 클라이언트(STM32)는 HMAC raw-body 서명만 쓰므로, 약한 경로를 닫아 둔다.
|
// 운영 클라이언트(STM32)는 HMAC raw-body 서명만 쓰므로, 약한 경로를 닫아 둔다.
|
||||||
// 과거 RPi 클라이언트가 남아 있을 때만 1 로 켠다.
|
// 과거 RPi 클라이언트가 남아 있을 때만 1 로 켠다.
|
||||||
|
|
@ -66,6 +72,61 @@ if (is_string($sms_recipients)) {
|
||||||
}
|
}
|
||||||
define('SMS_RECIPIENTS', $sms_recipients);
|
define('SMS_RECIPIENTS', $sms_recipients);
|
||||||
|
|
||||||
|
// Cafe24 SMS('S' 타입)는 EUC-KR 기준 약 90바이트까지만 단문으로 발송된다. 초과하면
|
||||||
|
// 게이트웨이가 잘라/거부하므로, 발송 직전에 EUC-KR 바이트 기준으로 안전하게 자른다
|
||||||
|
// (멀티바이트 경계 보존; sms_truncate_euckr). 위치/센서명이 길어도 무성 절단을 막는다.
|
||||||
|
define('SMS_MAX_EUCKR_BYTES', (int)cfg('SMS_MAX_EUCKR_BYTES', 90));
|
||||||
|
|
||||||
|
// ─── SMS 메시지 태그 (경보 상태머신의 단일 출처) ──────────────────────────────
|
||||||
|
// sms_log.message 의 접두 태그로 쿨다운/복구/대시보드/월간보고서가 경보 상태를
|
||||||
|
// 재구성한다. 별도 상태 테이블을 두지 않으므로 이 태그 문자열이 사실상의 상태 키다.
|
||||||
|
// 여러 파일(sms_send/dashboard/monthly_report)의 LIKE 문자열이 서로 어긋나 무성
|
||||||
|
// 장애가 나는 것을 막기 위해, 태그는 반드시 여기서만 정의하고 상수로 참조한다.
|
||||||
|
const SMS_TAG_HIGH_TEMP = '[고온경보]';
|
||||||
|
const SMS_TAG_LOW_TEMP = '[저온경보]';
|
||||||
|
const SMS_TAG_HIGH_HUMIDITY = '[고습경보]';
|
||||||
|
const SMS_TAG_LOW_HUMIDITY = '[저습경보]';
|
||||||
|
const SMS_TAG_RECOVERY = '[정상복귀]';
|
||||||
|
const SMS_TAG_DEVICE_OFFLINE = '[장비 오프라인]';
|
||||||
|
const SMS_TAG_DEVICE_ONLINE = '[장비 복구]';
|
||||||
|
const SMS_TAG_CONDENSATION = '[결로주의]'; // 이슬점 근접(결로 위험)
|
||||||
|
const SMS_TAG_BALANCE_LOW = '[SMS 잔액부족]'; // 발송 잔여 건수 부족 사전경보
|
||||||
|
const SMS_TAG_HEALTH = '[정기점검]'; // 일일 생존신호(데드맨)
|
||||||
|
const SMS_TAG_SENSOR_STUCK = '[센서고착]'; // 측정값 고착(live-but-stuck) 의심
|
||||||
|
const SMS_TAG_ESCALATION = '[에스컬레이션]'; // 미복귀 경보 2차(야간) 통지
|
||||||
|
const SMS_TAG_LEAK_ALERT = '[누수감지]'; // 1번 모듈(누수) 누수 발생
|
||||||
|
const SMS_TAG_LEAK_RECOVERY = '[누수복구]'; // 1번 모듈(누수) 누수 해소(복구)
|
||||||
|
|
||||||
|
// 경보 종류(metric kind) → 태그 매핑(단일 출처). evaluate_metric_thresholds 의
|
||||||
|
// breach 종류와 1:1 대응한다.
|
||||||
|
const SMS_METRIC_TAGS = [
|
||||||
|
'high_temp' => SMS_TAG_HIGH_TEMP,
|
||||||
|
'low_temp' => SMS_TAG_LOW_TEMP,
|
||||||
|
'high_humidity' => SMS_TAG_HIGH_HUMIDITY,
|
||||||
|
'low_humidity' => SMS_TAG_LOW_HUMIDITY,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sms_log 태그별 건수 집계 SELECT 절(단일 출처). 대시보드/월간보고서가 공유한다.
|
||||||
|
* 태그 문자열은 위 SMS_TAG_* 상수에서만 가져오므로, 태그를 바꿔도 모든 집계가 한
|
||||||
|
* 곳에서 따라온다(여러 파일의 LIKE 문자열이 어긋나 무성 장애 나는 것 방지).
|
||||||
|
* 반환 컬럼: high_temp, low_temp, high_humidity, low_humidity, recovery,
|
||||||
|
* device_offline, device_online, leak_alert, leak_recovery, sms_fail
|
||||||
|
*/
|
||||||
|
function sms_summary_select_sql(): string {
|
||||||
|
return
|
||||||
|
"SUM(message LIKE '" . SMS_TAG_HIGH_TEMP . "%') AS high_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_LOW_HUMIDITY . "%') AS low_humidity,\n"
|
||||||
|
. " SUM(message LIKE '" . SMS_TAG_RECOVERY . "%') AS recovery,\n"
|
||||||
|
. " SUM(message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%') AS device_offline,\n"
|
||||||
|
. " SUM(message LIKE '" . SMS_TAG_DEVICE_ONLINE . "%') AS device_online,\n"
|
||||||
|
. " SUM(message LIKE '" . SMS_TAG_LEAK_ALERT . "%') AS leak_alert,\n"
|
||||||
|
. " SUM(message LIKE '" . SMS_TAG_LEAK_RECOVERY . "%') AS leak_recovery,\n"
|
||||||
|
. " SUM(status = 'fail') AS sms_fail";
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 온습도 임계 경보 (고온/저온/고습/저습 → SMS) ──────────────────────────
|
// ─── 온습도 임계 경보 (고온/저온/고습/저습 → SMS) ──────────────────────────
|
||||||
// 측정값이 아래 운영 임계를 벗어나면 api/sensor_data.php 가 종류별 SMS를 보냅니다.
|
// 측정값이 아래 운영 임계를 벗어나면 api/sensor_data.php 가 종류별 SMS를 보냅니다.
|
||||||
// 임계 판정은 펌웨어가 아니라 서버에서 하므로, 폐쇄망에서도 재플래시 없이
|
// 임계 판정은 펌웨어가 아니라 서버에서 하므로, 폐쇄망에서도 재플래시 없이
|
||||||
|
|
@ -80,6 +141,56 @@ 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));
|
||||||
|
|
||||||
|
// 누수(leak) 경보 재발송 억제 쿨다운(초). 1번 모듈은 누수 지속 중 주기(periodic) 보고를
|
||||||
|
// 계속 보내므로, 같은 누수 에피소드에서 [누수감지] SMS 가 반복 발송되지 않도록 한다. 기본 30분.
|
||||||
|
define('LEAK_ALERT_COOLDOWN_SEC', (int)cfg('LEAK_ALERT_COOLDOWN_SEC', 1800));
|
||||||
|
|
||||||
|
// ─── 결로(이슬점) 경보 ────────────────────────────────────────────────────────
|
||||||
|
// 온도·습도가 각각 정상이어도 (현재온도 − 이슬점)=결로마진 이 좁으면 차가운 표면에
|
||||||
|
// 결로가 생긴다(장비 손상). 마진이 이 임계 이하이면 결로주의 경보를 보낸다.
|
||||||
|
// 단일 SHT30 측정만으로 산출(이슬점=Magnus 근사). 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));
|
||||||
|
|
||||||
|
// ─── SMS 잔액(잔여 건수) 사전경보 ─────────────────────────────────────────────
|
||||||
|
// Cafe24 응답의 잔여 건수가 이 값 이하로 내려가면 1회 '[SMS 잔액부족]' 경고를 보낸다
|
||||||
|
// (경보 채널이 잔액 소진으로 조용히 죽는 것을 사전 방지). 쿨다운으로 반복을 억제.
|
||||||
|
define('SMS_BALANCE_WARN_THRESHOLD', (int)cfg('SMS_BALANCE_WARN_THRESHOLD', 30));
|
||||||
|
define('SMS_BALANCE_WARN_COOLDOWN_SEC',(int)cfg('SMS_BALANCE_WARN_COOLDOWN_SEC', 21600)); // 6시간
|
||||||
|
|
||||||
|
// ─── 일일 헬스(생존신호) SMS ──────────────────────────────────────────────────
|
||||||
|
// 하루 1통 '[정기점검]' 요약(정상 센서 수/최근 수신/SMS 잔여)을 보낸다. '무소식=정상'
|
||||||
|
// 과 '무소식=경보경로 전체 고장'의 모호성을 제거하는 데드맨(관리자는 '안 오면 점검'만
|
||||||
|
// 기억하면 된다). HEALTH_SMS_HOUR 시(0~23) 이후의 첫 체크에서 하루 1회 발송.
|
||||||
|
define('HEALTH_SMS_ENABLED', (bool)cfg('HEALTH_SMS_ENABLED', true));
|
||||||
|
define('HEALTH_SMS_HOUR', (int)cfg('HEALTH_SMS_HOUR', 9));
|
||||||
|
|
||||||
|
// ─── 경보 신뢰성 (재시도 / 에스컬레이션 / 고착 검출 / 드릴) ────────────────────
|
||||||
|
// 오프라인 SMS 발송 실패 시 offline_alerted 를 되돌려 다음 주기에 재시도한다(critical
|
||||||
|
// 오프라인 미통지 방지 — 일회성 발송이 실패하면 영영 못 알리던 갭).
|
||||||
|
define('SMS_RETRY_OFFLINE', (bool)cfg('SMS_RETRY_OFFLINE', true));
|
||||||
|
|
||||||
|
// 미복귀 경보 에스컬레이션: 임계 경보가 ESCALATE_AFTER_SEC 이상 미복귀로 지속되면 2차
|
||||||
|
// (야간) 수신자(SMS_RECIPIENTS_TIER2)에게 1회 통지. TIER2 가 비면 비활성(opt-in).
|
||||||
|
$sms_tier2 = cfg('SMS_RECIPIENTS_TIER2', []);
|
||||||
|
if (is_string($sms_tier2)) {
|
||||||
|
$sms_tier2 = array_filter(array_map('trim', explode(',', $sms_tier2)));
|
||||||
|
}
|
||||||
|
define('SMS_RECIPIENTS_TIER2', $sms_tier2);
|
||||||
|
define('ESCALATE_AFTER_SEC', (int)cfg('ESCALATE_AFTER_SEC', 1800));
|
||||||
|
|
||||||
|
// 센서 고착(live-but-stuck) 검출: 최근 STUCK_DETECT_SAMPLES 측정의 표준편차가
|
||||||
|
// STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장(같은 값 반복)을 의심해 알린다.
|
||||||
|
// (하트비트는 '수신 끊김'만 본다 — I2C 는 살아있는데 값이 고착된 무성실패를 놓침.)
|
||||||
|
define('STUCK_DETECT_ENABLED', (bool)cfg('STUCK_DETECT_ENABLED', true));
|
||||||
|
define('STUCK_DETECT_SAMPLES', (int)cfg('STUCK_DETECT_SAMPLES', 6)); // 6 × 5분 = 30분
|
||||||
|
define('STUCK_DETECT_EPSILON', (float)cfg('STUCK_DETECT_EPSILON', 0.01)); // 분해능 이하
|
||||||
|
define('STUCK_ALERT_COOLDOWN_SEC', (int)cfg('STUCK_ALERT_COOLDOWN_SEC', 21600)); // 6시간
|
||||||
|
|
||||||
|
// 카오스 드릴: 켜면 send_sms_cafe24 가 실제 전송 없이 실패를 반환한다(실패 경로 시험).
|
||||||
|
// 운영에서는 반드시 false. tests/chaos_drill.php 가 일시적으로 켜서 검증한다.
|
||||||
|
define('SMS_FORCE_FAIL', (bool)cfg('SMS_FORCE_FAIL', false));
|
||||||
|
|
||||||
// ─── Heartbeat (장비 오프라인 감지) ────────────────────────
|
// ─── Heartbeat (장비 오프라인 감지) ────────────────────────
|
||||||
// SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300). 기본
|
// SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300). 기본
|
||||||
// 오프라인 판정은 약 4회 누락(20분)으로 둔다. config.local.php 로 조정 가능.
|
// 오프라인 판정은 약 4회 누락(20분)으로 둔다. config.local.php 로 조정 가능.
|
||||||
|
|
@ -92,7 +203,10 @@ 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 해시
|
||||||
define('ADMIN_TOTP_SECRET', cfg('ADMIN_TOTP_SECRET', '')); // TOTP base32 secret for authenticator apps
|
define('ADMIN_TOTP_SECRET', cfg('ADMIN_TOTP_SECRET', '')); // TOTP base32 secret for authenticator apps
|
||||||
define('MFA_SETUP_TOKEN', cfg('MFA_SETUP_TOKEN', '')); // temporary setup token for setup_mfa.php bootstrap
|
define('MFA_SETUP_TOKEN', cfg('MFA_SETUP_TOKEN', '')); // temporary setup token for setup_mfa.php bootstrap
|
||||||
define('SESSION_TIMEOUT', 3600); // 1시간
|
define('SESSION_TIMEOUT', 3600); // 유휴(idle) 타임아웃: 마지막 활동 후 1시간
|
||||||
|
// 절대 세션 수명(초). 연속 활동 중이어도 로그인 시각 기준 이 시간이 지나면 만료한다
|
||||||
|
// (유휴 타임아웃만 있으면 활동 중 세션이 무한정 유지되는 문제 보완). 기본 12시간.
|
||||||
|
define('SESSION_ABSOLUTE_TIMEOUT', (int)cfg('SESSION_ABSOLUTE_TIMEOUT', 43200));
|
||||||
|
|
||||||
// ─── Retention policy ────────────────────────────────────────────────────────
|
// ─── Retention policy ────────────────────────────────────────────────────────
|
||||||
define('SMS_LOG_RETENTION_DAYS', (int)cfg('SMS_LOG_RETENTION_DAYS', 365));
|
define('SMS_LOG_RETENTION_DAYS', (int)cfg('SMS_LOG_RETENTION_DAYS', 365));
|
||||||
|
|
@ -128,6 +242,100 @@ function json_response(array $data, int $http_code = 200): void {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 개인정보(수신번호) 마스킹 ─────────────────────────────
|
||||||
|
// 화면/보고서에 수신번호를 노출할 때 가운데를 가린다(010-****-1234). 발송 기록
|
||||||
|
// 자체(sms_log)는 보존하되 표시 단계에서만 최소화한다(개인정보 최소표시 원칙).
|
||||||
|
// 쉼표로 여러 번호가 들어오면 각각 마스킹한다.
|
||||||
|
function mask_phone(string $value): string {
|
||||||
|
if (strpos($value, ',') !== false) {
|
||||||
|
$parts = array_map('trim', explode(',', $value));
|
||||||
|
return implode(', ', array_map('mask_phone', $parts));
|
||||||
|
}
|
||||||
|
$d = preg_replace('/\D+/', '', $value);
|
||||||
|
$len = strlen($d);
|
||||||
|
if ($len < 4) {
|
||||||
|
return str_repeat('*', $len);
|
||||||
|
}
|
||||||
|
$last4 = substr($d, -4);
|
||||||
|
$prefix = $len >= 7 ? substr($d, 0, 3) : '';
|
||||||
|
return ($prefix !== '' ? $prefix . '-' : '') . '****-' . $last4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최근 성공 발송의 잔여 SMS 건수(result_message "발송 성공 (잔여 N건)" 에서 파싱).
|
||||||
|
// 별도 컬럼 없이 기존 기록에서 읽는다. 알 수 없으면 null. (잔액 점검/헬스 요약 공용)
|
||||||
|
function latest_sms_balance(PDO $db): ?int {
|
||||||
|
try {
|
||||||
|
$rm = $db->query("
|
||||||
|
SELECT result_message FROM sms_log
|
||||||
|
WHERE status = 'success' AND result_message LIKE '%잔여%'
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
")->fetchColumn();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($rm !== false && preg_match('/잔여\s*(\d+)\s*건/u', (string)$rm, $m)) {
|
||||||
|
return (int)$m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 수집 거부 사유 카운터 (파일 기반, DoS 안전) ──────────────────────────────
|
||||||
|
// 인증 전 거부(403/400/413/503/범위)를 사유별 일자 버킷으로 집계한다. DB 를 건드리지
|
||||||
|
// 않아(파일 flock) 위조요청 폭주에도 부담이 작다. 대시보드/ops 가 읽어 '왜 데이터가
|
||||||
|
// 안 들어오는가'(시계오차/키불일치/위조시도)를 빠르게 분별한다.
|
||||||
|
function ingest_rejection_path(): string {
|
||||||
|
return __DIR__ . '/var/ingest_rejections.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
function record_ingest_rejection(string $reason): void {
|
||||||
|
$fh = @fopen(ingest_rejection_path(), 'c+');
|
||||||
|
if ($fh === false) {
|
||||||
|
return; // 카운터 실패가 수집 처리를 막아선 안 됨(best-effort)
|
||||||
|
}
|
||||||
|
if (flock($fh, LOCK_EX)) {
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$raw = stream_get_contents($fh);
|
||||||
|
$data = ($raw !== false && $raw !== '') ? json_decode($raw, true) : [];
|
||||||
|
if (!is_array($data)) { $data = []; }
|
||||||
|
if (!isset($data[$today]) || !is_array($data[$today])) { $data[$today] = []; }
|
||||||
|
$data[$today][$reason] = ($data[$today][$reason] ?? 0) + 1;
|
||||||
|
if (count($data) > 14) { // 최근 14일만 보존(파일 무한증가 방지)
|
||||||
|
ksort($data);
|
||||||
|
$data = array_slice($data, -14, null, true);
|
||||||
|
}
|
||||||
|
ftruncate($fh, 0);
|
||||||
|
rewind($fh);
|
||||||
|
fwrite($fh, json_encode($data));
|
||||||
|
fflush($fh);
|
||||||
|
flock($fh, LOCK_UN);
|
||||||
|
}
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최근 $days 일의 거부 사유별 합계(내림차순). 반환: ['reason' => count, ...].
|
||||||
|
function read_ingest_rejections(int $days = 1): array {
|
||||||
|
$file = ingest_rejection_path();
|
||||||
|
if (!is_file($file)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$data = json_decode((string)@file_get_contents($file), true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$cutoff = date('Y-m-d', time() - max(0, $days - 1) * 86400);
|
||||||
|
$totals = [];
|
||||||
|
foreach ($data as $day => $reasons) {
|
||||||
|
if ((string)$day < $cutoff || !is_array($reasons)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($reasons as $reason => $cnt) {
|
||||||
|
$totals[$reason] = ($totals[$reason] ?? 0) + (int)$cnt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
arsort($totals);
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 서명 검증 (레거시: 본문 필드 기반, DEPRECATED) ───────
|
// ─── 서명 검증 (레거시: 본문 필드 기반, DEPRECATED) ───────
|
||||||
// 디코드된 데이터를 ksort 후 재직렬화하여 해시한다. 과거 RPi Python 클라이언트
|
// 디코드된 데이터를 ksort 후 재직렬화하여 해시한다. 과거 RPi Python 클라이언트
|
||||||
// 전용의 하위 호환 경로이며, 현재 운영 클라이언트(STM32 펌웨어)는 사용하지 않는다.
|
// 전용의 하위 호환 경로이며, 현재 운영 클라이언트(STM32 펌웨어)는 사용하지 않는다.
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,14 @@ function check_offline_sensors(): int {
|
||||||
'last_seen' => $sensor['last_seen'],
|
'last_seen' => $sensor['last_seen'],
|
||||||
];
|
];
|
||||||
|
|
||||||
send_device_offline_sms($sensor_info);
|
$sent = send_device_offline_sms($sensor_info);
|
||||||
|
if (!$sent && SMS_RETRY_OFFLINE) {
|
||||||
|
// 일회성 오프라인 SMS 가 실패하면 선점(offline_alerted=1)을 되돌려, 다음 주기에
|
||||||
|
// 재시도되게 한다(critical: 게이트웨이 일시오류로 장비 다운을 영영 못 알리던 갭).
|
||||||
|
$db->prepare("UPDATE sensor_status SET offline_alerted = 0 WHERE sensor_id = :sid")
|
||||||
|
->execute([':sid' => $sensor['sensor_id']]);
|
||||||
|
error_log("[Heartbeat] 오프라인 SMS 실패 - 센서{$sensor['sensor_id']} 재시도 예약");
|
||||||
|
}
|
||||||
|
|
||||||
$count++;
|
$count++;
|
||||||
error_log("[Heartbeat] 오프라인 감지 - 센서{$sensor['sensor_id']} ({$sensor['sensor_name']})");
|
error_log("[Heartbeat] 오프라인 감지 - 센서{$sensor['sensor_id']} ({$sensor['sensor_name']})");
|
||||||
|
|
@ -120,6 +127,19 @@ function check_offline_sensors_throttled(): void {
|
||||||
// heartbeat 실패가 호출자(dashboard, sensor_data)를 죽이면 안 됨
|
// heartbeat 실패가 호출자(dashboard, sensor_data)를 죽이면 안 됨
|
||||||
error_log("[Heartbeat] 체크 실패: " . $e->getMessage());
|
error_log("[Heartbeat] 체크 실패: " . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
// 일일 헬스(생존신호) SMS — 하루 1회 자체 dedup. 실패해도 호출자에 영향 없음.
|
||||||
|
try {
|
||||||
|
maybe_send_daily_health_sms(get_db());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("[Heartbeat] 헬스 SMS 실패: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
// 경보 신뢰성: 미복귀 에스컬레이션(opt-in) + 측정값 고착 검출(각자 쿨다운).
|
||||||
|
try {
|
||||||
|
maybe_escalate_alerts(get_db());
|
||||||
|
maybe_detect_stuck_sensors(get_db());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("[Heartbeat] 에스컬레이션/고착 검출 실패: " . $e->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,6 +151,15 @@ if ($_is_direct) {
|
||||||
$is_cli = (php_sapi_name() === 'cli');
|
$is_cli = (php_sapi_name() === 'cli');
|
||||||
|
|
||||||
if (!$is_cli) {
|
if (!$is_cli) {
|
||||||
|
// 기본 API_KEY 미교체 상태에서 HTTP 트리거를 허용하면 CRON_SECRET도 예측
|
||||||
|
// 가능한 기본값(hash_hmac('sha256','cron','change-this-api-key'))이 되므로,
|
||||||
|
// 신규 설치에서 인증 전 SMS/DB 부작용이 발생하는 것을 차단한다.
|
||||||
|
if (api_key_is_default()) {
|
||||||
|
http_response_code(503);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo 'server not configured';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
$key = $_GET['key'] ?? '';
|
$key = $_GET['key'] ?? '';
|
||||||
if (!hash_equals(CRON_SECRET, $key)) {
|
if (!hash_equals(CRON_SECRET, $key)) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
|
|
@ -141,6 +170,13 @@ if ($_is_direct) {
|
||||||
}
|
}
|
||||||
|
|
||||||
$count = check_offline_sensors();
|
$count = check_offline_sensors();
|
||||||
|
try {
|
||||||
|
maybe_send_daily_health_sms(get_db());
|
||||||
|
maybe_escalate_alerts(get_db());
|
||||||
|
maybe_detect_stuck_sensors(get_db());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("[Heartbeat] 헬스/에스컬레이션/고착 실패: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
if (!$is_cli) {
|
if (!$is_cli) {
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
|
||||||
|
|
@ -39,15 +39,9 @@ if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 세션 타임아웃 확인
|
// 세션 타임아웃(유휴 + 절대 수명) 강제 — 모든 인증 페이지 공통 헬퍼(admin_security.php).
|
||||||
if (isset($_SESSION['login_time']) && (time() - $_SESSION['login_time']) > SESSION_TIMEOUT) {
|
// 유휴 타이머 갱신도 헬퍼가 처리한다.
|
||||||
session_destroy();
|
admin_enforce_session_timeout();
|
||||||
header('Location: login.php?expired');
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 활동 시 로그인 시간 갱신
|
|
||||||
$_SESSION['login_time'] = time();
|
|
||||||
|
|
||||||
// ── Heartbeat: 오프라인 센서 체크 (HEARTBEAT_CHECK_INTERVAL_SEC 쓰로틀) ──────
|
// ── Heartbeat: 오프라인 센서 체크 (HEARTBEAT_CHECK_INTERVAL_SEC 쓰로틀) ──────
|
||||||
check_offline_sensors_throttled();
|
check_offline_sensors_throttled();
|
||||||
|
|
@ -75,6 +69,20 @@ function metric_display(?string $value, string $unit): string {
|
||||||
return number_format((float)$value, 1) . $unit;
|
return number_format((float)$value, 1) . $unit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 테이블 존재 확인(누수 미통합 서버에서 leak_incident/leak_photo 부재 시 graceful degrade).
|
||||||
|
function table_exists(PDO $db, string $table): bool {
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT COUNT(*) FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table
|
||||||
|
");
|
||||||
|
$stmt->execute([':table' => $table]);
|
||||||
|
return (int)$stmt->fetchColumn() > 0;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 센서별 현재 상태 + 최신 측정값 ──────────────────────────
|
// ── 센서별 현재 상태 + 최신 측정값 ──────────────────────────
|
||||||
$statuses = $db->query("SELECT * FROM sensor_status ORDER BY sensor_id")->fetchAll();
|
$statuses = $db->query("SELECT * FROM sensor_status ORDER BY sensor_id")->fetchAll();
|
||||||
|
|
||||||
|
|
@ -159,9 +167,9 @@ $recent_metrics = $db->query("
|
||||||
LIMIT 30
|
LIMIT 30
|
||||||
")->fetchAll();
|
")->fetchAll();
|
||||||
|
|
||||||
// ── 최근 센서 이벤트 로그 (sensor_log: startup/periodic) ────
|
// ── 최근 센서 이벤트 로그 (sensor_log: startup/periodic/leak_alert/leak_recovery) ──
|
||||||
$logs = $db->query("
|
$logs = $db->query("
|
||||||
SELECT id, sensor_name, device_location, event_type, sensor_time
|
SELECT id, sensor_name, device_location, event_type, is_leak, sensor_time
|
||||||
FROM sensor_log
|
FROM sensor_log
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT 30
|
LIMIT 30
|
||||||
|
|
@ -178,14 +186,7 @@ $sms_logs = $db->query("
|
||||||
// 종류별 임계 경보 SMS 건수 (sms_log message prefix 기준)
|
// 종류별 임계 경보 SMS 건수 (sms_log message prefix 기준)
|
||||||
$sms_summary_rows = $db->query("
|
$sms_summary_rows = $db->query("
|
||||||
SELECT
|
SELECT
|
||||||
SUM(message LIKE '[고온경보]%') AS high_temp,
|
" . sms_summary_select_sql() . "
|
||||||
SUM(message LIKE '[저온경보]%') AS low_temp,
|
|
||||||
SUM(message LIKE '[고습경보]%') AS high_humidity,
|
|
||||||
SUM(message LIKE '[저습경보]%') AS low_humidity,
|
|
||||||
SUM(message LIKE '[정상복귀]%') AS recovery,
|
|
||||||
SUM(message LIKE '[장비 오프라인]%') AS offline,
|
|
||||||
SUM(message LIKE '[장비 복구]%') AS device_online,
|
|
||||||
SUM(status = 'fail') AS sms_fail
|
|
||||||
FROM sms_log
|
FROM sms_log
|
||||||
WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||||
")->fetch();
|
")->fetch();
|
||||||
|
|
@ -196,8 +197,10 @@ $summary = [
|
||||||
'high_humidity' => (int)($sms_summary_rows['high_humidity'] ?? 0),
|
'high_humidity' => (int)($sms_summary_rows['high_humidity'] ?? 0),
|
||||||
'low_humidity' => (int)($sms_summary_rows['low_humidity'] ?? 0),
|
'low_humidity' => (int)($sms_summary_rows['low_humidity'] ?? 0),
|
||||||
'recovery' => (int)($sms_summary_rows['recovery'] ?? 0),
|
'recovery' => (int)($sms_summary_rows['recovery'] ?? 0),
|
||||||
'offline' => (int)($sms_summary_rows['offline'] ?? 0),
|
'offline' => (int)($sms_summary_rows['device_offline'] ?? 0),
|
||||||
'device_online' => (int)($sms_summary_rows['device_online'] ?? 0),
|
'device_online' => (int)($sms_summary_rows['device_online'] ?? 0),
|
||||||
|
'leak_alert' => (int)($sms_summary_rows['leak_alert'] ?? 0),
|
||||||
|
'leak_recovery' => (int)($sms_summary_rows['leak_recovery'] ?? 0),
|
||||||
'sms_fail' => (int)($sms_summary_rows['sms_fail'] ?? 0),
|
'sms_fail' => (int)($sms_summary_rows['sms_fail'] ?? 0),
|
||||||
];
|
];
|
||||||
$summary['alert_total'] = $summary['high_temp'] + $summary['low_temp']
|
$summary['alert_total'] = $summary['high_temp'] + $summary['low_temp']
|
||||||
|
|
@ -233,6 +236,62 @@ $offline_count = count(array_filter($statuses, fn($s) => $s['is_offline']));
|
||||||
$alert_count = count(array_filter($statuses, fn($s) => $s['is_alerting'] && !$s['is_offline']));
|
$alert_count = count(array_filter($statuses, fn($s) => $s['is_alerting'] && !$s['is_offline']));
|
||||||
$normal_count = count($statuses) - $offline_count - $alert_count;
|
$normal_count = count($statuses) - $offline_count - $alert_count;
|
||||||
|
|
||||||
|
// ── 경보 SMS 발송 실패 / 수집 거부 사유 (신뢰성·관측성) ─────
|
||||||
|
// 최근 1시간 내 경보/오프라인/에스컬레이션 SMS 가 실패했는가(미수신 가능 경고).
|
||||||
|
$recent_alert_fail = (int)$db->query("
|
||||||
|
SELECT COUNT(*) FROM sms_log
|
||||||
|
WHERE status = 'fail'
|
||||||
|
AND sent_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
|
||||||
|
AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%'
|
||||||
|
OR message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%' OR message LIKE '" . SMS_TAG_LOW_HUMIDITY . "%'
|
||||||
|
OR message LIKE '" . SMS_TAG_CONDENSATION . "%' OR message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%'
|
||||||
|
OR message LIKE '" . SMS_TAG_LEAK_ALERT . "%' OR message LIKE '" . SMS_TAG_LEAK_RECOVERY . "%'
|
||||||
|
OR message LIKE '" . SMS_TAG_ESCALATION . "%')
|
||||||
|
")->fetchColumn();
|
||||||
|
// 최근 24시간(오늘) 수집 거부 사유별 집계(왜 데이터가 안 들어오는가).
|
||||||
|
$ingest_rejections = read_ingest_rejections(1);
|
||||||
|
$rejection_total = array_sum($ingest_rejections);
|
||||||
|
|
||||||
|
// ── 누수(1번 모듈) 현재 상태 + 사고/사진 ───────────────────
|
||||||
|
// 누수 센서(sensor_id=1)의 sensor_status.is_leak 로 누수/정상을 표시한다. 오프라인
|
||||||
|
// 판정은 위 $statuses 계산과 동일한 로직을 따른다(행이 없으면 '데이터 없음').
|
||||||
|
$leak_status = null;
|
||||||
|
foreach ($statuses as $s) {
|
||||||
|
if ((int)$s['sensor_id'] === 1) {
|
||||||
|
$leak_status = $s;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최근 누수 사고(에피소드) 20건 + 각 사고의 사진(leak_photo) — 테이블 없으면 graceful.
|
||||||
|
$leak_incidents = [];
|
||||||
|
$leak_photos_by_log = []; // log_id => [ ['id'=>..,'log_id'=>..,'captured_at'=>..], ... ]
|
||||||
|
if (table_exists($db, 'leak_incident')) {
|
||||||
|
$leak_incidents = $db->query("
|
||||||
|
SELECT id, sensor_name, device_location, started_at, recovered_at, log_id_alert
|
||||||
|
FROM leak_incident
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 20
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
if (!empty($leak_incidents) && table_exists($db, 'leak_photo')) {
|
||||||
|
$photo_stmt = $db->prepare("
|
||||||
|
SELECT id, log_id, captured_at
|
||||||
|
FROM leak_photo
|
||||||
|
WHERE log_id = :lid
|
||||||
|
ORDER BY id ASC
|
||||||
|
");
|
||||||
|
foreach ($leak_incidents as $inc) {
|
||||||
|
$lid = $inc['log_id_alert'] !== null ? (int)$inc['log_id_alert'] : 0;
|
||||||
|
if ($lid < 1 || isset($leak_photos_by_log[$lid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$photo_stmt->execute([':lid' => $lid]);
|
||||||
|
$leak_photos_by_log[$lid] = $photo_stmt->fetchAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($alert_count > 0) {
|
if ($alert_count > 0) {
|
||||||
$system_state = 'danger';
|
$system_state = 'danger';
|
||||||
$system_title = '온습도 경보 발생';
|
$system_title = '온습도 경보 발생';
|
||||||
|
|
@ -256,7 +315,7 @@ if ($alert_count > 0) {
|
||||||
<head>
|
<head>
|
||||||
<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>
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body { font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #333; }
|
body { font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #333; }
|
||||||
|
|
@ -354,6 +413,17 @@ if ($alert_count > 0) {
|
||||||
.legend-swatch { width: 14px; height: 4px; border-radius: 2px; display: inline-block; }
|
.legend-swatch { width: 14px; height: 4px; border-radius: 2px; display: inline-block; }
|
||||||
.chart-svg-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
.chart-svg-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||||
.chart-empty { color: #94a3b8; padding: 24px; text-align: center; }
|
.chart-empty { color: #94a3b8; padding: 24px; text-align: center; }
|
||||||
|
/* 누수(leak) 패널 */
|
||||||
|
.leak-status-card { border-left: 6px solid #22c55e; }
|
||||||
|
.leak-status-card.leak { border-left-color: #ef4444; background: #fff5f5; }
|
||||||
|
.leak-status-card.offline { border-left-color: #f59e0b; background: #fffbeb; }
|
||||||
|
.pill.leak { background: #fee2e2; color: #991b1b; }
|
||||||
|
.pill.leak_alert { background: #fee2e2; color: #991b1b; }
|
||||||
|
.pill.leak_recovery { background: #dcfce7; color: #166534; }
|
||||||
|
.leak-thumbs { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.leak-thumbs a { display: inline-block; line-height: 0; }
|
||||||
|
.leak-thumbs img { width: 64px; height: 64px; object-fit: cover;
|
||||||
|
border-radius: 6px; border: 1px solid #e2e8f0; background: #f1f5f9; }
|
||||||
/* 테이블 가로 스크롤 컨테이너 */
|
/* 테이블 가로 스크롤 컨테이너 */
|
||||||
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch;
|
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch;
|
||||||
margin-bottom: 24px; border-radius: 8px; }
|
margin-bottom: 24px; border-radius: 8px; }
|
||||||
|
|
@ -416,7 +486,7 @@ if ($alert_count > 0) {
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header style="display:flex;align-items:center;justify-content:space-between">
|
<header style="display:flex;align-items:center;justify-content:space-between">
|
||||||
<h1>온습도 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></h1>
|
<h1>온습도·누수 통합 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></h1>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<a href="setup_wizard.php">설치 점검</a>
|
<a href="setup_wizard.php">설치 점검</a>
|
||||||
<a href="monthly_report.php">월간 보고</a>
|
<a href="monthly_report.php">월간 보고</a>
|
||||||
|
|
@ -437,6 +507,39 @@ if ($alert_count > 0) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<?php if ($recent_alert_fail > 0): ?>
|
||||||
|
<section class="status-hero danger">
|
||||||
|
<div>
|
||||||
|
<h2>경보 SMS 발송 실패</h2>
|
||||||
|
<p>최근 1시간 내 경보/오프라인 SMS <?= (int)$recent_alert_fail ?>건 발송 실패 — 미수신 가능. Cafe24 SMS 잔액·계정·발신번호를 확인하세요.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($rejection_total > 0): ?>
|
||||||
|
<section class="status-hero warning">
|
||||||
|
<div>
|
||||||
|
<h2>수집 거부 <?= (int)$rejection_total ?>건 (최근 24시간)</h2>
|
||||||
|
<p><?php
|
||||||
|
$reason_ko = [
|
||||||
|
'signature' => '서명불일치(API키/펌웨어 점검)',
|
||||||
|
'timestamp' => '시각오차(SNTP/RTC 점검)',
|
||||||
|
'key_default' => '기본 API키(미설정)',
|
||||||
|
'body_too_large' => '본문과대',
|
||||||
|
'invalid_json' => 'JSON오류',
|
||||||
|
'missing_field' => '필드누락',
|
||||||
|
'sensor_id_range' => 'sensor_id 범위',
|
||||||
|
];
|
||||||
|
$parts = [];
|
||||||
|
foreach ($ingest_rejections as $r => $c) {
|
||||||
|
$parts[] = htmlspecialchars($reason_ko[$r] ?? $r) . ' ' . (int)$c;
|
||||||
|
}
|
||||||
|
echo implode(' · ', $parts);
|
||||||
|
?></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<section class="mobile-quick" aria-label="모바일 빠른 조회">
|
<section class="mobile-quick" aria-label="모바일 빠른 조회">
|
||||||
<div class="quick-grid">
|
<div class="quick-grid">
|
||||||
<a class="quick-tile <?= $alert_count > 0 ? 'danger' : 'ok' ?>" href="#sensors-section">
|
<a class="quick-tile <?= $alert_count > 0 ? 'danger' : 'ok' ?>" href="#sensors-section">
|
||||||
|
|
@ -458,6 +561,7 @@ if ($alert_count > 0) {
|
||||||
</div>
|
</div>
|
||||||
<nav class="quick-links" aria-label="빠른 이동">
|
<nav class="quick-links" aria-label="빠른 이동">
|
||||||
<a href="#sensors-section">센서 상태</a>
|
<a href="#sensors-section">센서 상태</a>
|
||||||
|
<a href="#leak-section">누수 감지</a>
|
||||||
<a href="#chart-section">온습도 추이</a>
|
<a href="#chart-section">온습도 추이</a>
|
||||||
<a href="#summary-section">운영 요약</a>
|
<a href="#summary-section">운영 요약</a>
|
||||||
<a href="#metrics-section">측정 이력</a>
|
<a href="#metrics-section">측정 이력</a>
|
||||||
|
|
@ -585,6 +689,105 @@ if ($alert_count > 0) {
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 누수(1번 모듈) 상태 + 사고 이력 + 사진 -->
|
||||||
|
<h2 id="leak-section">누수 감지 (1번 모듈)</h2>
|
||||||
|
<?php
|
||||||
|
// 누수 센서 상태 표시: 오프라인 > 누수 > 정상 우선순위
|
||||||
|
if ($leak_status === null) {
|
||||||
|
$leak_card_class = '';
|
||||||
|
$leak_badge_class = 'offline';
|
||||||
|
$leak_badge_text = '데이터 없음';
|
||||||
|
} elseif ($leak_status['is_offline']) {
|
||||||
|
$leak_card_class = 'offline';
|
||||||
|
$leak_badge_class = 'offline';
|
||||||
|
$leak_badge_text = ((int)($leak_status['is_leak'] ?? 0) === 1) ? '오프라인 (누수 중 끊김)' : '오프라인';
|
||||||
|
} elseif ((int)($leak_status['is_leak'] ?? 0) === 1) {
|
||||||
|
$leak_card_class = 'leak';
|
||||||
|
$leak_badge_class = 'leak';
|
||||||
|
$leak_badge_text = '누수';
|
||||||
|
} else {
|
||||||
|
$leak_card_class = '';
|
||||||
|
$leak_badge_class = 'ok';
|
||||||
|
$leak_badge_text = '정상';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="sensor-grid">
|
||||||
|
<div class="sensor-card leak-status-card <?= $leak_card_class ?>">
|
||||||
|
<?php if ($leak_status === null): ?>
|
||||||
|
<strong>1번 센서 (누수)</strong><br>
|
||||||
|
<div style="margin-top:10px">
|
||||||
|
<span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-time" style="margin-top:10px">아직 수신된 누수 센서 데이터가 없습니다.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<strong><?= htmlspecialchars($leak_status['sensor_name']) ?></strong><br>
|
||||||
|
<small style="color:#64748b"><?= htmlspecialchars($leak_status['device_location']) ?> / <?= htmlspecialchars($leak_status['device_id']) ?></small><br>
|
||||||
|
<div style="margin-top:10px">
|
||||||
|
<span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:.75rem;margin-top:8px;<?= $leak_status['is_offline'] ? 'color:#dc2626;font-weight:600' : 'color:#94a3b8' ?>">
|
||||||
|
최종확인: <?= htmlspecialchars($leak_status['last_seen']) ?>
|
||||||
|
<?php if ($leak_status['is_offline']): ?>
|
||||||
|
(<?= $leak_status['offline_elapsed_min'] ?>분 전)
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mobile-detail" 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 style="color:#94a3b8">-</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($leak_incidents)): ?>
|
||||||
|
<tr><td colspan="6" style="text-align:center;color:#94a3b8">아직 누수 사고 이력이 없습니다.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<!-- 온습도 추이 차트 (인라인 SVG, 외부 라이브러리 없음) -->
|
<!-- 온습도 추이 차트 (인라인 SVG, 외부 라이브러리 없음) -->
|
||||||
<h2 id="chart-section">온습도 추이
|
<h2 id="chart-section">온습도 추이
|
||||||
<?php if ($chart_sensor_id > 0): ?>
|
<?php if ($chart_sensor_id > 0): ?>
|
||||||
|
|
@ -746,6 +949,14 @@ if ($alert_count > 0) {
|
||||||
<span>장비 복구 SMS</span>
|
<span>장비 복구 SMS</span>
|
||||||
<strong><?= $summary['device_online'] ?>건</strong>
|
<strong><?= $summary['device_online'] ?>건</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="report-stat">
|
||||||
|
<span>누수감지 SMS</span>
|
||||||
|
<strong style="color:<?= $summary['leak_alert'] > 0 ? '#dc2626' : 'inherit' ?>"><?= $summary['leak_alert'] ?>건</strong>
|
||||||
|
</div>
|
||||||
|
<div class="report-stat">
|
||||||
|
<span>누수복구 SMS</span>
|
||||||
|
<strong><?= $summary['leak_recovery'] ?>건</strong>
|
||||||
|
</div>
|
||||||
<div class="report-stat">
|
<div class="report-stat">
|
||||||
<span>SMS 실패</span>
|
<span>SMS 실패</span>
|
||||||
<strong style="color:<?= $summary['sms_fail'] > 0 ? '#dc2626' : 'inherit' ?>"><?= $summary['sms_fail'] ?>건</strong>
|
<strong style="color:<?= $summary['sms_fail'] > 0 ? '#dc2626' : 'inherit' ?>"><?= $summary['sms_fail'] ?>건</strong>
|
||||||
|
|
@ -818,17 +1029,26 @@ if ($alert_count > 0) {
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($logs as $row):
|
<?php foreach ($logs as $row):
|
||||||
$event_labels = [
|
$event_labels = [
|
||||||
'startup' => '장비시작',
|
'startup' => '장비시작',
|
||||||
'periodic' => '정기보고',
|
'periodic' => '정기보고',
|
||||||
|
'leak_alert' => '누수감지',
|
||||||
|
'leak_recovery' => '누수복구',
|
||||||
];
|
];
|
||||||
$evt = $row['event_type'];
|
$evt = $row['event_type'];
|
||||||
|
$is_leak_evt = ($evt === 'leak_alert' || $evt === 'leak_recovery' || (int)($row['is_leak'] ?? 0) === 1);
|
||||||
?>
|
?>
|
||||||
<tr>
|
<tr class="<?= $evt === 'leak_alert' ? 'alert-row' : '' ?>">
|
||||||
<td class="col-hide-xs"><?= (int)$row['id'] ?></td>
|
<td class="col-hide-xs"><?= (int)$row['id'] ?></td>
|
||||||
<td><?= htmlspecialchars($row['sensor_time']) ?></td>
|
<td><?= htmlspecialchars($row['sensor_time']) ?></td>
|
||||||
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
|
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
|
||||||
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></td>
|
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></td>
|
||||||
<td><?= htmlspecialchars($event_labels[$evt] ?? $evt) ?></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>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php if (empty($logs)): ?>
|
<?php if (empty($logs)): ?>
|
||||||
|
|
@ -854,7 +1074,7 @@ if ($alert_count > 0) {
|
||||||
<td class="col-hide-xs"><?= (int)$row['id'] ?></td>
|
<td class="col-hide-xs"><?= (int)$row['id'] ?></td>
|
||||||
<td><?= htmlspecialchars($row['sent_at']) ?></td>
|
<td><?= htmlspecialchars($row['sent_at']) ?></td>
|
||||||
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
|
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
|
||||||
<td><?= htmlspecialchars($row['receivers']) ?></td>
|
<td><?= htmlspecialchars(mask_phone($row['receivers'] ?? '')) ?></td>
|
||||||
<td>
|
<td>
|
||||||
<?php if ($row['status'] === 'success'): ?>
|
<?php if ($row['status'] === 'success'): ?>
|
||||||
<span class="ok-color">성공</span>
|
<span class="ok-color">성공</span>
|
||||||
|
|
|
||||||
|
|
@ -117,9 +117,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$is_locked && !$configuration_erro
|
||||||
});
|
});
|
||||||
|
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
$_SESSION['logged_in'] = true;
|
$_SESSION['logged_in'] = true;
|
||||||
$_SESSION['login_time'] = time();
|
$_SESSION['login_time'] = time(); // 절대 수명 기준(이후 갱신하지 않음)
|
||||||
$_SESSION['username'] = $username;
|
$_SESSION['last_activity'] = time(); // 유휴 타이머 기준(활동 시 갱신)
|
||||||
|
$_SESSION['username'] = $username;
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
admin_audit_log('login_success', $username, true);
|
admin_audit_log('login_success', $username, true);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
|
||||||
header('Location: login.php');
|
header('Location: login.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(대시보드 외 페이지에서도 강제)
|
||||||
|
|
||||||
function month_range(string $month): array {
|
function month_range(string $month): array {
|
||||||
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
|
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
|
||||||
|
|
@ -34,16 +35,17 @@ function month_range(string $month): array {
|
||||||
return [$month, $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s')];
|
return [$month, $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s')];
|
||||||
}
|
}
|
||||||
|
|
||||||
// SMS 메시지 태그(prefix) → 집계 키 매핑. sms_send.php 가 발송 시 붙이는 태그와 일치.
|
// SMS 메시지 태그(prefix) → 집계 키 매핑. 태그 문자열은 config.php 의 SMS_TAG_*
|
||||||
|
// 단일 출처 상수에서 가져온다(sms_send.php 가 발송 시 붙이는 태그와 자동 일치).
|
||||||
function report_sms_tags(): array {
|
function report_sms_tags(): array {
|
||||||
return [
|
return [
|
||||||
'high_temp' => '[고온경보]',
|
'high_temp' => SMS_TAG_HIGH_TEMP,
|
||||||
'low_temp' => '[저온경보]',
|
'low_temp' => SMS_TAG_LOW_TEMP,
|
||||||
'high_humidity' => '[고습경보]',
|
'high_humidity' => SMS_TAG_HIGH_HUMIDITY,
|
||||||
'low_humidity' => '[저습경보]',
|
'low_humidity' => SMS_TAG_LOW_HUMIDITY,
|
||||||
'recovery' => '[정상복귀]',
|
'recovery' => SMS_TAG_RECOVERY,
|
||||||
'device_offline' => '[장비 오프라인]',
|
'device_offline' => SMS_TAG_DEVICE_OFFLINE,
|
||||||
'device_online' => '[장비 복구]',
|
'device_online' => SMS_TAG_DEVICE_ONLINE,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,14 +79,7 @@ $report_scope = '온습도 임계 경보, 장비 오프라인, SMS';
|
||||||
// ── 월간 요약: SMS 태그별 건수 + 실패 건수 ───────────────────────────────────
|
// ── 월간 요약: SMS 태그별 건수 + 실패 건수 ───────────────────────────────────
|
||||||
$stmt = $db->prepare("
|
$stmt = $db->prepare("
|
||||||
SELECT
|
SELECT
|
||||||
SUM(message LIKE '[고온경보]%') AS high_temp,
|
" . sms_summary_select_sql() . "
|
||||||
SUM(message LIKE '[저온경보]%') AS low_temp,
|
|
||||||
SUM(message LIKE '[고습경보]%') AS high_humidity,
|
|
||||||
SUM(message LIKE '[저습경보]%') AS low_humidity,
|
|
||||||
SUM(message LIKE '[정상복귀]%') AS recovery,
|
|
||||||
SUM(message LIKE '[장비 오프라인]%') AS device_offline,
|
|
||||||
SUM(message LIKE '[장비 복구]%') AS device_online,
|
|
||||||
SUM(status = 'fail') AS sms_fail
|
|
||||||
FROM sms_log
|
FROM sms_log
|
||||||
WHERE sent_at >= :start_at AND sent_at < :end_at
|
WHERE sent_at >= :start_at AND sent_at < :end_at
|
||||||
");
|
");
|
||||||
|
|
@ -137,14 +132,7 @@ $summary['max_humidity'] = round_or_null($row['max_humidity'] ?? null);
|
||||||
// ── 센서별 SMS 태그별 건수 + 실패 건수 ───────────────────────────────────────
|
// ── 센서별 SMS 태그별 건수 + 실패 건수 ───────────────────────────────────────
|
||||||
$stmt = $db->prepare("
|
$stmt = $db->prepare("
|
||||||
SELECT sensor_id, sensor_name,
|
SELECT sensor_id, sensor_name,
|
||||||
SUM(message LIKE '[고온경보]%') AS high_temp,
|
" . sms_summary_select_sql() . "
|
||||||
SUM(message LIKE '[저온경보]%') AS low_temp,
|
|
||||||
SUM(message LIKE '[고습경보]%') AS high_humidity,
|
|
||||||
SUM(message LIKE '[저습경보]%') AS low_humidity,
|
|
||||||
SUM(message LIKE '[정상복귀]%') AS recovery,
|
|
||||||
SUM(message LIKE '[장비 오프라인]%') AS device_offline,
|
|
||||||
SUM(message LIKE '[장비 복구]%') AS device_online,
|
|
||||||
SUM(status = 'fail') AS sms_fail
|
|
||||||
FROM sms_log
|
FROM sms_log
|
||||||
WHERE sent_at >= :start_at AND sent_at < :end_at
|
WHERE sent_at >= :start_at AND sent_at < :end_at
|
||||||
GROUP BY sensor_id, sensor_name
|
GROUP BY sensor_id, sensor_name
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,12 @@ function collect_ops_checks(): array {
|
||||||
");
|
");
|
||||||
$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', 'sms_log 최근 실패 건수');
|
||||||
|
|
||||||
|
// SMS 잔여 건수(최근 성공 발송 기준). 임계 이하이면 충전 필요(경보 채널 보호).
|
||||||
|
$sms_balance = latest_sms_balance($db);
|
||||||
|
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 최근 발송 잔여');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$checks[] = ops_check('서버', 'DB 연결', false, '연결 실패: ' . $e->getMessage(), 'config.local.php DB 설정 확인', 'critical', 'DB 연결 오류 화면');
|
$checks[] = ops_check('서버', 'DB 연결', false, '연결 실패: ' . $e->getMessage(), 'config.local.php DB 설정 확인', 'critical', 'DB 연결 오류 화면');
|
||||||
|
|
@ -91,6 +97,8 @@ function collect_ops_checks(): array {
|
||||||
$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와 /etc/sht30-sensor.env(또는 펌웨어 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('API 보안', '레거시 서명 폴백', !ALLOW_LEGACY_BODY_SIGNATURE, ALLOW_LEGACY_BODY_SIGNATURE ? '활성(약한 키-접두 sha256 허용)' : '비활성(HMAC 전용)', '운영 클라이언트(STM32)는 HMAC만 사용하므로 config.local.php에서 ALLOW_LEGACY_BODY_SIGNATURE를 false로 두세요(레거시 RPi 클라이언트가 없는 경우).', 'warn', 'config.php ALLOW_LEGACY_BODY_SIGNATURE');
|
||||||
$checks[] = ops_check('관리자 인증', '감사로그', is_file(admin_audit_log_path()) || is_writable(__DIR__ . '/var'), admin_audit_log_path(), '로그인 성공/실패, 로그아웃, 관리자 활동 기록 확인', 'warn', 'php/var/admin_audit.log');
|
$checks[] = ops_check('관리자 인증', '감사로그', is_file(admin_audit_log_path()) || is_writable(__DIR__ . '/var'), admin_audit_log_path(), '로그인 성공/실패, 로그아웃, 관리자 활동 기록 확인', 'warn', 'php/var/admin_audit.log');
|
||||||
$checks[] = ops_check('개인정보', 'SMS 수신자', !empty(SMS_RECIPIENTS) && !$default_recipients, count(SMS_RECIPIENTS) . '명 설정', 'config.local.php의 SMS_RECIPIENTS 현행화', 'critical', '수신자 목록 현행화 확인표');
|
$checks[] = ops_check('개인정보', 'SMS 수신자', !empty(SMS_RECIPIENTS) && !$default_recipients, count(SMS_RECIPIENTS) . '명 설정', 'config.local.php의 SMS_RECIPIENTS 현행화', 'critical', '수신자 목록 현행화 확인표');
|
||||||
$thr_ok = (METRIC_TEMP_LOW_C < METRIC_TEMP_HIGH_C) && (METRIC_RH_LOW < METRIC_RH_HIGH);
|
$thr_ok = (METRIC_TEMP_LOW_C < METRIC_TEMP_HIGH_C) && (METRIC_RH_LOW < METRIC_RH_HIGH);
|
||||||
|
|
|
||||||
109
php/photo.php
Normal file
109
php/photo.php
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
<?php
|
||||||
|
// =============================================================================
|
||||||
|
// photo.php - 누수 사진 인증 열람 엔드포인트 (관리자 세션 전용)
|
||||||
|
//
|
||||||
|
// 누수 사진 원본은 php/var/photos/ 에 저장되며, 웹에서 직접 접근은 .htaccess 로
|
||||||
|
// 차단된다(var/photos/.htaccess: Require all denied). 따라서 대시보드의 사진
|
||||||
|
// 썸네일/원본은 반드시 이 엔드포인트(photo.php?id=<leak_photo.id>)를 통해서만
|
||||||
|
// 열람할 수 있다. 미인증 접근은 dashboard.php 와 동일하게 login.php 로 보낸다.
|
||||||
|
//
|
||||||
|
// [보안]
|
||||||
|
// - dashboard.php 와 동일한 관리자 세션 가드(세션 쿠키 하드닝 + logged_in 검사 +
|
||||||
|
// 세션 타임아웃 강제)를 그대로 사용한다(별도 인증 스킴 신설 금지).
|
||||||
|
// - id 는 양의 정수만 허용, leak_photo 에서 filename/mime/bytes 를 파라미터 바인딩
|
||||||
|
// 으로 조회. 없으면 404.
|
||||||
|
// - filename 은 서버 생성 패턴(^leak_\d+_\d+_[0-9a-f]+\.jpg$)으로 재검증
|
||||||
|
// (다중방어). 클라이언트 입력은 경로에 절대 들어가지 않는다.
|
||||||
|
// - realpath 가 var/photos/ 내부인지 확인(경로 탈출 차단) 후 readfile.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
require_once __DIR__ . '/admin_security.php';
|
||||||
|
|
||||||
|
// ── 세션 인증 (dashboard.php 와 동일한 가드) ────────────────
|
||||||
|
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) {
|
||||||
|
header('Location: login.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 세션 타임아웃(유휴 + 절대 수명) 강제 — 인증 페이지 공통 헬퍼(admin_security.php)
|
||||||
|
admin_enforce_session_timeout();
|
||||||
|
|
||||||
|
// ── 입력: id (양의 정수) ────────────────────────────────────
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
|
if ($id < 1) {
|
||||||
|
http_response_code(400);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo 'invalid id';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── leak_photo 조회 (파라미터 바인딩) ───────────────────────
|
||||||
|
try {
|
||||||
|
$db = get_db();
|
||||||
|
$stmt = $db->prepare("SELECT filename, mime, bytes FROM leak_photo WHERE id = :id");
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("[PHOTO] 조회 오류: " . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo 'server error';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo 'not found';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = (string)$row['filename'];
|
||||||
|
|
||||||
|
// ── 파일명 다중방어 검증 (서버 생성 패턴만 허용) ────────────
|
||||||
|
// 예: leak_1_42_a1b2c3d4e5f6a7b8.jpg (sensor_id, log_id, 무작위 hex)
|
||||||
|
if (!preg_match('/^leak_[0-9]+_[0-9]+_[0-9a-f]+\.jpg$/', $filename)) {
|
||||||
|
error_log("[PHOTO] 파일명 형식 거부: {$filename}");
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo 'not found';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 경로 결합 + 탈출 차단 (realpath 가 var/photos/ 내부인지 확인) ──
|
||||||
|
$photo_dir = realpath(__DIR__ . '/var/photos');
|
||||||
|
if ($photo_dir === false) {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo 'not found';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$path = realpath($photo_dir . '/' . $filename);
|
||||||
|
// realpath 실패(파일 없음) 또는 결합 경로가 var/photos/ 밖이면 거부(경로 탈출 방어).
|
||||||
|
if ($path === false || strpos($path, $photo_dir . DIRECTORY_SEPARATOR) !== 0) {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo 'not found';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JPEG 전송 (private, no-cache) ───────────────────────────
|
||||||
|
$size = @filesize($path);
|
||||||
|
header('Content-Type: image/jpeg');
|
||||||
|
if ($size !== false) {
|
||||||
|
header('Content-Length: ' . $size);
|
||||||
|
}
|
||||||
|
header('Cache-Control: private, no-store, no-cache, must-revalidate');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('Content-Disposition: inline; filename="' . $filename . '"');
|
||||||
|
readfile($path);
|
||||||
|
exit;
|
||||||
|
|
@ -17,6 +17,7 @@ if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
|
||||||
header('Location: login.php');
|
header('Location: login.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(대시보드 외 페이지에서도 강제)
|
||||||
|
|
||||||
$checks = collect_ops_checks();
|
$checks = collect_ops_checks();
|
||||||
$failed_count = ops_failed_count($checks);
|
$failed_count = ops_failed_count($checks);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ function setup_mfa_allowed(): bool {
|
||||||
if (!empty($_SESSION['logged_in']) && $_SESSION['logged_in'] === true) {
|
if (!empty($_SESSION['logged_in']) && $_SESSION['logged_in'] === true) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// 토큰 경로는 초기 등록 전에만 허용한다. MFA가 이미 설정된 상태에서
|
||||||
|
// MFA_SETUP_TOKEN이 남아 있어도 해당 토큰으로 재등록 화면에 접근할 수 없게 한다.
|
||||||
|
// (운영자가 등록 후 토큰을 삭제하지 않았을 때의 잔류 접근 차단.)
|
||||||
|
if (admin_mfa_configured()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
$token = $_GET['token'] ?? $_POST['token'] ?? '';
|
$token = $_GET['token'] ?? $_POST['token'] ?? '';
|
||||||
return MFA_SETUP_TOKEN !== '' && hash_equals(MFA_SETUP_TOKEN, (string)$token);
|
return MFA_SETUP_TOKEN !== '' && hash_equals(MFA_SETUP_TOKEN, (string)$token);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
|
||||||
header('Location: login.php');
|
header('Location: login.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(ops_checks → admin_security 경유 로드)
|
||||||
|
|
||||||
if (empty($_SESSION['csrf_token'])) {
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
|
@ -110,7 +111,7 @@ $failed_count = ops_failed_count($checks);
|
||||||
<section class="result">
|
<section class="result">
|
||||||
<strong>테스트 SMS 결과</strong>
|
<strong>테스트 SMS 결과</strong>
|
||||||
<?php foreach ($sms_test_result as $row): ?>
|
<?php foreach ($sms_test_result as $row): ?>
|
||||||
<p><?= htmlspecialchars($row['receiver']) ?> · <?= $row['result']['success'] ? '성공' : '실패' ?> · <?= htmlspecialchars($row['result']['message']) ?></p>
|
<p><?= htmlspecialchars(mask_phone($row['receiver'])) ?> · <?= $row['result']['success'] ? '성공' : '실패' ?> · <?= htmlspecialchars($row['result']['message']) ?></p>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
|
||||||
660
php/sms_send.php
660
php/sms_send.php
|
|
@ -6,6 +6,7 @@
|
||||||
// 담당 알림:
|
// 담당 알림:
|
||||||
// - 온습도 임계 경보 (고온/저온/고습/저습) : send_metric_alert_sms()
|
// - 온습도 임계 경보 (고온/저온/고습/저습) : send_metric_alert_sms()
|
||||||
// - 온습도 정상 복귀 : send_metric_recovery_sms()
|
// - 온습도 정상 복귀 : send_metric_recovery_sms()
|
||||||
|
// - 누수 감지 / 복구 (1번 모듈) : send_leak_alert_sms() / send_leak_recovery_sms()
|
||||||
// - 장비 오프라인 / 복구 : send_device_offline_sms() / online
|
// - 장비 오프라인 / 복구 : send_device_offline_sms() / online
|
||||||
// 임계 판정 자체는 api/sensor_data.php(서버, config.php 의 METRIC_* 상수)에서 한다.
|
// 임계 판정 자체는 api/sensor_data.php(서버, config.php 의 METRIC_* 상수)에서 한다.
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -32,6 +33,37 @@ function split_sender_number(string $phone): array {
|
||||||
return [substr($digits, 0, 3), substr($digits, 3, 4), substr($digits, 7)];
|
return [substr($digits, 0, 3), substr($digits, 3, 4), substr($digits, 7)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EUC-KR 로 인코딩된 문자열을 최대 바이트 수로 자른다(멀티바이트 경계 보존).
|
||||||
|
* Cafe24 단문(SMS)의 EUC-KR 길이 상한을 넘지 않도록 발송 직전에 사용한다.
|
||||||
|
*
|
||||||
|
* @param string $euckr EUC-KR 로 이미 인코딩된 바이트열
|
||||||
|
* @param int $maxBytes 허용 최대 바이트
|
||||||
|
*/
|
||||||
|
function sms_truncate_euckr(string $euckr, int $maxBytes): string {
|
||||||
|
if ($maxBytes <= 0 || strlen($euckr) <= $maxBytes) {
|
||||||
|
return $euckr;
|
||||||
|
}
|
||||||
|
if (function_exists('mb_strcut')) {
|
||||||
|
// mb_strcut 은 지정 바이트 안에서 멀티바이트 글자를 쪼개지 않는다.
|
||||||
|
$cut = mb_strcut($euckr, 0, $maxBytes, 'EUC-KR');
|
||||||
|
if ($cut !== '') {
|
||||||
|
return $cut;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// mbstring 미가용 폴백: EUC-KR 2바이트 글자 경계를 직접 맞춘다(0x00-0x7F 는 1바이트,
|
||||||
|
// 0xA1-0xFE 선행 바이트는 2바이트). maxBytes 위치가 2바이트 글자 중간이면 한 글자 뺀다.
|
||||||
|
$cut = substr($euckr, 0, $maxBytes);
|
||||||
|
$highBytes = 0;
|
||||||
|
for ($i = strlen($cut) - 1; $i >= 0 && ord($cut[$i]) >= 0x80; $i--) {
|
||||||
|
$highBytes++;
|
||||||
|
}
|
||||||
|
if ($highBytes % 2 === 1) {
|
||||||
|
$cut = substr($cut, 0, -1);
|
||||||
|
}
|
||||||
|
return $cut;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cafe24 SMS API로 문자 발송
|
* Cafe24 SMS API로 문자 발송
|
||||||
*
|
*
|
||||||
|
|
@ -46,6 +78,11 @@ function send_sms_cafe24(string $receiver, string $message): array {
|
||||||
return ['success' => false, 'result_code' => 'PARAM_ERR', 'message' => '수신자 또는 메시지가 비어있습니다.'];
|
return ['success' => false, 'result_code' => 'PARAM_ERR', 'message' => '수신자 또는 메시지가 비어있습니다.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 카오스 드릴: 실제 전송 없이 실패를 반환한다(실패 경로 시험). 운영에선 false.
|
||||||
|
if (SMS_FORCE_FAIL) {
|
||||||
|
return ['success' => false, 'result_code' => 'FORCED_FAIL', 'message' => '드릴: 강제 실패', 'remaining' => null];
|
||||||
|
}
|
||||||
|
|
||||||
[$sphone1, $sphone2, $sphone3] = split_sender_number(SMS_SENDER);
|
[$sphone1, $sphone2, $sphone3] = split_sender_number(SMS_SENDER);
|
||||||
|
|
||||||
$encodedMessage = iconv('UTF-8', 'EUC-KR//IGNORE', trim($message));
|
$encodedMessage = iconv('UTF-8', 'EUC-KR//IGNORE', trim($message));
|
||||||
|
|
@ -53,6 +90,15 @@ function send_sms_cafe24(string $receiver, string $message): array {
|
||||||
return ['success' => false, 'result_code' => 'ENCODE_ERR', 'message' => 'EUC-KR 인코딩 실패'];
|
return ['success' => false, 'result_code' => 'ENCODE_ERR', 'message' => 'EUC-KR 인코딩 실패'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 단문(SMS, 'S') 길이 상한(EUC-KR 바이트). 초과분은 게이트웨이가 잘라/거부하므로
|
||||||
|
// 멀티바이트 경계를 보존하며 발송 전에 안전하게 자른다(무성 절단 방지).
|
||||||
|
$origBytes = strlen($encodedMessage);
|
||||||
|
$encodedMessage = sms_truncate_euckr($encodedMessage, SMS_MAX_EUCKR_BYTES);
|
||||||
|
if (strlen($encodedMessage) < $origBytes) {
|
||||||
|
error_log(sprintf('[Cafe24 SMS] 메시지 길이 초과 절단: %dB → %dB (상한 %dB)',
|
||||||
|
$origBytes, strlen($encodedMessage), SMS_MAX_EUCKR_BYTES));
|
||||||
|
}
|
||||||
|
|
||||||
$postData = [
|
$postData = [
|
||||||
'action' => 'go',
|
'action' => 'go',
|
||||||
'user_id' => base64_encode(SMS_USER_ID),
|
'user_id' => base64_encode(SMS_USER_ID),
|
||||||
|
|
@ -117,6 +163,8 @@ function send_sms_cafe24(string $receiver, string $message): array {
|
||||||
'success' => $success,
|
'success' => $success,
|
||||||
'result_code' => $resultCode,
|
'result_code' => $resultCode,
|
||||||
'message' => $success ? "발송 성공 (잔여 {$remaining}건)" : "발송 실패: {$resultCode}",
|
'message' => $success ? "발송 성공 (잔여 {$remaining}건)" : "발송 실패: {$resultCode}",
|
||||||
|
// 게이트웨이가 돌려준 잔여 건수(숫자면 int, 아니면 null). 잔액 사전경보에 사용.
|
||||||
|
'remaining' => is_numeric($remaining) ? (int)$remaining : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,13 +177,18 @@ function send_sms_cafe24(string $receiver, string $message): array {
|
||||||
* @param string $logLabel error_log 식별 라벨
|
* @param string $logLabel error_log 식별 라벨
|
||||||
* @return bool 전원 발송 성공 여부
|
* @return bool 전원 발송 성공 여부
|
||||||
*/
|
*/
|
||||||
function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message, string $logLabel): 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_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
|
||||||
$allSuccess = true;
|
$allSuccess = true;
|
||||||
|
$lastRemaining = null; // 게이트웨이가 알려준 최신 잔여 건수(잔액 사전경보용)
|
||||||
|
$targets = $recipients ?? SMS_RECIPIENTS; // 기본 1차, 에스컬레이션은 TIER2 전달
|
||||||
|
|
||||||
foreach (SMS_RECIPIENTS as $receiver) {
|
foreach ($targets as $receiver) {
|
||||||
$receiverDigits = preg_replace('/\D+/', '', $receiver);
|
$receiverDigits = preg_replace('/\D+/', '', $receiver);
|
||||||
$result = send_sms_cafe24($receiverDigits, $message);
|
$result = send_sms_cafe24($receiverDigits, $message);
|
||||||
|
if (($result['remaining'] ?? null) !== null) {
|
||||||
|
$lastRemaining = (int)$result['remaining'];
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = $db->prepare("
|
$stmt = $db->prepare("
|
||||||
INSERT INTO sms_log
|
INSERT INTO sms_log
|
||||||
|
|
@ -164,9 +217,61 @@ function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 발송 직후 잔여 건수가 임계 이하이면(쿨다운 1회) 사전경보. 직접 발송이라 재귀 없음.
|
||||||
|
maybe_warn_sms_balance($db, $lastRemaining);
|
||||||
|
|
||||||
return $allSuccess;
|
return $allSuccess;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMS 잔여 건수가 임계 이하이면 '[SMS 잔액부족]' 경고를 1회 발송한다(쿨다운 적용).
|
||||||
|
* 게이트웨이 응답에서 얻은 잔여 건수를 그대로 받는다(추가 외부호출 없음).
|
||||||
|
* 경고는 sms_dispatch 를 거치지 않고 직접 발송·기록하여 재귀를 피한다.
|
||||||
|
*/
|
||||||
|
function maybe_warn_sms_balance(PDO $db, ?int $remaining): void {
|
||||||
|
if ($remaining === null || $remaining > SMS_BALANCE_WARN_THRESHOLD) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 쿨다운: 최근 '[SMS 잔액부족]' 성공/실패 불문 발송이 있으면 생략(반복 폭주 방지).
|
||||||
|
$recent = $db->prepare("
|
||||||
|
SELECT sent_at FROM sms_log
|
||||||
|
WHERE message LIKE :tag
|
||||||
|
ORDER BY sent_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
");
|
||||||
|
$recent->execute([':tag' => SMS_TAG_BALANCE_LOW . '%']);
|
||||||
|
$last_at = $recent->fetchColumn();
|
||||||
|
if ($last_at !== false && (time() - strtotime($last_at)) < SMS_BALANCE_WARN_COOLDOWN_SEC) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = SMS_TAG_BALANCE_LOW . "\n"
|
||||||
|
. "SMS 잔여 {$remaining}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건 이하)\n"
|
||||||
|
. "충전하지 않으면 경보 발송이 중단됩니다.\n"
|
||||||
|
. "시각: " . date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
foreach (SMS_RECIPIENTS as $receiver) {
|
||||||
|
$receiverDigits = preg_replace('/\D+/', '', $receiver);
|
||||||
|
$result = send_sms_cafe24($receiverDigits, $message);
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO sms_log
|
||||||
|
(sensor_id, sensor_name, device_id, message, receivers,
|
||||||
|
status, result_code, result_message, sent_at)
|
||||||
|
VALUES
|
||||||
|
(0, 'SMS잔액', '', :message, :receivers,
|
||||||
|
:status, :result_code, :result_message, NOW())
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
':message' => $message,
|
||||||
|
':receivers' => $receiverDigits,
|
||||||
|
':status' => $result['success'] ? 'success' : 'fail',
|
||||||
|
':result_code' => $result['result_code'],
|
||||||
|
':result_message' => $result['message'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
error_log("[SMS] 잔액부족 경고 발송 - 잔여 {$remaining}건");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 측정값을 사람이 읽는 짧은 문자열로 (불필요한 소수점 0 제거). 예: 30.0 -> "30".
|
* 측정값을 사람이 읽는 짧은 문자열로 (불필요한 소수점 0 제거). 예: 30.0 -> "30".
|
||||||
*/
|
*/
|
||||||
|
|
@ -178,6 +283,49 @@ function metric_value_str(float $value): string {
|
||||||
return $s;
|
return $s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이슬점(℃) — Magnus 근사(물, 약 -45~60℃, 계수 a=17.62 b=243.12).
|
||||||
|
* 상대습도 0% 이하면 이슬점 정의 불가로 null(결로 위험 없음으로 취급).
|
||||||
|
*/
|
||||||
|
function dew_point_c(float $temp_c, float $rh): ?float {
|
||||||
|
if ($rh <= 0.0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$a = 17.62;
|
||||||
|
$b = 243.12;
|
||||||
|
$gamma = ($a * $temp_c) / ($b + $temp_c) + log($rh / 100.0);
|
||||||
|
return ($b * $gamma) / ($a - $gamma);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결로마진(℃) = 현재온도 − 이슬점. 좁을수록 결로 위험. 측정 결측/산출불가면 null.
|
||||||
|
*/
|
||||||
|
function condensation_margin_c(?float $temp_c, ?float $rh): ?float {
|
||||||
|
if ($temp_c === null || $rh === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$td = dew_point_c($temp_c, $rh);
|
||||||
|
if ($td === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $temp_c - $td;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결로 위험이 해소(정상)인지(히스테리시스 적용). 결로 경보 비활성/결측/산출불가는
|
||||||
|
* 정상으로 간주한다. 복귀 판정에서 사용.
|
||||||
|
*/
|
||||||
|
function condensation_is_clear(?float $temp_c, ?float $rh): bool {
|
||||||
|
if (METRIC_DEWPOINT_MARGIN_C <= 0 || $temp_c === null || $rh === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$margin = condensation_margin_c($temp_c, $rh);
|
||||||
|
if ($margin === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return $margin > METRIC_DEWPOINT_MARGIN_C + METRIC_DEWPOINT_HYSTERESIS_C;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 온습도 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다.
|
* 온습도 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다.
|
||||||
* @return array{breaches: string[], status: string}
|
* @return array{breaches: string[], status: string}
|
||||||
|
|
@ -199,6 +347,14 @@ function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array {
|
||||||
$breaches[] = 'low_humidity';
|
$breaches[] = 'low_humidity';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 결로(이슬점) 위험: 온도·습도가 각각 정상이어도 결로마진이 좁으면 발화(말미에 추가
|
||||||
|
// 하여, 동시 발생 시 대표 status 는 온/습도 임계가 우선하도록 한다).
|
||||||
|
if (METRIC_DEWPOINT_MARGIN_C > 0 && $temp_c !== null && $rh !== null) {
|
||||||
|
$margin = condensation_margin_c($temp_c, $rh);
|
||||||
|
if ($margin !== null && $margin <= METRIC_DEWPOINT_MARGIN_C) {
|
||||||
|
$breaches[] = 'condensation_risk';
|
||||||
|
}
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'breaches' => $breaches,
|
'breaches' => $breaches,
|
||||||
'status' => empty($breaches) ? 'normal' : $breaches[0],
|
'status' => empty($breaches) ? 'normal' : $breaches[0],
|
||||||
|
|
@ -207,7 +363,7 @@ function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 정상 복귀 판정(히스테리시스 적용). 임계 안쪽으로 여유만큼 들어와야 true.
|
* 정상 복귀 판정(히스테리시스 적용). 임계 안쪽으로 여유만큼 들어와야 true.
|
||||||
* 측정 결측(null)은 해당 지표를 정상으로 간주한다.
|
* 결로 위험도 해소되어야 복귀로 본다. 측정 결측(null)은 해당 지표를 정상으로 간주한다.
|
||||||
*/
|
*/
|
||||||
function metric_is_recovered(?float $temp_c, ?float $rh): bool {
|
function metric_is_recovered(?float $temp_c, ?float $rh): bool {
|
||||||
$tOk = ($temp_c === null) || (
|
$tOk = ($temp_c === null) || (
|
||||||
|
|
@ -218,7 +374,7 @@ function metric_is_recovered(?float $temp_c, ?float $rh): bool {
|
||||||
$rh < METRIC_RH_HIGH - METRIC_RH_HYSTERESIS &&
|
$rh < METRIC_RH_HIGH - METRIC_RH_HYSTERESIS &&
|
||||||
$rh > METRIC_RH_LOW + METRIC_RH_HYSTERESIS
|
$rh > METRIC_RH_LOW + METRIC_RH_HYSTERESIS
|
||||||
);
|
);
|
||||||
return $tOk && $hOk;
|
return $tOk && $hOk && condensation_is_clear($temp_c, $rh);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -226,22 +382,34 @@ function metric_is_recovered(?float $temp_c, ?float $rh): bool {
|
||||||
* 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(sms_log 기준).
|
* 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(sms_log 기준).
|
||||||
*/
|
*/
|
||||||
function metric_was_alerting(PDO $db, int $sensor_id): bool {
|
function metric_was_alerting(PDO $db, int $sensor_id): bool {
|
||||||
$stmt = $db->prepare("
|
// 태그는 config.php 상수(단일 출처)에서 가져와 바인딩한다. 문자열 하드코딩 금지.
|
||||||
SELECT message FROM sms_log
|
// 결로(결로주의)도 1급 경보이므로 포함해야, 복귀(정상복귀) SMS 1회가 정상 발화한다.
|
||||||
WHERE sensor_id = :sid
|
$tags = [
|
||||||
AND status = 'success'
|
SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP,
|
||||||
AND (message LIKE '[고온경보]%' OR message LIKE '[저온경보]%'
|
SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY,
|
||||||
OR message LIKE '[고습경보]%' OR message LIKE '[저습경보]%'
|
SMS_TAG_CONDENSATION,
|
||||||
OR message LIKE '[정상복귀]%')
|
SMS_TAG_RECOVERY,
|
||||||
ORDER BY sent_at DESC
|
];
|
||||||
LIMIT 1
|
$likes = [];
|
||||||
");
|
$params = [':sid' => $sensor_id];
|
||||||
$stmt->execute([':sid' => $sensor_id]);
|
foreach ($tags as $i => $tag) {
|
||||||
|
$key = ":t{$i}";
|
||||||
|
$likes[] = "message LIKE {$key}";
|
||||||
|
$params[$key] = $tag . '%';
|
||||||
|
}
|
||||||
|
$sql = "SELECT message FROM sms_log
|
||||||
|
WHERE sensor_id = :sid
|
||||||
|
AND status = 'success'
|
||||||
|
AND (" . implode(' OR ', $likes) . ")
|
||||||
|
ORDER BY sent_at DESC, id DESC
|
||||||
|
LIMIT 1";
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
$msg = $stmt->fetchColumn();
|
$msg = $stmt->fetchColumn();
|
||||||
if ($msg === false) {
|
if ($msg === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return !str_starts_with($msg, '[정상복귀]');
|
return !str_starts_with($msg, SMS_TAG_RECOVERY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -259,12 +427,13 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
|
||||||
$db = get_db();
|
$db = get_db();
|
||||||
$sensor_id = (int)$sensor_data['sensor_id'];
|
$sensor_id = (int)$sensor_data['sensor_id'];
|
||||||
|
|
||||||
// [태그, 지표명, 단위, 임계값, 방향문구]
|
// [태그, 지표명, 단위, 임계값, 방향문구] (태그는 config.php 단일 출처 상수)
|
||||||
$labels = [
|
$labels = [
|
||||||
'high_temp' => ['[고온경보]', '온도', '℃', METRIC_TEMP_HIGH_C, '이상'],
|
'high_temp' => [SMS_TAG_HIGH_TEMP, '온도', '℃', METRIC_TEMP_HIGH_C, '이상'],
|
||||||
'low_temp' => ['[저온경보]', '온도', '℃', METRIC_TEMP_LOW_C, '이하'],
|
'low_temp' => [SMS_TAG_LOW_TEMP, '온도', '℃', METRIC_TEMP_LOW_C, '이하'],
|
||||||
'high_humidity' => ['[고습경보]', '습도', '%', METRIC_RH_HIGH, '이상'],
|
'high_humidity' => [SMS_TAG_HIGH_HUMIDITY, '습도', '%', METRIC_RH_HIGH, '이상'],
|
||||||
'low_humidity' => ['[저습경보]', '습도', '%', METRIC_RH_LOW, '이하'],
|
'low_humidity' => [SMS_TAG_LOW_HUMIDITY, '습도', '%', METRIC_RH_LOW, '이하'],
|
||||||
|
'condensation_risk' => [SMS_TAG_CONDENSATION, '결로마진', '℃', METRIC_DEWPOINT_MARGIN_C, '이하'],
|
||||||
];
|
];
|
||||||
if (!isset($labels[$kind])) {
|
if (!isset($labels[$kind])) {
|
||||||
error_log("[SMS] 알 수 없는 임계 경보 종류: {$kind}");
|
error_log("[SMS] 알 수 없는 임계 경보 종류: {$kind}");
|
||||||
|
|
@ -272,37 +441,71 @@ 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_log 메시지 prefix 기준) ──────────────
|
// ── 동시 발송 직렬화 (중복 SMS 방지) ────────────────────────
|
||||||
$recent = $db->prepare("
|
// 쿨다운 검사→발송→sms_log 기록 사이에 다른 요청(서로 다른 측정의 동시 수신 등)이
|
||||||
SELECT sent_at FROM sms_log
|
// 끼어들면 둘 다 쿨다운을 통과해 같은 경보를 중복 발송할 수 있다. (센서,종류) 단위
|
||||||
WHERE sensor_id = :sensor_id
|
// MySQL 네임드 락으로 직렬화한다. 락이 잡혀 있으면(busy) 다른 프로세스가 같은 경보를
|
||||||
AND status = 'success'
|
// 처리 중이므로 생략한다. GET_LOCK 미지원/오류(NULL)면 종전 동작(락 없이 진행)으로
|
||||||
AND message LIKE :tag
|
// 안전하게 폴백한다. (같은 본문 재시도/재생은 sensor_data.php 의 nonce 멱등에서 선차단)
|
||||||
ORDER BY sent_at DESC
|
$lockName = substr("sms_alert_{$sensor_id}_{$kind}", 0, 64);
|
||||||
LIMIT 1
|
$lockState = 'unsupported';
|
||||||
");
|
try {
|
||||||
$recent->execute([':sensor_id' => $sensor_id, ':tag' => $tag . '%']);
|
$lk = $db->prepare('SELECT GET_LOCK(:n, 0)');
|
||||||
$last_at = $recent->fetchColumn();
|
$lk->execute([':n' => $lockName]);
|
||||||
if ($last_at !== false && (time() - strtotime($last_at)) < METRIC_ALERT_COOLDOWN_SEC) {
|
$res = $lk->fetchColumn();
|
||||||
$elapsed = time() - strtotime($last_at);
|
$lockState = ($res === null || $res === false)
|
||||||
error_log("[SMS] {$tag} 쿨다운 생략 - 센서{$sensor_id} ({$elapsed}s/" . METRIC_ALERT_COOLDOWN_SEC . "s)");
|
? '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] {$tag} 동시 처리 감지 - 센서{$sensor_id} 중복 발송 생략");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 메시지 구성 ──────────────────────────────────────────
|
try {
|
||||||
$dt = date('Y-m-d H:i:s');
|
// ── 종류별 쿨다운 (sms_log 메시지 prefix 기준) ──────────────
|
||||||
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}";
|
$recent = $db->prepare("
|
||||||
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
SELECT sent_at FROM sms_log
|
||||||
$valStr = metric_value_str($value);
|
WHERE sensor_id = :sensor_id
|
||||||
$thrStr = metric_value_str((float)$threshold);
|
AND status = 'success'
|
||||||
|
AND message LIKE :tag
|
||||||
|
ORDER BY sent_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
");
|
||||||
|
$recent->execute([':sensor_id' => $sensor_id, ':tag' => $tag . '%']);
|
||||||
|
$last_at = $recent->fetchColumn();
|
||||||
|
if ($last_at !== false && (time() - strtotime($last_at)) < METRIC_ALERT_COOLDOWN_SEC) {
|
||||||
|
$elapsed = time() - strtotime($last_at);
|
||||||
|
error_log("[SMS] {$tag} 쿨다운 생략 - 센서{$sensor_id} ({$elapsed}s/" . METRIC_ALERT_COOLDOWN_SEC . "s)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$message = "{$tag}\n"
|
// ── 메시지 구성 ──────────────────────────────────────────
|
||||||
. "위치: {$loc}\n"
|
$dt = date('Y-m-d H:i:s');
|
||||||
. "센서: {$name}\n"
|
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}";
|
||||||
. "{$metricName}: {$valStr}{$unit} (임계 {$thrStr}{$unit} {$dir})\n"
|
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
||||||
. "시각: {$dt}";
|
$valStr = metric_value_str($value);
|
||||||
|
$thrStr = metric_value_str((float)$threshold);
|
||||||
|
|
||||||
return sms_dispatch_to_recipients($db, $sensor_data, $message, "임계경보 {$kind}");
|
$message = "{$tag}\n"
|
||||||
|
. "위치: {$loc}\n"
|
||||||
|
. "센서: {$name}\n"
|
||||||
|
. "{$metricName}: {$valStr}{$unit} (임계 {$thrStr}{$unit} {$dir})\n"
|
||||||
|
. "시각: {$dt}";
|
||||||
|
|
||||||
|
return sms_dispatch_to_recipients($db, $sensor_data, $message, "임계경보 {$kind}");
|
||||||
|
} 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -320,7 +523,7 @@ function send_metric_recovery_sms(array $sensor_data, float $temp_c, float $rh):
|
||||||
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
|
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
|
||||||
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
||||||
|
|
||||||
$message = "[정상복귀]\n"
|
$message = SMS_TAG_RECOVERY . "\n"
|
||||||
. "위치: {$loc}\n"
|
. "위치: {$loc}\n"
|
||||||
. "센서: {$name}\n"
|
. "센서: {$name}\n"
|
||||||
. "온도 " . metric_value_str($temp_c) . "℃ / 습도 " . metric_value_str($rh) . "%\n"
|
. "온도 " . metric_value_str($temp_c) . "℃ / 습도 " . metric_value_str($rh) . "%\n"
|
||||||
|
|
@ -330,6 +533,124 @@ function send_metric_recovery_sms(array $sensor_data, float $temp_c, float $rh):
|
||||||
return sms_dispatch_to_recipients($db, $sensor_data, $message, "정상복귀");
|
return sms_dispatch_to_recipients($db, $sensor_data, $message, "정상복귀");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 누수 감지 SMS 발송 (1번 모듈). 쿨다운 포함.
|
||||||
|
*
|
||||||
|
* 쿨다운: 같은 센서의 최근 성공한 [누수감지] SMS가 LEAK_ALERT_COOLDOWN_SEC 이내면
|
||||||
|
* 발송을 생략한다(누수 지속 중 주기 보고로 인한 중복 폭주 방지).
|
||||||
|
* 동시 처리 직렬화는 sms_send 의 metric 경보와 동일하게 MySQL 네임드 락으로 보장한다.
|
||||||
|
*
|
||||||
|
* @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location']
|
||||||
|
* @return bool 발송 시도 여부 (쿨다운/오류로 생략 시 false)
|
||||||
|
*/
|
||||||
|
function send_leak_alert_sms(array $sensor_data): bool {
|
||||||
|
$db = get_db();
|
||||||
|
$sensor_id = (int)$sensor_data['sensor_id'];
|
||||||
|
|
||||||
|
// ── 동시 발송 직렬화 (중복 SMS 방지) ──
|
||||||
|
$lockName = substr("sms_leak_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_LEAK_ALERT . " 동시 처리 감지 - 센서{$sensor_id} 중복 발송 생략");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── 쿨다운 (sms_log 메시지 prefix 기준) ──
|
||||||
|
$recent = $db->prepare("
|
||||||
|
SELECT sent_at FROM sms_log
|
||||||
|
WHERE sensor_id = :sensor_id
|
||||||
|
AND status = 'success'
|
||||||
|
AND message LIKE :tag
|
||||||
|
ORDER BY sent_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
");
|
||||||
|
$recent->execute([':sensor_id' => $sensor_id, ':tag' => SMS_TAG_LEAK_ALERT . '%']);
|
||||||
|
$last_at = $recent->fetchColumn();
|
||||||
|
if ($last_at !== false && (time() - strtotime($last_at)) < LEAK_ALERT_COOLDOWN_SEC) {
|
||||||
|
$elapsed = time() - strtotime($last_at);
|
||||||
|
error_log("[SMS] " . SMS_TAG_LEAK_ALERT . " 쿨다운 생략 - 센서{$sensor_id} ({$elapsed}s/" . LEAK_ALERT_COOLDOWN_SEC . "s)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 메시지 구성 ──
|
||||||
|
$dt = date('Y-m-d H:i:s');
|
||||||
|
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}";
|
||||||
|
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
||||||
|
|
||||||
|
$message = SMS_TAG_LEAK_ALERT . "\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 발송 (1번 모듈). 직전에 누수 경보([누수감지])가 있었고 그 뒤 복구
|
||||||
|
* 통지가 없었을 때만 1회 발송한다(복구만 단독으로 발송되는 것 방지).
|
||||||
|
*
|
||||||
|
* @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location']
|
||||||
|
* @return bool 발송 성공 여부 (경보 이력 없거나 이미 복구 통지면 false)
|
||||||
|
*/
|
||||||
|
function send_leak_recovery_sms(array $sensor_data): bool {
|
||||||
|
$db = get_db();
|
||||||
|
$sensor_id = (int)$sensor_data['sensor_id'];
|
||||||
|
|
||||||
|
// 직전 누수 상태 SMS([누수감지]/[누수복구]) 중 최신 1건을 본다. 최신이 [누수감지]일
|
||||||
|
// 때만(=아직 복구 통지 전) 복구 SMS 를 보낸다.
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT message FROM 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_LEAK_ALERT . '%',
|
||||||
|
':recovery' => SMS_TAG_LEAK_RECOVERY . '%',
|
||||||
|
]);
|
||||||
|
$last = $stmt->fetchColumn();
|
||||||
|
if ($last === false || !str_starts_with((string)$last, SMS_TAG_LEAK_ALERT)) {
|
||||||
|
return false; // 직전 누수 경보 없음(또는 이미 복구 통지함) → 발송 안 함
|
||||||
|
}
|
||||||
|
|
||||||
|
$dt = date('Y-m-d H:i:s');
|
||||||
|
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}";
|
||||||
|
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
||||||
|
|
||||||
|
$message = SMS_TAG_LEAK_RECOVERY . "\n"
|
||||||
|
. "위치: {$loc}\n"
|
||||||
|
. "센서: {$name}\n"
|
||||||
|
. "누수가 해소(복구)되었습니다.\n"
|
||||||
|
. "시각: {$dt}";
|
||||||
|
|
||||||
|
return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수복구");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 장비 오프라인 SMS 발송
|
* 장비 오프라인 SMS 발송
|
||||||
*
|
*
|
||||||
|
|
@ -344,7 +665,7 @@ function send_device_offline_sms(array $sensor_data): bool {
|
||||||
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
||||||
$last = $sensor_data['last_seen'] ?? '알 수 없음';
|
$last = $sensor_data['last_seen'] ?? '알 수 없음';
|
||||||
|
|
||||||
$message = "[장비 오프라인]\n"
|
$message = SMS_TAG_DEVICE_OFFLINE . "\n"
|
||||||
. "위치: {$loc}\n"
|
. "위치: {$loc}\n"
|
||||||
. "센서: {$name}\n"
|
. "센서: {$name}\n"
|
||||||
. "마지막 통신: {$last}\n"
|
. "마지막 통신: {$last}\n"
|
||||||
|
|
@ -367,7 +688,7 @@ function send_device_online_sms(array $sensor_data): bool {
|
||||||
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
|
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
|
||||||
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
||||||
|
|
||||||
$message = "[장비 복구]\n"
|
$message = SMS_TAG_DEVICE_ONLINE . "\n"
|
||||||
. "위치: {$loc}\n"
|
. "위치: {$loc}\n"
|
||||||
. "센서: {$name}\n"
|
. "센서: {$name}\n"
|
||||||
. "복구 시각: {$dt}\n"
|
. "복구 시각: {$dt}\n"
|
||||||
|
|
@ -375,3 +696,242 @@ function send_device_online_sms(array $sensor_data): bool {
|
||||||
|
|
||||||
return sms_dispatch_to_recipients($db, $sensor_data, $message, "장비복구");
|
return sms_dispatch_to_recipients($db, $sensor_data, $message, "장비복구");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일일 헬스(생존신호) 요약 문자열. 짧게 유지(SMS 단문 길이 고려).
|
||||||
|
* 예: "센서 2/2 정상, 최근수신 3분전, 24h경보 0건, 잔여 95건"
|
||||||
|
*/
|
||||||
|
function build_health_summary(PDO $db): string {
|
||||||
|
$online = (int)$db->query("SELECT COUNT(*) FROM sensor_status WHERE offline_alerted = 0")->fetchColumn();
|
||||||
|
$total = (int)$db->query("SELECT COUNT(*) FROM sensor_status")->fetchColumn();
|
||||||
|
|
||||||
|
$lastSeen = $db->query("SELECT TIMESTAMPDIFF(MINUTE, MAX(last_seen), NOW()) FROM sensor_status")->fetchColumn();
|
||||||
|
$recvStr = ($lastSeen === null) ? '수신없음' : ((int)$lastSeen . '분전');
|
||||||
|
|
||||||
|
$alerts = (int)$db->query("
|
||||||
|
SELECT COUNT(*) FROM sms_log
|
||||||
|
WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
|
||||||
|
AND status = 'success'
|
||||||
|
AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%'
|
||||||
|
OR message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%' OR message LIKE '" . SMS_TAG_LOW_HUMIDITY . "%'
|
||||||
|
OR message LIKE '" . SMS_TAG_CONDENSATION . "%' OR message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%')
|
||||||
|
")->fetchColumn();
|
||||||
|
|
||||||
|
$summary = "센서 {$online}/{$total} 정상, 최근수신 {$recvStr}, 24h경보 {$alerts}건";
|
||||||
|
$bal = latest_sms_balance($db);
|
||||||
|
if ($bal !== null) {
|
||||||
|
$summary .= ", 잔여 {$bal}건";
|
||||||
|
}
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 하루 1회 '[정기점검]' 생존신호 SMS 를 보낸다(데드맨). HEALTH_SMS_HOUR 시 이후의
|
||||||
|
* 첫 호출에서, 오늘자 발송이 없을 때만 발송한다. cron_heartbeat 가 주기적으로 호출.
|
||||||
|
* 날짜 dedup + 네임드 락으로 중복 발송을 막는다(락 미지원 시 날짜 dedup 만으로 방어).
|
||||||
|
*/
|
||||||
|
function maybe_send_daily_health_sms(PDO $db): void {
|
||||||
|
if (!HEALTH_SMS_ENABLED || (int)date('G') < HEALTH_SMS_HOUR) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$today = $db->prepare("
|
||||||
|
SELECT 1 FROM sms_log
|
||||||
|
WHERE message LIKE :tag AND DATE(sent_at) = CURDATE()
|
||||||
|
LIMIT 1
|
||||||
|
");
|
||||||
|
$today->execute([':tag' => SMS_TAG_HEALTH . '%']);
|
||||||
|
if ($today->fetchColumn() !== false) {
|
||||||
|
return; // 오늘 이미 발송함
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockState = 'unsupported';
|
||||||
|
try {
|
||||||
|
$lk = $db->prepare('SELECT GET_LOCK(:n, 0)');
|
||||||
|
$lk->execute([':n' => 'daily_health_sms']);
|
||||||
|
$res = $lk->fetchColumn();
|
||||||
|
$lockState = ($res === null || $res === false)
|
||||||
|
? 'unsupported'
|
||||||
|
: (((int)$res === 1) ? 'acquired' : 'busy');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$lockState = 'unsupported';
|
||||||
|
}
|
||||||
|
if ($lockState === 'busy') {
|
||||||
|
return; // 다른 프로세스가 처리 중
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 락 획득 후 이중 확인(획득 사이에 다른 프로세스가 발송했을 수 있음).
|
||||||
|
$today->execute([':tag' => SMS_TAG_HEALTH . '%']);
|
||||||
|
if ($today->fetchColumn() !== false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$message = SMS_TAG_HEALTH . "\n" . build_health_summary($db);
|
||||||
|
$info = ['sensor_id' => 0, 'sensor_name' => '정기점검', 'device_id' => '', 'device_location' => ''];
|
||||||
|
sms_dispatch_to_recipients($db, $info, $message, "정기점검");
|
||||||
|
error_log("[SMS] 정기점검(생존신호) 발송");
|
||||||
|
} finally {
|
||||||
|
if ($lockState === 'acquired') {
|
||||||
|
try {
|
||||||
|
$db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => 'daily_health_sms']);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
/* 해제 실패 무시 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 태그의 최근 발송이 $seconds 이내인지(쿨다운 판정). 태그별 1회 발송 억제에 사용.
|
||||||
|
*/
|
||||||
|
function recent_tag_within(PDO $db, int $sensor_id, string $tag, int $seconds): bool {
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT sent_at FROM sms_log
|
||||||
|
WHERE sensor_id = :sid AND message LIKE :tag
|
||||||
|
ORDER BY sent_at DESC LIMIT 1
|
||||||
|
");
|
||||||
|
$stmt->execute([':sid' => $sensor_id, ':tag' => $tag . '%']);
|
||||||
|
$last = $stmt->fetchColumn();
|
||||||
|
return $last !== false && (time() - strtotime($last)) < $seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모집단 표준편차(σ). 측정값 고착(변화 없음) 판정에 사용.
|
||||||
|
*/
|
||||||
|
function stddev_pop(array $values): float {
|
||||||
|
$n = count($values);
|
||||||
|
if ($n === 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$mean = array_sum($values) / $n;
|
||||||
|
$sum = 0.0;
|
||||||
|
foreach ($values as $v) {
|
||||||
|
$d = (float)$v - $mean;
|
||||||
|
$sum += $d * $d;
|
||||||
|
}
|
||||||
|
return sqrt($sum / $n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 센서별 최신 경보-상태 SMS 1건(경보/복귀/에스컬레이션 태그 중). 에스컬레이션 판정용.
|
||||||
|
*/
|
||||||
|
function latest_alert_state_row(PDO $db, int $sensor_id): ?array {
|
||||||
|
$tags = [
|
||||||
|
SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP, SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY,
|
||||||
|
SMS_TAG_CONDENSATION, SMS_TAG_RECOVERY, SMS_TAG_ESCALATION,
|
||||||
|
];
|
||||||
|
$likes = [];
|
||||||
|
$params = [':sid' => $sensor_id];
|
||||||
|
foreach ($tags as $i => $t) {
|
||||||
|
$k = ":t{$i}";
|
||||||
|
$likes[] = "message LIKE {$k}";
|
||||||
|
$params[$k] = $t . '%';
|
||||||
|
}
|
||||||
|
// id(AUTO_INCREMENT) 를 2차 정렬키로 둬, 같은 초에 경보·복귀가 기록돼도 최신 행이
|
||||||
|
// 결정적으로 선택되게 한다(sent_at 은 1초 해상도).
|
||||||
|
$sql = "SELECT message, sent_at FROM sms_log
|
||||||
|
WHERE sensor_id = :sid AND status = 'success'
|
||||||
|
AND (" . implode(' OR ', $likes) . ")
|
||||||
|
ORDER BY sent_at DESC, id DESC LIMIT 1";
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미복귀 임계 경보를 2차(야간) 수신자에게 에스컬레이션한다(에피소드당 1회). 각 센서의
|
||||||
|
* 최신 경보-상태가 '경보'이고 ESCALATE_AFTER_SEC 이상 지속(미복귀·미에스컬레이션)이면
|
||||||
|
* SMS_RECIPIENTS_TIER2 로 1회 통지. TIER2 가 비면 비활성(opt-in). cron_heartbeat 가 호출.
|
||||||
|
*/
|
||||||
|
function maybe_escalate_alerts(PDO $db): void {
|
||||||
|
if (empty(SMS_RECIPIENTS_TIER2) || ESCALATE_AFTER_SEC <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$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();
|
||||||
|
foreach ($sensors as $s) {
|
||||||
|
$sid = (int)$s['sensor_id'];
|
||||||
|
$row = latest_alert_state_row($db, $sid);
|
||||||
|
if ($row === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$msg = (string)$row['message'];
|
||||||
|
// 최신이 복귀/이미 에스컬레이션이면 에피소드 종료/처리됨 → 스킵.
|
||||||
|
if (str_starts_with($msg, SMS_TAG_RECOVERY) || str_starts_with($msg, SMS_TAG_ESCALATION)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$isAlert = false;
|
||||||
|
foreach ($alertTags as $t) {
|
||||||
|
if (str_starts_with($msg, $t)) { $isAlert = true; break; }
|
||||||
|
}
|
||||||
|
if (!$isAlert || (time() - strtotime((string)$row['sent_at'])) < ESCALATE_AFTER_SEC) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 이미 에스컬레이션을 시도했으면(성공/실패 불문) 재발화 금지. latest_alert_state_row
|
||||||
|
// 는 success 만 보므로, 실패한 에스컬레이션 행이 보이지 않아 매 주기 재발송되는 것을
|
||||||
|
// 막는다(상태 무관 recent_tag_within 으로 가드 — 에피소드당 1회 보장).
|
||||||
|
if (recent_tag_within($db, $sid, SMS_TAG_ESCALATION, ESCALATE_AFTER_SEC)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$firstLine = strtok($msg, "\n");
|
||||||
|
$name = $s['sensor_name'] ?: "센서{$sid}";
|
||||||
|
$loc = $s['device_location'] ?: '알 수 없음';
|
||||||
|
$mins = (int)floor((time() - strtotime((string)$row['sent_at'])) / 60);
|
||||||
|
$message = SMS_TAG_ESCALATION . "\n"
|
||||||
|
. "위치: {$loc}\n센서: {$name}\n"
|
||||||
|
. "{$firstLine} 미복귀 {$mins}분 경과\n"
|
||||||
|
. "1차 미대응 — 확인 바랍니다.\n시각: " . date('Y-m-d H:i:s');
|
||||||
|
$info = ['sensor_id' => $sid, 'sensor_name' => $name, 'device_id' => $s['device_id'], 'device_location' => $loc];
|
||||||
|
sms_dispatch_to_recipients($db, $info, $message, "에스컬레이션", SMS_RECIPIENTS_TIER2);
|
||||||
|
error_log("[SMS] 에스컬레이션 발송 - 센서{$sid} ({$mins}분 미복귀)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 측정값 고착(live-but-stuck) 검출: 최근 STUCK_DETECT_SAMPLES 측정의 온도·습도 표준편차가
|
||||||
|
* 둘 다 STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장을 의심해 1회 알린다(쿨다운).
|
||||||
|
* 하트비트(수신 끊김)로는 못 잡는 무성실패를 보완. cron_heartbeat 가 호출.
|
||||||
|
*/
|
||||||
|
function maybe_detect_stuck_sensors(PDO $db): void {
|
||||||
|
if (!STUCK_DETECT_ENABLED || STUCK_DETECT_SAMPLES < 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$n = (int)STUCK_DETECT_SAMPLES; // 정수 상수 — LIMIT 에 안전하게 인라인
|
||||||
|
$sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM sensor_status")->fetchAll();
|
||||||
|
foreach ($sensors as $s) {
|
||||||
|
$sid = (int)$s['sensor_id'];
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT temperature_c, humidity_percent
|
||||||
|
FROM sensor_metric
|
||||||
|
WHERE sensor_id = :sid
|
||||||
|
ORDER BY measured_at DESC
|
||||||
|
LIMIT {$n}
|
||||||
|
");
|
||||||
|
$stmt->execute([':sid' => $sid]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
if (count($rows) < $n) {
|
||||||
|
continue; // 표본 부족
|
||||||
|
}
|
||||||
|
$temps = array_values(array_filter(array_column($rows, 'temperature_c'), fn($v) => $v !== null));
|
||||||
|
$hums = array_values(array_filter(array_column($rows, 'humidity_percent'), fn($v) => $v !== null));
|
||||||
|
// 온도·습도 둘 다 충분한 표본이 완전 평탄해야 고착으로 본다(한쪽만 평탄은 정상 가능).
|
||||||
|
$tStuck = count($temps) >= $n && stddev_pop($temps) < STUCK_DETECT_EPSILON;
|
||||||
|
$hStuck = count($hums) >= $n && stddev_pop($hums) < STUCK_DETECT_EPSILON;
|
||||||
|
if (!($tStuck && $hStuck)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (recent_tag_within($db, $sid, SMS_TAG_SENSOR_STUCK, STUCK_ALERT_COOLDOWN_SEC)) {
|
||||||
|
continue; // 쿨다운
|
||||||
|
}
|
||||||
|
$name = $s['sensor_name'] ?: "센서{$sid}";
|
||||||
|
$loc = $s['device_location'] ?: '알 수 없음';
|
||||||
|
$tval = number_format((float)$rows[0]['temperature_c'], 2);
|
||||||
|
$hval = number_format((float)$rows[0]['humidity_percent'], 2);
|
||||||
|
$message = SMS_TAG_SENSOR_STUCK . "\n위치: {$loc}\n센서: {$name}\n"
|
||||||
|
. "최근 {$n}회 측정값 고착(온도 {$tval}℃ / 습도 {$hval}%)\n"
|
||||||
|
. "센서/배선 점검 필요\n시각: " . date('Y-m-d H:i:s');
|
||||||
|
$info = ['sensor_id' => $sid, 'sensor_name' => $name, 'device_id' => $s['device_id'], 'device_location' => $loc];
|
||||||
|
sms_dispatch_to_recipients($db, $info, $message, "센서고착");
|
||||||
|
error_log("[SMS] 센서고착 의심 - 센서{$sid} (최근 {$n}회 평탄)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
echo "OK - " . date('Y-m-d H:i:s') . " - " . $_SERVER['HTTP_USER_AGENT'];
|
// 모바일 접근 점검용 단순 핑. User-Agent 를 그대로 반사하면 반사형 XSS 가 되므로
|
||||||
|
// text/plain 으로 응답하고 출력값도 이스케이프한다(이중 방어).
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||||
|
echo "OK - " . date('Y-m-d H:i:s') . " - " . htmlspecialchars($ua, ENT_QUOTES, 'UTF-8') . "\n";
|
||||||
|
|
|
||||||
14
raspberry_pi4/.gitignore
vendored
Normal file
14
raspberry_pi4/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# 운영·비밀값 (절대 커밋 금지)
|
||||||
|
config_local.py
|
||||||
|
|
||||||
|
# Python venv / 캐시
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# 오프라인 설치 번들 (vendor.sh 산출물 — 용량이 큼, 전송은 별도)
|
||||||
|
wheelhouse/
|
||||||
|
debs/
|
||||||
|
|
||||||
|
# 런타임 로그
|
||||||
|
*.log
|
||||||
152
raspberry_pi4/README.md
Normal file
152
raspberry_pi4/README.md
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
# 누수감지 센서 모듈 — Raspberry Pi 4 Model B 전용
|
||||||
|
|
||||||
|
e-con **5C-08 저항형 누수감지센서**를 Raspberry Pi 4 Model B 의 GPIO 로 읽어,
|
||||||
|
누수 발생 시 즉시 PHP API 서버에 보고(+SMS 발송 트리거)하고 **Pi Camera 로 현장 사진을
|
||||||
|
촬영해 업로드**하는 모니터링 모듈입니다.
|
||||||
|
|
||||||
|
> 원래 **Raspberry Pi Zero 2 W** 용으로 작성된 모듈을 **Pi 4 Model B 전용**으로 이식한
|
||||||
|
> 폴더입니다. GPIO(BCM) 번호 체계와 `rpicam`/`libcamera` 카메라 스택은 두 보드가 동일해
|
||||||
|
> 센서·촬영 로직은 그대로이며, 차이는 **배선/전원/이더넷/카메라 커넥터**와 설치 환경뿐입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 구성 파일
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|---|---|
|
||||||
|
| `leak_sensor.py` | 메인 — GPIO 인터럽트 누수 감지, 주기 보고, 누수 시 촬영+업로드 |
|
||||||
|
| `config.py` | 비밀이 아닌 기본 설정(센서/임계/간격/카메라). `config_local` 에서 비밀값 로드 |
|
||||||
|
| `config_local.example.py` | 운영·비밀값 템플릿 → `config_local.py` 로 복사해 사용(미커밋) |
|
||||||
|
| `requirements.txt` | Python 의존성(핀 고정) |
|
||||||
|
| `install.sh` | Pi 4 설치 스크립트(폐쇄망 오프라인 + 온라인 폴백) |
|
||||||
|
| `vendor.sh` | 폐쇄망용 의존성 사전 다운로드(빌드 머신에서 실행) |
|
||||||
|
| `test_connection.py` | 서버 연결/서명 점검 |
|
||||||
|
| `systemd/leak-sensor.service` | 부팅 자동 실행 서비스 유닛 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 하드웨어 (Pi 4 Model B 기준)
|
||||||
|
|
||||||
|
- **Raspberry Pi 4 Model B** (BCM2711) + Raspberry Pi OS **Bookworm 64-bit**
|
||||||
|
- e-con **5C-08** 누수감지센서(저항형, 고임피던스 ~1.5MΩ)
|
||||||
|
- **외부 4.7MΩ 풀다운 저항** (필수 — 내부 풀저항은 끔)
|
||||||
|
- (선택) **Pi Camera** — Pi 4 = **15핀(1.0mm 피치) 표준 CSI**, Zero 2 W = **22핀(0.5mm 피치) CSI**
|
||||||
|
→ 리본/카메라 보드 호환 안 됨 (Pi 4용 15핀 리본 또는 15↔22 변환 어댑터 필요)
|
||||||
|
- 전원: **USB-C 5V/3A** (카메라 사용 시 여유 전원 권장)
|
||||||
|
- 네트워크: Pi 4 **Gigabit Ethernet**(유선) 또는 WiFi
|
||||||
|
|
||||||
|
### 2.1 핀 배치 (기본 센서 1개)
|
||||||
|
|
||||||
|
| 신호 | BCM | 40핀 헤더 물리 핀 |
|
||||||
|
|---|---|---|
|
||||||
|
| 센서 입력 | GPIO17 | 11 |
|
||||||
|
| 3.3V | — | 1 |
|
||||||
|
| GND | — | 6 (또는 9/14/20/25/30/34/39) |
|
||||||
|
|
||||||
|
> 센서를 추가하려면 `config.py` 의 `SENSORS` 에 BCM 번호로 항목을 추가합니다.
|
||||||
|
> (예: GPIO27=물리 13번, GPIO22=물리 15번)
|
||||||
|
|
||||||
|
### 2.2 전기 설계 (중요)
|
||||||
|
|
||||||
|
- `ACTIVE_LOW = False`: 건조 시 GPIO=LOW(0), 누수 시 GPIO=HIGH(1)
|
||||||
|
- `USE_INTERNAL_PULL = False`(**PUD_OFF 유지**): 내부 50kΩ 풀다운을 켜면 1.5MΩ 센서가
|
||||||
|
GPIO 를 HIGH 로 끌어올리지 못해 **감지 실패**합니다. 외부 4.7MΩ 풀다운만 사용하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 서버 호환성 (반드시 확인)
|
||||||
|
|
||||||
|
이 모듈은 `leak_alert` / `leak_recovery` 이벤트와 `photo_upload.php` 를 **수신하는 서버**에
|
||||||
|
보고합니다.
|
||||||
|
|
||||||
|
> ⚠️ **현재 이 repo 의 `php/` 서버는 SHT30 온습도 전용으로 전환**되었습니다
|
||||||
|
> (`sql/migration_drop_leak.sql` 가 `leak_photo`/`leak_incident` 테이블과 `is_leak` 컬럼을
|
||||||
|
> 제거하고 `event_type` 을 `startup|periodic` 로 축소). 따라서 현재 repo 서버는 leak 이벤트와
|
||||||
|
> 사진 업로드를 **거부**합니다.
|
||||||
|
>
|
||||||
|
> 이 Pi 4 모듈은 **leak 를 받는 기존 서버**(기존 `raspi_leck_detecter` 엔드포인트)로 보고하도록
|
||||||
|
> `config_local.py` 의 `API_URL` / `PHOTO_UPLOAD_URL` 을 설정해야 합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 설치
|
||||||
|
|
||||||
|
### 4.1 설정값 준비
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config_local.example.py config_local.py
|
||||||
|
# config_local.py 를 열어 API_URL / PHOTO_UPLOAD_URL / API_KEY / DEVICE_ID 채움
|
||||||
|
```
|
||||||
|
|
||||||
|
`config_local.py` 가 없으면 실행 시 **명확한 오류로 즉시 중단**됩니다(fail closed).
|
||||||
|
`config_local.py` 와 `API_KEY` 는 **절대 커밋하지 마세요**(`.gitignore` 처리됨).
|
||||||
|
|
||||||
|
### 4.2 온라인 설치 (인터넷 가능 환경)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x install.sh
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 폐쇄망(air-gapped) 오프라인 설치
|
||||||
|
|
||||||
|
인터넷이 되는 **동일 아키텍처(aarch64, 가급적 동일 Pi 4 + Bookworm 64-bit)** 머신에서
|
||||||
|
의존성을 먼저 받아 번들을 만든 뒤, 타깃 Pi 4 로 옮겨 설치합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# (빌드 머신, 인터넷 O) wheelhouse/ + debs/ 생성
|
||||||
|
./vendor.sh
|
||||||
|
|
||||||
|
# wheelhouse/ 와 debs/ 를 타깃 Pi 4 의 이 폴더로 복사한 뒤:
|
||||||
|
# (타깃 Pi 4, 인터넷 X)
|
||||||
|
./install.sh # debs/ · wheelhouse/ 가 있으면 자동으로 오프라인 설치
|
||||||
|
```
|
||||||
|
|
||||||
|
> `install.sh` 는 `debs/` 가 있으면 `dpkg` 오프라인, `wheelhouse/` 가 있으면
|
||||||
|
> `pip --no-index` 오프라인으로 설치합니다. 둘 다 없을 때만 인터넷 apt/pip 로 폴백합니다.
|
||||||
|
> wheel/deb 는 아키텍처 종속이므로 x86_64 PC 에서 받은 것은 Pi 4 에서 동작하지 않습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
python3 leak_sensor.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`RPi.GPIO` 가 없는 환경(개발 PC 등)에서는 자동으로 **시뮬레이션 모드**로 동작합니다.
|
||||||
|
|
||||||
|
### 연결 점검
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
python3 test_connection.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 부팅 자동 실행 (systemd)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 폴더를 /home/pi/raspberry_pi4 에 배치했다고 가정 (다르면 .service 의 경로 수정)
|
||||||
|
sudo cp systemd/leak-sensor.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable leak-sensor
|
||||||
|
sudo systemctl start leak-sensor
|
||||||
|
sudo systemctl status leak-sensor
|
||||||
|
journalctl -u leak-sensor -f
|
||||||
|
```
|
||||||
|
|
||||||
|
`SupplementaryGroups=video` 가 카메라 접근에 필요합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 보안 메모
|
||||||
|
|
||||||
|
- 비밀값(`API_KEY`)·서버 URL·기기 정보는 `config_local.py`(미커밋)에만 둡니다.
|
||||||
|
커밋되는 파일에는 placeholder 만 존재합니다.
|
||||||
|
- 보고 본문은 `API_KEY + 정렬된 JSON` 의 SHA-256 서명으로 위변조를 방지합니다
|
||||||
|
(`leak_sensor.make_signature` ↔ 서버 검증 로직과 동일 포맷).
|
||||||
|
- ⚠️ 이 서명은 **레거시 누수 서버와의 호환을 위해 의도적으로** `SHA-256(API_KEY + JSON)` 방식을 유지합니다. HMAC-SHA256 이 길이연장 공격에 면역이라 더 안전하지만, 전환하려면 **서버 검증부를 동시에 변경**해야 합니다.
|
||||||
|
- `config_local.py` 노출 시 즉시 `API_KEY` 를 교체(서버 동시 변경)하세요.
|
||||||
82
raspberry_pi4/config.py
Normal file
82
raspberry_pi4/config.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
# =============================================================================
|
||||||
|
# config.py - 누수감지 시스템 v2 (Pi 4 Model B) 설정 — 비밀이 아닌 기본값
|
||||||
|
#
|
||||||
|
# 서버 URL / API_KEY / 기기 식별자 등 운영·비밀값은 config_local.py 에서 로드한다.
|
||||||
|
# config_local.py 는 .gitignore 로 커밋 제외 대상이다.
|
||||||
|
# 설정 방법: cp config_local.example.py config_local.py → 실제 값으로 채움
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 센서 GPIO 설정 (BCM 번호 기준 — Pi 4 40핀 헤더와 동일)
|
||||||
|
# GPIO17 = 40핀 헤더 물리 11번 핀
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
SENSORS = [
|
||||||
|
{"id": 1, "gpio": 17, "name": "1번 센서 (서버실)"},
|
||||||
|
# 추가 센서가 있으면 아래에 추가 (BCM 번호 기준)
|
||||||
|
# {"id": 2, "gpio": 27, "name": "2번 센서 (기계실)"}, # 물리 13번
|
||||||
|
# {"id": 3, "gpio": 22, "name": "3번 센서 (화장실)"}, # 물리 15번
|
||||||
|
]
|
||||||
|
|
||||||
|
# 센서 활성 레벨 설정
|
||||||
|
# e-con 5C-08 저항형 센서 + 4.7MΩ 풀다운 사용 시:
|
||||||
|
# 건조: GPIO = LOW(0) → ACTIVE_LOW = False
|
||||||
|
# 누수: GPIO = HIGH(1)
|
||||||
|
ACTIVE_LOW = False
|
||||||
|
|
||||||
|
# GPIO 내부 풀저항 설정
|
||||||
|
# 반드시 False (PUD_OFF) 유지!
|
||||||
|
# 내부 풀다운(50kΩ)을 켜면 누수 시에도 HIGH가 되지 않아 감지 실패함.
|
||||||
|
# → 외부 4.7MΩ 풀다운 저항만 사용
|
||||||
|
USE_INTERNAL_PULL = False # True = PUD_DOWN, False = PUD_OFF
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 주기 설정
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
REPORT_INTERVAL = 3600 # 정상 상태 주기 보고 간격 (초) = 1시간
|
||||||
|
LEAK_REPORT_INTERVAL = 10 # 누수 감지 시 재보고 간격 (초)
|
||||||
|
DEBOUNCE_MS = 500 # 바운싱 방지 시간 (ms) — 고임피던스라 넉넉히 설정
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 로그 설정
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
LOG_FILE = "/var/log/leak_sensor.log"
|
||||||
|
LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB
|
||||||
|
LOG_BACKUP_COUNT = 3
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 재시도 설정
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
HTTP_TIMEOUT = 10 # HTTP 요청 타임아웃 (초)
|
||||||
|
HTTP_RETRY_COUNT = 3 # 재시도 횟수
|
||||||
|
HTTP_RETRY_DELAY = 5 # 재시도 대기 (초)
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 카메라 설정
|
||||||
|
# Pi 4 = 15핀(1.0mm 피치) 표준 CSI, Zero 2 W = 22핀(0.5mm 피치) CSI →
|
||||||
|
# 리본/카메라 보드 호환 안 됨 (Pi 4용 15핀 리본 또는 15↔22 변환 어댑터 필요).
|
||||||
|
# 촬영은 rpicam-still(Bookworm) / libcamera-still(Bullseye) 사용.
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
CAMERA_ENABLED = True # False로 설정하면 촬영/업로드 건너뜀
|
||||||
|
PHOTO_WIDTH = 640 # 촬영 해상도 (가로)
|
||||||
|
PHOTO_HEIGHT = 480 # 촬영 해상도 (세로)
|
||||||
|
PHOTO_QUALITY = 60 # JPEG 품질 (1-100, 60 ≈ 50-80KB)
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 운영·비밀값 로드 (fail closed)
|
||||||
|
# config_local.py 가 없으면 placeholder/하드코딩 비밀로 잘못 기동하는 대신
|
||||||
|
# 명확한 오류로 즉시 중단한다.
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
from config_local import ( # noqa: F401 (leak_sensor.py 가 config 에서 재수입)
|
||||||
|
API_URL,
|
||||||
|
API_KEY,
|
||||||
|
PHOTO_UPLOAD_URL,
|
||||||
|
DEVICE_ID,
|
||||||
|
DEVICE_LOCATION,
|
||||||
|
)
|
||||||
|
except ImportError as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
"config_local.py 를 찾을 수 없습니다. "
|
||||||
|
"`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / "
|
||||||
|
"기기 정보를 채우세요. (비밀값은 절대 커밋하지 마세요.)"
|
||||||
|
) from e
|
||||||
35
raspberry_pi4/config_local.example.py
Normal file
35
raspberry_pi4/config_local.example.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# =============================================================================
|
||||||
|
# config_local.example.py - 운영·비밀값 템플릿 (Pi 4 누수감지 모듈)
|
||||||
|
#
|
||||||
|
# 사용법: cp config_local.example.py config_local.py
|
||||||
|
# → 실제 값으로 채운 뒤 저장. config_local.py 는 절대 커밋하지 말 것
|
||||||
|
# (.gitignore 로 제외되어 있음).
|
||||||
|
#
|
||||||
|
# ⚠️ 서버 호환성
|
||||||
|
# 이 모듈(1번)은 2번 모듈(SHT30 온습도)과 "동일한" cafe24 서버 php/ 로 보고한다(통합).
|
||||||
|
# 누수 이벤트(leak_alert / leak_recovery)와 사진 업로드는 서버가 raw-body 서명을 요구한다:
|
||||||
|
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))
|
||||||
|
# 사진 업로드 canonical = device_id|sensor_id|log_id|timestamp (공백 없음)
|
||||||
|
# → API_KEY 는 서버 config.local.php 의 API_KEY 와 "정확히 동일"해야 한다.
|
||||||
|
# 서버에서 sql/migration_add_leak.sql 을 1회 실행하면(가산·멱등) 누수 수신이 활성화되고,
|
||||||
|
# 누수 상태·사진·온습도가 한 대시보드(dashboard.php)에 함께 표시된다.
|
||||||
|
# (구버전의 sha256(API_KEY + 정렬 JSON) body-field 서명은 폐기됨 — 서버가 거부한다.)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 서버 API 엔드포인트 (통합 서버 = cafe24 php/, 2번 모듈과 동일 서버)
|
||||||
|
# INSTALL_PI_SERVER.md 기준 웹 루트: public_html/raspi_leck_detecter/
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php"
|
||||||
|
PHOTO_UPLOAD_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/photo_upload.php"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 공유 비밀키 — 서버 측 API_KEY 와 정확히 동일해야 함. 절대 커밋 금지.
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
API_KEY = "CHANGE_ME"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 기기 식별 (여러 대 운영 시 구분)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
DEVICE_ID = "rpi4-leak-01"
|
||||||
|
DEVICE_LOCATION = "서버실"
|
||||||
107
raspberry_pi4/install.sh
Normal file
107
raspberry_pi4/install.sh
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# 누수감지 센서 v2 (Raspberry Pi 4 Model B) 설치 스크립트
|
||||||
|
# 대상 OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
|
||||||
|
#
|
||||||
|
# 폐쇄망(air-gapped) 지원:
|
||||||
|
# - debs/ 또는 wheelhouse/ 가 있으면 자동으로 "오프라인 모드"로 전환.
|
||||||
|
# - 오프라인 모드에서는 절대 네트워크에 접근하지 않는다. 번들이 일부만 있으면
|
||||||
|
# 온라인으로 몰래 빠지지 않고 명확한 오류로 중단한다(폐쇄망 규칙: 네트워크가
|
||||||
|
# 필요하면 반드시 FLAG). 사전 다운로드는 vendor.sh 로 빌드 머신에서 수행.
|
||||||
|
# - 환경변수 OFFLINE=1/0 으로 강제 지정 가능.
|
||||||
|
# - 로그 파일 소유자: SERVICE_USER (기본 pi).
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
SERVICE_USER="${SERVICE_USER:-pi}"
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " 누수감지 센서 v2 (Pi 4) 설치"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# 0) 보드 모델 안내 (강제 아님)
|
||||||
|
if [ -r /proc/device-tree/model ]; then
|
||||||
|
MODEL="$(tr -d '\0' < /proc/device-tree/model)"
|
||||||
|
echo "[INFO] 보드: ${MODEL}"
|
||||||
|
case "${MODEL}" in
|
||||||
|
*"Raspberry Pi 4"*) : ;;
|
||||||
|
*) echo "[WARN] 이 폴더는 Raspberry Pi 4 Model B 전용입니다 (계속 진행은 가능)." ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 오프라인 모드 판정
|
||||||
|
OFFLINE="${OFFLINE:-}"
|
||||||
|
if [ -z "${OFFLINE}" ]; then
|
||||||
|
if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then
|
||||||
|
OFFLINE=1
|
||||||
|
echo "[INFO] 오프라인 번들 감지 → 폐쇄망 오프라인 설치 모드 (네트워크 미사용)"
|
||||||
|
else
|
||||||
|
OFFLINE=0
|
||||||
|
echo "[INFO] 오프라인 번들 없음 → 온라인 설치 모드 (인터넷 필요)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1) 시스템 패키지: RPi.GPIO 대체(lgpio) + requests + venv + 카메라 앱
|
||||||
|
echo "[1/3] 시스템 패키지 설치..."
|
||||||
|
if [ "${OFFLINE}" = "1" ]; then
|
||||||
|
if ! compgen -G "debs/*.deb" >/dev/null; then
|
||||||
|
echo "[ERROR] 오프라인 모드인데 debs/ 가 비어있습니다. vendor.sh 산출물(debs/)을 이 폴더에 두고 재실행하세요." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)"
|
||||||
|
sudo apt-get install -y --no-download ./debs/*.deb
|
||||||
|
else
|
||||||
|
echo " 온라인 apt 설치 (인터넷 필요)"
|
||||||
|
sudo apt-get update
|
||||||
|
if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; fi
|
||||||
|
sudo apt-get install -y python3-rpi-lgpio python3-requests python3-venv "${CAM}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) Python venv + requests
|
||||||
|
echo "[2/3] Python 가상환경 설정..."
|
||||||
|
if [ ! -d venv ]; then
|
||||||
|
python3 -m venv --system-site-packages venv
|
||||||
|
echo " venv 생성 완료"
|
||||||
|
else
|
||||||
|
echo " venv 이미 존재 — 건너뜀"
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source venv/bin/activate
|
||||||
|
if [ "${OFFLINE}" = "1" ]; then
|
||||||
|
if [ ! -d wheelhouse ]; then
|
||||||
|
echo "[ERROR] 오프라인 모드인데 wheelhouse/ 가 없습니다. vendor.sh 산출물(wheelhouse/)을 두고 재실행하세요." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " 오프라인 wheel 설치 (--no-index — 네트워크 미사용)"
|
||||||
|
pip install --no-index --find-links=wheelhouse -r requirements.txt
|
||||||
|
else
|
||||||
|
echo " 온라인 pip 설치 (인터넷 필요)"
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
fi
|
||||||
|
echo " 패키지 설치 완료"
|
||||||
|
|
||||||
|
# 3) 로그 파일 (최소 권한: 소유자만 쓰기 — leak_sensor 가 단독 기록)
|
||||||
|
echo "[3/3] 로그 파일 생성..."
|
||||||
|
sudo mkdir -p /var/log
|
||||||
|
sudo touch /var/log/leak_sensor.log
|
||||||
|
if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/leak_sensor.log; then
|
||||||
|
echo "[WARN] chown 실패 — SERVICE_USER=${SERVICE_USER} 존재 여부 확인 (서비스 사용자와 일치해야 파일 로깅 가능)"
|
||||||
|
fi
|
||||||
|
sudo chmod 640 /var/log/leak_sensor.log
|
||||||
|
|
||||||
|
# 4) config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨)
|
||||||
|
if [ ! -f config_local.py ]; then
|
||||||
|
echo ""
|
||||||
|
echo "[!] config_local.py 가 없습니다. 다음을 실행 후 실제 값으로 채우세요:"
|
||||||
|
echo " cp config_local.example.py config_local.py"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " 설치 완료! (OFFLINE=${OFFLINE})"
|
||||||
|
echo "=========================================="
|
||||||
|
echo " 실행: source venv/bin/activate && python3 leak_sensor.py"
|
||||||
|
echo " 서비스: README.md 의 systemd 섹션 참고"
|
||||||
|
echo ""
|
||||||
531
raspberry_pi4/leak_sensor.py
Normal file
531
raspberry_pi4/leak_sensor.py
Normal file
|
|
@ -0,0 +1,531 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# =============================================================================
|
||||||
|
# leak_sensor.py - e-con 5C-08 누수감지센서 모니터링 메인 스크립트 (v2, Pi 4 전용)
|
||||||
|
#
|
||||||
|
# [대상 하드웨어] Raspberry Pi 4 Model B (BCM2711, 40핀 GPIO 헤더)
|
||||||
|
# - 원래 Raspberry Pi Zero 2 W 용이던 모듈을 Pi 4 Model B 전용으로 이식.
|
||||||
|
# - GPIO BCM 번호 체계·rpicam/libcamera 카메라 스택은 두 보드가 동일하므로
|
||||||
|
# 센서/촬영 로직은 그대로 유지. 차이는 배선/전원/이더넷/카메라 커넥터 문서뿐.
|
||||||
|
# (Pi 4 = 15핀 1.0mm CSI / Zero 2 W = 22핀 0.5mm CSI → 리본 비호환,
|
||||||
|
# Gigabit Ethernet, USB-C 5V/3A) → README.md 참고.
|
||||||
|
#
|
||||||
|
# [동작 흐름]
|
||||||
|
# 1. GPIO 초기화 → 센서 핀 입력 설정
|
||||||
|
# 2. 주기적(REPORT_INTERVAL)으로 현재 상태를 PHP API에 POST
|
||||||
|
# 3. 누수 감지 인터럽트 발생 시 즉시 알림 POST
|
||||||
|
# 4. 누수 지속 중엔 LEAK_REPORT_INTERVAL 마다 재보고
|
||||||
|
# 5. 복구(정상) 감지 시 복구 알림 POST
|
||||||
|
# 6. 누수 감지 시 Pi Camera로 촬영 → photo_upload.php에 업로드
|
||||||
|
# (촬영/업로드 실패해도 알림+SMS는 이미 전송된 상태)
|
||||||
|
#
|
||||||
|
# [설정·비밀값] config.py 는 비밀이 아닌 기본값만 보유하고, 서버 URL/API_KEY/
|
||||||
|
# 기기 식별자는 config_local.py(미커밋)에서 불러온다. config_local.py 가 없으면
|
||||||
|
# 기동 시 명확한 오류로 즉시 중단(fail closed)된다.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import requests
|
||||||
|
try:
|
||||||
|
import RPi.GPIO as GPIO
|
||||||
|
SIMULATION = False
|
||||||
|
except (ImportError, RuntimeError):
|
||||||
|
# 개발/테스트 환경에서 RPi.GPIO 없을 때 시뮬레이션 모드
|
||||||
|
SIMULATION = True
|
||||||
|
print("[WARNING] RPi.GPIO 없음 → 시뮬레이션 모드로 실행됩니다.")
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
SENSORS, ACTIVE_LOW, USE_INTERNAL_PULL,
|
||||||
|
API_URL, API_KEY,
|
||||||
|
REPORT_INTERVAL, LEAK_REPORT_INTERVAL, DEBOUNCE_MS,
|
||||||
|
DEVICE_ID, DEVICE_LOCATION,
|
||||||
|
LOG_FILE, LOG_MAX_BYTES, LOG_BACKUP_COUNT,
|
||||||
|
HTTP_TIMEOUT, HTTP_RETRY_COUNT, HTTP_RETRY_DELAY,
|
||||||
|
CAMERA_ENABLED, PHOTO_UPLOAD_URL,
|
||||||
|
PHOTO_WIDTH, PHOTO_HEIGHT, PHOTO_QUALITY,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 로거 설정
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
def setup_logger():
|
||||||
|
logger = logging.getLogger("leak_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 # 권한 없으면 콘솔만 사용
|
||||||
|
|
||||||
|
# 콘솔 핸들러
|
||||||
|
ch = logging.StreamHandler(sys.stdout)
|
||||||
|
ch.setFormatter(fmt)
|
||||||
|
logger.addHandler(ch)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
log = setup_logger()
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 시뮬레이션 GPIO (테스트용)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
class SimGPIO:
|
||||||
|
BCM = "BCM"
|
||||||
|
IN = "IN"
|
||||||
|
PUD_UP = "PUD_UP"
|
||||||
|
PUD_DOWN = "PUD_DOWN"
|
||||||
|
PUD_OFF = "PUD_OFF"
|
||||||
|
FALLING = "FALLING"
|
||||||
|
RISING = "RISING"
|
||||||
|
BOTH = "BOTH"
|
||||||
|
_state = {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setmode(mode): pass
|
||||||
|
@staticmethod
|
||||||
|
def setup(pin, mode, pull_up_down=None): SimGPIO._state[pin] = 1
|
||||||
|
@staticmethod
|
||||||
|
def input(pin): return SimGPIO._state.get(pin, 1)
|
||||||
|
@staticmethod
|
||||||
|
def add_event_detect(pin, edge, callback=None, bouncetime=0): pass
|
||||||
|
@staticmethod
|
||||||
|
def cleanup(): pass
|
||||||
|
|
||||||
|
if SIMULATION:
|
||||||
|
GPIO = SimGPIO()
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 누수 상태 관리
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
class SensorState:
|
||||||
|
def __init__(self):
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
# 센서별 상태 딕셔너리 {sensor_id: bool}
|
||||||
|
self.leak_detected = {s["id"]: False for s in SENSORS}
|
||||||
|
self.last_report_time = {s["id"]: 0 for s in SENSORS}
|
||||||
|
|
||||||
|
def is_leaking(self, sensor_id):
|
||||||
|
with self.lock:
|
||||||
|
return self.leak_detected.get(sensor_id, False)
|
||||||
|
|
||||||
|
def any_leaking(self):
|
||||||
|
with self.lock:
|
||||||
|
return any(self.leak_detected.values())
|
||||||
|
|
||||||
|
def set_leak(self, sensor_id, value):
|
||||||
|
with self.lock:
|
||||||
|
changed = self.leak_detected.get(sensor_id) != value
|
||||||
|
self.leak_detected[sensor_id] = value
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
state = SensorState()
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# HTTP POST 유틸
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# [보안 메모 — raw-body HMAC 서명 (2번 모듈과 동일 스킴)]
|
||||||
|
# 서버(php/api/sensor_data.php + config.php::verify_signature_raw)는 "받은 본문
|
||||||
|
# 바이트 그대로"에 대해 X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY,
|
||||||
|
# msg=raw_body)) 를 검증한다. 따라서 "서명한 바이트"와 "실제 전송한 바이트"가
|
||||||
|
# 100% 동일해야 한다. requests 의 json= 직렬화에 맡기면 공백/순서/부동소수 포맷이
|
||||||
|
# 달라져 서명이 깨질 수 있으므로, 본문을 직접 문자열로 만들고 그 바이트에 서명한 뒤
|
||||||
|
# 그 바이트를 그대로 POST(data=body_bytes) 한다.
|
||||||
|
# (구버전의 sha256(API_KEY + 정렬 JSON) body-field 서명은 폐기 — 서버가 거부한다.)
|
||||||
|
def _esc(s: str) -> str:
|
||||||
|
r"""JSON 문자열 값 이스케이프 ('"' / '\\' / 제어문자<0x20 만). 멀티바이트는 그대로."""
|
||||||
|
out = []
|
||||||
|
for ch in s:
|
||||||
|
o = ord(ch)
|
||||||
|
if ch == '"':
|
||||||
|
out.append('\\"')
|
||||||
|
elif ch == '\\':
|
||||||
|
out.append('\\\\')
|
||||||
|
elif o < 0x20:
|
||||||
|
out.append('\\u%04x' % o)
|
||||||
|
else:
|
||||||
|
out.append(ch)
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def leak_event_body(event_type: str, sensor: dict, is_leak: bool, timestamp: int) -> str:
|
||||||
|
"""누수 이벤트 본문 JSON 문자열을 고정 필드 순서로 생성(서명 호환).
|
||||||
|
|
||||||
|
필드 순서(서버 파싱은 순서 무관이나, 서명은 바이트 동일성을 요구하므로 고정):
|
||||||
|
device_id, device_location, sensor_id, sensor_name, event_type, is_leak, timestamp
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
'{'
|
||||||
|
f'"device_id":"{_esc(DEVICE_ID)}",'
|
||||||
|
f'"device_location":"{_esc(DEVICE_LOCATION)}",'
|
||||||
|
f'"sensor_id":{int(sensor["id"])},'
|
||||||
|
f'"sensor_name":"{_esc(sensor["name"])}",'
|
||||||
|
f'"event_type":"{_esc(event_type)}",'
|
||||||
|
f'"is_leak":{1 if is_leak else 0},'
|
||||||
|
f'"timestamp":{int(timestamp)}'
|
||||||
|
'}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sign_raw(body: str) -> str:
|
||||||
|
"""raw-body 서명: lowercase_hex(HMAC-SHA256(key=API_KEY, msg=body 바이트))."""
|
||||||
|
return hmac.new(API_KEY.encode("utf-8"), body.encode("utf-8"),
|
||||||
|
hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _post_event(event_type: str, sensor: dict, is_leak: bool, want_response: bool):
|
||||||
|
"""누수 이벤트를 raw-body HMAC 서명으로 POST.
|
||||||
|
|
||||||
|
body 문자열을 만들고 그 바이트에 서명한 뒤 data=body_bytes 로 그대로 전송한다.
|
||||||
|
want_response=False 면 성공 여부(bool), True 면 응답 JSON(dict) 또는 None 을 반환.
|
||||||
|
"""
|
||||||
|
body = leak_event_body(event_type, sensor, is_leak, int(time.time()))
|
||||||
|
body_bytes = body.encode("utf-8")
|
||||||
|
signature = sign_raw(body)
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Device-Id": DEVICE_ID,
|
||||||
|
"X-Signature": signature,
|
||||||
|
"User-Agent": "LeakSensor/2.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
for attempt in range(1, HTTP_RETRY_COUNT + 1):
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
API_URL,
|
||||||
|
data=body_bytes,
|
||||||
|
timeout=HTTP_TIMEOUT,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
log.info(f"[POST OK] 센서{sensor['id']} event={event_type} leak={is_leak} → {resp.text[:80]}")
|
||||||
|
if not want_response:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except ValueError:
|
||||||
|
log.warning("[POST] 응답 JSON 파싱 실패")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
log.warning(f"[POST {resp.status_code}] 시도 {attempt}/{HTTP_RETRY_COUNT}: {resp.text[:120]}")
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
log.warning(f"[POST TIMEOUT] 시도 {attempt}/{HTTP_RETRY_COUNT}")
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
log.warning(f"[POST CONN ERR] 시도 {attempt}/{HTTP_RETRY_COUNT}: {e}")
|
||||||
|
|
||||||
|
if attempt < HTTP_RETRY_COUNT:
|
||||||
|
time.sleep(HTTP_RETRY_DELAY)
|
||||||
|
|
||||||
|
log.error(f"[POST FAIL] {HTTP_RETRY_COUNT}회 모두 실패. 센서{sensor['id']}")
|
||||||
|
return False if not want_response else None
|
||||||
|
|
||||||
|
|
||||||
|
def post_to_server(event_type: str, sensor: dict, is_leak: bool):
|
||||||
|
"""PHP API 서버에 센서 데이터 POST (성공 여부 bool 반환).
|
||||||
|
|
||||||
|
event_type: "periodic" | "startup" | "leak_alert" | "leak_recovery"
|
||||||
|
"""
|
||||||
|
return _post_event(event_type, sensor, is_leak, want_response=False)
|
||||||
|
|
||||||
|
|
||||||
|
def post_to_server_with_response(event_type: str, sensor: dict, is_leak: bool):
|
||||||
|
"""post_to_server 와 동일하나 응답 JSON(dict) 또는 None 을 반환."""
|
||||||
|
return _post_event(event_type, sensor, is_leak, want_response=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 카메라 촬영 / 사진 업로드
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
def capture_photo(output_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
rpicam-still (Bookworm+) 우선, 없으면 libcamera-still (Bullseye)로 사진 촬영.
|
||||||
|
성공 시 True, 실패 시 False.
|
||||||
|
"""
|
||||||
|
for cmd in ["rpicam-still", "libcamera-still"]:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
cmd,
|
||||||
|
"--width", str(PHOTO_WIDTH),
|
||||||
|
"--height", str(PHOTO_HEIGHT),
|
||||||
|
"--quality", str(PHOTO_QUALITY),
|
||||||
|
"--nopreview",
|
||||||
|
"--timeout", "2000",
|
||||||
|
"-o", output_path,
|
||||||
|
],
|
||||||
|
capture_output=True, text=True, timeout=15
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and os.path.exists(output_path):
|
||||||
|
file_size = os.path.getsize(output_path)
|
||||||
|
log.info(f"[CAMERA] 촬영 성공: {output_path} ({file_size} bytes, cmd={cmd})")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
log.warning(f"[CAMERA] {cmd} 실패 (rc={result.returncode}): {result.stderr[:200]}")
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue # 이 명령어가 없으면 다음 것 시도
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
log.warning(f"[CAMERA] {cmd} 타임아웃")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"[CAMERA] {cmd} 예외: {e}")
|
||||||
|
|
||||||
|
log.error("[CAMERA] 촬영 실패: rpicam-still, libcamera-still 모두 사용 불가")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def upload_photo(photo_path: str, log_id: int, sensor: dict) -> bool:
|
||||||
|
"""
|
||||||
|
촬영된 사진을 photo_upload.php에 multipart POST.
|
||||||
|
성공 시 True, 실패 시 False.
|
||||||
|
|
||||||
|
[인증] X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=canonical))
|
||||||
|
canonical = f"{DEVICE_ID}|{sensor_id}|{log_id}|{timestamp}" (공백 없음)
|
||||||
|
서버 php/api/photo_upload.php 의 canonical 과 바이트 동일해야 한다.
|
||||||
|
"""
|
||||||
|
timestamp = int(time.time())
|
||||||
|
sensor_id = int(sensor["id"])
|
||||||
|
# 서버와 정확히 동일한 문자열: device_id|sensor_id|log_id|timestamp (공백 없음).
|
||||||
|
# 폼 필드도 동일한 문자열 표현으로 보내야 서버 재구성 canonical 과 일치한다.
|
||||||
|
sensor_id_str = str(sensor_id)
|
||||||
|
log_id_str = str(log_id)
|
||||||
|
timestamp_str = str(timestamp)
|
||||||
|
canonical = f"{DEVICE_ID}|{sensor_id_str}|{log_id_str}|{timestamp_str}"
|
||||||
|
signature = hmac.new(API_KEY.encode("utf-8"), canonical.encode("utf-8"),
|
||||||
|
hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(photo_path, "rb") as f:
|
||||||
|
files = {"photo": (os.path.basename(photo_path), f, "image/jpeg")}
|
||||||
|
form_data = {
|
||||||
|
"device_id": DEVICE_ID,
|
||||||
|
"sensor_id": sensor_id_str,
|
||||||
|
"log_id": log_id_str,
|
||||||
|
"timestamp": timestamp_str,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
PHOTO_UPLOAD_URL,
|
||||||
|
files=files,
|
||||||
|
data=form_data,
|
||||||
|
timeout=30,
|
||||||
|
headers={
|
||||||
|
"X-Device-Id": DEVICE_ID,
|
||||||
|
"X-Signature": signature,
|
||||||
|
"User-Agent": "LeakSensor/2.0",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
log.info(f"[PHOTO UPLOAD OK] log_id={log_id} → {resp.text[:80]}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
log.warning(f"[PHOTO UPLOAD {resp.status_code}] {resp.text[:120]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"[PHOTO UPLOAD ERR] {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_leak_with_photo(sensor: dict):
|
||||||
|
"""
|
||||||
|
누수 감지 시 처리 순서:
|
||||||
|
1. sensor_data.php에 POST → log_id 수신 (+ SMS 발송)
|
||||||
|
2. 카메라 촬영 (CAMERA_ENABLED 시)
|
||||||
|
3. photo_upload.php에 업로드
|
||||||
|
4. 임시파일 삭제
|
||||||
|
촬영/업로드 실패해도 1단계(알림+SMS)는 이미 완료.
|
||||||
|
"""
|
||||||
|
# 1) leak_alert POST → log_id 수신
|
||||||
|
resp_data = post_to_server_with_response("leak_alert", sensor, True)
|
||||||
|
if resp_data is None:
|
||||||
|
log.error(f"[PHOTO] 센서{sensor['id']} leak_alert POST 실패 → 촬영 건너뜀")
|
||||||
|
return
|
||||||
|
|
||||||
|
log_id = resp_data.get("log_id")
|
||||||
|
if not log_id:
|
||||||
|
log.warning(f"[PHOTO] 센서{sensor['id']} 응답에 log_id 없음 → 촬영 건너뜀")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2) 카메라 촬영
|
||||||
|
if not CAMERA_ENABLED:
|
||||||
|
log.info(f"[PHOTO] 카메라 비활성화 → 촬영 건너뜀 (log_id={log_id})")
|
||||||
|
return
|
||||||
|
|
||||||
|
# /tmp에 임시 JPEG 파일 생성
|
||||||
|
fd, photo_path = tempfile.mkstemp(suffix=".jpg", prefix="leak_")
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not capture_photo(photo_path):
|
||||||
|
log.warning(f"[PHOTO] 촬영 실패 → 업로드 건너뜀 (log_id={log_id})")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3) 업로드
|
||||||
|
upload_photo(photo_path, log_id, sensor)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# 4) 임시파일 정리
|
||||||
|
try:
|
||||||
|
if os.path.exists(photo_path):
|
||||||
|
os.unlink(photo_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# GPIO 인터럽트 콜백
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
def make_callback(sensor: dict):
|
||||||
|
def callback(channel):
|
||||||
|
gpio_val = GPIO.input(sensor["gpio"])
|
||||||
|
|
||||||
|
# ACTIVE_LOW: GPIO=0 이면 누수, GPIO=1 이면 정상
|
||||||
|
# ACTIVE_HIGH(트랜지스터 반전): GPIO=1 이면 누수
|
||||||
|
if ACTIVE_LOW:
|
||||||
|
is_leak = (gpio_val == 0)
|
||||||
|
else:
|
||||||
|
is_leak = (gpio_val == 1)
|
||||||
|
|
||||||
|
changed = state.set_leak(sensor["id"], is_leak)
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
if is_leak:
|
||||||
|
log.warning(f"[LEAK DETECTED] 센서{sensor['id']} ({sensor['name']}) !!!")
|
||||||
|
# leak_alert + 사진 촬영/업로드를 한 스레드에서 순차 실행
|
||||||
|
threading.Thread(
|
||||||
|
target=_handle_leak_with_photo,
|
||||||
|
args=(sensor,),
|
||||||
|
daemon=True
|
||||||
|
).start()
|
||||||
|
else:
|
||||||
|
log.info(f"[LEAK RECOVERED] 센서{sensor['id']} ({sensor['name']}) 정상 복구")
|
||||||
|
threading.Thread(
|
||||||
|
target=post_to_server,
|
||||||
|
args=("leak_recovery", sensor, False),
|
||||||
|
daemon=True
|
||||||
|
).start()
|
||||||
|
|
||||||
|
return callback
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 주기 보고 스레드
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
def periodic_reporter():
|
||||||
|
"""
|
||||||
|
정상 상태: REPORT_INTERVAL 주기로 보고
|
||||||
|
누수 상태: LEAK_REPORT_INTERVAL 주기로 반복 보고 (SMS 재발송 방지는 PHP에서 처리)
|
||||||
|
"""
|
||||||
|
log.info("[REPORTER] 주기 보고 스레드 시작")
|
||||||
|
last_sent = {s["id"]: 0 for s in SENSORS}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
now = time.time()
|
||||||
|
for sensor in SENSORS:
|
||||||
|
sid = sensor["id"]
|
||||||
|
is_leak = state.is_leaking(sid)
|
||||||
|
interval = LEAK_REPORT_INTERVAL if is_leak else REPORT_INTERVAL
|
||||||
|
|
||||||
|
if now - last_sent[sid] >= interval:
|
||||||
|
post_to_server("periodic", sensor, is_leak)
|
||||||
|
last_sent[sid] = now
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# GPIO 초기화
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
def setup_gpio():
|
||||||
|
GPIO.setmode(GPIO.BCM)
|
||||||
|
|
||||||
|
# 외부 4.7MΩ 풀다운만 사용 → 내부 풀저항은 PUD_OFF 필수
|
||||||
|
# (내부 50kΩ 풀다운을 켜면 1.5MΩ 센서가 GPIO를 HIGH로 올리지 못함)
|
||||||
|
pull = GPIO.PUD_DOWN if USE_INTERNAL_PULL else GPIO.PUD_OFF
|
||||||
|
|
||||||
|
for sensor in SENSORS:
|
||||||
|
gpio = sensor["gpio"]
|
||||||
|
GPIO.setup(gpio, GPIO.IN, pull_up_down=pull)
|
||||||
|
|
||||||
|
# 초기 상태 읽기
|
||||||
|
gpio_val = GPIO.input(gpio)
|
||||||
|
is_leak = (gpio_val == 0) if ACTIVE_LOW else (gpio_val == 1)
|
||||||
|
state.set_leak(sensor["id"], is_leak)
|
||||||
|
log.info(f"[INIT] 센서{sensor['id']} GPIO{gpio} → {'누수!' if is_leak else '정상'}")
|
||||||
|
|
||||||
|
# 엣지 감지 인터럽트 등록
|
||||||
|
GPIO.add_event_detect(
|
||||||
|
gpio,
|
||||||
|
GPIO.BOTH,
|
||||||
|
callback=make_callback(sensor),
|
||||||
|
bouncetime=DEBOUNCE_MS
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(f"[INIT] GPIO 설정 완료. 총 {len(SENSORS)}개 센서")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 메인
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
def main():
|
||||||
|
log.info("=" * 60)
|
||||||
|
log.info(f"누수감지 시스템 v2 (Pi 4) 시작 | 기기={DEVICE_ID} | 위치={DEVICE_LOCATION}")
|
||||||
|
log.info(f"플랫폼={platform.machine()} | API={API_URL}")
|
||||||
|
log.info(f"카메라={'활성' if CAMERA_ENABLED else '비활성'}")
|
||||||
|
log.info(f"센서={[s['name'] for s in SENSORS]}")
|
||||||
|
log.info(f"모드={'시뮬레이션' if SIMULATION else '실제 GPIO'}")
|
||||||
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
setup_gpio()
|
||||||
|
|
||||||
|
# 시작 시 현재 상태 즉시 보고
|
||||||
|
for sensor in SENSORS:
|
||||||
|
post_to_server("startup", sensor, state.is_leaking(sensor["id"]))
|
||||||
|
|
||||||
|
# 주기 보고 스레드 시작
|
||||||
|
reporter_thread = threading.Thread(target=periodic_reporter, daemon=True)
|
||||||
|
reporter_thread.start()
|
||||||
|
|
||||||
|
log.info("[MAIN] 모니터링 시작. Ctrl+C로 종료.")
|
||||||
|
|
||||||
|
# 메인 스레드는 살아있어야 인터럽트 작동
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log.info("[MAIN] 사용자에 의해 종료됨.")
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(f"[MAIN] 예외 발생: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
GPIO.cleanup()
|
||||||
|
log.info("[MAIN] GPIO 정리 완료.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
4
raspberry_pi4/requirements.txt
Normal file
4
raspberry_pi4/requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Pi 4 누수감지 모듈 Python 의존성 (폐쇄망 대비 정확 핀 고정)
|
||||||
|
# 오프라인 설치: vendor.sh 로 wheelhouse/ 에 미리 받아두고
|
||||||
|
# pip install --no-index --find-links=wheelhouse -r requirements.txt
|
||||||
|
requests==2.32.3
|
||||||
45
raspberry_pi4/systemd/leak-sensor.service
Normal file
45
raspberry_pi4/systemd/leak-sensor.service
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# =============================================================================
|
||||||
|
# /etc/systemd/system/leak-sensor.service
|
||||||
|
# Raspberry Pi 4 부팅 시 누수감지 모니터를 자동 실행하는 서비스
|
||||||
|
#
|
||||||
|
# 설치 방법:
|
||||||
|
# sudo cp systemd/leak-sensor.service /etc/systemd/system/
|
||||||
|
# sudo systemctl daemon-reload
|
||||||
|
# sudo systemctl enable leak-sensor
|
||||||
|
# sudo systemctl start leak-sensor
|
||||||
|
# sudo systemctl status leak-sensor
|
||||||
|
#
|
||||||
|
# ※ WorkingDirectory/ExecStart 경로는 실제 배치 경로에 맞게 수정.
|
||||||
|
# 아래는 /home/pi/raspberry_pi4 에 배치한 경우 기준.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=e-con 5C-08 Leak Sensor Monitor v2 (Pi 4, with Camera)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=pi
|
||||||
|
Group=pi
|
||||||
|
SupplementaryGroups=video
|
||||||
|
WorkingDirectory=/home/pi/raspberry_pi4
|
||||||
|
ExecStart=/home/pi/raspberry_pi4/venv/bin/python leak_sensor.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=leak-sensor
|
||||||
|
|
||||||
|
# 환경 변수
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# 누수 촬영 임시파일을 프로세스 전용 /tmp 로 격리 (공용 /tmp 노출 방지)
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
# 재시작 상한 (10분 내 5번 실패 시 중단)
|
||||||
|
StartLimitBurst=5
|
||||||
|
StartLimitInterval=600s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
112
raspberry_pi4/test_connection.py
Normal file
112
raspberry_pi4/test_connection.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""서버 연결 테스트 스크립트 — Pi 4 에서 실행.
|
||||||
|
비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다 (config 경유)."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from config import API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION
|
||||||
|
|
||||||
|
# API_URL 에서 베이스 경로 추출 (.../api/sensor_data.php → .../api)
|
||||||
|
API_BASE = API_URL.rsplit("/", 1)[0]
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print(" 서버 연결 테스트 (Pi 4)")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f" API_BASE = {API_BASE}")
|
||||||
|
|
||||||
|
# ── 1단계: curl로 test.php 호출 ──
|
||||||
|
print("\n[1/3] curl로 test.php 연결 확인...")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||||
|
"-A", "LeakSensor/2.0",
|
||||||
|
"-X", "POST", f"{API_BASE}/test.php"],
|
||||||
|
capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
code = result.stdout.strip()
|
||||||
|
print(f" HTTP 응답코드: {code}")
|
||||||
|
if code == "200":
|
||||||
|
print(" → 성공! 서버 연결 정상")
|
||||||
|
elif code == "403":
|
||||||
|
print(" → 403 Forbidden: 서버/호스팅 자체가 차단 중")
|
||||||
|
print(" → 호스팅 설정 또는 상위 .htaccess 확인 필요")
|
||||||
|
else:
|
||||||
|
print(f" → 예상 외 응답: {code}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" → curl 실패: {e}")
|
||||||
|
|
||||||
|
# ── 2단계: curl로 sensor_data.php 호출 ──
|
||||||
|
print("\n[2/3] curl로 sensor_data.php 연결 확인...")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["curl", "-s", "-w", "\n%{http_code}",
|
||||||
|
"-A", "LeakSensor/2.0",
|
||||||
|
"-X", "POST", API_URL,
|
||||||
|
"-H", "Content-Type: application/json",
|
||||||
|
"-d", '{"device_id":"test","sensor_id":1,"event_type":"startup","is_leak":0,"timestamp":1234567890}'],
|
||||||
|
capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
lines = result.stdout.strip().split("\n")
|
||||||
|
code = lines[-1] if lines else "?"
|
||||||
|
body = "\n".join(lines[:-1])
|
||||||
|
print(f" HTTP 응답코드: {code}")
|
||||||
|
print(f" 응답 내용: {body[:200]}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" → curl 실패: {e}")
|
||||||
|
|
||||||
|
# ── 3단계: Python requests로 서명 포함 호출 ──
|
||||||
|
print("\n[3/3] Python requests로 sensor_data.php 호출...")
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# raw-body X-Signature HMAC (서버 verify_signature_raw 와 동일 — leak_sensor.leak_event_body
|
||||||
|
# 와 같은 고정 필드 순서로 본문 문자열을 만들고 그 바이트에 서명한 뒤 그대로 POST 한다).
|
||||||
|
ts = int(time.time())
|
||||||
|
body = (
|
||||||
|
'{'
|
||||||
|
f'"device_id":"{DEVICE_ID}",'
|
||||||
|
f'"device_location":"{DEVICE_LOCATION}",'
|
||||||
|
f'"sensor_id":1,'
|
||||||
|
f'"sensor_name":"테스트 센서",'
|
||||||
|
f'"event_type":"startup",'
|
||||||
|
f'"is_leak":0,'
|
||||||
|
f'"timestamp":{ts}'
|
||||||
|
'}'
|
||||||
|
)
|
||||||
|
body_bytes = body.encode("utf-8")
|
||||||
|
signature = hmac.new(API_KEY.encode("utf-8"), body_bytes, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
API_URL,
|
||||||
|
data=body_bytes,
|
||||||
|
timeout=10,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Device-Id": DEVICE_ID,
|
||||||
|
"X-Signature": signature,
|
||||||
|
"User-Agent": "LeakSensor/2.0",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" HTTP 응답코드: {resp.status_code}")
|
||||||
|
print(f" 응답 내용: {resp.text[:200]}")
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
print(" → 성공! 모든 연결 정상")
|
||||||
|
elif resp.status_code == 403:
|
||||||
|
print(" → 403: requests 라이브러리가 차단됨 또는 서명 불일치")
|
||||||
|
print(f" → User-Agent: {resp.request.headers.get('User-Agent', '?')}")
|
||||||
|
else:
|
||||||
|
print(f" → 응답코드: {resp.status_code}")
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
print(" → requests 미설치. install.sh 실행 또는 sudo apt install python3-requests")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" → 실패: {e}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(" 테스트 완료")
|
||||||
|
print("=" * 50)
|
||||||
58
raspberry_pi4/vendor.sh
Normal file
58
raspberry_pi4/vendor.sh
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# vendor.sh - 폐쇄망 설치용 의존성 사전 다운로드
|
||||||
|
# 인터넷에 연결된 빌드/스테이징 머신에서 실행한다.
|
||||||
|
# 산출물: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → 타깃 Pi 4 로 전송.
|
||||||
|
#
|
||||||
|
# wheel/deb 는 아키텍처에 종속된다. 기본값은 타깃(Raspberry Pi OS Bookworm 64-bit,
|
||||||
|
# aarch64, Python 3.11)에 맞춰 명시 핀하므로 x86_64 빌드 머신에서 받아도 올바른
|
||||||
|
# aarch64 wheel 을 확보한다(빌드호스트 arch 와 무관). 그래도 .deb 는 타깃 OS/arch
|
||||||
|
# 에서 받는 것이 가장 안전하다. 필요 시 환경변수로 타깃 조정:
|
||||||
|
# TARGET_PLATFORM (기본 manylinux2014_aarch64), TARGET_PYVER (기본 311), CAM
|
||||||
|
# =============================================================================
|
||||||
|
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
|
||||||
|
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 "[ERROR] 타깃 wheel 다운로드 실패 (TARGET_PLATFORM/TARGET_PYVER 확인)"; exit 1; }
|
||||||
|
python3 -m pip download -r requirements.txt -d wheelhouse --no-binary=:all: \
|
||||||
|
|| echo "[WARN] sdist 폴백 일부 실패 (바이너리 wheel 이 모두 있으면 무시 가능)"
|
||||||
|
WHEEL_COUNT="$(ls -A wheelhouse 2>/dev/null | wc -l)"
|
||||||
|
echo " wheelhouse 파일 수: ${WHEEL_COUNT}"
|
||||||
|
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse 비어있음"; exit 1; }
|
||||||
|
|
||||||
|
echo "[vendor] (2/2) apt .deb → debs/"
|
||||||
|
mkdir -p debs
|
||||||
|
CAM="${CAM:-}"
|
||||||
|
if [ -z "${CAM}" ]; then
|
||||||
|
if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; fi
|
||||||
|
fi
|
||||||
|
BASE_PKGS="python3-rpi-lgpio python3-requests python3-venv ${CAM}"
|
||||||
|
DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
|
||||||
|
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
|
||||||
|
| awk '/^\w/{print $1}' | sort -u)"
|
||||||
|
( cd debs && apt-get download ${BASE_PKGS} ${DEPS} ) 2>/dev/null \
|
||||||
|
|| echo "[WARN] 일부 .deb 다운로드 실패 (가상패키지/전이 의존성일 수 있음 — 필수 패키지는 아래에서 검증)"
|
||||||
|
|
||||||
|
MISS=0
|
||||||
|
for p in ${BASE_PKGS}; do
|
||||||
|
if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then
|
||||||
|
echo "[ERROR] 필수 패키지 .deb 누락: ${p}"
|
||||||
|
MISS=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; }
|
||||||
|
echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)"
|
||||||
|
|
||||||
|
echo "[vendor] 완료. wheelhouse/ 와 debs/ 를 타깃 Pi 4 로 전송 후 install.sh 실행."
|
||||||
14
raspberry_pi4_sht30/.gitignore
vendored
Normal file
14
raspberry_pi4_sht30/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# 운영·비밀값 (절대 커밋 금지)
|
||||||
|
config_local.py
|
||||||
|
|
||||||
|
# Python venv / 캐시
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# 오프라인 설치 번들 (vendor.sh 산출물 — 용량이 큼, 전송은 별도)
|
||||||
|
wheelhouse/
|
||||||
|
debs/
|
||||||
|
|
||||||
|
# 런타임 로그
|
||||||
|
*.log
|
||||||
156
raspberry_pi4_sht30/README.md
Normal file
156
raspberry_pi4_sht30/README.md
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
# 온습도(SHT30) 센서 모듈 — Raspberry Pi 4 Model B 전용 (2번 모듈)
|
||||||
|
|
||||||
|
**Sensirion SHT30**(한진데이터 P4422-3, I2C) 온습도 센서를 Raspberry Pi 4 Model B 의
|
||||||
|
I2C 버스로 읽어, 주기적으로 cafe24 서버(`php/api/sensor_data.php`)에 보고하는 모듈입니다.
|
||||||
|
서버가 측정값을 저장하고, **고온/저온/고습/저습 임계 판정과 SMS, 대시보드 표시**를 모두 수행합니다.
|
||||||
|
|
||||||
|
> 이 폴더는 STM32F407 펌웨어(`sht30_fw`)의 동작을 **Raspberry Pi 4 + Python 으로 이식**한
|
||||||
|
> "2번 모듈"입니다. wire 포맷(본문/서명)·CRC·변환식은 펌웨어와 **바이트 단위로 동일**합니다.
|
||||||
|
> "1번 모듈"(누수+카메라)은 `../raspberry_pi4/` 폴더입니다. 전체 구성은 루트의
|
||||||
|
> [`SYSTEM_OVERVIEW.md`](../SYSTEM_OVERVIEW.md)를 보세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 구성 파일
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|---|---|
|
||||||
|
| `sht30_sensor.py` | 메인 — 주기 측정 → 본문 생성/서명 → POST(재시도), startup→periodic |
|
||||||
|
| `sht30_driver.py` | SHT30 I2C 측정 + CRC-8 검증 + 물리값 변환 (smbus2, 시뮬레이션 폴백) |
|
||||||
|
| `wire.py` | 본문 JSON + raw-body HMAC 서명 (펌웨어 `jsonbody.c`/`sig.c`와 바이트 동일) |
|
||||||
|
| `config.py` | 비밀 아닌 기본 설정(센서 id/주기/범위/ I2C). `config_local` 에서 비밀값 로드 |
|
||||||
|
| `config_local.example.py` | 운영·비밀값 템플릿 → `config_local.py` 로 복사해 사용(미커밋) |
|
||||||
|
| `requirements.txt` | Python 의존성(`requests`, `smbus2`, 핀 고정) |
|
||||||
|
| `install.sh` | Pi 4 설치(폐쇄망 오프라인 + 온라인 폴백, I2C 활성화 포함) |
|
||||||
|
| `vendor.sh` | 폐쇄망용 의존성 사전 다운로드(빌드 머신에서 실행) |
|
||||||
|
| `test_connection.py` | 서버 연결/서명 POST 점검 |
|
||||||
|
| `test_sensor.py` | CRC/포맷 자가점검 + 측정 1회(하드웨어 없이도 포맷 점검 통과) |
|
||||||
|
| `systemd/sht30-sensor.service` | 부팅 자동 실행 서비스 유닛 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 하드웨어 (Pi 4 Model B 기준)
|
||||||
|
|
||||||
|
- **Raspberry Pi 4 Model B** (BCM2711) + Raspberry Pi OS **Bookworm 64-bit**
|
||||||
|
- **SHT30** 온습도 센서 모듈 (I2C, 한진데이터 P4422-3)
|
||||||
|
- I2C 풀업 저항 **4.7kΩ × 2** (모듈에 내장된 경우 생략)
|
||||||
|
- 전원: **USB-C 5V/3A**, 네트워크: Pi 4 **Gigabit Ethernet**(유선) 또는 WiFi
|
||||||
|
|
||||||
|
### 2.1 핀 배치 (I2C 버스 1)
|
||||||
|
|
||||||
|
| 신호 | Pi 4 핀 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| SHT30 VCC | 3.3V (물리 1) | **5V 금지** |
|
||||||
|
| SHT30 GND | GND (물리 6/9/14/…) | |
|
||||||
|
| SHT30 SDA | GPIO2 / SDA1 (물리 3) | + 4.7kΩ 풀업 → 3.3V |
|
||||||
|
| SHT30 SCL | GPIO3 / SCL1 (물리 5) | + 4.7kΩ 풀업 → 3.3V |
|
||||||
|
| SHT30 ADDR | GND → 주소 **0x44** | VDD 면 0x45 (`config.py` `I2C_ADDR`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 서버 호환성 (반드시 확인)
|
||||||
|
|
||||||
|
이 모듈은 **현재 repo 의 `php/` 서버(=cafe24 SHT30 대시보드 서버)**로 보고합니다.
|
||||||
|
서버는 **raw-body HMAC 서명**을 요구합니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
X-Signature = lowercase_hex( HMAC-SHA256( key=API_KEY, msg=요청본문 바이트 ) )
|
||||||
|
```
|
||||||
|
|
||||||
|
- `config_local.py` 의 `API_KEY` 는 서버 `config.local.php` 의 `API_KEY` 와 **정확히 동일**해야 합니다.
|
||||||
|
- 서버는 `timestamp` 신선도(±600초)로 재전송을 막으므로 Pi 시각이 **NTP 로 동기**되어 있어야 합니다(Raspberry Pi OS 기본 활성).
|
||||||
|
- `sensor_id=2` 로 보고하며, 서버 대시보드(`dashboard.php`)는 기본 차트로 sensor 2 를 표시합니다.
|
||||||
|
- 임계 경보(고온/저온/고습/저습)·SMS·복구 판정은 **모두 서버**가 수행합니다. 이 모듈은 측정값만 보고합니다.
|
||||||
|
|
||||||
|
> 서버 측 스키마/배포는 [`../sql/schema_sht30.sql`](../sql/schema_sht30.sql),
|
||||||
|
> [`../docs/INSTALL_PI_SERVER.md`](../docs/INSTALL_PI_SERVER.md),
|
||||||
|
> [`../docs/SHT30_SENSOR2_SETUP.md`](../docs/SHT30_SENSOR2_SETUP.md) 참고.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 설치
|
||||||
|
|
||||||
|
### 4.1 설정값 준비
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config_local.example.py config_local.py
|
||||||
|
# config_local.py 를 열어 API_URL / API_KEY / DEVICE_ID / DEVICE_LOCATION 채움
|
||||||
|
```
|
||||||
|
|
||||||
|
`config_local.py` 가 없으면 실행 시 **명확한 오류로 즉시 중단**됩니다(fail closed).
|
||||||
|
`config_local.py` 와 `API_KEY` 는 **절대 커밋하지 마세요**(`.gitignore` 처리됨).
|
||||||
|
|
||||||
|
### 4.2 온라인 설치 (인터넷 가능 환경)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x install.sh
|
||||||
|
./install.sh
|
||||||
|
sudo reboot # I2C 를 새로 켠 경우 1회 재부팅
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 폐쇄망(air-gapped) 오프라인 설치
|
||||||
|
|
||||||
|
인터넷이 되는 **동일 아키텍처(aarch64, 가급적 동일 Pi 4 + Bookworm 64-bit)** 머신에서
|
||||||
|
의존성을 먼저 받아 번들을 만든 뒤, 타깃 Pi 4 로 옮겨 설치합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# (빌드 머신, 인터넷 O) wheelhouse/ + debs/ 생성
|
||||||
|
./vendor.sh
|
||||||
|
|
||||||
|
# wheelhouse/ 와 debs/ 를 타깃 Pi 4 의 이 폴더로 복사한 뒤:
|
||||||
|
# (타깃 Pi 4, 인터넷 X)
|
||||||
|
./install.sh # debs/ · wheelhouse/ 가 있으면 자동으로 오프라인 설치
|
||||||
|
```
|
||||||
|
|
||||||
|
> `smbus2` 는 순수 파이썬(arch 무관)이라 x86 빌드 머신에서 받아도 됩니다.
|
||||||
|
> `requests` 등 다른 wheel/deb 는 **aarch64** 용을 받아야 합니다(타깃 arch 에서 `vendor.sh` 권장).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 실행 / 점검
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 센서가 보이는지 (0x44)
|
||||||
|
i2cdetect -y 1
|
||||||
|
|
||||||
|
# 포맷/CRC 자가점검 + 측정 1회 (하드웨어 없으면 시뮬레이션)
|
||||||
|
source venv/bin/activate
|
||||||
|
python3 test_sensor.py
|
||||||
|
|
||||||
|
# 서버 연결/서명 점검
|
||||||
|
python3 test_connection.py
|
||||||
|
|
||||||
|
# 실제 실행
|
||||||
|
python3 sht30_sensor.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`smbus2` 미설치 또는 I2C 미활성 환경(개발 PC 등)에서는 자동으로 **시뮬레이션 모드**로 동작합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 부팅 자동 실행 (systemd)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 폴더를 /home/pi/raspberry_pi4_sht30 에 배치했다고 가정 (다르면 .service 의 경로 수정)
|
||||||
|
sudo cp systemd/sht30-sensor.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable sht30-sensor
|
||||||
|
sudo systemctl start sht30-sensor
|
||||||
|
sudo systemctl status sht30-sensor
|
||||||
|
journalctl -u sht30-sensor -f
|
||||||
|
```
|
||||||
|
|
||||||
|
`SupplementaryGroups=i2c` 가 `/dev/i2c-1` 접근에 필요합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 보안 메모
|
||||||
|
|
||||||
|
- 비밀값(`API_KEY`)·서버 URL·기기 정보는 `config_local.py`(미커밋)에만 둡니다.
|
||||||
|
커밋되는 파일에는 placeholder 만 존재합니다.
|
||||||
|
- 보고 본문은 `HMAC-SHA256(API_KEY, raw_body)` 서명(`X-Signature` 헤더)으로 위변조를 방지합니다
|
||||||
|
(길이확장 공격에 면역). 레거시 body-field sha256 폴백은 서버에서 기본 비활성입니다.
|
||||||
|
- 서명하는 바이트와 전송하는 바이트는 **항상 동일**합니다(`wire.py` 가 본문을 직접 만들어
|
||||||
|
그 바이트에 서명 후 raw POST). 펌웨어 `sig.c` / `reference.py` / 서버 `verify_signature_raw`
|
||||||
|
와 바이트 단위로 일치합니다.
|
||||||
|
- `config_local.py` 노출 시 즉시 `API_KEY` 를 교체(서버 동시 변경)하세요.
|
||||||
82
raspberry_pi4_sht30/config.py
Normal file
82
raspberry_pi4_sht30/config.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
# =============================================================================
|
||||||
|
# config.py - 온습도(SHT30) 모니터링 모듈 (Pi 4 Model B) 설정 — 비밀이 아닌 기본값
|
||||||
|
#
|
||||||
|
# 서버 URL / API_KEY / 기기 식별자(DEVICE_ID/LOCATION) 등 운영·비밀값은
|
||||||
|
# config_local.py 에서 로드한다. config_local.py 는 .gitignore 로 커밋 제외.
|
||||||
|
# 설정 방법: cp config_local.example.py config_local.py → 실제 값으로 채움
|
||||||
|
#
|
||||||
|
# 이 모듈은 "2번 모듈"(온습도)이다. 서버(php/) 대시보드는 sensor_id=2 를 기본
|
||||||
|
# 온습도 센서로 표시하므로 SENSOR_ID 기본값은 2 로 둔다.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 센서 식별 (서버 대시보드 표시용; 비밀 아님)
|
||||||
|
# SENSOR_ID 는 서버 sensor_metric / sensor_status 의 키. 대시보드 기본 차트=2.
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
SENSOR_ID = 2
|
||||||
|
SENSOR_NAME = "2번 센서 (SHT30)"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# I2C 버스 설정 (Pi 4 40핀 헤더)
|
||||||
|
# SDA = GPIO2 (물리 3번), SCL = GPIO3 (물리 5번) → I2C 버스 1
|
||||||
|
# ADDR 핀: GND=0x44, VDD=0x45
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
I2C_BUS = 1
|
||||||
|
I2C_ADDR = 0x44
|
||||||
|
|
||||||
|
# 강제 시뮬레이션. True 면 하드웨어 없이 더미 측정값으로 동작(서버 연동 점검용).
|
||||||
|
# False 라도 smbus2 미설치/ I2C 미활성이면 드라이버가 자동 시뮬레이션으로 폴백.
|
||||||
|
FORCE_SIMULATION = False
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 측정/보고 주기 (firmware app_config.h 와 동일 기본값)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
REPORT_INTERVAL = 300 # 정상 측정·보고 간격 (초) = 5분
|
||||||
|
MEASURE_RETRY_DELAY = 5 # 측정 실패 후 재시도 대기 (초)
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 온습도 물리 plausibility 범위 (firmware classify_metric_status 와 동일)
|
||||||
|
# 이 범위를 벗어나면 metric_status="out_of_range" 로 보고한다.
|
||||||
|
# ※ 운영 임계(고온/저온/고습/저습) 판정·SMS 는 모두 서버(config.php METRIC_*)가
|
||||||
|
# 수행한다. 이 모듈은 측정값만 충실히 보고한다.
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
TEMP_LOW_C = -40.0
|
||||||
|
TEMP_HIGH_C = 125.0
|
||||||
|
RH_LOW = 0.0
|
||||||
|
RH_HIGH = 100.0
|
||||||
|
|
||||||
|
# 모듈 식별 버전 (서버 app_version 필드; 서버는 검증하지 않고 기록만 함)
|
||||||
|
APP_VERSION = "v2606-sht30-rpi4"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 로그 설정
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
LOG_FILE = "/var/log/sht30_sensor.log"
|
||||||
|
LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB
|
||||||
|
LOG_BACKUP_COUNT = 3
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# HTTP 재시도 설정 (firmware APP_HTTP_* 와 동일 기본값)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
HTTP_TIMEOUT = 10 # 단일 요청 타임아웃 (초)
|
||||||
|
HTTP_RETRY_COUNT = 3 # 재시도 횟수
|
||||||
|
HTTP_RETRY_DELAY = 5 # 재시도 간 대기 (초)
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 운영·비밀값 로드 (fail closed)
|
||||||
|
# config_local.py 가 없으면 placeholder/하드코딩 비밀로 잘못 기동하는 대신
|
||||||
|
# 명확한 오류로 즉시 중단한다.
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
from config_local import ( # noqa: F401 (sht30_sensor.py 가 config 에서 재수입)
|
||||||
|
API_URL,
|
||||||
|
API_KEY,
|
||||||
|
DEVICE_ID,
|
||||||
|
DEVICE_LOCATION,
|
||||||
|
)
|
||||||
|
except ImportError as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
"config_local.py 를 찾을 수 없습니다. "
|
||||||
|
"`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / "
|
||||||
|
"기기 정보를 채우세요. (비밀값은 절대 커밋하지 마세요.)"
|
||||||
|
) from e
|
||||||
31
raspberry_pi4_sht30/config_local.example.py
Normal file
31
raspberry_pi4_sht30/config_local.example.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# =============================================================================
|
||||||
|
# config_local.example.py - 운영·비밀값 템플릿 (Pi 4 온습도 SHT30 모듈 / 2번 모듈)
|
||||||
|
#
|
||||||
|
# 사용법: cp config_local.example.py config_local.py
|
||||||
|
# → 실제 값으로 채운 뒤 저장. config_local.py 는 절대 커밋하지 말 것
|
||||||
|
# (.gitignore 로 제외되어 있음).
|
||||||
|
#
|
||||||
|
# ⚠️ 서버 호환성
|
||||||
|
# 이 모듈(2번)은 SHT30 온습도 측정을 cafe24 서버 php/api/sensor_data.php 로
|
||||||
|
# 보고한다. 서버는 raw-body 서명을 요구한다:
|
||||||
|
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))
|
||||||
|
# → API_KEY 는 서버 config.local.php 의 API_KEY 와 "정확히 동일"해야 한다.
|
||||||
|
# 서버 임계 판정(고온/저온/고습/저습)·SMS·대시보드는 모두 서버가 수행한다.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 서버 API 엔드포인트 (SHT30 수신 서버 = cafe24 php/)
|
||||||
|
# INSTALL_PI_SERVER.md 기준 웹 루트: public_html/raspi_leck_detecter/
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 공유 비밀키 — 서버 config.local.php 의 API_KEY 와 정확히 동일해야 함. 절대 커밋 금지.
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
API_KEY = "CHANGE_ME"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 기기 식별 (여러 대 운영 시 구분). sensor_id/sensor_name 은 config.py 기본값(2)을 사용.
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
DEVICE_ID = "rpi4-sht30-01"
|
||||||
|
DEVICE_LOCATION = "서버실"
|
||||||
140
raspberry_pi4_sht30/install.sh
Normal file
140
raspberry_pi4_sht30/install.sh
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# 온습도(SHT30) 모듈 v2 (Raspberry Pi 4 Model B) 설치 스크립트 — 2번 모듈
|
||||||
|
# 대상 OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
|
||||||
|
#
|
||||||
|
# 폐쇄망(air-gapped) 지원:
|
||||||
|
# - debs/ 또는 wheelhouse/ 가 있으면 자동으로 "오프라인 모드"로 전환.
|
||||||
|
# - 오프라인 모드에서는 절대 네트워크에 접근하지 않는다. 번들이 일부만 있으면
|
||||||
|
# 온라인으로 몰래 빠지지 않고 명확한 오류로 중단한다(폐쇄망 규칙). 사전
|
||||||
|
# 다운로드는 vendor.sh 로 빌드 머신에서 수행.
|
||||||
|
# - 환경변수 OFFLINE=1/0 으로 강제 지정 가능.
|
||||||
|
# - 로그 파일 소유자: SERVICE_USER (기본 pi).
|
||||||
|
#
|
||||||
|
# 이 스크립트는 I2C 도 활성화한다(dtparam=i2c_arm=on + i2c-dev 로드 + i2c 그룹).
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
SERVICE_USER="${SERVICE_USER:-pi}"
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " 온습도(SHT30) 모듈 v2 (Pi 4) 설치 — 2번 모듈"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# 0) 보드 모델 안내 (강제 아님)
|
||||||
|
if [ -r /proc/device-tree/model ]; then
|
||||||
|
MODEL="$(tr -d '\0' < /proc/device-tree/model)"
|
||||||
|
echo "[INFO] 보드: ${MODEL}"
|
||||||
|
case "${MODEL}" in
|
||||||
|
*"Raspberry Pi 4"*) : ;;
|
||||||
|
*) echo "[WARN] 이 폴더는 Raspberry Pi 4 Model B 전용입니다 (계속 진행은 가능)." ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 오프라인 모드 판정
|
||||||
|
OFFLINE="${OFFLINE:-}"
|
||||||
|
if [ -z "${OFFLINE}" ]; then
|
||||||
|
if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then
|
||||||
|
OFFLINE=1
|
||||||
|
echo "[INFO] 오프라인 번들 감지 → 폐쇄망 오프라인 설치 모드 (네트워크 미사용)"
|
||||||
|
else
|
||||||
|
OFFLINE=0
|
||||||
|
echo "[INFO] 오프라인 번들 없음 → 온라인 설치 모드 (인터넷 필요)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1) 시스템 패키지: requests + venv + i2c-tools
|
||||||
|
echo "[1/4] 시스템 패키지 설치..."
|
||||||
|
if [ "${OFFLINE}" = "1" ]; then
|
||||||
|
if ! compgen -G "debs/*.deb" >/dev/null; then
|
||||||
|
echo "[ERROR] 오프라인 모드인데 debs/ 가 비어있습니다. vendor.sh 산출물(debs/)을 이 폴더에 두고 재실행하세요." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)"
|
||||||
|
sudo apt-get install -y --no-download ./debs/*.deb
|
||||||
|
else
|
||||||
|
echo " 온라인 apt 설치 (인터넷 필요)"
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y python3-requests python3-venv i2c-tools
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) I2C 활성화 (dtparam + 모듈 로드 + 그룹)
|
||||||
|
echo "[2/4] I2C 활성화..."
|
||||||
|
# 부트 설정 경로: Bookworm=/boot/firmware/config.txt, 구버전=/boot/config.txt
|
||||||
|
BOOT_CFG=""
|
||||||
|
for c in /boot/firmware/config.txt /boot/config.txt; do
|
||||||
|
if [ -f "$c" ]; then BOOT_CFG="$c"; break; fi
|
||||||
|
done
|
||||||
|
if [ -n "${BOOT_CFG}" ]; then
|
||||||
|
if ! grep -q "^dtparam=i2c_arm=on" "${BOOT_CFG}"; then
|
||||||
|
echo " ${BOOT_CFG} 에 dtparam=i2c_arm=on 추가 (재부팅 후 적용)"
|
||||||
|
echo "dtparam=i2c_arm=on" | sudo tee -a "${BOOT_CFG}" >/dev/null
|
||||||
|
NEED_REBOOT=1
|
||||||
|
else
|
||||||
|
echo " dtparam=i2c_arm=on 이미 설정됨"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[WARN] config.txt 를 찾지 못함 — raspi-config 로 수동 I2C 활성화 필요"
|
||||||
|
fi
|
||||||
|
# i2c-dev 모듈을 즉시 로드 + 부팅 시 자동 로드 등록
|
||||||
|
sudo modprobe i2c-dev 2>/dev/null || echo "[WARN] i2c-dev 즉시 로드 실패 (재부팅 후 적용될 수 있음)"
|
||||||
|
if [ -d /etc/modules-load.d ] && ! grep -qrs "i2c-dev" /etc/modules-load.d/ 2>/dev/null; then
|
||||||
|
echo "i2c-dev" | sudo tee /etc/modules-load.d/i2c.conf >/dev/null
|
||||||
|
fi
|
||||||
|
# 서비스 사용자를 i2c 그룹에 추가 (/dev/i2c-1 접근 권한)
|
||||||
|
if getent group i2c >/dev/null 2>&1; then
|
||||||
|
sudo usermod -aG i2c "${SERVICE_USER}" || echo "[WARN] usermod 실패 — SERVICE_USER=${SERVICE_USER} 확인"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3) Python venv + requests + smbus2
|
||||||
|
echo "[3/4] Python 가상환경 설정..."
|
||||||
|
if [ ! -d venv ]; then
|
||||||
|
python3 -m venv --system-site-packages venv
|
||||||
|
echo " venv 생성 완료"
|
||||||
|
else
|
||||||
|
echo " venv 이미 존재 — 건너뜀"
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source venv/bin/activate
|
||||||
|
if [ "${OFFLINE}" = "1" ]; then
|
||||||
|
if [ ! -d wheelhouse ]; then
|
||||||
|
echo "[ERROR] 오프라인 모드인데 wheelhouse/ 가 없습니다. vendor.sh 산출물(wheelhouse/)을 두고 재실행하세요." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " 오프라인 wheel 설치 (--no-index — 네트워크 미사용)"
|
||||||
|
pip install --no-index --find-links=wheelhouse -r requirements.txt
|
||||||
|
else
|
||||||
|
echo " 온라인 pip 설치 (인터넷 필요)"
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
fi
|
||||||
|
echo " 패키지 설치 완료"
|
||||||
|
|
||||||
|
# 4) 로그 파일 (최소 권한: 소유자만 쓰기)
|
||||||
|
echo "[4/4] 로그 파일 생성..."
|
||||||
|
sudo mkdir -p /var/log
|
||||||
|
sudo touch /var/log/sht30_sensor.log
|
||||||
|
if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/sht30_sensor.log; then
|
||||||
|
echo "[WARN] chown 실패 — SERVICE_USER=${SERVICE_USER} 존재 여부 확인"
|
||||||
|
fi
|
||||||
|
sudo chmod 640 /var/log/sht30_sensor.log
|
||||||
|
|
||||||
|
# config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨)
|
||||||
|
if [ ! -f config_local.py ]; then
|
||||||
|
echo ""
|
||||||
|
echo "[!] config_local.py 가 없습니다. 다음을 실행 후 실제 값으로 채우세요:"
|
||||||
|
echo " cp config_local.example.py config_local.py"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " 설치 완료! (OFFLINE=${OFFLINE})"
|
||||||
|
echo "=========================================="
|
||||||
|
if [ "${NEED_REBOOT:-0}" = "1" ]; then
|
||||||
|
echo " ⚠️ I2C 를 방금 활성화했습니다 → 'sudo reboot' 후 센서가 인식됩니다."
|
||||||
|
fi
|
||||||
|
echo " 센서 확인: i2cdetect -y 1 (0x44 또는 0x45 가 보여야 함)"
|
||||||
|
echo " 실행: source venv/bin/activate && python3 sht30_sensor.py"
|
||||||
|
echo " 서비스: README.md 의 systemd 섹션 참고"
|
||||||
|
echo ""
|
||||||
8
raspberry_pi4_sht30/requirements.txt
Normal file
8
raspberry_pi4_sht30/requirements.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Pi 4 온습도(SHT30) 모듈 Python 의존성 (폐쇄망 대비 정확 핀 고정)
|
||||||
|
# 오프라인 설치: vendor.sh 로 wheelhouse/ 에 미리 받아두고
|
||||||
|
# pip install --no-index --find-links=wheelhouse -r requirements.txt
|
||||||
|
#
|
||||||
|
# smbus2 는 순수 파이썬(arch 무관 py3-none-any wheel)이라 x86 빌드 머신에서 받아도
|
||||||
|
# Pi 4(aarch64)에서 그대로 동작한다. requests 도 함께 핀 고정.
|
||||||
|
requests==2.32.3
|
||||||
|
smbus2==0.5.0
|
||||||
139
raspberry_pi4_sht30/sht30_driver.py
Normal file
139
raspberry_pi4_sht30/sht30_driver.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# =============================================================================
|
||||||
|
# sht30_driver.py - SHT30 I2C 측정 드라이버 (Raspberry Pi 4, I2C 버스 1)
|
||||||
|
#
|
||||||
|
# [이식 출처]
|
||||||
|
# firmware/common/sht30.c (0x2C06 명령 -> ~20ms 대기 -> 6바이트 read)
|
||||||
|
# firmware/common/sht30_convert.c (CRC-8 검증 + 물리값 변환)
|
||||||
|
# firmware/test/host/reference.py (sht30_crc8 / sht30_parse 파이썬 레퍼런스)
|
||||||
|
#
|
||||||
|
# [동작]
|
||||||
|
# 1) 측정 명령 0x2C 0x06 전송 (고반복성, clock stretching 비활성)
|
||||||
|
# 2) ~20ms 대기 (고반복성 측정 최대 15ms + 마진)
|
||||||
|
# 3) 6바이트 read: T_msb,T_lsb,T_crc, RH_msb,RH_lsb,RH_crc
|
||||||
|
# 4) CRC-8(poly 0x31, init 0xFF) 검증 후 변환:
|
||||||
|
# temp_c = -45 + 175 * raw_t / 65535
|
||||||
|
# rh = 100 * raw_rh / 65535
|
||||||
|
#
|
||||||
|
# [I2C 접근] smbus2(순수 파이썬, arch 무관 wheel) 의 i2c_msg 로 write/read 를
|
||||||
|
# 명시적으로 분리 전송한다. read_i2c_block_data 는 레지스터 바이트를 먼저
|
||||||
|
# 쓰므로 SHT30 측정 프레임을 깨뜨린다 → i2c_msg.read 사용.
|
||||||
|
#
|
||||||
|
# [시뮬레이션] smbus2 미설치 또는 /dev/i2c-* 부재(개발 PC)면 SIMULATION=True 로
|
||||||
|
# 전환되어 그럴듯한 더미 측정값을 돌려준다(서버 연동/포맷 점검용).
|
||||||
|
# =============================================================================
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
# ── 상수 (firmware/common/sht30.c, app_config.h 와 동일) ─────────────────────
|
||||||
|
SHT30_CMD = [0x2C, 0x06] # high-repeatability, clock stretching off
|
||||||
|
MEAS_DELAY_SEC = 0.02 # 측정 완료 대기 (15ms + 마진)
|
||||||
|
FRAME_LEN = 6
|
||||||
|
|
||||||
|
try:
|
||||||
|
from smbus2 import SMBus, i2c_msg
|
||||||
|
SMBUS_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
SMBUS_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
# ── CRC-8 (sht30_convert.c::sht30_crc8 와 동일) ──────────────────────────────
|
||||||
|
def crc8(data: bytes) -> int:
|
||||||
|
"""SHT3x CRC-8: poly 0x31, init 0xFF, no reflection, no final xor."""
|
||||||
|
crc = 0xFF
|
||||||
|
for b in data:
|
||||||
|
crc ^= b
|
||||||
|
for _ in range(8):
|
||||||
|
crc = ((crc << 1) ^ 0x31) & 0xFF if (crc & 0x80) else (crc << 1) & 0xFF
|
||||||
|
return crc
|
||||||
|
|
||||||
|
|
||||||
|
# ── 프레임 파싱 (sht30_convert.c::sht30_parse 와 동일) ───────────────────────
|
||||||
|
def parse_frame(frame: bytes):
|
||||||
|
"""6바이트 측정 프레임 검증 + 변환 → (temp_c, rh).
|
||||||
|
|
||||||
|
CRC 불일치 시 ValueError 를 던진다(호출측이 재시도/스킵).
|
||||||
|
"""
|
||||||
|
if len(frame) != FRAME_LEN:
|
||||||
|
raise ValueError(f"프레임 길이 오류: {len(frame)} (기대 {FRAME_LEN})")
|
||||||
|
if crc8(frame[0:2]) != frame[2]:
|
||||||
|
raise ValueError("온도 CRC 불일치")
|
||||||
|
if crc8(frame[3:5]) != frame[5]:
|
||||||
|
raise ValueError("습도 CRC 불일치")
|
||||||
|
raw_t = (frame[0] << 8) | frame[1]
|
||||||
|
raw_rh = (frame[3] << 8) | frame[4]
|
||||||
|
temp_c = -45.0 + 175.0 * (raw_t / 65535.0)
|
||||||
|
rh = 100.0 * (raw_rh / 65535.0)
|
||||||
|
return temp_c, rh
|
||||||
|
|
||||||
|
|
||||||
|
class SHT30:
|
||||||
|
"""SHT30 단발 측정 드라이버.
|
||||||
|
|
||||||
|
bus_num: I2C 버스 번호 (Pi 4 40핀 헤더 = 1)
|
||||||
|
addr: 7-bit I2C 주소 (ADDR=GND 면 0x44, ADDR=VDD 면 0x45)
|
||||||
|
simulate: True 면 하드웨어 없이 더미값 반환(강제 시뮬레이션)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bus_num: int = 1, addr: int = 0x44, simulate: bool = False):
|
||||||
|
self.bus_num = bus_num
|
||||||
|
self.addr = addr
|
||||||
|
self.simulate = simulate or not SMBUS_AVAILABLE
|
||||||
|
self._bus = None
|
||||||
|
self._sim_tick = 0
|
||||||
|
|
||||||
|
def open(self):
|
||||||
|
if self.simulate:
|
||||||
|
return
|
||||||
|
# /dev/i2c-<bus_num> 가 없으면(예: i2c 미활성) 명확한 오류로 안내한다.
|
||||||
|
try:
|
||||||
|
self._bus = SMBus(self.bus_num)
|
||||||
|
except (FileNotFoundError, PermissionError, OSError) as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"I2C 버스 {self.bus_num} 열기 실패: {e}. "
|
||||||
|
f"`sudo raspi-config` 로 I2C 활성화 및 사용자 i2c 그룹 확인."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self._bus is not None:
|
||||||
|
try:
|
||||||
|
self._bus.close()
|
||||||
|
finally:
|
||||||
|
self._bus = None
|
||||||
|
|
||||||
|
def measure(self):
|
||||||
|
"""단발 측정 → (temp_c, rh). 실패 시 예외(통신/CRC).
|
||||||
|
|
||||||
|
시뮬레이션 모드에서는 24~26℃, 48~54% 부근의 더미값을 반환한다.
|
||||||
|
"""
|
||||||
|
if self.simulate:
|
||||||
|
self._sim_tick += 1
|
||||||
|
temp = 24.0 + (self._sim_tick % 5) * 0.5 # 24.0~26.0
|
||||||
|
rh = 48.0 + (self._sim_tick % 7) * 1.0 # 48.0~54.0
|
||||||
|
return temp, rh
|
||||||
|
|
||||||
|
if self._bus is None:
|
||||||
|
self.open()
|
||||||
|
|
||||||
|
# 1) 측정 명령 전송 (write)
|
||||||
|
write = i2c_msg.write(self.addr, SHT30_CMD)
|
||||||
|
self._bus.i2c_rdwr(write)
|
||||||
|
|
||||||
|
# 2) 측정 완료 대기 (clock stretching 비활성 → 호스트가 명시적 대기)
|
||||||
|
time.sleep(MEAS_DELAY_SEC)
|
||||||
|
|
||||||
|
# 3) 6바이트 read
|
||||||
|
read = i2c_msg.read(self.addr, FRAME_LEN)
|
||||||
|
self._bus.i2c_rdwr(read)
|
||||||
|
frame = bytes(read)
|
||||||
|
|
||||||
|
# 4) CRC 검증 + 변환
|
||||||
|
return parse_frame(frame)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.open()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
self.close()
|
||||||
|
return False
|
||||||
246
raspberry_pi4_sht30/sht30_sensor.py
Normal file
246
raspberry_pi4_sht30/sht30_sensor.py
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# =============================================================================
|
||||||
|
# sht30_sensor.py - SHT30 온습도 모니터링 메인 (2번 모듈, Raspberry Pi 4 전용)
|
||||||
|
#
|
||||||
|
# [대상 하드웨어] Raspberry Pi 4 Model B (BCM2711, 40핀 GPIO 헤더)
|
||||||
|
# - SHT30(Sensirion, 한진데이터 P4422-3) I2C 온습도 센서를 I2C 버스 1에 연결.
|
||||||
|
# - SDA=GPIO2(물리 3), SCL=GPIO3(물리 5), ADDR=GND → 0x44, 3.3V/GND, 4.7kΩ 풀업.
|
||||||
|
#
|
||||||
|
# [이식 출처] STM32 펌웨어 sht30_fw 의 동작을 Raspberry Pi 4 + Python 으로 이식.
|
||||||
|
# firmware/board_sht30/app_sht30.c (측정 루프 / startup→periodic / 범위 분류)
|
||||||
|
# firmware/common/reporter.c (본문 생성 → 서명 → POST → 재시도)
|
||||||
|
# firmware/common/sht30*.c (I2C 측정 / CRC / 변환) → sht30_driver.py
|
||||||
|
# firmware/test/host/reference.py (wire 포맷) → wire.py
|
||||||
|
#
|
||||||
|
# [동작 흐름]
|
||||||
|
# 1. SHT30 드라이버 open (실패/미가용 시 시뮬레이션 폴백)
|
||||||
|
# 2. 주기(REPORT_INTERVAL)마다 측정 → 서버에 POST
|
||||||
|
# 3. "최초 성공" 측정만 event_type="startup", 이후 "periodic"
|
||||||
|
# 4. 측정 실패(통신/CRC)는 크래시하지 않고 로그 후 짧게 쉬고 재시도
|
||||||
|
# 5. 물리 범위를 벗어나면 metric_status="out_of_range" (서버가 운영 임계 재판정)
|
||||||
|
#
|
||||||
|
# [인증] 서버는 raw-body HMAC 서명을 요구한다(레거시 body-sig 비활성).
|
||||||
|
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=전송 본문 바이트))
|
||||||
|
# → 서명한 바이트와 전송 바이트가 동일하도록 본문을 직접 만들어 raw POST 한다.
|
||||||
|
#
|
||||||
|
# [설정·비밀값] config.py 는 비밀이 아닌 기본값만, 서버 URL/API_KEY/기기 식별자는
|
||||||
|
# config_local.py(미커밋)에서 로드한다. 없으면 기동 시 fail-closed 로 즉시 중단.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
import time
|
||||||
|
import math
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
SENSOR_ID, SENSOR_NAME,
|
||||||
|
I2C_BUS, I2C_ADDR, FORCE_SIMULATION,
|
||||||
|
API_URL, API_KEY,
|
||||||
|
DEVICE_ID, DEVICE_LOCATION,
|
||||||
|
REPORT_INTERVAL, MEASURE_RETRY_DELAY,
|
||||||
|
TEMP_LOW_C, TEMP_HIGH_C, RH_LOW, RH_HIGH,
|
||||||
|
APP_VERSION,
|
||||||
|
LOG_FILE, LOG_MAX_BYTES, LOG_BACKUP_COUNT,
|
||||||
|
HTTP_TIMEOUT, HTTP_RETRY_COUNT, HTTP_RETRY_DELAY,
|
||||||
|
)
|
||||||
|
import wire
|
||||||
|
from sht30_driver import SHT30, SMBUS_AVAILABLE
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 로거 설정
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
def setup_logger():
|
||||||
|
logger = logging.getLogger("sht30_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 # 권한 없으면 콘솔만 사용
|
||||||
|
|
||||||
|
ch = logging.StreamHandler(sys.stdout)
|
||||||
|
ch.setFormatter(fmt)
|
||||||
|
logger.addHandler(ch)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
log = setup_logger()
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 종료 시그널 처리 (systemd stop = SIGTERM 깔끔 종료)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
class StopFlag:
|
||||||
|
def __init__(self):
|
||||||
|
self.stop = False
|
||||||
|
|
||||||
|
def request(self, signum, frame):
|
||||||
|
self.stop = True
|
||||||
|
log.info(f"[SIGNAL] {signal.Signals(signum).name} 수신 → 종료 준비")
|
||||||
|
|
||||||
|
stop_flag = StopFlag()
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 온습도 물리 범위 분류 (firmware classify_metric_status 와 동일)
|
||||||
|
# 서버는 이 값을 신뢰하지 않고 운영 임계로 재판정하므로, 여기서는 센서
|
||||||
|
# 물리 plausibility(범위/ NaN·Inf)만 본다.
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
def classify_metric_status(temp_c: float, rh: float) -> str:
|
||||||
|
if not (math.isfinite(temp_c) and math.isfinite(rh)):
|
||||||
|
return "out_of_range"
|
||||||
|
if temp_c < TEMP_LOW_C or temp_c > TEMP_HIGH_C:
|
||||||
|
return "out_of_range"
|
||||||
|
if rh < RH_LOW or rh > RH_HIGH:
|
||||||
|
return "out_of_range"
|
||||||
|
return "normal"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 서버 보고 (raw-body + X-Signature HMAC)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
def post_reading(event_type: str, temp_c: float, rh: float, metric_status: str) -> bool:
|
||||||
|
"""SHT30 측정값을 서버에 POST. 성공(HTTP 200) 시 True.
|
||||||
|
|
||||||
|
본문을 직접 만들어 그 바이트에 서명하고 그 바이트를 그대로 전송한다
|
||||||
|
(서명한 바이트 == 전송 바이트 보장 → 서버 raw-body 검증과 일치).
|
||||||
|
"""
|
||||||
|
timestamp = int(time.time())
|
||||||
|
body = wire.sht30_event_body(
|
||||||
|
device_id=DEVICE_ID,
|
||||||
|
device_location=DEVICE_LOCATION,
|
||||||
|
sensor_id=SENSOR_ID,
|
||||||
|
sensor_name=SENSOR_NAME,
|
||||||
|
event_type=event_type,
|
||||||
|
timestamp=timestamp,
|
||||||
|
temperature_c=temp_c,
|
||||||
|
humidity_percent=rh,
|
||||||
|
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": "SHT30Sensor/2.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
for attempt in range(1, HTTP_RETRY_COUNT + 1):
|
||||||
|
try:
|
||||||
|
# data=body_bytes 로 raw 본문을 그대로 전송(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} T={temp_c:.2f}C RH={rh:.2f}% "
|
||||||
|
f"status={metric_status} → {resp.text[:100]}")
|
||||||
|
return True
|
||||||
|
log.warning(f"[POST {resp.status_code}] 시도 {attempt}/{HTTP_RETRY_COUNT}: "
|
||||||
|
f"{resp.text[:160]}")
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
log.warning(f"[POST TIMEOUT] 시도 {attempt}/{HTTP_RETRY_COUNT}")
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
log.warning(f"[POST CONN ERR] 시도 {attempt}/{HTTP_RETRY_COUNT}: {e}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
log.warning(f"[POST ERR] 시도 {attempt}/{HTTP_RETRY_COUNT}: {e}")
|
||||||
|
|
||||||
|
if attempt < HTTP_RETRY_COUNT and not stop_flag.stop:
|
||||||
|
time.sleep(HTTP_RETRY_DELAY)
|
||||||
|
|
||||||
|
log.error(f"[POST FAIL] {HTTP_RETRY_COUNT}회 모두 실패 (event={event_type})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 측정 루프 (firmware sht30_measure_task 대응)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
def measure_loop(sensor: SHT30):
|
||||||
|
# 최초 성공 측정만 startup, 이후 periodic (서버 sensor_log 1회 startup 기록)
|
||||||
|
first_success = True
|
||||||
|
|
||||||
|
log.info(f"[LOOP] 측정 시작 (간격={REPORT_INTERVAL}s, addr=0x{I2C_ADDR:02X}, bus={I2C_BUS})")
|
||||||
|
|
||||||
|
while not stop_flag.stop:
|
||||||
|
try:
|
||||||
|
temp_c, rh = sensor.measure()
|
||||||
|
except (OSError, ValueError, RuntimeError) as e:
|
||||||
|
# 측정 실패(통신/CRC): 크래시 금지. 로그 후 짧게 쉬고 재시도.
|
||||||
|
log.error(f"[MEASURE FAIL] {e} → {MEASURE_RETRY_DELAY}s 후 재시도")
|
||||||
|
_interruptible_sleep(MEASURE_RETRY_DELAY)
|
||||||
|
continue
|
||||||
|
|
||||||
|
metric_status = classify_metric_status(temp_c, rh)
|
||||||
|
event_type = "startup" if first_success else "periodic"
|
||||||
|
|
||||||
|
log.info(f"[MEASURE] {event_type}: T={temp_c:.2f}C RH={rh:.2f}% status={metric_status}")
|
||||||
|
|
||||||
|
ok = post_reading(event_type, temp_c, rh, metric_status)
|
||||||
|
if ok and first_success:
|
||||||
|
# 최초 성공 보고 후에만 startup→periodic 전환(최초 보고가 반드시 startup)
|
||||||
|
first_success = False
|
||||||
|
|
||||||
|
# 정상 보고 주기 대기 (종료 시그널이 오면 즉시 깸)
|
||||||
|
_interruptible_sleep(REPORT_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
def _interruptible_sleep(seconds: float):
|
||||||
|
"""1초 단위로 나눠 자며 종료 시그널에 빠르게 반응한다."""
|
||||||
|
end = time.time() + seconds
|
||||||
|
while not stop_flag.stop and time.time() < end:
|
||||||
|
time.sleep(min(1.0, end - time.time()))
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 메인
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
def main():
|
||||||
|
signal.signal(signal.SIGTERM, stop_flag.request)
|
||||||
|
signal.signal(signal.SIGINT, stop_flag.request)
|
||||||
|
|
||||||
|
simulate = FORCE_SIMULATION or not SMBUS_AVAILABLE
|
||||||
|
log.info("=" * 60)
|
||||||
|
log.info(f"온습도(SHT30) 모니터링 v2 (Pi 4, 2번 모듈) 시작")
|
||||||
|
log.info(f"기기={DEVICE_ID} | 위치={DEVICE_LOCATION} | 센서={SENSOR_NAME}(id={SENSOR_ID})")
|
||||||
|
log.info(f"API={API_URL}")
|
||||||
|
log.info(f"모드={'시뮬레이션' if simulate else f'실제 I2C (bus={I2C_BUS}, addr=0x{I2C_ADDR:02X})'}")
|
||||||
|
if simulate and not FORCE_SIMULATION:
|
||||||
|
log.warning("smbus2 미설치/ I2C 미가용 → 시뮬레이션 모드. 실측은 install.sh 후 재실행.")
|
||||||
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
sensor = SHT30(bus_num=I2C_BUS, addr=I2C_ADDR, simulate=simulate)
|
||||||
|
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 — 최상위 안전망(로그 후 종료)
|
||||||
|
log.exception(f"[MAIN] 예외 발생: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
sensor.close()
|
||||||
|
log.info("[MAIN] 종료 완료.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
46
raspberry_pi4_sht30/systemd/sht30-sensor.service
Normal file
46
raspberry_pi4_sht30/systemd/sht30-sensor.service
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# =============================================================================
|
||||||
|
# /etc/systemd/system/sht30-sensor.service
|
||||||
|
# Raspberry Pi 4 부팅 시 온습도(SHT30) 모니터를 자동 실행하는 서비스 (2번 모듈)
|
||||||
|
#
|
||||||
|
# 설치 방법:
|
||||||
|
# sudo cp systemd/sht30-sensor.service /etc/systemd/system/
|
||||||
|
# sudo systemctl daemon-reload
|
||||||
|
# sudo systemctl enable sht30-sensor
|
||||||
|
# sudo systemctl start sht30-sensor
|
||||||
|
# sudo systemctl status sht30-sensor
|
||||||
|
#
|
||||||
|
# ※ WorkingDirectory/ExecStart 경로는 실제 배치 경로에 맞게 수정.
|
||||||
|
# 아래는 /home/pi/raspberry_pi4_sht30 에 배치한 경우 기준.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=SHT30 Temp/Humidity Sensor Monitor v2 (Pi 4, Module 2)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=pi
|
||||||
|
Group=pi
|
||||||
|
# /dev/i2c-1 접근에 i2c 그룹 필요
|
||||||
|
SupplementaryGroups=i2c
|
||||||
|
WorkingDirectory=/home/pi/raspberry_pi4_sht30
|
||||||
|
ExecStart=/home/pi/raspberry_pi4_sht30/venv/bin/python sht30_sensor.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=sht30-sensor
|
||||||
|
|
||||||
|
# 환경 변수
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# 프로세스 전용 /tmp 격리
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
# 재시작 상한 (10분 내 5번 실패 시 중단)
|
||||||
|
StartLimitBurst=5
|
||||||
|
StartLimitInterval=600s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
85
raspberry_pi4_sht30/test_connection.py
Normal file
85
raspberry_pi4_sht30/test_connection.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""서버 연결 테스트 — Pi 4 SHT30(2번) 모듈에서 실행.
|
||||||
|
|
||||||
|
wire.py 로 본문을 만들고 raw-body HMAC(X-Signature)을 붙여 실제로 한 번
|
||||||
|
서명된 POST 를 보낸다. 비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION,
|
||||||
|
SENSOR_ID, SENSOR_NAME, APP_VERSION,
|
||||||
|
)
|
||||||
|
import wire
|
||||||
|
|
||||||
|
API_BASE = API_URL.rsplit("/", 1)[0]
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print(" 서버 연결 테스트 (Pi 4 SHT30 / 2번 모듈)")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f" API_URL = {API_URL}")
|
||||||
|
|
||||||
|
# ── 1단계: curl 로 엔드포인트 도달 확인 (서명 없음 → 403 인증실패가 정상) ──
|
||||||
|
print("\n[1/2] curl 로 sensor_data.php 도달 확인 (서명 없이 → 인증실패 403 이 정상)...")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||||
|
"-A", "SHT30Sensor/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 응답코드: {code}")
|
||||||
|
if code in ("400", "403"):
|
||||||
|
print(" → 엔드포인트 도달 OK (서명/필드 없이 거부됨 — 정상)")
|
||||||
|
elif code == "200":
|
||||||
|
print(" → 200? 서버가 서명 없이 수락 — 서버 설정 점검 필요")
|
||||||
|
elif code == "404":
|
||||||
|
print(" → 404: API_URL 경로 확인 필요")
|
||||||
|
else:
|
||||||
|
print(f" → 응답: {code} (호스팅/방화벽/.htaccess 점검)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" → curl 실패: {e}")
|
||||||
|
|
||||||
|
# ── 2단계: wire.py 로 서명한 실제 측정 POST (startup, 더미값) ──
|
||||||
|
print("\n[2/2] 서명된 측정 POST (startup, 더미 24.00C/48.00%)...")
|
||||||
|
try:
|
||||||
|
ts = int(time.time())
|
||||||
|
body = wire.sht30_event_body(
|
||||||
|
DEVICE_ID, DEVICE_LOCATION, SENSOR_ID, SENSOR_NAME,
|
||||||
|
"startup", ts, 24.0, 48.0, "normal", APP_VERSION,
|
||||||
|
)
|
||||||
|
sig = wire.sign_raw(API_KEY, body)
|
||||||
|
print(f" 본문: {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": "SHT30Sensor/2.0",
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
print(f" HTTP 응답코드: {resp.status_code}")
|
||||||
|
print(f" 응답 내용: {resp.text[:200]}")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
print(" → 성공! 서명 검증 통과, 측정 저장됨 (대시보드 sensor_id=2 확인)")
|
||||||
|
elif resp.status_code == 403:
|
||||||
|
print(" → 403 인증 실패: API_KEY 가 서버 config.local.php 와 다르거나 서명 불일치")
|
||||||
|
elif resp.status_code == 400:
|
||||||
|
print(" → 400: timestamp 신선도(±600s) 확인 — Pi 시각(NTP) 동기 점검")
|
||||||
|
else:
|
||||||
|
print(f" → 응답코드: {resp.status_code}")
|
||||||
|
except ImportError:
|
||||||
|
print(" → requests 미설치. install.sh 실행 또는 sudo apt install python3-requests")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" → 실패: {e}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(" 테스트 완료")
|
||||||
|
print("=" * 50)
|
||||||
87
raspberry_pi4_sht30/test_sensor.py
Normal file
87
raspberry_pi4_sht30/test_sensor.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""센서/포맷 자가점검 — Pi 4 SHT30(2번) 모듈.
|
||||||
|
|
||||||
|
1) CRC-8 / fixed2 / 본문 포맷이 펌웨어·서버 규약과 일치하는지 단위 점검
|
||||||
|
2) 실제(또는 시뮬레이션) SHT30 측정 1회 → 변환값 출력
|
||||||
|
|
||||||
|
하드웨어 없이도 1)은 항상 통과해야 한다(서명 호환성의 핵심 보증).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import wire
|
||||||
|
from sht30_driver import crc8, parse_frame, SHT30, SMBUS_AVAILABLE
|
||||||
|
|
||||||
|
# 포맷/CRC 자가점검은 비밀값(config_local.py) 없이도 가능해야 한다.
|
||||||
|
# config.py 는 config_local.py 가 없으면 import 시 fail-closed 로 중단하므로,
|
||||||
|
# 개발 PC 점검을 위해 기본값으로 폴백한다(서버 연결은 test_connection.py 담당).
|
||||||
|
try:
|
||||||
|
from config import I2C_BUS, I2C_ADDR, FORCE_SIMULATION
|
||||||
|
except RuntimeError:
|
||||||
|
I2C_BUS, I2C_ADDR, FORCE_SIMULATION = 1, 0x44, False
|
||||||
|
print(" [info] config_local.py 없음 → I2C 기본값(bus=1, 0x44)으로 점검")
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, got, want):
|
||||||
|
ok = got == want
|
||||||
|
print(f" [{'OK ' if ok else 'FAIL'}] {name}: got={got!r} want={want!r}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print(" SHT30 자가점검 (2번 모듈)")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
passed = True
|
||||||
|
|
||||||
|
# ── 1) CRC-8 점검 (Sensirion 표준 벡터 0xBEEF → 0x92) ──
|
||||||
|
print("\n[1] CRC-8 (poly 0x31, init 0xFF):")
|
||||||
|
passed &= check("crc8(0xBEEF)", crc8(b"\xBE\xEF"), 0x92)
|
||||||
|
|
||||||
|
# ── 2) fixed2 점검 (round-half-up, reference.py/jb_fixed2 동일) ──
|
||||||
|
print("\n[2] fixed2 (소수 2자리 round-half-up):")
|
||||||
|
passed &= check("24.0", wire.fixed2(24.0), "24.00")
|
||||||
|
passed &= check("48.5", wire.fixed2(48.5), "48.50")
|
||||||
|
passed &= check("-3.245", wire.fixed2(-3.245), "-3.25")
|
||||||
|
passed &= check("0.0", wire.fixed2(0.0), "0.00")
|
||||||
|
|
||||||
|
# ── 3) 프레임 파싱 점검 (CRC 포함 합성 프레임) ──
|
||||||
|
print("\n[3] parse_frame (합성 프레임 변환):")
|
||||||
|
raw_t, raw_rh = 0x6400, 0x8000 # 임의 raw 값
|
||||||
|
frame = bytes([
|
||||||
|
raw_t >> 8, raw_t & 0xFF, crc8(bytes([raw_t >> 8, raw_t & 0xFF])),
|
||||||
|
raw_rh >> 8, raw_rh & 0xFF, crc8(bytes([raw_rh >> 8, raw_rh & 0xFF])),
|
||||||
|
])
|
||||||
|
t, rh = parse_frame(frame)
|
||||||
|
exp_t = -45.0 + 175.0 * (raw_t / 65535.0)
|
||||||
|
exp_rh = 100.0 * (raw_rh / 65535.0)
|
||||||
|
passed &= check("temp_c", round(t, 4), round(exp_t, 4))
|
||||||
|
passed &= check("rh", round(rh, 4), round(exp_rh, 4))
|
||||||
|
|
||||||
|
# ── 4) 본문 포맷 점검 (필드 순서/타입) ──
|
||||||
|
print("\n[4] 본문 포맷 (필드 순서 고정):")
|
||||||
|
body = wire.sht30_event_body("dev", "loc", 2, "s2", "periodic",
|
||||||
|
1700000000, 24.0, 48.0, "normal", "vX")
|
||||||
|
expected = ('{"device_id":"dev","device_location":"loc","sensor_id":2,'
|
||||||
|
'"sensor_name":"s2","event_type":"periodic","timestamp":1700000000,'
|
||||||
|
'"metric_type":"sht30","temperature_c":24.00,"humidity_percent":48.00,'
|
||||||
|
'"metric_status":"normal","app_version":"vX"}')
|
||||||
|
passed &= check("body", body, expected)
|
||||||
|
print(f" 서명(key=test): {wire.sign_raw('test', body)[:16]}...")
|
||||||
|
|
||||||
|
# ── 5) 실제/시뮬레이션 측정 1회 ──
|
||||||
|
print("\n[5] 측정 1회:")
|
||||||
|
sim = FORCE_SIMULATION or not SMBUS_AVAILABLE
|
||||||
|
print(f" 모드: {'시뮬레이션' if sim else f'실제 I2C(bus={I2C_BUS},0x{I2C_ADDR:02X})'}")
|
||||||
|
try:
|
||||||
|
with SHT30(bus_num=I2C_BUS, addr=I2C_ADDR, simulate=sim) as s:
|
||||||
|
t, rh = s.measure()
|
||||||
|
print(f" 측정값: T={t:.2f}C RH={rh:.2f}%")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 측정 실패: {e}")
|
||||||
|
if not sim:
|
||||||
|
print(" (i2cdetect -y 1 로 0x44 응답 확인 / 배선·풀업·I2C 활성화 점검)")
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f" 결과: {'전체 통과 ✅' if passed else '실패 항목 있음 ❌'}")
|
||||||
|
print("=" * 50)
|
||||||
|
sys.exit(0 if passed else 1)
|
||||||
55
raspberry_pi4_sht30/vendor.sh
Normal file
55
raspberry_pi4_sht30/vendor.sh
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# vendor.sh - 폐쇄망 설치용 의존성 사전 다운로드 (SHT30 2번 모듈)
|
||||||
|
# 인터넷에 연결된 빌드/스테이징 머신에서 실행한다.
|
||||||
|
# 산출물: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → 타깃 Pi 4 로 전송.
|
||||||
|
#
|
||||||
|
# requests 는 arch 종속이 적으나 smbus2 는 순수 파이썬(py3-none-any)이라 어떤
|
||||||
|
# 빌드 머신에서 받아도 Pi 4 에서 동작한다. wheel 핀은 타깃(Bookworm 64-bit,
|
||||||
|
# aarch64, Python 3.11)에 맞춘다. .deb 는 타깃 OS/arch 에서 받는 것이 가장 안전.
|
||||||
|
# 환경변수: TARGET_PLATFORM(기본 manylinux2014_aarch64), TARGET_PYVER(기본 311)
|
||||||
|
# =============================================================================
|
||||||
|
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 등 arch 종속 wheel: 타깃 플랫폼 명시 핀.
|
||||||
|
python3 -m pip download -r requirements.txt -d wheelhouse \
|
||||||
|
--only-binary=:all: \
|
||||||
|
--platform "${TARGET_PLATFORM}" \
|
||||||
|
--python-version "${TARGET_PYVER}" \
|
||||||
|
--implementation cp \
|
||||||
|
--abi "${TARGET_ABI}" \
|
||||||
|
|| echo "[WARN] 일부 wheel 의 타깃 플랫폼 핀 실패 — 순수파이썬(any) 폴백을 아래에서 받는다."
|
||||||
|
# smbus2(py3-none-any) 등 순수 파이썬 wheel 및 sdist 폴백.
|
||||||
|
python3 -m pip download -r requirements.txt -d wheelhouse \
|
||||||
|
|| echo "[WARN] sdist/any 폴백 일부 실패 (바이너리 wheel 이 모두 있으면 무시 가능)"
|
||||||
|
WHEEL_COUNT="$(ls -A wheelhouse 2>/dev/null | wc -l)"
|
||||||
|
echo " wheelhouse 파일 수: ${WHEEL_COUNT}"
|
||||||
|
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse 비어있음"; exit 1; }
|
||||||
|
|
||||||
|
echo "[vendor] (2/2) apt .deb → debs/"
|
||||||
|
mkdir -p debs
|
||||||
|
BASE_PKGS="python3-requests python3-venv i2c-tools"
|
||||||
|
DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
|
||||||
|
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
|
||||||
|
| awk '/^\w/{print $1}' | sort -u)"
|
||||||
|
( cd debs && apt-get download ${BASE_PKGS} ${DEPS} ) 2>/dev/null \
|
||||||
|
|| echo "[WARN] 일부 .deb 다운로드 실패 (가상패키지/전이 의존성일 수 있음 — 필수 패키지는 아래에서 검증)"
|
||||||
|
|
||||||
|
MISS=0
|
||||||
|
for p in ${BASE_PKGS}; do
|
||||||
|
if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then
|
||||||
|
echo "[ERROR] 필수 패키지 .deb 누락: ${p}"
|
||||||
|
MISS=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; }
|
||||||
|
echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)"
|
||||||
|
|
||||||
|
echo "[vendor] 완료. wheelhouse/ 와 debs/ 를 타깃 Pi 4 로 전송 후 install.sh 실행."
|
||||||
98
raspberry_pi4_sht30/wire.py
Normal file
98
raspberry_pi4_sht30/wire.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
# =============================================================================
|
||||||
|
# wire.py - 서버 전송용 wire 포맷 (본문 JSON + raw-body HMAC 서명)
|
||||||
|
#
|
||||||
|
# [왜 별도 모듈인가]
|
||||||
|
# 서버(php/api/sensor_data.php)는 받은 "본문 바이트 그대로"에 대해
|
||||||
|
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)) 를 검증한다.
|
||||||
|
# 따라서 "서명한 바이트"와 "실제 전송한 바이트"가 100% 동일해야 한다.
|
||||||
|
# 본문을 직접 문자열로 만들고 그 바이트에 서명한 뒤, 그 바이트를 그대로 POST 한다.
|
||||||
|
# (requests 의 json= 직렬화에 맡기면 부동소수/공백/순서가 달라져 서명이 깨질 수 있다.)
|
||||||
|
#
|
||||||
|
# [바이트 단위 호환 대상] — 아래 넷과 출력이 동일해야 한다.
|
||||||
|
# - firmware/common/jsonbody.c::jb_sht30_event (STM32 본문 생성)
|
||||||
|
# - firmware/common/sig.c::sig_raw_body (STM32 HMAC 서명)
|
||||||
|
# - firmware/test/host/reference.py (검증된 Python 레퍼런스)
|
||||||
|
# - php/config.php::verify_signature_raw (서버 검증부)
|
||||||
|
#
|
||||||
|
# fixed2() <-> jb_fixed2() / reference.fixed2()
|
||||||
|
# sht30_event_body() <-> jb_sht30_event() / reference.sht30_event_body()
|
||||||
|
# sign_raw() <-> sig_raw_body() / reference.sign_raw()
|
||||||
|
# =============================================================================
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
|
||||||
|
def fixed2(v: float) -> str:
|
||||||
|
"""소수 2자리 고정 포맷 (round-half-up, locale 무관).
|
||||||
|
|
||||||
|
jb_fixed2() / reference.fixed2() 와 바이트 동일.
|
||||||
|
24.0 -> "24.00"
|
||||||
|
48.5 -> "48.50"
|
||||||
|
-3.245 -> "-3.25"
|
||||||
|
"""
|
||||||
|
neg = v < 0
|
||||||
|
if neg:
|
||||||
|
v = -v
|
||||||
|
scaled = int(v * 100.0 + 0.5) # round half up (C: (long long)(v*100+0.5))
|
||||||
|
ip = scaled // 100
|
||||||
|
fp = scaled % 100
|
||||||
|
sign = "-" if (neg and scaled != 0) else ""
|
||||||
|
return f"{sign}{ip}.{fp:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _esc(s: str) -> str:
|
||||||
|
r"""JSON 문자열 값 이스케이프 (jb_estr / reference._esc 와 동일).
|
||||||
|
|
||||||
|
'"' 와 '\\' 와 제어문자(<0x20)만 이스케이프한다. '/' 는 그대로 둔다.
|
||||||
|
멀티바이트 UTF-8(한글 등)은 그대로 둔다(서버가 raw 바이트로 검증).
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for ch in s:
|
||||||
|
o = ord(ch)
|
||||||
|
if ch == '"':
|
||||||
|
out.append('\\"')
|
||||||
|
elif ch == '\\':
|
||||||
|
out.append('\\\\')
|
||||||
|
elif o < 0x20:
|
||||||
|
out.append('\\u%04x' % o)
|
||||||
|
else:
|
||||||
|
out.append(ch)
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def sht30_event_body(device_id: str, device_location: str, sensor_id: int,
|
||||||
|
sensor_name: str, event_type: str, timestamp: int,
|
||||||
|
temperature_c: float, humidity_percent: float,
|
||||||
|
metric_status: str, app_version: str) -> str:
|
||||||
|
"""SHT30 이벤트 본문 JSON 문자열 생성.
|
||||||
|
|
||||||
|
필드 순서는 펌웨어 jb_sht30_event() 와 동일하게 고정한다(서명 호환).
|
||||||
|
metric_type 은 항상 "sht30" 고정. 온도/습도는 fixed2 로 2자리 직렬화.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
'{'
|
||||||
|
f'"device_id":"{_esc(device_id)}",'
|
||||||
|
f'"device_location":"{_esc(device_location)}",'
|
||||||
|
f'"sensor_id":{int(sensor_id)},'
|
||||||
|
f'"sensor_name":"{_esc(sensor_name)}",'
|
||||||
|
f'"event_type":"{_esc(event_type)}",'
|
||||||
|
f'"timestamp":{int(timestamp)},'
|
||||||
|
f'"metric_type":"sht30",'
|
||||||
|
f'"temperature_c":{fixed2(temperature_c)},'
|
||||||
|
f'"humidity_percent":{fixed2(humidity_percent)},'
|
||||||
|
f'"metric_status":"{_esc(metric_status)}",'
|
||||||
|
f'"app_version":"{_esc(app_version)}"'
|
||||||
|
'}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sign_raw(api_key: str, body: str) -> str:
|
||||||
|
"""raw-body 서명: lowercase_hex(HMAC-SHA256(key=api_key, msg=body)).
|
||||||
|
|
||||||
|
sig_raw_body() / reference.sign_raw() / php verify_signature_raw 와 동일.
|
||||||
|
본문은 UTF-8 로 인코딩한 뒤 서명한다(전송 바이트와 동일).
|
||||||
|
"""
|
||||||
|
return hmac.new(api_key.encode("utf-8"), body.encode("utf-8"),
|
||||||
|
hashlib.sha256).hexdigest()
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
[SMS 발송 / 대시보드]
|
[SMS 발송 / 대시보드]
|
||||||
```
|
```
|
||||||
|
|
||||||
> 인증: 펌웨어는 `X-Signature: sha256(API_KEY + 요청본문)`(raw-body) 헤더로 서명하며,
|
> 인증: 펌웨어는 `X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`(raw-body) 헤더로 서명하며,
|
||||||
> 서버는 이 서명을 검증합니다. 온습도 임계 판정은 모두 서버(`php/config.php` `METRIC_*`
|
> 서버는 이 서명을 검증합니다. 온습도 임계 판정은 모두 서버(`php/config.php` `METRIC_*`
|
||||||
> 상수 + `api/sensor_data.php`)에서 수행하므로, 폐쇄망에서 임계값 변경 시 서버 설정만
|
> 상수 + `api/sensor_data.php`)에서 수행하므로, 폐쇄망에서 임계값 변경 시 서버 설정만
|
||||||
> 바꾸면 되고 펌웨어 재플래시가 필요하지 않습니다.
|
> 바꾸면 되고 펌웨어 재플래시가 필요하지 않습니다.
|
||||||
|
|
|
||||||
52
run_tests.sh
Normal file
52
run_tests.sh
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# run_tests.sh - 전체 회귀 테스트 러너 (CI / 수동)
|
||||||
|
#
|
||||||
|
# 실행 대상:
|
||||||
|
# 1) PHP 단위/회귀 테스트 3종 (tests/*.php)
|
||||||
|
# 2) 펌웨어 호스트 패리티 (firmware/test/host/parity_test.py)
|
||||||
|
# - C↔Python↔PHP 서명 패리티(gcc/clang + php 가용 시 C 포함)
|
||||||
|
#
|
||||||
|
# php / python3 / gcc 가 없으면 해당 묶음을 SKIP 으로 *명확히* 보고한다(무성 스킵 금지).
|
||||||
|
# 하나라도 FAIL 이면 종료코드 1.
|
||||||
|
# =============================================================================
|
||||||
|
set -u
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
fail=0; ran=0; skipped=0
|
||||||
|
|
||||||
|
have() { command -v "$1" >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
run_php() { # test_file
|
||||||
|
local f="$1"
|
||||||
|
if ! have php; then echo "SKIP (php 없음): ${f}"; skipped=$((skipped+1)); return; fi
|
||||||
|
echo "── php ${f}"
|
||||||
|
if php "${ROOT}/${f}"; then ran=$((ran+1)); else echo " FAIL: ${f}"; fail=1; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== 1) PHP 회귀 테스트 ==="
|
||||||
|
run_php tests/raw_body_signature_test.php
|
||||||
|
run_php tests/security_hardening_test.php
|
||||||
|
run_php tests/threshold_sms_test.php
|
||||||
|
run_php tests/chaos_drill.php # SMS 실패 주입 경로 드릴
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 2) 펌웨어 호스트 패리티 (C↔Python↔PHP) ==="
|
||||||
|
if have python3 && have php; then
|
||||||
|
echo "── python3 firmware/test/host/parity_test.py"
|
||||||
|
if ( cd "${ROOT}/firmware/test/host" && python3 parity_test.py ); then
|
||||||
|
ran=$((ran+1))
|
||||||
|
else
|
||||||
|
echo " FAIL: parity_test.py"; fail=1
|
||||||
|
fi
|
||||||
|
if ! have gcc && ! have cc && ! have clang; then
|
||||||
|
echo " NOTE: C 컴파일러 없음 — 위 패리티는 Python↔PHP 만 검증(C 서명 미컴파일)."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "SKIP (python3/php 없음): firmware/test/host/parity_test.py"; skipped=$((skipped+1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 요약: 실행 ${ran}, 스킵 ${skipped} ==="
|
||||||
|
if [ "${fail}" -ne 0 ]; then echo "결과: FAIL"; exit 1; fi
|
||||||
|
if [ "${ran}" -eq 0 ]; then echo "결과: NO-OP (실행된 테스트 없음 — php/python3 설치 필요)"; exit 0; fi
|
||||||
|
echo "결과: PASS"
|
||||||
105
sql/migration_add_leak.sql
Normal file
105
sql/migration_add_leak.sql
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
-- =============================================================================
|
||||||
|
-- migration_add_leak.sql - SHT30 전용 서버에 누수(leak) 수신을 다시 추가(통합)
|
||||||
|
--
|
||||||
|
-- [목적] 1번 모듈(누수+카메라, sensor_id=1)과 2번 모듈(SHT30 온습도, sensor_id=2)이
|
||||||
|
-- "하나의 cafe24 서버 php/" 로 함께 보고하도록 통합한다. 이 마이그레이션은
|
||||||
|
-- sql/migration_drop_leak.sql 가 제거했던 누수 요소를 "가산(additive)·멱등"으로
|
||||||
|
-- 되돌린다(= migration_drop_leak.sql 의 역방향, 단 데이터 삭제 없이 가산만 수행).
|
||||||
|
--
|
||||||
|
-- [안전성] 운영 중인 SHT30 서버에서 그대로 실행해도 안전하다:
|
||||||
|
-- - sensor_metric(SHT30 측정) / sms_log / sensor_status / ingest_nonce 등 기존 테이블·
|
||||||
|
-- 데이터는 전혀 건드리지 않는다.
|
||||||
|
-- - 모든 변경은 "없을 때만 추가"(IF NOT EXISTS / information_schema 가드)이므로
|
||||||
|
-- 여러 번 재실행해도 오류가 나지 않는다.
|
||||||
|
--
|
||||||
|
-- [되돌리는 항목]
|
||||||
|
-- - sensor_log.event_type ENUM 에 leak_alert / leak_recovery 재추가
|
||||||
|
-- - sensor_log.is_leak 컬럼(없으면 추가)
|
||||||
|
-- - sensor_status.is_leak 컬럼(없으면 추가)
|
||||||
|
-- - leak_incident 테이블(없으면 생성) — 누수 사고(에피소드) 시작/복구 추적
|
||||||
|
-- - leak_photo 테이블(없으면 생성) — 누수 시 촬영 사진 메타데이터
|
||||||
|
--
|
||||||
|
-- 신규(통합) 설치라면 이 파일이 아니라 sql/schema_sht30.sql 하나만 실행하면 된다
|
||||||
|
-- (schema_sht30.sql 이 이미 누수 요소를 포함하도록 통합되어 있다).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
-- 1) sensor_log.event_type: 누수 enum 값을 다시 허용(멱등 — 동일 enum 재적용은 안전).
|
||||||
|
ALTER TABLE `sensor_log`
|
||||||
|
MODIFY `event_type` ENUM('startup','periodic','leak_alert','leak_recovery')
|
||||||
|
NOT NULL DEFAULT 'periodic';
|
||||||
|
|
||||||
|
-- 2~3) 컬럼/인덱스 추가는 "없을 때만" 수행해 재실행해도 오류가 없게 한다.
|
||||||
|
-- (MySQL/MariaDB 는 ADD COLUMN IF NOT EXISTS 호환이 일정치 않으므로
|
||||||
|
-- information_schema 로 가드하는 멱등 프로시저를 사용한다.)
|
||||||
|
DROP PROCEDURE IF EXISTS _mig_add_leak;
|
||||||
|
DELIMITER //
|
||||||
|
CREATE PROCEDURE _mig_add_leak()
|
||||||
|
BEGIN
|
||||||
|
DECLARE db VARCHAR(64);
|
||||||
|
SELECT DATABASE() INTO db;
|
||||||
|
|
||||||
|
-- sensor_log.is_leak: 누수 여부(0/1) — 없으면 추가
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_log' AND COLUMN_NAME = 'is_leak') THEN
|
||||||
|
ALTER TABLE `sensor_log`
|
||||||
|
ADD COLUMN `is_leak` TINYINT(1) NOT NULL DEFAULT 0
|
||||||
|
COMMENT '누수 여부 (1=누수, 0=정상)' AFTER `event_type`;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- sensor_status.is_leak: 최신 누수 상태(0/1) — 없으면 추가
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_status' AND COLUMN_NAME = 'is_leak') THEN
|
||||||
|
ALTER TABLE `sensor_status`
|
||||||
|
ADD COLUMN `is_leak` TINYINT(1) NOT NULL DEFAULT 0
|
||||||
|
COMMENT '최신 누수 상태 (1=누수, 0=정상)' AFTER `last_event_type`;
|
||||||
|
END IF;
|
||||||
|
END //
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
CALL _mig_add_leak();
|
||||||
|
DROP PROCEDURE IF EXISTS _mig_add_leak;
|
||||||
|
|
||||||
|
-- 4) leak_incident: 누수 사고(에피소드) 추적. leak_alert 시 행을 열고(open), 같은
|
||||||
|
-- 센서의 leak_recovery 시 가장 최근 미복구 행을 닫는다(recovered_at 채움).
|
||||||
|
CREATE TABLE IF NOT EXISTS `leak_incident` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`sensor_id` TINYINT UNSIGNED NOT NULL COMMENT '센서 번호(누수=1)',
|
||||||
|
`device_id` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
`device_location` VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
`started_at` DATETIME NOT NULL COMMENT '누수 감지(시작) 시각',
|
||||||
|
`recovered_at` DATETIME NULL DEFAULT NULL COMMENT '누수 복구 시각(미복구면 NULL)',
|
||||||
|
`log_id_alert` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_alert sensor_log.id',
|
||||||
|
`log_id_recovery` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_recovery sensor_log.id',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_sensor_open` (`sensor_id`, `recovered_at`),
|
||||||
|
INDEX `idx_started_at` (`started_at`)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
DEFAULT CHARSET=utf8mb4
|
||||||
|
COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='누수 사고(에피소드) 추적 (시작/복구)';
|
||||||
|
|
||||||
|
-- 5) leak_photo: 누수 시 촬영한 사진 메타데이터. 실제 파일은 php/var/photos/ 에 저장되고
|
||||||
|
-- filename 은 서버가 생성한 무작위 파일명(클라이언트 경로 미사용 — 경로주입 방지).
|
||||||
|
CREATE TABLE IF NOT EXISTS `leak_photo` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`log_id` BIGINT UNSIGNED NOT NULL COMMENT 'leak_alert sensor_log.id',
|
||||||
|
`sensor_id` TINYINT UNSIGNED NOT NULL,
|
||||||
|
`device_id` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
`filename` VARCHAR(128) NOT NULL COMMENT '서버 생성 파일명(var/photos/ 하위)',
|
||||||
|
`mime` VARCHAR(40) NOT NULL DEFAULT 'image/jpeg',
|
||||||
|
`bytes` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`captured_at` DATETIME NOT NULL COMMENT '촬영(업로드) 시각',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_log_id` (`log_id`),
|
||||||
|
INDEX `idx_captured_at` (`captured_at`)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
DEFAULT CHARSET=utf8mb4
|
||||||
|
COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='누수 사진 메타데이터 (파일은 var/photos/)';
|
||||||
|
|
@ -93,3 +93,14 @@ DELIMITER ;
|
||||||
|
|
||||||
CALL _mig_drop_leak();
|
CALL _mig_drop_leak();
|
||||||
DROP PROCEDURE IF EXISTS _mig_drop_leak;
|
DROP PROCEDURE IF EXISTS _mig_drop_leak;
|
||||||
|
|
||||||
|
-- 5) 수집 재전송(replay) 멱등 nonce 테이블 추가(없을 때만 — 가산/멱등).
|
||||||
|
-- api/sensor_data.php 가 서명을 nonce 로 기록해 시계오차 창 내 중복 수신을 멱등
|
||||||
|
-- 처리한다(중복 저장·중복 SMS 방지). 이 테이블이 없어도 수집은 동작한다.
|
||||||
|
CREATE TABLE IF NOT EXISTS `ingest_nonce` (
|
||||||
|
`signature` VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||||
|
`seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`signature`),
|
||||||
|
INDEX `idx_seen_at` (`seen_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='수집 재전송 멱등 nonce (시계오차 창 내 중복 차단; 주기적 정리)';
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- schema_sht30.sql - SHT30 온습도 모니터링 시스템 신규 설치용 통합 스키마
|
-- schema_sht30.sql - 통합 모니터링 시스템 신규 설치용 스키마
|
||||||
|
-- (1번 모듈=누수+카메라 sensor_id=1, 2번 모듈=SHT30 온습도 sensor_id=2 — 단일 서버)
|
||||||
--
|
--
|
||||||
-- 신규 설치: 이 파일 하나만 실행합니다.
|
-- 신규 설치: 이 파일 하나만 실행합니다.
|
||||||
-- SOURCE /path/to/sql/schema_sht30.sql;
|
-- SOURCE /path/to/sql/schema_sht30.sql;
|
||||||
--
|
--
|
||||||
-- 기존(누수 v2605) 설치를 SHT30 전용으로 전환할 때는 이 파일이 아니라
|
-- 기존(누수 v2605) 설치를 정리·재설치할 때는 운영 데이터를 보존하려면 마이그레이션을
|
||||||
-- sql/migration_drop_leak.sql 을 사용합니다(데이터 보존 + 누수 잔재 정리).
|
-- 사용합니다: 누수를 빼려면 sql/migration_drop_leak.sql, 빠진 서버에 누수를 다시
|
||||||
|
-- 더하려면 sql/migration_add_leak.sql(가산·멱등).
|
||||||
--
|
--
|
||||||
-- 누수(leak) 관련 요소는 포함하지 않습니다:
|
-- 이 파일은 두 모듈을 하나의 서버에서 받는 "통합" 스키마입니다. 포함 요소:
|
||||||
-- - 테이블: leak_photo, leak_incident
|
-- - 온습도(SHT30): sensor_metric 테이블 + 서버 임계 판정(config.php METRIC_*).
|
||||||
-- - 컬럼: sensor_log.is_leak, sensor_status.is_leak, leak_periodic_count
|
-- - 누수(leak): sensor_log.is_leak / sensor_status.is_leak 컬럼,
|
||||||
-- - 이벤트: event_type 의 leak_alert / leak_recovery
|
-- event_type 의 leak_alert / leak_recovery,
|
||||||
-- 온습도 임계 경보(고온/저온/고습/저습) 판정과 SMS 발송은 서버 PHP(config.php
|
-- leak_incident(사고 추적) + leak_photo(사진 메타) 테이블.
|
||||||
-- 의 METRIC_* 상수 + api/sensor_data.php)에서 수행하며, 발송 이력은 sms_log 에
|
-- 임계/누수 경보 SMS 발송은 서버 PHP(api/sensor_data.php + sms_send.php)에서 수행하며,
|
||||||
-- 남습니다. 별도의 경보 상태 테이블은 두지 않습니다(sms_log 기준 쿨다운/복구 판정).
|
-- 발송 이력은 sms_log 에 남깁니다(sms_log 기준 쿨다운/복구 판정 — 별도 경보 상태 테이블 없음).
|
||||||
|
-- 누수 사진 실제 파일은 php/var/photos/ 에 저장되고, leak_photo 에는 메타만 둡니다.
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
SET NAMES utf8mb4;
|
SET NAMES utf8mb4;
|
||||||
|
|
@ -25,8 +28,9 @@ CREATE TABLE IF NOT EXISTS `sensor_log` (
|
||||||
`device_location` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '설치 위치',
|
`device_location` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '설치 위치',
|
||||||
`sensor_id` TINYINT UNSIGNED NOT NULL COMMENT '센서 번호',
|
`sensor_id` TINYINT UNSIGNED NOT NULL COMMENT '센서 번호',
|
||||||
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '센서 이름',
|
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '센서 이름',
|
||||||
`event_type` ENUM('startup','periodic')
|
`event_type` ENUM('startup','periodic','leak_alert','leak_recovery')
|
||||||
NOT NULL DEFAULT 'periodic',
|
NOT NULL DEFAULT 'periodic',
|
||||||
|
`is_leak` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '누수 여부 (1=누수, 0=정상)',
|
||||||
`sensor_time` DATETIME NOT NULL COMMENT '센서 측정 시각 (기기 기준)',
|
`sensor_time` DATETIME NOT NULL COMMENT '센서 측정 시각 (기기 기준)',
|
||||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'DB 저장 시각',
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'DB 저장 시각',
|
||||||
|
|
||||||
|
|
@ -45,6 +49,7 @@ CREATE TABLE IF NOT EXISTS `sensor_status` (
|
||||||
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '',
|
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
`device_location` VARCHAR(100) NOT NULL DEFAULT '',
|
`device_location` VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
`last_event_type` VARCHAR(20) NOT NULL DEFAULT '',
|
`last_event_type` VARCHAR(20) NOT NULL DEFAULT '',
|
||||||
|
`is_leak` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '최신 누수 상태 (1=누수, 0=정상)',
|
||||||
`last_seen` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
`last_seen` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
ON UPDATE CURRENT_TIMESTAMP,
|
ON UPDATE CURRENT_TIMESTAMP,
|
||||||
`offline_alerted` TINYINT(1) NOT NULL DEFAULT 0
|
`offline_alerted` TINYINT(1) NOT NULL DEFAULT 0
|
||||||
|
|
@ -66,7 +71,7 @@ CREATE TABLE IF NOT EXISTS `sensor_metric` (
|
||||||
`temperature_c` DECIMAL(6,2) NULL,
|
`temperature_c` DECIMAL(6,2) NULL,
|
||||||
`humidity_percent` DECIMAL(5,2) NULL,
|
`humidity_percent` DECIMAL(5,2) NULL,
|
||||||
`metric_status` VARCHAR(30) NOT NULL DEFAULT 'normal'
|
`metric_status` VARCHAR(30) NOT NULL DEFAULT 'normal'
|
||||||
COMMENT '서버 임계 판정 결과: normal/high_temp/low_temp/high_humidity/low_humidity',
|
COMMENT '서버 임계 판정 결과: normal/high_temp/low_temp/high_humidity/low_humidity/condensation_risk',
|
||||||
`measured_at` DATETIME NOT NULL,
|
`measured_at` DATETIME NOT NULL,
|
||||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
|
@ -99,3 +104,64 @@ CREATE TABLE IF NOT EXISTS `sms_log` (
|
||||||
DEFAULT CHARSET=utf8mb4
|
DEFAULT CHARSET=utf8mb4
|
||||||
COLLATE=utf8mb4_unicode_ci
|
COLLATE=utf8mb4_unicode_ci
|
||||||
COMMENT='SMS 발송 이력 (온습도 임계 경보/복구, 장비 오프라인/복구)';
|
COMMENT='SMS 발송 이력 (온습도 임계 경보/복구, 장비 오프라인/복구)';
|
||||||
|
|
||||||
|
-- 수집 재전송(replay) 멱등 nonce. api/sensor_data.php 가 인증·신선도를 통과한 요청의
|
||||||
|
-- 서명(X-Signature)을 여기에 원자적으로 기록한다. 시계오차 창(INGEST_MAX_CLOCK_SKEW_SEC)
|
||||||
|
-- 안에서 같은 서명 본문이 다시 오면(펌웨어 재시도/캡처 재생) 중복 저장·중복 SMS 없이
|
||||||
|
-- 멱등 처리된다. 오래된 행은 서버가 주기적으로 정리한다(이 테이블이 없어도 수집은
|
||||||
|
-- 동작하며 멱등 방어만 비활성화된다 — graceful degrade).
|
||||||
|
CREATE TABLE IF NOT EXISTS `ingest_nonce` (
|
||||||
|
`signature` VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL
|
||||||
|
COMMENT '요청 서명(X-Signature) — 멱등 키',
|
||||||
|
`seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
COMMENT '최초 수신 시각(정리 기준)',
|
||||||
|
|
||||||
|
PRIMARY KEY (`signature`),
|
||||||
|
INDEX `idx_seen_at` (`seen_at`)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
DEFAULT CHARSET=utf8mb4
|
||||||
|
COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='수집 재전송 멱등 nonce (시계오차 창 내 중복 차단; 주기적 정리)';
|
||||||
|
|
||||||
|
-- 누수 사고(에피소드) 추적. leak_alert 시 행을 열고(open), 같은 센서의 leak_recovery
|
||||||
|
-- 시 가장 최근 미복구(recovered_at IS NULL) 행을 닫는다. 대시보드의 누수 상태/이력 표시에 사용.
|
||||||
|
CREATE TABLE IF NOT EXISTS `leak_incident` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`sensor_id` TINYINT UNSIGNED NOT NULL COMMENT '센서 번호(누수=1)',
|
||||||
|
`device_id` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
`device_location` VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
`started_at` DATETIME NOT NULL COMMENT '누수 감지(시작) 시각',
|
||||||
|
`recovered_at` DATETIME NULL DEFAULT NULL COMMENT '누수 복구 시각(미복구면 NULL)',
|
||||||
|
`log_id_alert` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_alert sensor_log.id',
|
||||||
|
`log_id_recovery` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_recovery sensor_log.id',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_sensor_open` (`sensor_id`, `recovered_at`),
|
||||||
|
INDEX `idx_started_at` (`started_at`)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
DEFAULT CHARSET=utf8mb4
|
||||||
|
COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='누수 사고(에피소드) 추적 (시작/복구)';
|
||||||
|
|
||||||
|
-- 누수 사진 메타데이터. 실제 파일은 php/var/photos/ 에 서버 생성 무작위 파일명으로 저장
|
||||||
|
-- (클라이언트 파일명/경로 미사용 — 경로주입 방지). api/photo_upload.php 가 기록한다.
|
||||||
|
CREATE TABLE IF NOT EXISTS `leak_photo` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`log_id` BIGINT UNSIGNED NOT NULL COMMENT 'leak_alert sensor_log.id',
|
||||||
|
`sensor_id` TINYINT UNSIGNED NOT NULL,
|
||||||
|
`device_id` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
`filename` VARCHAR(128) NOT NULL COMMENT '서버 생성 파일명(var/photos/ 하위)',
|
||||||
|
`mime` VARCHAR(40) NOT NULL DEFAULT 'image/jpeg',
|
||||||
|
`bytes` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`captured_at` DATETIME NOT NULL COMMENT '촬영(업로드) 시각',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_log_id` (`log_id`),
|
||||||
|
INDEX `idx_captured_at` (`captured_at`)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
DEFAULT CHARSET=utf8mb4
|
||||||
|
COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='누수 사진 메타데이터 (파일은 var/photos/)';
|
||||||
|
|
|
||||||
50
tests/chaos_drill.php
Normal file
50
tests/chaos_drill.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
// =============================================================================
|
||||||
|
// chaos_drill.php - 경보 실패 경로 드릴 (CLI 전용)
|
||||||
|
//
|
||||||
|
// SMS_FORCE_FAIL 을 주입해 실제 전송 없이 '발송 실패' 경로를 시험한다. 발송실패
|
||||||
|
// 가시화/재시도/잔액경보 등 *실패에 의존하는* 신뢰성 기능이 동작하는지 손시연 없이
|
||||||
|
// 검증한다(기존 회귀 테스트는 정상 경로만 본다).
|
||||||
|
//
|
||||||
|
// 라이브 종단 드릴(운영 점검): config.local.php 에 일시적으로 'SMS_FORCE_FAIL' => true
|
||||||
|
// 를 두고 임계 초과를 유발한 뒤, 대시보드의 '경보 SMS 발송 실패' 배너와 오프라인 재시도
|
||||||
|
// 예약(error_log)을 확인하고, 끝나면 반드시 다시 false 로 되돌린다.
|
||||||
|
//
|
||||||
|
// 주의: config.local.php 가 SMS_FORCE_FAIL 을 명시 설정하면 아래 env 주입은 무시된다.
|
||||||
|
// =============================================================================
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
exit("CLI only\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
putenv('SMS_FORCE_FAIL=1'); // config 로드 전에 주입(cfg 가 getenv 로 읽음)
|
||||||
|
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
require_once $root . '/php/sms_send.php'; // config.php 포함
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
function chk(bool $cond, string $msg): void {
|
||||||
|
global $failures;
|
||||||
|
if (!$cond) { $failures[] = $msg; }
|
||||||
|
}
|
||||||
|
|
||||||
|
chk(SMS_FORCE_FAIL === true, 'SMS_FORCE_FAIL should be forced ON for the drill (env override). config.local.php 가 명시 설정하면 무시될 수 있음.');
|
||||||
|
|
||||||
|
$r = send_sms_cafe24('01000000000', '드릴 메시지');
|
||||||
|
chk(($r['success'] ?? null) === false, 'forced send must report failure (success=false).');
|
||||||
|
chk(($r['result_code'] ?? '') === 'FORCED_FAIL', 'forced send must use FORCED_FAIL result code.');
|
||||||
|
chk(array_key_exists('remaining', $r), 'result must include the remaining key.');
|
||||||
|
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Chaos drill FAILURES:\n");
|
||||||
|
foreach ($failures as $f) {
|
||||||
|
fwrite(STDERR, "- {$f}\n");
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Chaos drill passed: SMS 실패 주입 경로 동작 확인.\n";
|
||||||
|
echo "라이브 드릴: config.local.php 에 'SMS_FORCE_FAIL'=>true 후 임계초과 유발 → 대시보드 실패 배너/오프라인 재시도 확인 → 반드시 false 로 복구.\n";
|
||||||
|
|
@ -85,14 +85,46 @@ check(str_contains($smsSend, 'function send_metric_alert_sms'), 'sms_send.php sh
|
||||||
check(str_contains($smsSend, 'function send_metric_recovery_sms'), 'sms_send.php should provide send_metric_recovery_sms().');
|
check(str_contains($smsSend, 'function send_metric_recovery_sms'), 'sms_send.php should provide send_metric_recovery_sms().');
|
||||||
check(str_contains($smsSend, 'function evaluate_metric_thresholds'), 'sms_send.php should provide evaluate_metric_thresholds().');
|
check(str_contains($smsSend, 'function evaluate_metric_thresholds'), 'sms_send.php should provide evaluate_metric_thresholds().');
|
||||||
|
|
||||||
// ── 제거된 누수/사진/사고 기능이 재유입되지 않았는지 ────────
|
// ── 통합 누수+카메라(1번 모듈) 기능 회귀 (소스 기반 고정) ────
|
||||||
|
// 누수 모듈이 SHT30 서버에 통합되었으므로, 누수/사진/사고 기능은 존재해야 한다.
|
||||||
$dashboard = file_text($root . '/php/dashboard.php');
|
$dashboard = file_text($root . '/php/dashboard.php');
|
||||||
$monthly = file_text($root . '/php/monthly_report.php');
|
$monthly = file_text($root . '/php/monthly_report.php');
|
||||||
check(!str_contains($dashboard, 'leak_photo') && !str_contains($dashboard, 'admin_photo_url'), 'dashboard.php should not reference removed photo/leak features.');
|
$sensorApi = file_text($root . '/php/api/sensor_data.php');
|
||||||
check(!str_contains($monthly, 'leak_incident') && !str_contains($monthly, 'admin_photo_url'), 'monthly_report.php should not reference removed photo/incident features.');
|
|
||||||
check(!is_file($root . '/php/photo.php'), 'php/photo.php (leak photo proxy) should be removed.');
|
// 수집 API 가 누수 이벤트(leak_alert/leak_recovery)를 수신한다
|
||||||
check(!is_file($root . '/php/incidents.php'), 'php/incidents.php should be removed.');
|
check(str_contains($sensorApi, 'leak_alert') && str_contains($sensorApi, 'leak_recovery'),
|
||||||
check(!is_file($root . '/php/api/photo_upload.php'), 'php/api/photo_upload.php should be removed.');
|
'sensor_data.php should accept leak_alert/leak_recovery events.');
|
||||||
|
|
||||||
|
// 사진 업로드 API 존재 + HMAC 서명/상수시간 비교/기본키 fail-closed
|
||||||
|
$photoUpload = $root . '/php/api/photo_upload.php';
|
||||||
|
check(is_file($photoUpload), 'php/api/photo_upload.php should exist (leak photo upload endpoint).');
|
||||||
|
$photoUploadText = file_text($photoUpload);
|
||||||
|
check(str_contains($photoUploadText, 'hash_hmac'), 'photo_upload.php should verify an HMAC signature.');
|
||||||
|
check(str_contains($photoUploadText, 'hash_equals'), 'photo_upload.php should use constant-time hash_equals.');
|
||||||
|
check(str_contains($photoUploadText, 'api_key_is_default'), 'photo_upload.php should fail closed on the default API key.');
|
||||||
|
|
||||||
|
// SMS 누수 경보/복구 헬퍼
|
||||||
|
check(str_contains($smsSend, 'function send_leak_alert_sms'), 'sms_send.php should provide send_leak_alert_sms().');
|
||||||
|
check(str_contains($smsSend, 'function send_leak_recovery_sms'), 'sms_send.php should provide send_leak_recovery_sms().');
|
||||||
|
|
||||||
|
// 인증 사진 열람 엔드포인트(photo.php): 관리자 세션 가드 + 파일명 정규식 검증
|
||||||
|
check(is_file($root . '/php/photo.php'), 'php/photo.php (authenticated leak photo endpoint) should exist.');
|
||||||
|
$photoText = file_text($root . '/php/photo.php');
|
||||||
|
check(str_contains($photoText, 'admin_enforce_session_timeout') && str_contains($photoText, "\$_SESSION['logged_in']"),
|
||||||
|
'photo.php should enforce the same admin session guard as dashboard.php.');
|
||||||
|
check(str_contains($photoText, 'leak_[0-9]+_[0-9]+_[0-9a-f]+'),
|
||||||
|
'photo.php should validate the server-generated filename regex (defense-in-depth).');
|
||||||
|
|
||||||
|
// 대시보드가 누수 패널(사진 열람은 인증 endpoint 경유)을 노출
|
||||||
|
check(str_contains($dashboard, 'leak_photo') || str_contains($dashboard, 'photo.php?id='),
|
||||||
|
'dashboard.php should render the leak panel via the authenticated photo endpoint.');
|
||||||
|
|
||||||
|
// var/ 자가보호: 사진 디렉터리가 웹 직접 접근으로 노출되지 않도록 .htaccess 가 있어야 한다
|
||||||
|
check(is_file($root . '/php/var/.htaccess'), 'php/var/.htaccess should exist (self-contained var/ protection).');
|
||||||
|
$varHtaccess = file_text($root . '/php/var/.htaccess');
|
||||||
|
check(str_contains($varHtaccess, 'Require all denied'),
|
||||||
|
"php/var/.htaccess should deny web access ('Require all denied').");
|
||||||
|
check(is_file($root . '/php/var/photos/.htaccess'), 'php/var/photos/.htaccess should exist.');
|
||||||
|
|
||||||
$retention = $root . '/php/retention_cleanup.php';
|
$retention = $root . '/php/retention_cleanup.php';
|
||||||
check(is_file($retention), 'php/retention_cleanup.php should implement retention cleanup.');
|
check(is_file($retention), 'php/retention_cleanup.php should implement retention cleanup.');
|
||||||
|
|
@ -114,6 +146,107 @@ check(str_contains($securityEvidence, '감사로그'), 'security_evidence.php sh
|
||||||
check(str_contains($securityEvidence, '보관기간'), 'security_evidence.php should report retention controls.');
|
check(str_contains($securityEvidence, '보관기간'), 'security_evidence.php should report retention controls.');
|
||||||
check(str_contains($securityEvidence, '백업'), 'security_evidence.php should report backup evidence controls.');
|
check(str_contains($securityEvidence, '백업'), 'security_evidence.php should report backup evidence controls.');
|
||||||
|
|
||||||
|
// ── 보안 하드닝 개선 회귀 (소스 기반 고정) ──────────────────
|
||||||
|
$sensorApi = file_text($root . '/php/api/sensor_data.php');
|
||||||
|
$schemaSql = file_text($root . '/sql/schema_sht30.sql');
|
||||||
|
$testMobile = file_text($root . '/php/test_mobile.php');
|
||||||
|
$opsChecks = file_text($root . '/php/ops_checks.php');
|
||||||
|
|
||||||
|
// 재전송(replay) 멱등 nonce
|
||||||
|
check(str_contains($config, 'INGEST_NONCE_ENABLED'), 'config.php should define INGEST_NONCE_ENABLED.');
|
||||||
|
check(str_contains($sensorApi, 'ingest_nonce_claim'), 'sensor_data.php should claim a replay nonce.');
|
||||||
|
check(str_contains($sensorApi, 'ingest_nonce_release'), 'sensor_data.php should release the nonce on failure.');
|
||||||
|
check(str_contains($schemaSql, 'ingest_nonce'), 'schema_sht30.sql should create the ingest_nonce table.');
|
||||||
|
// sensor_id 범위 검증 (TINYINT 경계 → 깔끔한 400)
|
||||||
|
check(str_contains($sensorApi, 'sensor_id 범위'), 'sensor_data.php should validate sensor_id range.');
|
||||||
|
|
||||||
|
// SMS 길이 캡 + 동시 발송 직렬화(중복 SMS 방지)
|
||||||
|
check(str_contains($config, 'SMS_MAX_EUCKR_BYTES'), 'config.php should define SMS_MAX_EUCKR_BYTES.');
|
||||||
|
check(str_contains($smsSend, 'function sms_truncate_euckr'), 'sms_send.php should cap SMS length (sms_truncate_euckr).');
|
||||||
|
check(str_contains($smsSend, 'GET_LOCK'), 'sms_send.php should serialize duplicate alert SMS with a named lock.');
|
||||||
|
|
||||||
|
// 경보 태그 단일 출처(상수). 하드코딩 LIKE 리터럴이 제거되어야 한다.
|
||||||
|
check(str_contains($config, 'SMS_TAG_HIGH_TEMP'), 'config.php should centralize SMS alert tags as constants.');
|
||||||
|
check(str_contains($config, 'function sms_summary_select_sql'), 'config.php should expose sms_summary_select_sql().');
|
||||||
|
check(!str_contains($dashboard, "LIKE '[고온경보]%'"), 'dashboard.php should not hardcode alert-tag LIKE literals.');
|
||||||
|
check(!str_contains($monthly, "LIKE '[고온경보]%'"), 'monthly_report.php should not hardcode alert-tag LIKE literals.');
|
||||||
|
|
||||||
|
// 세션 절대 수명 캡(유휴 타임아웃만으로 무한정 유지되는 문제 보완)
|
||||||
|
check(str_contains($config, 'SESSION_ABSOLUTE_TIMEOUT'), 'config.php should define an absolute session lifetime cap.');
|
||||||
|
check(str_contains($login, 'last_activity'), 'login.php should record last_activity for the idle timer.');
|
||||||
|
// 세션 타임아웃은 모든 인증 페이지에서 강제되어야 한다(한 페이지만 검사하면 다른 페이지로 우회 가능)
|
||||||
|
check(str_contains(file_text($adminSecurity), 'function admin_enforce_session_timeout'),
|
||||||
|
'admin_security.php should provide a shared admin_enforce_session_timeout() helper.');
|
||||||
|
foreach (['dashboard.php', 'monthly_report.php', 'security_evidence.php', 'setup_wizard.php'] as $authPage) {
|
||||||
|
check(str_contains(file_text($root . '/php/' . $authPage), 'admin_enforce_session_timeout'),
|
||||||
|
"{$authPage} should enforce session timeout on every authenticated page.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ①+② 신규 기능 회귀 (소스 기반 고정) ─────────────────────
|
||||||
|
$appConfig = file_text($root . '/firmware/common/app_config.h');
|
||||||
|
$cron = file_text($root . '/php/cron_heartbeat.php');
|
||||||
|
|
||||||
|
// ① 폐쇄망 SNTP: 인터넷 NTP 기본값 금지 + 서버시각 응답
|
||||||
|
// #define 값만 검사한다(설명 주석에 등장하는 'pool.ntp.org' 토큰에 오탐하지 않도록).
|
||||||
|
check(!preg_match('/#define\s+APP_SNTP_SERVER\s+"[^"]*pool\.ntp\.org/', $appConfig), 'app_config.h must NOT default APP_SNTP_SERVER to internet pool.ntp.org (air-gap violation).');
|
||||||
|
check(str_contains($appConfig, 'APP_SNTP_SERVER'), 'app_config.h should still define APP_SNTP_SERVER.');
|
||||||
|
check(str_contains($sensorApi, 'server_now'), 'sensor_data.php should return server_now (firmware clock correction input).');
|
||||||
|
|
||||||
|
// ②a SMS 잔액 사전경보
|
||||||
|
check(str_contains($config, 'SMS_BALANCE_WARN_THRESHOLD'), 'config.php should define SMS_BALANCE_WARN_THRESHOLD.');
|
||||||
|
check(str_contains($config, 'function latest_sms_balance'), 'config.php should expose latest_sms_balance().');
|
||||||
|
check(str_contains($smsSend, 'function maybe_warn_sms_balance'), 'sms_send.php should warn on low SMS balance.');
|
||||||
|
check(str_contains($opsChecks, 'SMS 잔여'), 'ops_checks.php should surface SMS balance.');
|
||||||
|
|
||||||
|
// ②b 일일 헬스(생존신호) SMS
|
||||||
|
check(str_contains($config, 'HEALTH_SMS_ENABLED'), 'config.php should define HEALTH_SMS_ENABLED.');
|
||||||
|
check(str_contains($smsSend, 'function maybe_send_daily_health_sms'), 'sms_send.php should provide the daily health SMS.');
|
||||||
|
check(str_contains($cron, 'maybe_send_daily_health_sms'), 'cron_heartbeat.php should invoke the daily health SMS.');
|
||||||
|
|
||||||
|
// ②c 이슬점/결로 경보
|
||||||
|
check(str_contains($config, 'METRIC_DEWPOINT_MARGIN_C'), 'config.php should define METRIC_DEWPOINT_MARGIN_C.');
|
||||||
|
check(str_contains($config, 'SMS_TAG_CONDENSATION'), 'config.php should define the condensation alert tag.');
|
||||||
|
check(str_contains($smsSend, 'function dew_point_c'), 'sms_send.php should compute dew point.');
|
||||||
|
check(str_contains($smsSend, 'condensation_risk'), 'sms_send.php should classify condensation_risk.');
|
||||||
|
|
||||||
|
// ②d 수신번호 마스킹
|
||||||
|
check(str_contains($config, 'function mask_phone'), 'config.php should provide mask_phone().');
|
||||||
|
check(str_contains($dashboard, 'mask_phone'), 'dashboard.php should mask receiver numbers.');
|
||||||
|
|
||||||
|
// ── 고가치(M) 기능 회귀 (소스 기반 고정) ─────────────────────
|
||||||
|
// M#1 수집 거부사유 카운터
|
||||||
|
check(str_contains($config, 'function record_ingest_rejection'), 'config.php should provide record_ingest_rejection().');
|
||||||
|
check(str_contains($config, 'function read_ingest_rejections'), 'config.php should provide read_ingest_rejections().');
|
||||||
|
check(str_contains($sensorApi, 'record_ingest_rejection'), 'sensor_data.php should count ingest rejections.');
|
||||||
|
check(str_contains($dashboard, 'ingest_rejections'), 'dashboard.php should surface ingest rejections.');
|
||||||
|
// M#2 발송실패 가시화 + 오프라인 재시도
|
||||||
|
check(str_contains($cron, 'SMS_RETRY_OFFLINE') && str_contains($cron, 'offline_alerted = 0'), 'cron_heartbeat should retry offline SMS on send failure.');
|
||||||
|
check(str_contains($dashboard, 'recent_alert_fail'), 'dashboard.php should show a failed-alert-SMS banner.');
|
||||||
|
// M#3 에스컬레이션
|
||||||
|
check(str_contains($config, 'SMS_RECIPIENTS_TIER2'), 'config.php should define SMS_RECIPIENTS_TIER2.');
|
||||||
|
check(str_contains($config, 'SMS_TAG_ESCALATION'), 'config.php should define the escalation tag.');
|
||||||
|
check(str_contains($smsSend, 'function maybe_escalate_alerts'), 'sms_send.php should escalate unrecovered alerts.');
|
||||||
|
check(str_contains($cron, 'maybe_escalate_alerts'), 'cron_heartbeat should invoke escalation.');
|
||||||
|
// M#4 센서 고착 검출
|
||||||
|
check(str_contains($config, 'STUCK_DETECT_ENABLED'), 'config.php should define STUCK_DETECT_ENABLED.');
|
||||||
|
check(str_contains($config, 'SMS_TAG_SENSOR_STUCK'), 'config.php should define the sensor-stuck tag.');
|
||||||
|
check(str_contains($smsSend, 'function maybe_detect_stuck_sensors'), 'sms_send.php should detect stuck sensors.');
|
||||||
|
check(str_contains($smsSend, 'function stddev_pop'), 'sms_send.php should provide stddev_pop().');
|
||||||
|
check(str_contains($cron, 'maybe_detect_stuck_sensors'), 'cron_heartbeat should invoke stuck detection.');
|
||||||
|
// M#6 카오스 드릴 훅
|
||||||
|
check(str_contains($config, 'SMS_FORCE_FAIL'), 'config.php should define SMS_FORCE_FAIL.');
|
||||||
|
check(str_contains($smsSend, 'FORCED_FAIL'), 'sms_send.php should honor SMS_FORCE_FAIL.');
|
||||||
|
check(is_file($root . '/tests/chaos_drill.php'), 'tests/chaos_drill.php should exist.');
|
||||||
|
|
||||||
|
// 반사형 XSS 차단: test_mobile.php 는 raw User-Agent 를 그대로 반사하지 않는다
|
||||||
|
check(str_contains($testMobile, 'htmlspecialchars') && str_contains($testMobile, 'text/plain'),
|
||||||
|
'test_mobile.php should escape and text/plain the reflected User-Agent.');
|
||||||
|
|
||||||
|
// 운영 자가진단: 레거시 서명 폴백 / MFA 설정 토큰 잔존 경고
|
||||||
|
check(str_contains($opsChecks, 'ALLOW_LEGACY_BODY_SIGNATURE'), 'ops_checks.php should flag enabled legacy signature fallback.');
|
||||||
|
check(str_contains($opsChecks, 'MFA_SETUP_TOKEN'), 'ops_checks.php should flag a lingering MFA setup token.');
|
||||||
|
check(str_contains($configExample, 'ALLOW_LEGACY_BODY_SIGNATURE'), 'config.local.example.php should document ALLOW_LEGACY_BODY_SIGNATURE.');
|
||||||
|
|
||||||
if ($failures) {
|
if ($failures) {
|
||||||
fwrite(STDERR, "Security hardening regression failures:\n");
|
fwrite(STDERR, "Security hardening regression failures:\n");
|
||||||
foreach ($failures as $failure) {
|
foreach ($failures as $failure) {
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,63 @@ eq(metric_value_str(-3.0), '-3', '-3.0 -> -3');
|
||||||
eq(metric_value_str(100.0), '100', '100.0 -> 100');
|
eq(metric_value_str(100.0), '100', '100.0 -> 100');
|
||||||
eq(metric_value_str(0.0), '0', '0.0 -> 0');
|
eq(metric_value_str(0.0), '0', '0.0 -> 0');
|
||||||
|
|
||||||
|
// ── SMS 길이 캡(EUC-KR, sms_truncate_euckr) ─────────────────
|
||||||
|
check(function_exists('sms_truncate_euckr'), 'sms_truncate_euckr() should exist.');
|
||||||
|
check(defined('SMS_MAX_EUCKR_BYTES'), 'SMS_MAX_EUCKR_BYTES should be defined.');
|
||||||
|
eq(sms_truncate_euckr('abcdefghij', 5), 'abcde', 'ascii truncate to 5 bytes');
|
||||||
|
eq(sms_truncate_euckr('abc', 90), 'abc', 'message under cap is unchanged');
|
||||||
|
eq(sms_truncate_euckr('', 90), '', 'empty stays empty');
|
||||||
|
eq(sms_truncate_euckr('abc', 0), 'abc', 'non-positive cap is a no-op');
|
||||||
|
// EUC-KR 한글은 글자당 2바이트. 홀수 바이트 경계에서 멀티바이트 글자를 쪼개지 않아야 한다.
|
||||||
|
$euckr = iconv('UTF-8', 'EUC-KR', '가나다라마'); // 5글자 = 10바이트
|
||||||
|
check(strlen($euckr) === 10, 'precondition: 5 hangul = 10 EUC-KR bytes');
|
||||||
|
$cut5 = sms_truncate_euckr($euckr, 5); // 5바이트 → '가나'(4바이트)로 절단(글자 보존)
|
||||||
|
check(strlen($cut5) <= 5 && strlen($cut5) % 2 === 0, 'euckr truncate keeps 2-byte char boundary');
|
||||||
|
eq(iconv('EUC-KR', 'UTF-8', $cut5), '가나', 'euckr 10B truncated to 5B -> 가나');
|
||||||
|
$cut6 = sms_truncate_euckr($euckr, 6); // 6바이트 → '가나다'(6바이트)
|
||||||
|
eq(iconv('EUC-KR', 'UTF-8', $cut6), '가나다', 'euckr 10B truncated to 6B -> 가나다');
|
||||||
|
|
||||||
|
// ── 이슬점 / 결로 마진 ───────────────────────────────────────
|
||||||
|
check(function_exists('dew_point_c'), 'dew_point_c() should exist.');
|
||||||
|
check(function_exists('condensation_margin_c'), 'condensation_margin_c() should exist.');
|
||||||
|
check(function_exists('condensation_is_clear'), 'condensation_is_clear() should exist.');
|
||||||
|
// Magnus 근사: Td(20℃,50%) ≈ 9.3℃ (±0.5)
|
||||||
|
$td = dew_point_c(20.0, 50.0);
|
||||||
|
check($td !== null && abs($td - 9.3) < 0.5, 'dew point of 20C/50% ~= 9.3C (got ' . var_export($td, true) . ')');
|
||||||
|
// 결로마진 = T - Td ≈ 10.7℃
|
||||||
|
$m = condensation_margin_c(20.0, 50.0);
|
||||||
|
check($m !== null && abs($m - 10.7) < 0.6, 'condensation margin 20C/50% ~= 10.7C (got ' . var_export($m, true) . ')');
|
||||||
|
// 습도 0%/결측은 산출 불가 → null, 위험 없음
|
||||||
|
check(dew_point_c(20.0, 0.0) === null, 'dew point undefined at 0% RH');
|
||||||
|
check(condensation_margin_c(null, 50.0) === null, 'margin null when temp missing');
|
||||||
|
// 고온고습(margin 좁음)은 결로 위험으로 미해소
|
||||||
|
check(condensation_is_clear(18.0, 92.0) === false, '18C/92% has tight margin -> not clear');
|
||||||
|
// 여유 있는 값은 해소
|
||||||
|
check(condensation_is_clear(20.0, 50.0) === true, '20C/50% margin wide -> clear');
|
||||||
|
// 결측은 정상(해소)로 간주
|
||||||
|
check(condensation_is_clear(null, 50.0) === true, 'null temp -> condensation clear');
|
||||||
|
// evaluate_metric_thresholds 가 결로 위험을 별도 종류로 추가
|
||||||
|
if (METRIC_DEWPOINT_MARGIN_C > 0) {
|
||||||
|
check(in_array('condensation_risk', evaluate_metric_thresholds(18.0, 92.0)['breaches'], true),
|
||||||
|
'18C/92% should include condensation_risk breach');
|
||||||
|
check(!in_array('condensation_risk', evaluate_metric_thresholds($tMid, $hMid)['breaches'], true),
|
||||||
|
'normal mid values have no condensation_risk');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 수신번호 마스킹 ─────────────────────────────────────────
|
||||||
|
check(function_exists('mask_phone'), 'mask_phone() should exist.');
|
||||||
|
eq(mask_phone('01012345678'), '010-****-5678', 'mask 11-digit mobile');
|
||||||
|
eq(mask_phone('010-1234-5678'), '010-****-5678', 'mask with hyphens');
|
||||||
|
eq(mask_phone('123'), '***', 'too short -> all masked');
|
||||||
|
eq(mask_phone('01011112222, 01033334444'), '010-****-2222, 010-****-4444', 'mask comma-separated list');
|
||||||
|
|
||||||
|
// ── 표준편차 (센서 고착 검출) ────────────────────────────────
|
||||||
|
check(function_exists('stddev_pop'), 'stddev_pop() should exist.');
|
||||||
|
check(stddev_pop([1.0, 1.0, 1.0, 1.0]) === 0.0, 'identical values -> stddev 0 (stuck signal)');
|
||||||
|
check(abs(stddev_pop([2, 4, 4, 4, 5, 5, 7, 9]) - 2.0) < 1e-9, 'known population stddev == 2.0');
|
||||||
|
check(stddev_pop([]) === 0.0, 'empty -> 0');
|
||||||
|
check(stddev_pop([23.45, 23.45, 23.45, 23.45, 23.45, 23.45]) === 0.0, 'flat SHT30 readings -> stuck');
|
||||||
|
|
||||||
if ($failures) {
|
if ($failures) {
|
||||||
fwrite(STDERR, "Threshold SMS logic test failures:\n");
|
fwrite(STDERR, "Threshold SMS logic test failures:\n");
|
||||||
foreach ($failures as $f) {
|
foreach ($failures as $f) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue