Compare commits
No commits in common. "feat/pi-c-sensor3-leak-node" and "main" have entirely different histories.
feat/pi-c-
...
main
163 changed files with 2801 additions and 18753 deletions
|
|
@ -1,7 +1,10 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(tail:*)"
|
||||
"Bash(tail:*)",
|
||||
"Bash(cd \"C:/Users/Posafe/leak_sms_v2/windows_checker\" && pyinstaller --onefile --noconsole --name sensor_checker sensor_checker.py 2>&1)",
|
||||
"Bash(cd \"C:/Users/Posafe/leak_sms_v2/windows_checker\" && python sensor_checker.py 2>&1)",
|
||||
"Bash(sort -k2)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(rm -rf:*)",
|
||||
|
|
@ -11,12 +14,13 @@
|
|||
"Bash(chmod 777:*)",
|
||||
"Bash(dd:*)",
|
||||
"Bash(mkfs:*)",
|
||||
"Bash(:(){ :|:& };:)",
|
||||
"Bash(git push --force:*)",
|
||||
"Bash(git push -f:*)",
|
||||
"Bash(curl* | sh)",
|
||||
"Bash(curl* | bash)",
|
||||
"Bash(wget* | sh)",
|
||||
"Bash(wget* | bash)",
|
||||
"Bash(curl:* | sh)",
|
||||
"Bash(curl:* | bash)",
|
||||
"Bash(wget:* | sh)",
|
||||
"Bash(wget:* | bash)",
|
||||
"Bash(ssh:*)",
|
||||
"Bash(scp:*)",
|
||||
"Read(./**/config.local.php)",
|
||||
|
|
|
|||
14
.gitignore
vendored
14
.gitignore
vendored
|
|
@ -5,9 +5,6 @@
|
|||
# ── 운영 비밀값 / 로컬 설정 (절대 커밋 금지) ──────────────────────────────
|
||||
php/config.local.php
|
||||
firmware/**/secrets.h
|
||||
raspberry_pi_zero2w_display/config_local.py
|
||||
raspberry_pi4/config_local.py
|
||||
raspberry_pi4_sht30/config_local.py
|
||||
*.env
|
||||
*.env.*
|
||||
!*.env.example
|
||||
|
|
@ -15,13 +12,6 @@ raspberry_pi4_sht30/config_local.py
|
|||
# ── 레거시 백업: 하드코딩된 실 운영 비밀값(DB 암호/API 키/SMS 키) 포함 → 미공개
|
||||
_backup/
|
||||
|
||||
# ── 배포 번들: 라이브 API 키·관리자 비밀번호·난독화 경로 포함 (생성기가 내부
|
||||
# .gitignore 도 만들지만, 생성 실패 시에도 새지 않도록 루트에서 이중 차단) ──
|
||||
deploy/
|
||||
|
||||
# ── 배포판 zip: 서버 번들(API 키·관리자 비밀번호 포함)을 담으므로 절대 커밋 금지 ──
|
||||
dist/
|
||||
|
||||
# ── 런타임 상태 (접속 IP·감사로그·브루트포스 카운터·하트비트 throttle) ────
|
||||
php/var/*
|
||||
!php/var/.gitkeep
|
||||
|
|
@ -40,7 +30,3 @@ firmware/build/
|
|||
# ── OS 잡파일 ─────────────────────────────────────────────────────────────
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# ── 파이썬 캐시 ──
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
{
|
||||
"$schema": "https://aka.ms/codetour-schema",
|
||||
"title": "전체 시스템 아키텍처 투어 — 누수+온습도 모니터링 (RPi4×3 → cafe24)",
|
||||
"description": "센서 노드(RPi4) → HMAC 서명 HTTPS 수집 → MySQL(leak_ 접두사 테이블) → SMS 경보/대시보드까지 전체 데이터 경로를 따라가는 아키텍처 투어. v2607 기준 (테이블 leak_ 접두사, sensor_id 3 신규 지점, 턴키 배포 번들).",
|
||||
"ref": "feat/pi-c-sensor3-leak-node",
|
||||
"steps": [
|
||||
{
|
||||
"file": "SYSTEM_OVERVIEW.md",
|
||||
"line": 1,
|
||||
"title": "1. 전체 그림",
|
||||
"description": "**상황**: 2종 센서 모듈(누수+카메라 / SHT30 온습도)이 단일 cafe24 PHP 서버로 보고하는 통합 시스템. **구조**: 모든 모듈이 동일한 raw-body HMAC-SHA256 X-Signature 스킴 사용. **왜 중요**: 이 문서가 모듈↔서버 계약(서명·sensor_id·엔드포인트)의 단일 출처. **주의**: v2607부터 DB 테이블 전체가 leak_ 접두사 — 같은 DB의 타 시스템 테이블과 격리."
|
||||
},
|
||||
{
|
||||
"directory": "raspberry_pi4",
|
||||
"title": "2. 모듈 1 — 누수 + 카메라 (sensor_id 1, 3)",
|
||||
"description": "**상황**: e-con 5C-08 저항형 누수센서(GPIO17, 외부 4.7MΩ 풀다운) + Pi Camera. **구조**: leak_sensor.py 단일 데몬 + config.py(비밀 아님) + config_local.py(비밀, 미커밋). **왜 중요**: Pi-A(sensor 1)와 Pi-C(sensor 3, 신규 지점)가 같은 코드 폴더를 씀 — 기기별 차이는 전부 config_local.py로."
|
||||
},
|
||||
{
|
||||
"file": "raspberry_pi4/config.py",
|
||||
"line": 13,
|
||||
"title": "3. 센서 정의 + 보고 주기",
|
||||
"description": "**상황**: SENSORS 리스트가 이 기기의 sensor_id/GPIO를 정의. **구조**: REPORT_INTERVAL=600초 — 서버 HEARTBEAT_TIMEOUT_SEC(1200초)의 절반이라 2회 누락 시 오프라인 판정. **주의**: 예전 3600초는 매시간 오프라인 오탐을 냈던 버그 — 주기를 늘릴 땐 반드시 서버 타임아웃과 함께 판단."
|
||||
},
|
||||
{
|
||||
"file": "raspberry_pi4/config.py",
|
||||
"line": 92,
|
||||
"title": "4. 기기별 오버라이드 (Pi-C 핵심)",
|
||||
"description": "**상황**: config_local.py에 SENSORS가 있으면 기본값을 덮어씀. **구조**: 2단 import — 필수 비밀값은 fail-closed(없으면 기동 거부), SENSORS는 선택. **왜 중요**: Pi-C(sensor_id 3)는 추적 파일(config.py)을 건드리지 않고 배포 가능 — 기기별 drift 방지. **주의**: sensor_id 1/2 재사용 시 기존 지점 상태를 조용히 덮어씀(leak_sensor_status는 sensor_id 단독 키)."
|
||||
},
|
||||
{
|
||||
"file": "raspberry_pi4/leak_sensor.py",
|
||||
"line": 194,
|
||||
"title": "5. 클라이언트 서명 — raw-body HMAC",
|
||||
"description": "**상황**: 요청 본문 바이트에 HMAC-SHA256(API_KEY)을 계산해 X-Signature 헤더로 전송. **구조**: 본문을 고정 필드 순서로 직렬화한 뒤 그 바이트 그대로 서명·전송 — 서버도 같은 바이트로 검증. **주의**: 구버전 sha256(API_KEY+정렬JSON) 스킴은 폐기 — 서버가 기본 거부(ALLOW_LEGACY_BODY_SIGNATURE=false)."
|
||||
},
|
||||
{
|
||||
"file": "raspberry_pi4/leak_sensor.py",
|
||||
"line": 451,
|
||||
"title": "6. 보고 상태머신",
|
||||
"description": "**상황**: 메인 루프의 주기 선택 — 정상 600초, 누수 중 10초(LEAK_REPORT_INTERVAL). **구조**: 누수 감지 → leak_alert 즉시 보고 + 사진 촬영/업로드 → 지속 재보고 → 복구 시 leak_recovery. **왜 중요**: SMS 중복 방지는 클라이언트가 아니라 서버 쿨다운이 담당 — 클라이언트는 상태만 계속 밀어넣는 단순 설계."
|
||||
},
|
||||
{
|
||||
"file": "raspberry_pi4_sht30/sht30_sensor.py",
|
||||
"line": 179,
|
||||
"title": "7. 모듈 2 — SHT30 온습도 (sensor_id 2)",
|
||||
"description": "**상황**: I2C(0x44) SHT30을 300초 주기로 측정·보고하는 메인 루프. **구조**: 측정 → CRC 검증 → 같은 HMAC 서명 → 같은 엔드포인트. 임계 판정은 전부 서버 몫. **주의**: smbus2 부재/I2C 실패 시 시뮬레이션 값으로 폴백(WARN 로그만) — 배포 검증 때 journalctl에서 시뮬레이션 경고 부재를 반드시 확인."
|
||||
},
|
||||
{
|
||||
"file": "php/api/sensor_data.php",
|
||||
"line": 73,
|
||||
"title": "8. 수집 경계 — fail-closed 인증",
|
||||
"description": "**상황**: 모든 센서 보고가 들어오는 단일 진입점. **구조**: API_KEY가 기본값이면 503 거부 → 크기 상한 → 타임스탬프 ±600초 → raw-body HMAC 검증 순서. **왜 중요**: 여기가 시스템의 유일한 신뢰 경계 — 이 파일 통과 = DB 기록 + SMS 발송 권한."
|
||||
},
|
||||
{
|
||||
"file": "php/api/sensor_data.php",
|
||||
"line": 112,
|
||||
"title": "9. 재전송(replay) 멱등 방어",
|
||||
"description": "**상황**: 서명 자체를 nonce로 leak_ingest_nonce에 기록. **구조**: 같은 서명 재수신 = 중복 → 재저장/재발송 생략. 시계오차 창 지나면 자동 정리. **주의**: 테이블 없으면 조용히 비활성(graceful degrade) — setup_wizard가 이 테이블 누락을 검사하지 않으므로 신규 설치 후 7개 테이블 존재를 직접 확인."
|
||||
},
|
||||
{
|
||||
"file": "php/api/sensor_data.php",
|
||||
"line": 371,
|
||||
"title": "10. 누수 사고(incident) 추적",
|
||||
"description": "**상황**: leak_alert 수신 시 leak_incident 에피소드 개시, leak_recovery 시 종료. **구조**: sensor_id 단위로 열린 사고를 찾아 갱신 — sensor_id 3도 코드 수정 없이 동작(자동 등록 upsert). **왜 중요**: 사고 단위 추적이라 SMS·사진·대시보드 이력이 에피소드로 묶임."
|
||||
},
|
||||
{
|
||||
"file": "php/api/photo_upload.php",
|
||||
"line": 221,
|
||||
"title": "11. 사진 업로드 검증 체인",
|
||||
"description": "**상황**: 누수 사진 전용 업로드 엔드포인트. **구조**: canonical 문자열(device_id|sensor_id|log_id|timestamp) HMAC 서명 → log_id 실존 확인 → finfo MIME(image/jpeg만) → 크기 상한 → 서버 생성 무작위 파일명. **주의**: 파일명을 클라이언트가 정하지 않음 — 경로 조작 원천 차단. 저장 위치 php/var/photos/는 웹에서 403."
|
||||
},
|
||||
{
|
||||
"file": "php/sms_send.php",
|
||||
"line": 546,
|
||||
"title": "12. SMS 경보 상태머신",
|
||||
"description": "**상황**: 누수 SMS 발송 함수 — 쿨다운(기본 30분) 적용, 복구 SMS는 경보 후에만. **구조**: 별도 상태 테이블 없이 leak_sms_log의 메시지 접두 태그([누수감지] 등)가 사실상 상태 키 — 태그는 config.php SMS_TAG_* 상수가 단일 출처. **주의**: 태그 문자열을 직접 쓰면 여러 파일의 LIKE 검색이 어긋나 무성 장애 — 반드시 상수 참조."
|
||||
},
|
||||
{
|
||||
"file": "php/cron_heartbeat.php",
|
||||
"line": 22,
|
||||
"title": "13. 오프라인 감지 (cron 1분)",
|
||||
"description": "**상황**: last_seen이 HEARTBEAT_TIMEOUT_SEC(1200초) 초과한 센서를 찾아 오프라인 SMS. **구조**: offline_alerted 0→1 원자적 선점(UPDATE ... WHERE offline_alerted=0)으로 다중 진입점 동시 실행에도 중복 발송 방지. **주의**: 타임아웃은 전 센서 공통 — 어떤 모듈의 보고 주기보다 길어야 오탐 없음."
|
||||
},
|
||||
{
|
||||
"file": "php/config.php",
|
||||
"line": 217,
|
||||
"title": "14. LEAK_SENSOR_IDS — 지점 확장점",
|
||||
"description": "**상황**: 대시보드 누수 카드 표시 대상 sensor_id 목록 (기본 [1]). **구조**: config.local.php에서 [1,3] 오버라이드 — 1..255 정수만 통과, 잘못된 값은 버리고 비면 [1] 복귀. **왜 중요**: 신규 누수 지점 추가 시 서버 코드 수정 없이 이 설정 하나로 대시보드 노출."
|
||||
},
|
||||
{
|
||||
"file": "php/dashboard.php",
|
||||
"line": 700,
|
||||
"title": "15. 대시보드 — 누수 카드 렌더",
|
||||
"description": "**상황**: LEAK_SENSOR_IDS 루프로 지점별 누수 카드 렌더 (v2607 이전엔 sensor_id 1 하드코딩). **구조**: 우선순위 오프라인 > 누수 > 정상, 미수신이면 '데이터 없음' 카드. **주의**: 온습도 차트는 여전히 sensor 2 고정 — SHT30 지점을 추가하면 차트 선택 UI 작업 필요(누수 지점은 무관)."
|
||||
},
|
||||
{
|
||||
"file": "php/login.php",
|
||||
"line": 110,
|
||||
"title": "16. 관리자 인증 — 비밀번호 + TOTP",
|
||||
"description": "**상황**: bcrypt 해시 검증 + TOTP MFA. **구조**: ADMIN_TOTP_SECRET 미설정이면 로그인 자체가 불가(fail-closed) — setup_mfa.php?token= 으로 최초 1회 등록 후 MFA_SETUP_TOKEN을 반드시 비움. **주의**: 세션 쿠키 Secure라 HTTPS 없이는 로그인 불가 — cafe24 패널에서 HTTPS 강제가 선행 조건."
|
||||
},
|
||||
{
|
||||
"file": "sql/schema_sht30.sql",
|
||||
"line": 24,
|
||||
"title": "17. 스키마 — 단일 파일, leak_ 네임스페이스",
|
||||
"description": "**상황**: 신규 설치용 완전 스키마 — 테이블 7개(leak_sensor_log/status/metric, leak_sms_log, leak_ingest_nonce, leak_incident, leak_photo) 전부 이 파일 하나. **왜 중요**: v2607에서 구버전 마이그레이션 파일 폐기 — 이 파일이 유일한 DB 설치 경로. **주의**: 컬럼명은 접두사 없음(sensor_id, is_leak 등) — 테이블명만 leak_."
|
||||
},
|
||||
{
|
||||
"file": "tools/make_deploy_bundles.py",
|
||||
"line": 29,
|
||||
"title": "18. 턴키 배포 번들 생성기",
|
||||
"description": "**상황**: deploy/ 아래 서버 1 + Pi 3대 폴더를 생성 — API_KEY 자동 생성·4곳 동일 주입, 관리자 bcrypt, LF 정규화. **구조**: WEB_DIR/BASE_URL만 고치고 재실행하면 전체 재생성(.secrets/ 비밀 보존). **주의**: deploy/는 비밀 포함이라 gitignore — 절대 커밋 금지. 남는 수동 설정은 DB 계정·SMS 계정·TOTP 등록 3가지뿐."
|
||||
},
|
||||
{
|
||||
"title": "다음 단계",
|
||||
"description": "이제 센서 GPIO 인터럽트부터 SMS 발송까지 전체 경로를 파일 단위로 추적할 수 있다. 배포 절차는 docs/DEPLOYMENT_PLAN_RPI4x3.md, 기기별 설치는 deploy/*/SETUP.md, 보안 증적은 docs/SOURCE_SECURITY_EVIDENCE.md 참고. 신규 지점 추가 = Pi 폴더 복사 + config_local.py(SENSORS id) + 서버 LEAK_SENSOR_IDS 갱신 — 코드 수정 불필요."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
# ArtronShop_SHT3x
|
||||
|
||||
Arduino library of SHT30, SHT31, SHT35 Digital humidity and temperature sensor
|
||||
|
||||
## Examples
|
||||
|
||||
```C++
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
#include <ArtronShop_SHT3x.h>
|
||||
|
||||
ArtronShop_SHT3x sht3x(0x44, &Wire); // ADDR: 0 => 0x44, ADDR: 1 => 0x45
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
Wire.begin();
|
||||
while (!sht3x.begin()) {
|
||||
Serial.println("SHT3x not found !");
|
||||
delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (sht3x.measure()) {
|
||||
Serial.print("Temperature: ");
|
||||
Serial.print(sht3x.temperature(), 1);
|
||||
Serial.print(" *C\tHumidity: ");
|
||||
Serial.print(sht3x.humidity(), 1);
|
||||
Serial.print(" %RH");
|
||||
Serial.println();
|
||||
} else {
|
||||
Serial.println("SHT3x read error");
|
||||
}
|
||||
delay(1000);
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
#include <ArtronShop_SHT3x.h>
|
||||
|
||||
ArtronShop_SHT3x sht3x(0x44, &Wire); // ADDR: 0 => 0x44, ADDR: 1 => 0x45
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
Wire.begin();
|
||||
while (!sht3x.begin()) {
|
||||
Serial.println("SHT3x not found !");
|
||||
delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (sht3x.measure()) {
|
||||
Serial.print("Temperature: ");
|
||||
Serial.print(sht3x.temperature(), 1);
|
||||
Serial.print(" *C\tHumidity: ");
|
||||
Serial.print(sht3x.humidity(), 1);
|
||||
Serial.print(" %RH");
|
||||
Serial.println();
|
||||
} else {
|
||||
Serial.println("SHT3x read error");
|
||||
}
|
||||
delay(1000);
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
#######################################
|
||||
# Syntax Coloring Map For
|
||||
#######################################
|
||||
|
||||
#######################################
|
||||
# Datatypes (KEYWORD1)
|
||||
#######################################
|
||||
|
||||
ArtronShop_SHT3x KEYWORD1
|
||||
|
||||
#######################################
|
||||
# Methods and Functions (KEYWORD2)
|
||||
#######################################
|
||||
|
||||
begin KEYWORD2
|
||||
measure KEYWORD2
|
||||
temperature KEYWORD2
|
||||
humidity KEYWORD2
|
||||
|
||||
#######################################
|
||||
# Constants (LITERAL1)
|
||||
#######################################
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
name=ArtronShop_SHT3x
|
||||
version=1.0.0
|
||||
author=ArtronShop Co.,Ltd.
|
||||
maintainer=Sonthaya.NT <sonthaya.nt@artronshop.co.th>
|
||||
sentence=Arduino library of SHT3x Digital humidity and temperature sensor
|
||||
paragraph=Arduino library of SHT3x Digital humidity and temperature sensor
|
||||
category=Sensors
|
||||
url=https://github.com/ArtronShop/ArtronShop_SHT3x
|
||||
architectures=*
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
#include "ArtronShop_SHT3x.h"
|
||||
|
||||
ArtronShop_SHT3x::ArtronShop_SHT3x(uint8_t addr, TwoWire *wire) : _addr(addr), _wire(wire) {
|
||||
// -----
|
||||
}
|
||||
|
||||
bool ArtronShop_SHT3x::begin() {
|
||||
// periodic data acquisition mode
|
||||
this->_wire->beginTransmission(this->_addr);
|
||||
this->_wire->write(0x27); // mps => 10
|
||||
this->_wire->write(0x37); // Repeatability => High
|
||||
return this->_wire->endTransmission() == 0;
|
||||
}
|
||||
|
||||
bool ArtronShop_SHT3x::measure() {
|
||||
this->_wire->beginTransmission(this->_addr);
|
||||
this->_wire->write(0xE0);
|
||||
this->_wire->write(0x00);
|
||||
if (this->_wire->endTransmission(false) != 0) {
|
||||
this->_t = -99;
|
||||
this->_h = -99;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this->_wire->requestFrom(this->_addr, (uint8_t)6) != 6) {
|
||||
this->_t = -99;
|
||||
this->_h = -99;
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t rx_bytes[6];
|
||||
memset(rx_bytes, 0, 6);
|
||||
this->_wire->readBytes(rx_bytes, 6);
|
||||
|
||||
uint16_t t_ticks = ((uint16_t)rx_bytes[0] << 8) | rx_bytes[1];
|
||||
// checksum_t = rx_bytes[2];
|
||||
uint16_t rh_ticks = ((uint16_t)rx_bytes[3] << 8) | rx_bytes[4];
|
||||
// checksum_rh = rx_bytes[5];
|
||||
this->_t = -45.0f + (175.0f * (t_ticks / 65535.0f));
|
||||
this->_h = 100 * (rh_ticks / 65535.0f);
|
||||
if (this->_h > 100) {
|
||||
this->_h = 100;
|
||||
}
|
||||
if (this->_h < 0) {
|
||||
this->_h = 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
float ArtronShop_SHT3x::temperature() {
|
||||
return this->_t;
|
||||
}
|
||||
|
||||
float ArtronShop_SHT3x::humidity() {
|
||||
return this->_h;
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
#ifndef __ARTRONSHOP_SHT3X_H__
|
||||
#define __ARTRONSHOP_SHT3X_H__
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
|
||||
#define SHT3X_ADDR0 (0x44)
|
||||
#define SHT3X_ADDR1 (0x45)
|
||||
|
||||
class ArtronShop_SHT3x {
|
||||
private:
|
||||
uint8_t _addr;
|
||||
TwoWire *_wire = NULL;
|
||||
float _t, _h;
|
||||
|
||||
public:
|
||||
ArtronShop_SHT3x(uint8_t addr = SHT3X_ADDR0, TwoWire *wire = &Wire) ;
|
||||
bool begin() ;
|
||||
bool measure() ;
|
||||
float temperature() ;
|
||||
float humidity() ;
|
||||
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
#include <ArtronShop_SHT3x.h>
|
||||
|
||||
ArtronShop_SHT3x sht3x(0x44, &Wire); // ADDR: 0 => 0x44, ADDR: 1 => 0x45
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
Wire.begin();
|
||||
while (!sht3x.begin()) {
|
||||
Serial.println("SHT3x not found !");
|
||||
delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (sht3x.measure()) {
|
||||
Serial.print("Temperature: ");
|
||||
Serial.print(sht3x.temperature(), 1);
|
||||
Serial.print(" *C\tHumidity: ");
|
||||
Serial.print(sht3x.humidity(), 1);
|
||||
Serial.print(" %RH");
|
||||
Serial.println();
|
||||
} else {
|
||||
Serial.println("SHT3x read error");
|
||||
}
|
||||
delay(1000);
|
||||
}
|
||||
23
README.md
23
README.md
|
|
@ -1,23 +0,0 @@
|
|||
# 서버실 통합 모니터링 시스템 (누수 + 온습도)
|
||||
|
||||
Raspberry Pi 4 센서 모듈 2대 — **1번: 누수 감지 + 카메라**, **2번: SHT30 온습도** — 와
|
||||
선택적 **3번째 노드**(신규 지점, sensor_id 3)가 **동일한 cafe24 PHP 서버**로 HTTPS 보고
|
||||
(raw-body 서명 `X-Signature = HMAC-SHA256(API_KEY, 요청본문)`)를 하고, 서버가 MySQL 저장,
|
||||
임계/누수 판정, SMS 발송, 통합 대시보드(`dashboard.php`) 표시를 담당하는 단일 시스템입니다.
|
||||
|
||||
## 모듈 구성
|
||||
|
||||
| 폴더 | 역할 | sensor_id |
|
||||
|---|---|---|
|
||||
| `raspberry_pi4/` | 누수 감지(e-con 5C-08) + Pi Camera 촬영·사진 업로드 | 1 (신규 지점 노드는 3) |
|
||||
| `raspberry_pi4_sht30/` | SHT30 온습도 측정(I2C) | 2 |
|
||||
| `php/` | cafe24 서버 — API 수신, DB 저장, 판정/SMS, 대시보드 | — |
|
||||
| `sql/` | DB 스키마(`schema_sht30.sql` = 신규 설치 완결) + 마이그레이션 | — |
|
||||
| `firmware/` | STM32F407 마이그레이션 트랙(별도) — **RPi4 배포와 무관** | — |
|
||||
|
||||
## 문서
|
||||
|
||||
- [SYSTEM_OVERVIEW.md](./SYSTEM_OVERVIEW.md) — 전체 아키텍처·모듈 매핑·서명 스킴 (기준 문서)
|
||||
- [docs/DEPLOYMENT_PLAN_RPI4x3.md](./docs/DEPLOYMENT_PLAN_RPI4x3.md) — RPi4 × 3 + cafe24 신규 설치 배포 계획
|
||||
- [docs/INSTALL_PI_SERVER.md](./docs/INSTALL_PI_SERVER.md) — cafe24 서버 설치 절차 (STM32 펌웨어 중심)
|
||||
- [docs/OPERATIONS_SECURITY_CHECKLIST.md](./docs/OPERATIONS_SECURITY_CHECKLIST.md) — 운영·보안 체크리스트
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
| 구분 | 폴더 | 보드 | 센서 | 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` + 온도/습도 (`leak_sensor_metric`) |
|
||||
| **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` 후 채움.
|
||||
|
|
@ -36,8 +36,8 @@
|
|||
## 서버 (cafe24 `php/`)
|
||||
|
||||
- 웹 루트(예): `public_html/raspi_leck_detecter/` — `docs/INSTALL_PI_SERVER.md` 참고.
|
||||
- DB 스키마: `sql/schema_sht30.sql` — 신규 설치는 이 파일 하나로 완결(테이블 전체 `leak_` 접두사).
|
||||
- 대시보드: `dashboard.php` — `leak_sensor_status`/`leak_sensor_metric`/`leak_sms_log` 표시, 기본 차트 sensor 2.
|
||||
- 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`.
|
||||
|
||||
---
|
||||
|
|
@ -54,20 +54,21 @@
|
|||
본문 JSON 을 고정 필드 순서 문자열로 만들어 그 바이트에 서명하고 그대로 POST 합니다.
|
||||
(구버전의 `sha256(API_KEY + 정렬 JSON)` body-field 서명은 폐기 — 서버 기본 설정이 거부.)
|
||||
사진 업로드 서명 canonical = `device_id|sensor_id|log_id|timestamp` (공백 없음).
|
||||
- **스키마(가산·멱등):** `leak_sensor_log.is_leak` / `leak_sensor_status.is_leak` 컬럼, `event_type` 의
|
||||
- **스키마(가산·멱등):** `sensor_log.is_leak` / `sensor_status.is_leak` 컬럼, `event_type` 의
|
||||
`leak_alert`/`leak_recovery`, `leak_incident`(사고 추적) + `leak_photo`(사진 메타) 테이블을
|
||||
다시 추가했습니다. `leak_sensor_metric`(SHT30)은 그대로 보존합니다.
|
||||
- **수신:** `api/sensor_data.php` 가 누수 이벤트를 받아 `leak_sensor_log`/`leak_sensor_status` 갱신,
|
||||
다시 추가했습니다. `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/schema_sht30.sql` 1회 실행** — 이 파일 하나로 완결(누수 요소 포함, 테이블 전체 `leak_` 접두사).
|
||||
(구버전 무접두 테이블 대상 마이그레이션 파일들은 v2607 테이블 리네임과 함께 폐기되었습니다.)
|
||||
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`.
|
||||
`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` 와 동일하게 설정.
|
||||
|
|
|
|||
|
|
@ -1,279 +0,0 @@
|
|||
# 배포 계획: 누수+온습도 통합 모니터링 시스템 (cafe24 서버 1 + Raspberry Pi 4 × 3)
|
||||
|
||||
기준 저장소: `C:/Users/USER/Desktop/complete/leak_sms_v3`
|
||||
확정 조건: RPi4 3대 모두 신품(OS 없음, SD 새로 플래시), 설치 현장 인터넷 가용(오프라인 vendor 번들은 선택이나 재현성 위해 권장), cafe24 서버 신규 설치.
|
||||
|
||||
---
|
||||
|
||||
## 1. 전체 로드맵
|
||||
|
||||
**순서 원칙: 서버 먼저.** 모든 Pi의 `test_connection.py` 검증과 API_KEY 발급이 서버에 의존하므로 서버가 선행되어야 한다 (`raspberry_pi4/config_local.example.py`, `raspberry_pi4_sht30/README.md` — API_KEY는 서버 `config.local.php`와 byte-단위 동일해야 함).
|
||||
|
||||
```
|
||||
[0단계] cafe24 서버 신규 설치 ──────────────► API_KEY 확정, 엔드포인트 URL 확정
|
||||
│
|
||||
├─ (병렬 가능) SD 카드 3장 OS 플래시 + 공통 OS 프로비저닝
|
||||
│ ※ 서버와 의존성 없음 — 서버 작업과 동시 진행 가능
|
||||
│
|
||||
▼
|
||||
[1단계] Pi-A (모듈1: 누수+카메라, sensor_id=1) ┐
|
||||
[2단계] Pi-B (모듈2: SHT30, sensor_id=2) ├─ 서로 독립, 병렬 설치 가능
|
||||
[3단계] Pi-C (sensor_id=3, 신규 지점) ┘ (단, Pi-C는 센서 종류 결정 선행 — §6)
|
||||
│
|
||||
▼
|
||||
[4단계] end-to-end 검증 (대시보드 / SMS / 사진 / 오프라인 감지) + 보안 체크리스트 (§7)
|
||||
```
|
||||
|
||||
**병렬 가능 구간:**
|
||||
- 서버 설치(0단계)와 Pi 3대의 OS 플래시·공통 프로비저닝(§3)은 완전 병렬.
|
||||
- Pi-A / Pi-B / Pi-C의 모듈 설치는 서로 독립 — 서버의 API_KEY만 확정되면 동시 진행 가능.
|
||||
- (선택) 오프라인 vendor 번들 생성(`vendor.sh`)은 인터넷 연결된 aarch64 Bookworm Pi 1대에서 선행 — 첫 번째로 부팅한 Pi에서 만들어 나머지에 복사하는 흐름 권장 (`raspberry_pi4/vendor.sh`, `raspberry_pi4_sht30/vendor.sh`의 .deb은 타깃 아키텍처에서만 받을 수 있음).
|
||||
|
||||
**Pi-C 블로커:** 센서 종류(누수 / SHT30 / 둘 다) 미결정 → §6의 작업량 비교로 먼저 결정 필요.
|
||||
|
||||
---
|
||||
|
||||
## 2. 0단계: cafe24 서버 신규 설치
|
||||
|
||||
### 2.1 DB 스키마
|
||||
- [ ] cafe24 phpMyAdmin(또는 MySQL 콘솔)에서 **`sql/schema_sht30.sql` 1회 실행**. 신규 설치는 이 파일 하나로 완결 — leak_sensor_log, leak_sensor_status, leak_sensor_metric, leak_sms_log, leak_ingest_nonce, leak_incident, leak_photo 및 leak 컬럼/ENUM 전부 포함 (`sql/schema_sht30.sql:25-167`).
|
||||
- [ ] 마이그레이션 파일 없음 — v2607 테이블 리네임(`leak_` 접두사)과 함께 폐기. 신규 설치는 스키마 파일 하나로 완결.
|
||||
|
||||
### 2.2 파일 업로드
|
||||
- [ ] `php/` 전체를 웹루트 `public_html/<WEB_DIR>/`에 업로드 (`docs/INSTALL_PI_SERVER.md` §3, `php/.htaccess` 헤더).
|
||||
- [ ] **주의: `docs/INSTALL_PI_SERVER.md:33-56`의 업로드 목록은 STALE** — `api/photo_upload.php`, `photo.php`, `var/.htaccess`, `var/index.html`, `var/photos/`가 누락되어 있음. 누수 사진 파이프라인에 필수이므로 **php/ 트리 전체**를 기준으로 업로드할 것.
|
||||
- [ ] `php/var/` 를 PHP 쓰기 가능하게 설정 (heartbeat_last.txt, ingest_rejections.json, login_attempts.json, admin_audit.log 저장). 코드 기준 디렉터리 0750 / 파일 0640 (`php/api/photo_upload.php:303,321`) — cafe24 공유호스팅에서 755/775가 필요할 수 있음(갭, §8). `var/photos/`는 첫 업로드 시 자동 생성 + .htaccess 가드 자동 작성 (`php/api/photo_upload.php:139-153, 301-321`).
|
||||
- [ ] 웹에서 `/var/` 접근이 403인지 확인 (`php/.htaccess:14` + `php/var/.htaccess`).
|
||||
|
||||
### 2.3 config.local.php / API_KEY
|
||||
- [ ] `php/config.local.example.php` 복사 → `config.local.php` 작성:
|
||||
- `DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASS` — cafe24 MySQL 자격증명
|
||||
- `API_KEY` — **긴 랜덤 비밀키 생성.** 기본값 `'change-this-api-key'`이면 ingest가 503으로 거부됨(fail-closed, `php/api/sensor_data.php:73-77`). 이 키가 Pi 3대 전부의 `config_local.py` API_KEY와 byte-단위 동일해야 함.
|
||||
- `ALLOW_LEGACY_BODY_SIGNATURE` — **false 유지** (`php/config.php:60`, `php/ops_checks.php:101`)
|
||||
- `SMS_USER_ID / SMS_SECURE / SMS_SENDER / SMS_RECIPIENTS` — cafe24 SMS 계정, **사전 등록된 발신번호**, 잔액 충전 필요 (게이트웨이 `php/config.php:66`)
|
||||
- 임계값: `METRIC_TEMP_HIGH_C=27`, `METRIC_RH_HIGH=60`, `METRIC_RH_LOW=-1`(저습 비활성), `METRIC_DEWPOINT_MARGIN_C=0`(결로 비활성) — 예시값 기준 (`php/config.local.example.php:44-50`, commit c8b448c)
|
||||
|
||||
### 2.4 관리자 인증 (fail-closed — 미설정 시 로그인 자체가 불가)
|
||||
- [ ] `php setup_hash.php "새-관리자-비밀번호"` 실행 → bcrypt 출력물을 `ADMIN_PASSWORD_HASH`에 (`docs/INSTALL_PI_SERVER.md:135-141`)
|
||||
- [ ] MFA 등록: `MFA_SETUP_TOKEN`에 임시 랜덤값 설정 → `setup_mfa.php?token=<토큰>` 접속 → TOTP 앱 등록 → 표시된 `ADMIN_TOTP_SECRET`을 config.local.php에 → **`MFA_SETUP_TOKEN` 반드시 비우기** (`docs/INSTALL_PI_SERVER.md:100-106`, `php/ops_checks.php:100`)
|
||||
- [ ] `ADMIN_TOTP_SECRET`은 서버에서 생성하기 전까지 **절대 자리표시자 값을 넣지 말 것** (`php/config.local.example.php:33-40`)
|
||||
|
||||
### 2.5 HTTPS / cron
|
||||
- [ ] HTTPS 리다이렉트는 **cafe24 호스팅 관리 패널에서** 설정 — .htaccess 리다이렉트는 리버스프록시 뒤에서 무한루프 위험으로 의도적으로 비활성 (`php/.htaccess:6-8`, `docs/SOURCE_SECURITY_EVIDENCE.md` §2.1). 세션 쿠키가 Secure라 HTTPS 없이는 로그인 불가.
|
||||
- [ ] cron 1분 주기: CLI형 `* * * * * php /home/<user>/public_html/<WEB_DIR>/cron_heartbeat.php`. HTTP형 사용 시 **`?key=<CRON_SECRET>` 필수** — `CRON_SECRET = hash_hmac('sha256','cron',API_KEY)` (`php/config.php:199`, `php/cron_heartbeat.php:163-169`). 이 값은 UI 어디에도 표시되지 않아 직접 계산해야 함(갭). `docs/INSTALL_PI_SERVER.md:165`의 URL 예시는 key 파라미터가 빠져 있어 그대로 쓰면 403.
|
||||
- [ ] (선택) 보존기간 정리: `php retention_cleanup.php --execute` CLI 전용 (`php/retention_cleanup.php:11-16`) — cron 등록 안 하면 365일 보존이 실제로 집행되지 않음.
|
||||
|
||||
### 2.6 서버 검증
|
||||
- [ ] `login.php` 로그인(비밀번호+TOTP) → `setup_wizard.php`에서 전체 점검: DB 연결, 필수 테이블, API_KEY 교체 여부, 관리자 해시/TOTP, MFA 토큰 제거, 레거시 서명 비활성, SMS 수신자, 임계값, var 쓰기권한, **테스트 SMS 발송 버튼** (`php/setup_wizard.php:29-45`, `php/ops_checks.php:44-111`)
|
||||
- [ ] 주의: setup_wizard는 4개 코어 테이블만 검사 — `leak_incident`/`leak_photo`/`leak_ingest_nonce` 누락은 감지 못 하고 조용히 기능 저하됨 (`php/ops_checks.php:51`). 스키마 실행 후 phpMyAdmin에서 7개 테이블 존재를 직접 확인할 것.
|
||||
- [ ] CORS 오리진이 `https://pores.kr`로 하드코딩되어 있음 (`php/api/sensor_data.php:49`, `php/api/photo_upload.php:29`) — 다른 도메인 배포 시 수정 필요(갭).
|
||||
|
||||
---
|
||||
|
||||
## 3. 공통: 새 Pi OS 프로비저닝 (3대 공통)
|
||||
|
||||
### 3.1 문서화된 요구사항 (repo에 근거 있음)
|
||||
| 항목 | 값 | 근거 |
|
||||
|---|---|---|
|
||||
| OS | Raspberry Pi OS **Bookworm 64-bit (aarch64)** | `raspberry_pi4/install.sh:4`, `raspberry_pi4_sht30/install.sh:4`, 양쪽 README §2 |
|
||||
| Python | 시스템 3.11 (Bookworm 기본; wheel이 cp311로 핀) | `vendor.sh` TARGET_PYVER=311 |
|
||||
| 사용자 | `pi` (sudo 가능) — systemd 유닛 User=pi, 경로 /home/pi/* 하드코딩 | 양쪽 `systemd/*.service`, install.sh SERVICE_USER 기본값 |
|
||||
| 네트워크 | 유선 Gigabit 또는 WiFi, 서버로 HTTPS egress | 양쪽 README §2 |
|
||||
| 시각 동기 | NTP 필수 — 서버가 타임스탬프 ±600초 밖이면 거부 | `raspberry_pi4_sht30/README.md:61`, `php/config.php:45` |
|
||||
| I2C (Pi-B) | install.sh가 자동 처리 (dtparam=i2c_arm=on 등) — raspi-config는 수동 폴백만 | `raspberry_pi4_sht30/install.sh:62-88` |
|
||||
|
||||
### 3.2 문서에 없어 현장에서 결정해야 하는 것 (전부 갭 — repo에 절차 없음)
|
||||
- **SD 이미징 절차 없음**: OS 이미지 버전/체크섬/Imager 사용법 미문서화. → 결정: Raspberry Pi Imager 등으로 Bookworm 64-bit(Lite 권장 여부도 미명시)를 플래시하되, 사용한 이미지 버전을 기록해 3대 동일하게 맞출 것.
|
||||
- **`pi` 사용자 생성 절차 없음**: Bookworm 이미지는 기본 `pi` 계정이 없음 — Imager 커스텀 설정 또는 userconf.txt로 `pi` 계정을 만들어야 systemd 유닛/경로가 무수정으로 동작. 비밀번호 정책 미문서화.
|
||||
- **SSH/헤드리스 활성화 절차 없음**: Imager 설정 또는 boot 파티션 `ssh` 파일 방식 중 결정 필요.
|
||||
- **호스트네임 규약 없음**: DEVICE_ID(rpi4-leak-01 등)는 애플리케이션 식별자일 뿐 OS 호스트네임 아님. → 결정 권장: 호스트네임을 DEVICE_ID와 일치시켜 운영 혼선 방지.
|
||||
- **네트워크 설정(고정 IP vs DHCP, WiFi 프로비저닝) 없음.**
|
||||
- **로케일/타임존, apt 보안 업데이트 정책, OS 하드닝 절차 없음** (§7·§8 참조).
|
||||
|
||||
### 3.3 (선택·권장) 오프라인 vendor 번들
|
||||
현장에 인터넷이 있으므로 install.sh 온라인 경로로도 설치 가능하나, 재현성을 위해:
|
||||
- [ ] 첫 부팅한 Pi(인터넷 연결, aarch64 Bookworm) 1대에서 각 모듈의 `./vendor.sh` 실행 → `wheelhouse/` + `debs/` 생성. **.deb은 반드시 타깃 아키텍처(aarch64 Bookworm)에서만 받을 수 있음** (`vendor.sh:7-10,55`) — x86 빌드머신 불가.
|
||||
- [ ] 생성된 번들을 나머지 Pi의 모듈 폴더에 복사 → `install.sh`가 자동으로 오프라인 모드 진입(불완전 번들은 하드 에러, 온라인 폴백 없음).
|
||||
- [ ] 갭: 번들 무결성 해시 검증 절차 없음 — sha256sum 목록을 직접 만들어 전달 권장.
|
||||
|
||||
---
|
||||
|
||||
## 4. Pi-A: 모듈 1 (누수 + Pi Camera, sensor_id=1)
|
||||
|
||||
소스: `raspberry_pi4/` — 근거는 `raspberry_pi4/README.md`, `install.sh`, `config.py`, `systemd/leak-sensor.service`.
|
||||
|
||||
### 4.1 배선 (README.md §2.1)
|
||||
- [ ] e-con 5C-08 누수센서 (~1.5MΩ 고임피던스): 센서 입력 → **GPIO17(BCM) = 물리핀 11**, 3.3V → 핀 1, GND → 핀 6
|
||||
- [ ] **외부 4.7MΩ 풀다운 필수**, 내부 풀 OFF 유지 (`config.py` USE_INTERNAL_PULL=False → PUD_OFF; 내부 50kΩ 풀다운이 1.5MΩ 센서 신호를 죽임 — README.md:49-53)
|
||||
- [ ] 로직: ACTIVE_LOW=False — 건조=LOW, 누수=HIGH; 디바운스 500ms (`config.py:20-24`)
|
||||
- [ ] Pi Camera: Pi 4의 **15핀 1.0mm CSI 커넥터** (Zero 2 W용 22핀 리본 비호환 — 15핀 리본 또는 어댑터 필요). 전원 USB-C 5V/3A(카메라 사용 시 여유 권장).
|
||||
|
||||
### 4.2 설치
|
||||
- [ ] 폴더를 `/home/pi/raspberry_pi4`에 배치 (유닛 파일이 경로 하드코딩 — `systemd/leak-sensor.service:12-13,26-27`; 다르면 유닛 수정)
|
||||
- [ ] `cp config_local.example.py config_local.py` 후 5개 값 기입:
|
||||
- `API_URL` = `https://<서버>/<WEB_DIR>/api/sensor_data.php`
|
||||
- `PHOTO_UPLOAD_URL` = `.../api/photo_upload.php`
|
||||
- `API_KEY` = 서버 config.local.php와 byte-단위 동일
|
||||
- `DEVICE_ID` = 예: `rpi4-leak-01`, `DEVICE_LOCATION` = 설치 장소
|
||||
- 누락 시 fail-closed로 기동 거부 (`config.py:69-83`); 절대 커밋 금지 (`.gitignore`)
|
||||
- [ ] `chmod +x install.sh && ./install.sh` — 온라인: apt로 `python3-rpi-lgpio python3-requests python3-venv rpicam-apps`(또는 libcamera-apps) 설치; 오프라인 번들 있으면 자동 오프라인. venv(--system-site-packages) + `requests==2.32.3`, 로그파일 `/var/log/leak_sensor.log` (640, pi 소유) 생성.
|
||||
- [ ] **주의(모순 문서)**: `raspberry_pi4/README.md` §3(57-68행)의 "php/ 서버는 누수 이벤트 거부, 레거시 서버로 향하라"는 안내는 **STALE** — `config_local.example.py:8-16`과 머지된 PR #1 기준 **통합 서버가 정답**. README §3을 따르지 말 것.
|
||||
|
||||
### 4.3 systemd
|
||||
- [ ] `sudo cp systemd/leak-sensor.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable leak-sensor && sudo systemctl start leak-sensor`
|
||||
- [ ] 유닛 특성: User=pi, **SupplementaryGroups=video**(카메라 접근 필수), Restart=always/10s, PrivateTmp=true, network-online.target 이후 기동
|
||||
|
||||
### 4.4 End-to-end 검증
|
||||
- [ ] `source venv/bin/activate && python3 test_connection.py` — 3단계: test.php POST / 무서명 POST(거부 확인) / HMAC 서명 POST(200 확인)
|
||||
- [ ] 수동 실행 `python3 leak_sensor.py` → startup 이벤트가 서버 sensor_log에 기록되는지 대시보드 확인
|
||||
- [ ] **카메라 사전 검증(갭 — 문서화된 절차 없음)**: 설치 절차에 카메라 감지 확인이 없어 첫 누수 때에야 실패가 드러남. `rpicam-still`로 수동 테스트샷을 찍어볼 것을 권장.
|
||||
- [ ] 누수 모의 시험: 센서 단락 → 대시보드 누수 카드(sensor_id=1 전용, `php/dashboard.php:256-264`) 표시 + `[누수감지]` SMS + leak_incident 행 + `photo.php?id=N`으로 사진 열람(관리자 세션 필요)
|
||||
- [ ] `systemctl status leak-sensor`, `journalctl -u leak-sensor -f` 정상 확인
|
||||
|
||||
---
|
||||
|
||||
## 5. Pi-B: 모듈 2 (SHT30 온습도, sensor_id=2)
|
||||
|
||||
소스: `raspberry_pi4_sht30/` — 근거는 해당 README.md, install.sh, config.py, systemd/sht30-sensor.service.
|
||||
|
||||
### 5.1 배선 (README.md §2.1)
|
||||
- [ ] Sensirion SHT30 (한진데이타 P4422-3), I2C 버스 1: VCC → **3.3V 핀 1 (5V 금지)**, GND → 핀 6 등, SDA → GPIO2/핀 3, SCL → GPIO3/핀 5
|
||||
- [ ] **ADDR → GND = 주소 0x44** (VDD면 0x45 — `config.py` I2C_ADDR와 일치시킬 것)
|
||||
- [ ] SDA/SCL에 3.3V 풀업 4.7kΩ ×2 (모듈 온보드 풀업 있으면 생략)
|
||||
|
||||
### 5.2 설치
|
||||
- [ ] 폴더를 `/home/pi/raspberry_pi4_sht30`에 배치 (유닛 경로 하드코딩)
|
||||
- [ ] `cp config_local.example.py config_local.py` → `API_URL`(Pi-A와 동일 엔드포인트) / `API_KEY`(동일 키) / `DEVICE_ID`(예: rpi4-sht30-01) / `DEVICE_LOCATION` 기입 (fail-closed)
|
||||
- [ ] `./install.sh` — [1/4] apt: `python3-requests python3-venv i2c-tools`; **[2/4] I2C 자동 활성화**: `/boot/firmware/config.txt`에 dtparam=i2c_arm=on, i2c-dev 모듈 로드+영속화, `usermod -aG i2c pi`; [3/4] venv + `requests==2.32.3, smbus2==0.5.0`; [4/4] `/var/log/sht30_sensor.log`
|
||||
- [ ] install.sh가 리부트 안내를 출력하면 `sudo reboot` (I2C 신규 활성화 시 — README §4.2). **주의(갭)**: `usermod -aG i2c`도 재로그인/리부트 후 반영 — 리부트 없이 곧바로 test_sensor.py를 돌리면 /dev/i2c-1 권한 오류 가능.
|
||||
|
||||
### 5.3 systemd
|
||||
- [ ] `sudo cp systemd/sht30-sensor.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable sht30-sensor && sudo systemctl start sht30-sensor`
|
||||
- [ ] 유닛 특성: User=pi, **SupplementaryGroups=i2c**, Restart=always/10s, PrivateTmp=true. install.sh는 유닛을 설치하지 않으므로 이 수동 단계 누락 금지(갭).
|
||||
|
||||
### 5.4 End-to-end 검증
|
||||
- [ ] `i2cdetect -y 1` → **0x44** 표시 확인 (README §5)
|
||||
- [ ] `python3 test_sensor.py` (CRC/포맷 자가검사 + 1회 측정) — **주의: 하드웨어 없이도 시뮬레이션으로 통과하므로**, i2cdetect 확인을 반드시 선행. 시뮬레이션 폴백이 배선 오류를 가릴 수 있음(갭): 서비스 로그에 시뮬레이션 WARN이 없는지 `journalctl`로 확인할 것.
|
||||
- [ ] `python3 test_connection.py` — 무서명 400/403, 서명 200; 403이면 API_KEY 불일치, 400이면 NTP/타임스탬프 의심
|
||||
- [ ] 수동 실행 후 startup 이벤트 → 이후 300초 주기 보고. 대시보드에서 sensor_id=2 카드 + 차트(기본 sensor 2, `php/dashboard.php:125-129`) + leak_sensor_metric 5분 축적 확인
|
||||
- [ ] 임계값 시험: 고온/고습 유발 → 태그된 SMS + 30분 쿨다운 + 회복 SMS 확인
|
||||
|
||||
---
|
||||
|
||||
## 6. Pi-C: sensor_id=3 신규 지점 (extension-pattern 기준)
|
||||
|
||||
근거: `docs/solutions/best-practices/sensor-telemetry-extension-pattern-2026-05-21.md` (단, 이 문서 자체가 legacy 선언 — 현행 코드 기준으로 재검증된 아래 내용을 따를 것).
|
||||
|
||||
### 6.1 그냥 되는 것 (서버 무변경)
|
||||
- **등록 절차 없음**: sensor_id는 HMAC 서명된 본문으로 도착, 1..255 범위검사 후 `leak_sensor_status`에 UPSERT로 자동 등록 (`php/api/sensor_data.php:199-205, 316-340`). SQL 마이그레이션 불필요.
|
||||
- 온습도 보고 시 metric 저장, **임계값 SMS/회복 SMS(센서별 쿨다운)**, 오프라인/온라인 SMS, 에스컬레이션, stuck 감지, 월간 리포트 GROUP BY — 전부 sensor_id 단위로 자동 동작 (`php/cron_heartbeat.php:22-83`, `php/sms_send.php:426-509`).
|
||||
- 대시보드 **센서 상태 카드 목록**에는 sensor 3이 자동 표시 (`php/dashboard.php:87, 635-690`).
|
||||
- 누수 이벤트도 서버 처리 자체는 됨: `[누수감지]` SMS + leak_incident 생성 + 사고 테이블 표시 (`php/sms_send.php:546-608`, `php/api/sensor_data.php:371-411`, incident 쿼리는 무필터).
|
||||
|
||||
### 6.2 코드/스키마 작업이 필요한 것
|
||||
| 항목 | 내용 | 근거 |
|
||||
|---|---|---|
|
||||
| 차트 | sensor_id=2 하드코딩, 선택 UI 없음 — sensor 3 이력은 저장되지만 차트로 못 봄 | `php/dashboard.php:126-129` |
|
||||
| 최근 온습도 카드 | 차트 센서(=2)의 최신값만 표시 | `php/dashboard.php:218-220, 596-601` |
|
||||
| 30일 평균 | 전 센서 혼합 집계(GROUP BY 없음) — 지점이 다른 sensor 3이 들어오면 평균이 무의미해짐 | `php/dashboard.php:210-216` |
|
||||
| 누수 상태 카드 | sensor_id===1 하드코딩 — Pi-C가 누수형이면 실시간 카드 미표시 | `php/dashboard.php:256-264, 693` |
|
||||
| 임계값/쿨다운 | METRIC_* 전역 단일값 — 지점별 임계 필요 시 코드/스키마 변경 | `php/config.php:134-153` |
|
||||
| 하트비트 타임아웃 | 전역 1200초 — **누수형 Pi-C(보고 주기 3600초)는 매 사이클 오프라인 오탐** → 전역 상향 시 5분 센서의 감지 지연 | `php/config.php:197`, `raspberry_pi4/config.py` REPORT_INTERVAL=3600 |
|
||||
| SENSOR_ID 위치 (SHT30형) | git 추적되는 `config.py:16-17`을 직접 수정해야 함 — config_local.py에 넣어도 무시됨(import 목록 고정, `config.py:70-76`) | drift 위험 |
|
||||
|
||||
### 6.3 센서 종류별 작업량 비교
|
||||
|
||||
| | **SHT30형 (권장 최소)** | **누수형** | **둘 다 (한 Pi)** |
|
||||
|---|---|---|---|
|
||||
| Pi측 | `raspberry_pi4_sht30/` 복사, `config.py` SENSOR_ID=3/SENSOR_NAME 수정(추적 파일!), config_local.py 작성(DEVICE_ID 예: rpi4-sht30-02) | `raspberry_pi4/` 복사, `config.py` SENSORS에 `{"id":3,"gpio":22,...}` 추가(주석 예시 기존재, `config.py:17`), config_local.py + PHOTO_UPLOAD_URL | 두 모듈 폴더 모두 설치 — 핀 충돌 없음(PR_BODY.md §7). 단 두 모듈이 각각 sensor_id를 가지므로 id 3·4 두 개를 쓸지 결정 필요(갭 — 문서화된 패턴 없음) |
|
||||
| 서버측 필수 | **없음** | **없음** (수신·SMS·incident 동작) | 없음 |
|
||||
| 서버측 권장 수정 | 차트/최근값 카드가 sensor 2 고정 → 보고 싶으면 dashboard.php 수정; 30일 평균 혼합 문제 | + 누수 카드 sensor 1 고정 수정, **HEARTBEAT_TIMEOUT_SEC 충돌 해결(가장 큰 이슈)** | 양쪽 전부 |
|
||||
| 결론 | **작업량 최소** — 대시보드 차트만 감수하면 무수정 운영 가능 | 서버 무수정 시 오프라인 오탐 + 대시보드 비가시 감수 필요 | 최대 작업량 |
|
||||
|
||||
공통 주의: sensor_status는 sensor_id 단독 키 — Pi-C가 실수로 id 1/2를 재사용하면 기존 지점 상태를 **조용히 덮어씀** (`php/api/sensor_data.php:316-340`). DEVICE_ID는 표시용일 뿐 인증 정체성이 아님.
|
||||
|
||||
---
|
||||
|
||||
## 7. 배포 시점 보안 체크리스트 (repo 근거 항목만)
|
||||
|
||||
**서버 (근거: `docs/OPERATIONS_SECURITY_CHECKLIST.md`, `docs/SOURCE_SECURITY_EVIDENCE.md`, `php/ops_checks.php`)**
|
||||
- [ ] API_KEY 기본값 교체 확인 (기본값이면 ingest 503 — `php/api/sensor_data.php:73-77`)
|
||||
- [ ] `ALLOW_LEGACY_BODY_SIGNATURE=false` (`php/ops_checks.php:101`)
|
||||
- [ ] MFA_SETUP_TOKEN 비움 확인 (`php/ops_checks.php:100`)
|
||||
- [ ] HTTPS를 cafe24 패널에서 강제 + 인증서/리다이렉트 화면 증적 캡처 (`docs/SOURCE_SECURITY_EVIDENCE.md` §2.1 — .htaccess는 의도적으로 리다이렉트 안 함, **조용히 누락되기 쉬운 수동 단계**)
|
||||
- [ ] 위조 서명 403 / 정상 서명 200 테스트 증적 (`docs/SOURCE_SECURITY_EVIDENCE.md` §5)
|
||||
- [ ] config.local.php 등 민감 파일 직접 접근 차단 확인 (`php/.htaccess` FilesMatch — `docs/SOURCE_SECURITY_EVIDENCE.md` §2.8)
|
||||
- [ ] `php/var/admin_audit.log`에 로그인/로그아웃/MFA 이벤트 기록 확인
|
||||
- [ ] `curl -I https://<도메인>/<WEB_DIR>/login.php` 응답 헤더 점검 (checklist §3)
|
||||
- [ ] SMS 잔액/발신번호/수신자 최신성 확인; 백업 + 복원 테스트(`php scripts/backup_evidence.php`) — checklist §9
|
||||
|
||||
**Pi 3대 공통**
|
||||
- [ ] `config_local.py` 커밋 금지(.gitignore 확인); 노출 시 서버·전 노드 동시 API_KEY 교체 (양쪽 README §7)
|
||||
- [ ] 단일 공유 API_KEY 구조 인지: Pi 1대 탈취 = 전 지점 ingest 자격 노출, 기기별 폐기 불가 (extension-pattern 갭) — 지점 추가마다 노출면 확대
|
||||
- [ ] 기기 인바운드 차단 증적: `ss -lntup`으로 불필요 리스닝 데몬 없음 확인 (`docs/SOURCE_SECURITY_EVIDENCE.md` §4 — 단 달성 방법은 미문서화, §8 갭)
|
||||
- [ ] `systemctl status leak-sensor`(및 sht30-sensor) 화면 증적 (§5 최소 증적 세트)
|
||||
- [ ] NTP 동기 확인 (±600초 초과 시 전 POST 거부)
|
||||
|
||||
**repo에 없어 자체 수립 필요 (문서화 갭 — §8):** Pi 계정/SSH 하드닝, 호스트 방화벽(ufw 등), 시크릿 파일 권한(chmod 600), OS 패치 정책.
|
||||
|
||||
---
|
||||
|
||||
## 8. 문서화 갭 / 결정 필요 사항 (통합·우선순위)
|
||||
|
||||
### P0 — 배포를 잘못되게 만들 수 있는 것
|
||||
1. **STALE 문서 모순 — 모듈1 대상 서버**: `raspberry_pi4/README.md` §3·§7은 "php/ 서버가 누수 거부 / 레거시 sha256 서명"이라 함. 현행은 통합 서버 + raw-body HMAC (`config_local.example.py:8-16`, SYSTEM_OVERVIEW.md, PR #1). README만 보고 배포하면 오설정. → README §3/§7 갱신 필요.
|
||||
2. **`docs/INSTALL_PI_SERVER.md` 업로드 목록 STALE**: photo_upload.php/photo.php/var 가드 누락 (33-56행) — 그대로 따르면 누수 사진 파이프라인 결손. 또한 이 문서 자체가 STM32(v2606) 중심이며 RPi4 신규 설치 통합 가이드는 부재 (root README.md는 0바이트).
|
||||
3. **cron URL 오류**: INSTALL_PI_SERVER.md:165에 `?key=<CRON_SECRET>` 누락 → 403. CRON_SECRET 표시 UI 없음, 수동 계산 필요.
|
||||
4. **신품 Pi 프로비저닝 절차 전무**: SD 이미징, `pi` 계정 생성(Bookworm 기본 미존재), SSH 활성화, 호스트네임, 네트워크 설정 — 전부 미문서화. **결정 필요**: 이미지 버전, 계정/비밀번호 정책, 고정IP vs DHCP, 호스트네임 규약.
|
||||
5. **Pi-C 센서 종류 결정**: §6.3 비교 기준으로 결정. 누수형 선택 시 HEARTBEAT_TIMEOUT_SEC 충돌(전역 1200초 vs 보고 3600초)을 반드시 먼저 해결.
|
||||
|
||||
### P1 — 운영 중 사고로 이어질 수 있는 것
|
||||
6. **Pi OS 하드닝 부재**: 계정/SSH/방화벽/시크릿 파일 권한(600)/패치 정책이 4개 보안 문서 어디에도 없음 — `docs/SOURCE_SECURITY_EVIDENCE.md`도 "코드로 증명 불가, 기관 자료 필요"로만 표기. → 자체 표준 수립 필요.
|
||||
7. **단일 공유 API_KEY / 센서 레지스트리 부재**: 키 보유자는 임의 sensor_id 생성·기존 id 덮어쓰기 가능; 기기별 폐기 불가. 지점이 늘수록(Pi-C) 위험 증가 — device_id↔sensor_id 서버측 바인딩 검토 권장.
|
||||
8. **시뮬레이션 폴백이 오류 은폐**: SHT30 모듈은 I2C 실패 시 그럴듯한 더미값을 운영 서버로 전송(WARN 로그만) — FORCE_REAL 옵션 없음. 배포 검증 시 journalctl 확인을 절차화할 것.
|
||||
9. **NTP 의존 미절차화**: 서버가 ±600초 강제하는데 시각동기 확인 단계가 설치 절차에 없음 (현장 인터넷 가용이라 기본 NTP로 충족 예상되나 확인 단계 추가 권장).
|
||||
10. **카메라 검증 단계 부재** (Pi-A): 첫 누수 때에야 실패 발견 — 설치 시 테스트샷 절차 추가 필요.
|
||||
|
||||
### P2 — 정리/개선
|
||||
11. **설정 규약 이원화**: `docs/SHT30_SENSOR2_SETUP.md` §3과 `php/ops_checks.php:97`은 `/etc/sht30-sensor.env`를 참조하나 실제 모듈은 `config_local.py`만 읽음 — 문서 갱신 필요.
|
||||
12. **SENSOR_ID가 추적 파일(config.py)에 위치** (SHT30형): config_local 오버라이드 허용하는 소규모 수정 권장 (Pi-C에서 실익).
|
||||
13. **systemd 유닛 경로/User=pi 하드코딩** 2개 파일 — 비표준 배치 시 수동 수정 2곳.
|
||||
14. **센서 폐기 UI 없음**: leak_sensor_status 행 수동 SQL 삭제 전까지 영구 오프라인 표시.
|
||||
15. **임계값 문서 드리프트**: SOURCE_SECURITY_EVIDENCE.md §2.4(30℃/70%)와 현행 권장값(27℃/60%, commit c8b448c) 불일치.
|
||||
16. **retention_cleanup cron 미문서화**; **오프라인 번들 해시 검증 부재**(--require-hashes 없음); **CORS pores.kr 잔재**; **로그 권한 실패 시 조용한 콘솔 폴백**(양 모듈); **var/ 권한 비트 미문서화**(cafe24에서 setup_wizard '상태 파일 폴더' 체크로 확인).
|
||||
17. **firmware/(STM32) 트랙은 이번 배포와 무관** — RPi4 배포에 불필요 (`docs/stm32f407_migration_plan.md`는 별도 마이그레이션 트랙, 링크/실행 미검증 상태).
|
||||
|
||||
---
|
||||
|
||||
## 9. [확정] Pi-C = 누수 + 카메라 (sensor_id 3) — 결정 반영 부록
|
||||
|
||||
> ✅ **v2607 구현 완료:** §9.2 필수 코드 작업 3건 전부 반영됨 — REPORT_INTERVAL=600(하트비트 오탐 해소), 대시보드 LEAK_SENSOR_IDS 루프(누수 카드 다중 지점), config_local.py SENSORS 오버라이드(+형식 검증 fail-closed). 테이블은 전부 leak_ 접두사로 리네임됨. 배포는 tools/make_deploy_bundles.py 번들 사용.
|
||||
|
||||
사용자 결정(2026-07-02): Pi-C는 **누수 + 카메라** 구성.
|
||||
|
||||
### 9.1 누수형 선택의 이점 (SHT30형 대비)
|
||||
- 대시보드 차트 sensor 2 하드코딩 문제(§6.2) **해당 없음** — Pi-C는 metric을 안 보냄.
|
||||
- 30일 평균 혼합 오염(§6.2) **해당 없음** — 동일 이유.
|
||||
- SENSOR_ID 추적파일(config.py) 문제는 누수 모듈도 동일하나, `raspberry_pi4/config.py:13` SENSORS 리스트에 id=3 항목 추가 형태라 구조는 명확.
|
||||
|
||||
### 9.2 필수 코드 작업 (검증 완료 — file:line 확인됨)
|
||||
1. **하트비트 오탐 (P0, Pi-A에도 이미 존재하는 버그)**
|
||||
- `php/cron_heartbeat.php:25-30` — leak_sensor_status 전체를 전역 `HEARTBEAT_TIMEOUT_SEC`(기본 1200s, `php/config.php:197`)로 검사. 필터 없음.
|
||||
- `raspberry_pi4/config.py:35` — 누수 모듈 정상 주기 보고 3600s.
|
||||
- 결과: 누수 노드(id 1, id 3)는 **매 사이클 오프라인 SMS 오탐 → 복귀 SMS 반복**.
|
||||
- 권장 수정(택1):
|
||||
- (a) **최소 변경**: 누수 모듈 `REPORT_INTERVAL` 3600 → 600 (Pi-A·Pi-C 공통). 주기 POST는 소형 JSON이라 부하 무시 가능. 서버 무수정.
|
||||
- (b) **구조적**: sensor_status에 per-sensor timeout 컬럼 추가 + cron_heartbeat/dashboard 반영 (스키마+코드 변경).
|
||||
2. **대시보드 누수 카드 (Pi-C 가시성)**
|
||||
- `php/dashboard.php:260` — `(int)$s['sensor_id'] === 1` 하드코딩. Pi-C 누수 상태 카드 미표시.
|
||||
- 수정: is_leak 값이 있는 모든 센서에 대해 카드 렌더 (사고 테이블은 이미 무필터 — 수정 불요).
|
||||
3. **Pi-C 설정값**
|
||||
- `raspberry_pi4/config.py` SENSORS에 `{"id": 3, ...}` (gpio 17 유지 — 별도 Pi이므로 핀 충돌 없음), DEVICE_ID 예: `rpi4-leak-02`, DEVICE_LOCATION = 신규 지점명.
|
||||
|
||||
### 9.3 Pi-C 추가 하드웨어 (구매 확인 필요)
|
||||
- e-con 5C-08 누수센서 1개 + 4.7MΩ 풀다운 저항
|
||||
- Pi Camera (15핀 CSI 리본) 1개
|
||||
- SD카드·USB-C 5V/3A 전원 (3대 공통)
|
||||
|
||||
### 9.4 보안 유의 (extension-pattern 갭)
|
||||
- 단일 공유 API_KEY로 지점 3개 — 노출면 확대. sensor_status는 sensor_id 단독 키이므로 Pi-C가 id 1/2 오설정 시 기존 지점 상태를 조용히 덮어씀 (`php/api/sensor_data.php:316-340`). 배포 전 config 교차 점검 필수.
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
# 프로젝트 진행 INDEX
|
||||
|
||||
작업 단락(커밋·빌드·배포·결정) 완료 기록. 완료 결과만.
|
||||
|
||||
## 2026-07-20
|
||||
|
||||
- **배선 핀 정정·리포트 그림 재생성·프로젝트 소개서** (feat/pi-c-sensor3-leak-node)
|
||||
- `f29dc7c` docs(wiring): 누수 실제 사용핀 **1(3V3)·9(GND)·11(GPIO17)** 로 정정(GND Pin 6→9,
|
||||
`WIRING.html` §2/§3/§4/범례). 리포트 임베드 base64 SVG 재생성 — [그림 3-1a] combined→
|
||||
**센서 1(누수 단독) 40핀 연결 현황(1/9/11만)**, [그림 3-1b] 3선→**2선 회로**,
|
||||
[그림 3-3b] SHT30 외부 풀업→**모듈 4선 직결**. 브라우저 렌더 겹침 0.
|
||||
- `01770e2` docs: **프로젝트 소개서**(`docs/시스템_소개서-v2606-20260719.html`) — 시스템 흐름
|
||||
(노드→서버→SMS·대시보드), SMS 발송 시점(14종 조건·수신 계층 현장/전역/야간2차·쿨다운),
|
||||
대시보드 구성(히어로·KPI6·탭5), 운영 임계 요약.
|
||||
- 교훈: 리포트 그림은 WIRING.html SVG의 **base64 별도 사본** — 리포트 텍스트만 고치면
|
||||
그림은 안 바뀐다. 소스 수정 후 그림 재생성·재임베드 필요.
|
||||
|
||||
## 2026-07-19
|
||||
|
||||
- **미커밋 노드 통합 작업 정리 + 커밋** (feat/pi-c-sensor3-leak-node)
|
||||
- `86960a8` test(threshold): 결로 판정 단언 운영설정 독립 가드 (테스트 스위트 RED→green)
|
||||
- `ec78be0` feat(pms7003): 누수 노드 미세먼지(PM2.5/PM10) 통합 — Pi 노드+서버 수집+SQL+마이그레이션
|
||||
- `42a6ee4` feat(display): Zero 2 W ST7789 상태 디스플레이 노드 (렌더 11종 선검증)
|
||||
- `6f8bf94` feat(dashboard): v2 재테마 + status_summary API + 미세먼지 카드
|
||||
- `90b93f8` chore(deploy+docs): 번들 생성기·하드닝·설계문서
|
||||
- `f5313a6` chore(gitignore): dist/ 배포판 zip 차단
|
||||
- 테스트: 9 run / 0 skip / **PASS**. secret 유출 없음(config_local.py·config.local.php gitignore 확인).
|
||||
|
||||
- **배포판 빌드 (서버 번들)**
|
||||
- 산출물: `dist/leak-sms-server-v2606-20260719.zip` (VERSION=v2606, 40파일, 166KB)
|
||||
- API_KEY 보존: prefix `bbf737d8…` 전/후 동일 — 배포노드 orphan 없음
|
||||
- 서버 번들 PHP 전수 `php -l` 통과. `status_summary.php`·`dash-v2.css` 반영. schema.sql 멱등+pms7003 컬럼 포함.
|
||||
- 배포 방식: **수동(체크리스트)** · DB **초기화 후 새 설치** 결정
|
||||
- 체크리스트: `dist/DEPLOY-CHECKLIST-server-v2606.md`
|
||||
|
||||
- **배포 setup.sh 위치-독립화 (flatten 제거)** — `3fda1f2`
|
||||
- systemd 유닛 `/home/pi/<sub>` 경로를 설치 시 `$BASE=$(pwd)` 로 sed 치환 → 번들 폴더를 이름
|
||||
그대로 /home/pi 아래 어디든(USB 통째 복사) 두고 실행 가능. flatten 단계 삭제.
|
||||
- 부수 버그 수정: zero2w 유닛 `/home/pi/display` 하드코딩이 실제 설치경로와 불일치(서비스
|
||||
기동 실패)하던 것 해소.
|
||||
- 검증: BASE 시뮬레이션으로 유닛 경로 정합 확인, 4개 번들 setup.sh 전부 BASE 주입,
|
||||
88개 스크립트 CRLF/BOM 0(전부 LF).
|
||||
- 산출물: `dist/leak-sms-pi-nodes-v2606-20260719.zip` (4개 Pi 번들, 127파일, 406KB)
|
||||
|
||||
- 잔여 작업(운영):
|
||||
- [ ] cafe24 서버 업로드 + DB 초기화/schema.sql (운영자 수동 — 체크리스트 참조)
|
||||
- [ ] 노드 4종 배포 (USB: 폴더째 /home/pi 복사 → `cd /home/pi/<folder> && bash setup.sh`)
|
||||
- [ ] `feat/pi-c-sensor3-leak-node` push / PR
|
||||
- [ ] `1.png`·`2.png` (repo 루트 스트레이 스크린샷) 정리 결정
|
||||
|
||||
- **센서 형태·내용 보고서 (납품 HTML)**
|
||||
- 산출물: `docs/센서_형태_내용_보고서-v2606-20260719.html` (자기완결 단일 파일, 125KB)
|
||||
- 내용: 누수(e-con 5C-08/GPIO17)·SHT30(I2C)·PMS7003(UART)·카메라(CSI)·디스플레이(ST7789V/SPI)
|
||||
형태·인터페이스·핀·서버 임계값 + 노드 구성(id1/2/3). 배선도 6종(핀맵+회로도) base64 SVG 임베드.
|
||||
- 소스: WIRING.html 인라인 SVG 6종 추출 → xmlns 주입·`→` 엔티티 정정·클래스 CSS를
|
||||
presentation 속성으로 인라인화(주석 스트립 포함)·viewBox 아웃워드 패딩.
|
||||
- 검증: 브라우저 실렌더 6종 전부 정상(레일 회색·사용핀 색상·라벨 판독), XML well-formed,
|
||||
수평 오버플로 없음. 무관 대시보드 PNG(1·2.png) 미포함 확인.
|
||||
|
||||
- **화재 전환·SMS 단문화·배선도 정정** (feat/pi-c-sensor3-leak-node)
|
||||
- `2046b2f` docs(wiring): 누수 저항식 2선 프로브(전극 A→3V3, 전극 B→GPIO17; 10MΩ 풀다운=보드 부품)·
|
||||
SHT30 모듈 4선 직결(외부 풀업 제거)로 배선도 §4 + 납품 리포트 정정. 브라우저 렌더 텍스트/선 겹침 0.
|
||||
- `f7fbeac` feat: PM 급상승 → **화재의심** 전환. 서버실/항온항습 화재 프록시(여과·양압 평상 PM≈0).
|
||||
- 임계 하향 75/150 → **35/75**(연기 조기감지), SMS 태그 `[미세먼지경보]`→`[화재의심]`.
|
||||
- SMS 단문화: 전 본문 EUC-KR ≤90B(관리주체 제거·날짜 `m-d H:i`·태그+위치 1줄). 실측 45~70B → 단문 절단 해소.
|
||||
- 센서고착 검출 기본 비활성(`STUCK_DETECT_ENABLED=false`): 항온항습 문서고 평탄값 오탐 차단.
|
||||
- 대시보드 라벨 통일(경보=화재의심, PM 측정=화재감시). `metric_status`(high_pm25/10) 코드·값·판정 불변 — 표시단만.
|
||||
- 결정: PMS7003 = **화재 보조 조기징후**(광학 먼지센서, 인증 화재감지기 아님 — 연기/열 감지기 병행 권장).
|
||||
- 검증: 회귀 스위트 **PASS(9 run/0 fail)**, 편집 PHP `php -l` 통과.
|
||||
- 잔여(운영): `php/` 트리 cafe24 업로드 · 배선 HTML Pi 번들 재생성 · 배포 후 대시보드 브라우저 확인.
|
||||
|
|
@ -281,12 +281,13 @@ return [
|
|||
<div class="step">
|
||||
<h3>스키마 실행</h3>
|
||||
<p><b>신규 설치</b>는 <code>sql/schema_sht30.sql</code> 전체를 복사해 <span class="kbd">SQL</span> 탭에 붙여넣고 <span class="kbd">실행</span>.
|
||||
생성 테이블: <code>leak_sensor_log</code>, <code>leak_sensor_status</code>, <code>leak_sensor_metric</code>, <code>leak_sms_log</code>
|
||||
(+ 재전송 방어용 <code>leak_ingest_nonce</code>).</p>
|
||||
생성 테이블: <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> 이미 적용한 마이그레이션을 다시 실행하면 오류가 날 수 있습니다.
|
||||
|
|
@ -300,7 +301,7 @@ return [
|
|||
<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 스키마 실행 결과 (leak_sensor_log/leak_sensor_status/leak_sensor_metric/leak_sms_log/leak_ingest_nonce)</figcaption>
|
||||
<figcaption>그림 3. DB 스키마 실행 결과 (sensor_log/sensor_status/sensor_metric/sms_log/ingest_nonce)</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
|
|
@ -574,7 +575,7 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php</code></pr
|
|||
<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>leak_sensor_metric</code> 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록</li>
|
||||
<li><code>sensor_metric</code> 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록</li>
|
||||
<li>임계 초과 테스트 시 <code>metric_status</code> 경보 + SMS 수신 / 월간 보고서에 내역 조회</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -598,8 +599,8 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php</code></pr
|
|||
<tr><td>API 403</td><td>서버 <code>API_KEY</code> ↔ 펌웨어 <code>APP_API_KEY</code> 일치, raw-body 서명</td></tr>
|
||||
<tr><td>API 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>leak_sms_log</code>. 대시보드 ‘수집 거부/발송 실패’ 배너 확인</td></tr>
|
||||
<tr><td>온습도 미표시</td><td>I²C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, <code>leak_sensor_metric</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>
|
||||
|
|
|
|||
|
|
@ -281,12 +281,13 @@ return [
|
|||
<div class="step">
|
||||
<h3>스키마 실행</h3>
|
||||
<p><b>신규 설치</b>는 <code>sql/schema_sht30.sql</code> 전체를 복사해 <span class="kbd">SQL</span> 탭에 붙여넣고 <span class="kbd">실행</span>.
|
||||
생성 테이블: <code>leak_sensor_log</code>, <code>leak_sensor_status</code>, <code>leak_sensor_metric</code>, <code>leak_sms_log</code>
|
||||
(+ 재전송 방어용 <code>leak_ingest_nonce</code>).</p>
|
||||
생성 테이블: <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> 이미 적용한 마이그레이션을 다시 실행하면 오류가 날 수 있습니다.
|
||||
|
|
@ -300,7 +301,7 @@ return [
|
|||
<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 스키마 실행 결과 (leak_sensor_log/leak_sensor_status/leak_sensor_metric/leak_sms_log/leak_ingest_nonce)</figcaption>
|
||||
<figcaption>그림 3. DB 스키마 실행 결과 (sensor_log/sensor_status/sensor_metric/sms_log/ingest_nonce)</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
|
|
@ -574,7 +575,7 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php</code></pr
|
|||
<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>leak_sensor_metric</code> 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록</li>
|
||||
<li><code>sensor_metric</code> 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록</li>
|
||||
<li>임계 초과 테스트 시 <code>metric_status</code> 경보 + SMS 수신 / 월간 보고서에 내역 조회</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -598,8 +599,8 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php</code></pr
|
|||
<tr><td>API 403</td><td>서버 <code>API_KEY</code> ↔ 펌웨어 <code>APP_API_KEY</code> 일치, raw-body 서명</td></tr>
|
||||
<tr><td>API 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>leak_sms_log</code>. 대시보드 ‘수집 거부/발송 실패’ 배너 확인</td></tr>
|
||||
<tr><td>온습도 미표시</td><td>I²C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, <code>leak_sensor_metric</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>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
|
||||
이 문서는 서버실 온습도(SHT30) 모니터링 시스템 v2606을 실제 운영 환경에 배포할 때 STM32F407VGT6 펌웨어와 Cafe24 PHP/MySQL 서버에 각각 무엇을 설치하고 설정해야 하는지 정리한 설치용 문서입니다.
|
||||
|
||||
> **참고:** 이 문서는 **STM32 펌웨어 중심**의 설치 문서입니다. Raspberry Pi 4 모듈(누수+카메라 / SHT30) 배포자는 [docs/DEPLOYMENT_PLAN_RPI4x3.md](./DEPLOYMENT_PLAN_RPI4x3.md)와 [SYSTEM_OVERVIEW.md](../SYSTEM_OVERVIEW.md)를 먼저 참고하세요. 서버 설치 절차(§3~§6)는 공통입니다.
|
||||
|
||||
## 1. 설치 대상
|
||||
|
||||
| 구분 | 설치 위치 | 역할 |
|
||||
|
|
@ -30,20 +28,19 @@
|
|||
|
||||
## 3. 서버에 설치할 파일
|
||||
|
||||
Cafe24 웹 루트 아래에 **repo 의 `php/` 트리 전체를 그대로 업로드**합니다. 개별 파일을 선별해 올리면 누수 사진 파이프라인(`api/photo_upload.php`, `photo.php`, `var/photos/`)이 빠지기 쉽습니다.
|
||||
Cafe24 웹 루트 아래에 다음 구조로 PHP 파일을 업로드합니다.
|
||||
|
||||
```text
|
||||
public_html/raspi_leck_detecter/ ← php/ 트리 전체 업로드
|
||||
public_html/raspi_leck_detecter/
|
||||
.htaccess
|
||||
blocked.php
|
||||
config.php
|
||||
config.local.php ← 서버에서 example 복사 후 작성(저장소에 없음)
|
||||
config.local.php
|
||||
config.local.example.php
|
||||
ops_checks.php
|
||||
sms_send.php
|
||||
cron_heartbeat.php
|
||||
dashboard.php
|
||||
photo.php ← 누수 사진 조회(관리자 인증 필요)
|
||||
setup_mfa.php
|
||||
admin_security.php
|
||||
setup_wizard.php
|
||||
|
|
@ -52,17 +49,10 @@ public_html/raspi_leck_detecter/ ← php/ 트리 전체 업로드
|
|||
login.php
|
||||
retention_cleanup.php
|
||||
setup_hash.php
|
||||
test_mobile.php
|
||||
api/
|
||||
sensor_data.php
|
||||
photo_upload.php ← 누수 사진 업로드 API
|
||||
var/
|
||||
.gitkeep
|
||||
.htaccess ← var/ 웹 직접 접근 차단(403)
|
||||
index.html
|
||||
photos/ ← 누수 사진 저장 폴더(.htaccess/index.html 포함,
|
||||
.htaccess 없으면 첫 업로드 시 자동 생성 시도)
|
||||
index.html
|
||||
```
|
||||
|
||||
운영 서버에는 `config.local.php`가 반드시 필요합니다. `config.local.example.php`를 복사해서 만들고 실제 운영 값으로 교체합니다.
|
||||
|
|
@ -123,7 +113,16 @@ https://your-domain.example/raspi_leck_detecter/setup_mfa.php?token=임시토큰
|
|||
SOURCE /path/to/sql/schema_sht30.sql;
|
||||
```
|
||||
|
||||
phpMyAdmin에서는 [schema_sht30.sql](../sql/schema_sht30.sql) 내용을 복사해서 SQL 실행 창에 붙여넣습니다. 생성 테이블은 총 7개 — `leak_sensor_log`, `leak_sensor_status`, `leak_sensor_metric`, `leak_sms_log`, `leak_ingest_nonce`(재전송 방어), `leak_incident`, `leak_photo`(누수 사고/사진) — 이며, 신규 설치는 이 파일 하나로 완결됩니다(누수 컬럼/테이블 포함). 모든 테이블은 `leak_` 접두사를 사용해 같은 DB 의 다른 테이블과 격리됩니다.
|
||||
phpMyAdmin에서는 [schema_sht30.sql](../sql/schema_sht30.sql) 내용을 복사해서 SQL 실행 창에 붙여넣습니다. 생성 테이블은 `sensor_log`, `sensor_status`, `sensor_metric`, `sms_log`입니다.
|
||||
|
||||
기존 누수 설치를 온습도 전용으로 전환하는 경우에는 전환 마이그레이션을 적용합니다.
|
||||
|
||||
| SQL 파일 | 적용 상황 |
|
||||
|---|---|
|
||||
| [schema_sht30.sql](../sql/schema_sht30.sql) | 신규 설치(통합 스키마) |
|
||||
| [migration_drop_leak.sql](../sql/migration_drop_leak.sql) | 기존 누수 설치 → 온습도 전용 전환(레거시 컬럼/테이블 정리, `sensor_metric` 보장) |
|
||||
|
||||
이미 적용된 마이그레이션을 중복 실행하면 DB 오류가 날 수 있으므로, 기존 설치에서는 `setup_wizard.php`의 점검 결과를 먼저 확인합니다.
|
||||
|
||||
## 5. 서버 권한 및 관리자 설정
|
||||
|
||||
|
|
@ -150,7 +149,7 @@ https://your-domain.example/raspi_leck_detecter/setup_wizard.php
|
|||
점검 화면에서 확인할 항목은 다음과 같습니다.
|
||||
|
||||
- DB 연결
|
||||
- 필수 테이블 존재 여부(`leak_sensor_log`, `leak_sensor_status`, `leak_sensor_metric`, `leak_sms_log`)
|
||||
- 필수 테이블 존재 여부(`sensor_log`, `sensor_status`, `sensor_metric`, `sms_log`)
|
||||
- API 키 기본값 교체 여부
|
||||
- 관리자 해시 설정 여부
|
||||
- 관리자 TOTP 설정 여부
|
||||
|
|
@ -163,12 +162,10 @@ https://your-domain.example/raspi_leck_detecter/setup_wizard.php
|
|||
장비 오프라인 감지를 위해 `cron_heartbeat.php`를 주기 실행합니다. Cafe24 cron 또는 외부 스케줄러에서 다음 URL을 1분 간격으로 호출합니다.
|
||||
|
||||
```text
|
||||
https://your-domain.example/raspi_leck_detecter/cron_heartbeat.php?key=<CRON_SECRET>
|
||||
https://your-domain.example/raspi_leck_detecter/cron_heartbeat.php
|
||||
```
|
||||
|
||||
HTTP 호출에는 `?key=<CRON_SECRET>` 파라미터가 **필수**입니다(누락 시 403). `CRON_SECRET`은 `API_KEY`에서 파생되며 다음으로 계산합니다: `php -r "echo hash_hmac('sha256','cron','<API_KEY>');"`
|
||||
|
||||
서버 CLI cron을 사용할 수 있다면 다음처럼 구성할 수 있습니다(CLI 실행에는 key가 필요 없습니다).
|
||||
서버 CLI cron을 사용할 수 있다면 다음처럼 구성할 수 있습니다.
|
||||
|
||||
```cron
|
||||
* * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php
|
||||
|
|
@ -226,7 +223,7 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php
|
|||
- `dashboard.php`에 최신 온도/습도와 센서 상태(`sensor_id=2`)가 표시
|
||||
- `security_evidence.php`에서 운영 점검 결과와 보안통제 매트릭스 조회 가능
|
||||
- STM32 콘솔 로그에 서버 200 응답 기록 확인
|
||||
- `leak_sensor_metric`에 주기 측정값(periodic) 저장
|
||||
- `sensor_metric`에 주기 측정값(periodic) 저장
|
||||
- 임계 초과 테스트 시 `metric_status` 경보 기록 및 SMS 수신
|
||||
- 월간 보고서에서 임계 경보/복귀/오프라인 내역 조회 가능
|
||||
|
||||
|
|
@ -237,8 +234,8 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php
|
|||
| API 403 | 서버 `API_KEY`와 펌웨어 `APP_API_KEY` 일치 여부, raw-body 서명 |
|
||||
| API 500 | Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부 |
|
||||
| 로그인 불가 | `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, 인증 앱 시간 동기화 |
|
||||
| SMS 미발송 | Cafe24 SMS 계정, secure key, 발신번호 등록, 잔액, `leak_sms_log` |
|
||||
| 온습도 미표시 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `leak_sensor_metric` 테이블, 펌웨어 측정 로그 |
|
||||
| SMS 미발송 | Cafe24 SMS 계정, secure key, 발신번호 등록, 잔액, `sms_log` |
|
||||
| 온습도 미표시 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `sensor_metric` 테이블, 펌웨어 측정 로그 |
|
||||
| 임계 경보 미발송 | `config.php`/`config.local.php`의 `METRIC_*`, `METRIC_ALERT_COOLDOWN_SEC`(1800초) |
|
||||
| TLS 실패 | `firmware/certs/server_ca.c` 실제 CA 반영, SNTP 시간 동기(인증서 유효기간 검증) |
|
||||
| 오프라인 알림 과다/지연 | `HEARTBEAT_CHECK_INTERVAL_SEC`, `HEARTBEAT_TIMEOUT_SEC`(기본 1200초), cron 주기 |
|
||||
|
|
@ -255,4 +252,4 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php
|
|||
- SMS 수신자 번호
|
||||
- 관리자 계정과 원문 비밀번호, `ADMIN_TOTP_SECRET`
|
||||
- 서버 업로드 경로
|
||||
- 장비 ID(`rpi4-sht30-01`, 배포=Raspberry Pi 4)와 설치 위치
|
||||
- 장비 ID(`stm32-sht30-01`)와 설치 위치
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
| 주기 | 점검 목적 | 보관 증적 |
|
||||
|---|---|---|
|
||||
| 일일 | 온습도 임계 경보, 장비 오프라인, SMS 실패 여부 확인 | 대시보드 화면 또는 이상 없음 기록 |
|
||||
| 주간 | STM32 보고 수신 상태, 최근 측정값, 측정 공백 여부 확인 | 대시보드 온습도 추이, `leak_sensor_metric` 최근 기록 |
|
||||
| 주간 | STM32 보고 수신 상태, 최근 측정값, 측정 공백 여부 확인 | 대시보드 온습도 추이, `sensor_metric` 최근 기록 |
|
||||
| 월간 | 보안통제 매트릭스 점검, 백업, 수신자 현행화 | 보안 증적 보고서, 월간 보고서, 백업 목록 |
|
||||
| 월간 | 관리자 MFA, 감사로그, 보관기간 정리 확인 | MFA 등록 확인표, `php/var/admin_audit.log`, retention 실행 결과 |
|
||||
| 변경 시 | API 키, 서버 경로, 수신자, 임계값, 네트워크 변경 검증 | 변경 승인 기록, 재점검 결과 |
|
||||
|
|
@ -23,9 +23,9 @@
|
|||
| 설치 점검 | `setup_wizard.php` 접속 | 중요 항목 0건 또는 조치계획 기록 | 설치 점검 캡처 |
|
||||
| 보안 증적 | `security_evidence.php` 접속 | 운영 점검 결과와 보안통제 매트릭스 출력 | Markdown 다운로드 |
|
||||
| 월간 보고 | `monthly_report.php` 조회 | 임계 경보, 복귀, 오프라인, SMS 현황 표시 | PDF 또는 CSV |
|
||||
| 장비 상태 | `leak_sensor_status`(`sensor_id=2`) | 최근 수신 시각이 `HEARTBEAT_TIMEOUT_SEC` 이내 | 대시보드 센서 카드 |
|
||||
| 측정 수집 | `leak_sensor_metric` | 5분 주기 측정값 누적 | 온습도 추이 캡처 |
|
||||
| SMS | 최근 실패 | 반복 실패 없음 | `leak_sms_log` 또는 대시보드 |
|
||||
| 장비 상태 | `sensor_status`(`sensor_id=2`) | 최근 수신 시각이 `HEARTBEAT_TIMEOUT_SEC` 이내 | 대시보드 센서 카드 |
|
||||
| 측정 수집 | `sensor_metric` | 5분 주기 측정값 누적 | 온습도 추이 캡처 |
|
||||
| SMS | 최근 실패 | 반복 실패 없음 | `sms_log` 또는 대시보드 |
|
||||
| 임계 설정 | `METRIC_*` | 기관 기준과 일치 | `config.local.php` 발췌(값만) |
|
||||
| 백업 | DB/설정 백업 | 정해진 주기대로 백업 존재 | 백업 파일 목록 |
|
||||
| 관리자 MFA | `ADMIN_TOTP_SECRET` | 인증 앱 등록 완료 | MFA 등록 확인표 |
|
||||
|
|
@ -65,7 +65,7 @@ USART3(PD8/PD9, 115200) 콘솔 로그로 다음을 확인합니다.
|
|||
|---|---|---|
|
||||
| 임계 초과 감지 | 측정값이 임계 초과 시 `metric_status` 경보가 기록되는가 | 감지 시각/항목 |
|
||||
| SMS 발송 | 수신자가 임계 경보 SMS를 받는가 | 수신 시각 |
|
||||
| 쿨다운 | 동일 종류 경보가 30분 내 중복 발송되지 않는가 | `leak_sms_log` 간격 |
|
||||
| 쿨다운 | 동일 종류 경보가 30분 내 중복 발송되지 않는가 | `sms_log` 간격 |
|
||||
| 정상복귀 | 히스테리시스 포함 정상 회복 시 복귀 SMS 1회 발송되는가 | 복귀 시각 |
|
||||
| 보고서 반영 | 월간 보고서에 경보/복귀가 반영되는가 | 보고서 파일 |
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ USART3(PD8/PD9, 115200) 콘솔 로그로 다음을 확인합니다.
|
|||
| 임계 경보 발생 | 당일 원인 확인 후 조치 결과 기록(공조/환기/장비 점검) |
|
||||
| SMS 반복 실패 | Cafe24 SMS 잔액, 발신번호, secure key, 수신자 확인 |
|
||||
| 장비 오프라인 | 전원, LAN 연결, PHY 링크, 펌웨어 콘솔 로그 확인 |
|
||||
| 온습도 미수집 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `leak_sensor_metric` 테이블 확인 |
|
||||
| 온습도 미수집 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `sensor_metric` 테이블 확인 |
|
||||
| HTTPS/TLS 미확인 | Cafe24 SSL 설정, 펌웨어 CA 반영, SNTP 시간 동기 확인 |
|
||||
| 백업 미수행 | 백업 주기 재설정 및 다음 월 점검에 포함 |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
# 서버실 온습도(SHT30) 모니터링 시스템 v2606
|
||||
|
||||
Sensirion SHT30 온습도 센서를 **Raspberry Pi 4 Model B(Python)**로 I2C 측정하여 Cafe24 PHP/MySQL 서버에 5분 주기로 보고하고, 웹 대시보드 표시와 고온/저온/고습/저습 임계 초과 SMS 알림을 제공하는 시스템입니다. Pi 는 유선 Ethernet(또는 WiFi) + HTTPS(raw-body HMAC 서명)로 보고합니다. (STM32F407VGT6 베어메탈 펌웨어는 별도의 마이그레이션 트랙 `firmware/` — 하드웨어 미검증, 현재 배포와 무관.)
|
||||
Sensirion SHT30 온습도 센서, STM32F407VGT6(FreeRTOS) 펌웨어, Cafe24 PHP/MySQL, Cafe24 SMS를 이용한 서버실 온습도 24시간 모니터링 및 임계 초과 SMS 알림 시스템입니다. 측정값은 5분 주기로 서버에 전송되어 웹 대시보드에 표시되고, 고온/저온/고습/저습 임계를 초과하면 SMS로 통보합니다. 펌웨어는 유선 Ethernet(LAN8720 RMII) + 온칩 TLS(mbedTLS HTTPS)로 서버에 보고합니다.
|
||||
|
||||
## 구성
|
||||
|
||||
```text
|
||||
[SHT30] --I2C--> [Raspberry Pi 4 (raspberry_pi4_sht30, Python)] --Ethernet/TLS--> [Cafe24 PHP API]
|
||||
[SHT30] --I2C--> [STM32F407VGT6 (sht30_fw)] --유선 Ethernet/TLS--> [Cafe24 PHP API]
|
||||
|
|
||||
[MySQL DB]
|
||||
|
|
||||
[Cafe24 SMS / Dashboard]
|
||||
```
|
||||
|
||||
- Raspberry Pi 4 노드 `raspberry_pi4_sht30`(= `deploy/03_pi_b_sht30_id2`) — `sensor_id=2`, `device_id=rpi4-sht30-01`.
|
||||
- SHT30 → Pi I2C 버스 1(GPIO2 SDA / GPIO3 SCL, 주소 0x44, VCC=3.3V/GND, 4.7kΩ 풀업). 상세 배선은 [SHT30_SENSOR2_SETUP.md](./SHT30_SENSOR2_SETUP.md) 및 `deploy/03_pi_b_sht30_id2/WIRING.html` 참조. (STM32 대안 배선 PB6/PB7 은 [wiring_diagram.md](./wiring_diagram.md).)
|
||||
- 측정 주기: 5분(`raspberry_pi4_sht30/config.py REPORT_INTERVAL=300`). 최초 1회 startup, 이후 periodic.
|
||||
- 인증: Pi 가 `X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`(raw-body) 헤더로 서명하고 서버가 검증합니다. 관리자 로그인은 비밀번호 해시 + TOTP MFA + 감사로그로 보호합니다.
|
||||
- 단일 보드 `sht30_fw` (`sensor_id=2`, `device_id=stm32-sht30-01`).
|
||||
- SHT30 → STM32 I2C1(PB6 SCL / PB7 SDA, 주소 0x44, 3.3V/GND, 4.7kΩ 풀업). 상세 배선은 [wiring_diagram.md](./wiring_diagram.md) 및 [firmware/docs/HARDWARE.md](../firmware/docs/HARDWARE.md) §3 참조.
|
||||
- 측정 주기: 5분(펌웨어 `APP_SHT30_REPORT_INTERVAL_SEC=300`). 최초 1회 startup, 이후 periodic.
|
||||
- 인증: 펌웨어가 `X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`(raw-body) 헤더로 서명하고 서버가 검증합니다. 관리자 로그인은 비밀번호 해시 + TOTP MFA + 감사로그로 보호합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
docs/
|
||||
README.md # 설치/운영 허브 문서
|
||||
INSTALL_PI_SERVER.md # Cafe24 서버 설치 설명서 (Pi/STM32 공통)
|
||||
INSTALL_PI_SERVER.md # STM32 펌웨어 및 Cafe24 서버 설치 설명서
|
||||
OPERATIONS_SECURITY_CHECKLIST.md # 월간 운영·보안 점검표
|
||||
SECURITY_EVIDENCE_PACKAGE.md # 보안대책서 첨부자료 패키지
|
||||
wiring_diagram.md # SHT30 I2C 배선도
|
||||
SHT30_SENSOR2_SETUP.md # SHT30 설치 가이드(Raspberry Pi 4 기본 / STM32 마이그레이션 대안)
|
||||
SHT30_SENSOR2_SETUP.md # SHT30 설치 가이드(STM32 기본 / RPi 대안)
|
||||
solutions/ # 실행-검토 사이클에서 얻은 교훈/반복 방지 기록
|
||||
firmware/ # STM32F407VGT6 베어메탈 펌웨어 (sht30_fw)
|
||||
common/ # 이식 코어(sha256/sig/jsonbody/sht30_convert/httpapi/net 등)
|
||||
|
|
@ -51,7 +51,8 @@ php/
|
|||
blocked.php # 차단 안내 화면
|
||||
api/sensor_data.php # STM32 센서 데이터 수신
|
||||
sql/
|
||||
schema_sht30.sql # 신규 설치용 통합 스키마 (이 파일 하나로 완결)
|
||||
schema_sht30.sql # 신규 설치용 통합 스키마
|
||||
migration_drop_leak.sql # 기존 누수 설치 → 온습도 전용 전환 마이그레이션
|
||||
_backup/
|
||||
pre_v2605_20260519/ # 정리 전 원본 백업
|
||||
```
|
||||
|
|
@ -68,7 +69,7 @@ _backup/
|
|||
SOURCE /path/to/sql/schema_sht30.sql;
|
||||
```
|
||||
|
||||
phpMyAdmin에서 실행할 때는 `sql/schema_sht30.sql` 내용을 복사해 실행합니다. 생성 테이블은 `leak_sensor_log`, `leak_sensor_status`, `leak_sensor_metric`, `leak_sms_log`입니다.
|
||||
phpMyAdmin에서 실행할 때는 `sql/schema_sht30.sql` 내용을 복사해 실행합니다. 생성 테이블은 `sensor_log`, `sensor_status`, `sensor_metric`, `sms_log`입니다.
|
||||
|
||||
3. PHP 파일을 업로드합니다.
|
||||
|
||||
|
|
@ -125,21 +126,29 @@ _backup/
|
|||
* * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php
|
||||
```
|
||||
|
||||
## 설치 스키마
|
||||
## 기존 누수 설치 → 온습도 전용 전환
|
||||
|
||||
신규 설치는 `sql/schema_sht30.sql` 하나만 실행합니다(누수+온습도 통합, 테이블 전체 `leak_` 접두사).
|
||||
구버전 무접두 테이블 대상 마이그레이션 파일들은 v2607 테이블 리네임과 함께 폐기되었습니다(git 이력 참고).
|
||||
1. 현재 운영 파일과 DB를 백업합니다.
|
||||
2. `php/config.local.example.php` 기준으로 `config.local.php`를 갱신합니다(METRIC_* 임계 확인).
|
||||
3. DB에 전환 마이그레이션을 적용합니다.
|
||||
|
||||
```sql
|
||||
SOURCE /path/to/sql/migration_drop_leak.sql;
|
||||
```
|
||||
|
||||
이 마이그레이션은 레거시 누수 컬럼/테이블(`leak_photo`, `leak_incident`, `is_leak` 등)을 정리하고 `sensor_metric`을 보장합니다.
|
||||
4. 신규 설치라면 위 마이그레이션 대신 `sql/schema_sht30.sql`만 사용합니다.
|
||||
|
||||
## 이벤트 정책
|
||||
|
||||
| 이벤트 | 조건 | DB | SMS |
|
||||
|---|---|---|---|
|
||||
| `startup` | 기기 시작 첫 측정 | `leak_sensor_log` | X |
|
||||
| `periodic` | 정상 주기 측정(5분) | `leak_sensor_metric` | X |
|
||||
| 고온/저온/고습/저습 경보 | 측정값이 임계 초과 | `leak_sensor_metric.metric_status` | O (종류별 30분 쿨다운) |
|
||||
| 정상복귀 | 경보 후 히스테리시스 포함 정상 회복 | `leak_sensor_metric` | O (1회) |
|
||||
| 장비 오프라인 | 마지막 수신 후 `HEARTBEAT_TIMEOUT_SEC`(기본 1200초=20분) 초과 | `leak_sensor_status` | O |
|
||||
| 장비 복구 | 오프라인 후 재수신 | `leak_sensor_status` | O |
|
||||
| `startup` | 기기 시작 첫 측정 | `sensor_log` | X |
|
||||
| `periodic` | 정상 주기 측정(5분) | `sensor_metric` | X |
|
||||
| 고온/저온/고습/저습 경보 | 측정값이 임계 초과 | `sensor_metric.metric_status` | O (종류별 30분 쿨다운) |
|
||||
| 정상복귀 | 경보 후 히스테리시스 포함 정상 회복 | `sensor_metric` | O (1회) |
|
||||
| 장비 오프라인 | 마지막 수신 후 `HEARTBEAT_TIMEOUT_SEC`(기본 1200초=20분) 초과 | `sensor_status` | O |
|
||||
| 장비 복구 | 오프라인 후 재수신 | `sensor_status` | O |
|
||||
|
||||
## 운영 기준
|
||||
|
||||
|
|
@ -152,7 +161,7 @@ _backup/
|
|||
- 로그인 성공/실패, 로그아웃, MFA 등록 검증은 `php/var/admin_audit.log`에 감사로그로 기록합니다.
|
||||
- heartbeat 체크 실행 주기와 오프라인 판정 시간(`HEARTBEAT_TIMEOUT_SEC`, 기본 1200초)은 분리되어 있습니다.
|
||||
- 대시보드는 위험 상태를 첫 화면에 우선 표시하고, 최신 온습도, 운영 자가진단, 30일 운영 요약을 함께 보여줍니다.
|
||||
- 운영 자가진단은 API 키, 관리자 해시, SMS 수신자, 필수 테이블(`leak_sensor_metric` 포함), 상태 파일 폴더 권한, 최근 SMS 실패를 확인합니다.
|
||||
- 운영 자가진단은 API 키, 관리자 해시, SMS 수신자, 필수 테이블(`sensor_metric` 포함), 상태 파일 폴더 권한, 최근 SMS 실패를 확인합니다.
|
||||
- 월간 보고서는 온습도 임계 경보, 정상복귀, 오프라인/복구, SMS 현황을 조회하고 CSV/인쇄/PDF로 출력할 수 있습니다.
|
||||
- 보관기간 정리는 `php/retention_cleanup.php --dry-run`으로 삭제 대상을 먼저 확인한 뒤, 운영 백업 완료 후 `--execute`로 수행합니다.
|
||||
- 실행-검토 과정에서 나온 실수와 교훈은 `docs/solutions/`에 남겨 다음 개선 사이클에서 반복하지 않습니다.
|
||||
|
|
@ -189,10 +198,10 @@ php scripts/backup_evidence.php --backup-dir /path/to/backups --restore-test "YY
|
|||
| 증상 | 확인 지점 |
|
||||
|---|---|
|
||||
| API 403 | 펌웨어 `APP_API_KEY`와 서버 `API_KEY` 일치 여부, raw-body 서명 |
|
||||
| API 500 | Cafe24 PHP 오류 로그, DB 접속 정보, `leak_sensor_metric` 등 테이블 생성 여부 |
|
||||
| API 500 | Cafe24 PHP 오류 로그, DB 접속 정보, `sensor_metric` 등 테이블 생성 여부 |
|
||||
| 로그인 불가 | `config.local.php`의 `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, 인증 앱 시간 동기화 |
|
||||
| SMS 미발송 | Cafe24 SMS 인증값, 발신번호 등록, 잔액, `leak_sms_log` |
|
||||
| 온습도 미표시 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `leak_sensor_metric` 테이블, 펌웨어 측정 로그 |
|
||||
| SMS 미발송 | Cafe24 SMS 인증값, 발신번호 등록, 잔액, `sms_log` |
|
||||
| 온습도 미표시 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `sensor_metric` 테이블, 펌웨어 측정 로그 |
|
||||
| 임계 경보 미발송 | `config.php`/`config.local.php`의 `METRIC_*` 임계, `METRIC_ALERT_COOLDOWN_SEC`(30분) |
|
||||
| 오프라인 알림 지연 | `HEARTBEAT_CHECK_INTERVAL_SEC`, `HEARTBEAT_TIMEOUT_SEC`(기본 1200초) |
|
||||
| 대시보드 진단 경고 | `config.local.php`, DB 마이그레이션, `php/var` 권한 |
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@
|
|||
| raw-body API 서명 검증 (HMAC-SHA256) | `config.php` `verify_signature_raw()`=`hash_hmac('sha256', $raw_body, API_KEY)`, 펌웨어 `sig.c`/`secrets.h` (`X-Signature`) | 반영 (서명 방식 정정 후 일치, 아래 §2.1 참조) |
|
||||
| 측정값 범위 검증 | `api/sensor_data.php` (온도 -40~125℃ / 습도 0~100%) | 반영 |
|
||||
| 서버 임계 판정·쿨다운 | `config.php` `METRIC_*`(고온30/저온10·고습70/저습20, 30분 쿨다운, 히스테리시스), `api/sensor_data.php` | 반영 |
|
||||
| 임계 경보/정상복귀 SMS | `api/sensor_data.php`, `leak_sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) | 반영 |
|
||||
| 장비 오프라인/복구 SMS | `cron_heartbeat.php`, `leak_sensor_status.offline_alerted`, `leak_sms_log` | 반영 |
|
||||
| 측정 이력 저장 | `leak_sensor_metric` 테이블 | 반영 |
|
||||
| 임계 경보/정상복귀 SMS | `api/sensor_data.php`, `sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) | 반영 |
|
||||
| 장비 오프라인/복구 SMS | `cron_heartbeat.php`, `sensor_status.offline_alerted`, `sms_log` | 반영 |
|
||||
| 측정 이력 저장 | `sensor_metric` 테이블 | 반영 |
|
||||
| 보관기간 정리 | `retention_cleanup.php --dry-run`, `--execute` (SMS/센서/측정/감사로그) | 반영 |
|
||||
| 백업·복구 증빙 | `scripts/backup_evidence.php` | 반영 |
|
||||
| 보안 증적 보고서 | `security_evidence.php?format=md` | 반영 |
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
| 5 | 네트워크 증적 | 방화벽 정책, 단말 인바운드 차단 확인, HTTPS 인증서 화면 |
|
||||
| 6 | 물리 설치 증적 | STM32 보드, SHT30 센서 설치 사진과 장비 ID |
|
||||
| 7 | 관리자 접근통제 증적 | MFA 등록 화면, 로그인 화면, 감사로그 존재 확인 |
|
||||
| 8 | 측정/임계 경보 증적 | 측정값 저장(`leak_sensor_metric`), 종류별 경보·정상복귀 SMS(`leak_sms_log`) 화면 |
|
||||
| 8 | 측정/임계 경보 증적 | 측정값 저장(`sensor_metric`), 종류별 경보·정상복귀 SMS(`sms_log`) 화면 |
|
||||
| 9 | 운영 절차 증적 | 운영·보안 점검표, 보관기간 정리 dry-run, 백업 파일 목록, 수신자 현행화 확인 |
|
||||
| 10 | 소스코드 기반 설명 | `SOURCE_SECURITY_EVIDENCE.md`의 기술통제 설명 |
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ security-evidence-YYYYMMDD/
|
|||
- [ ] `security_evidence.php` 점검 결과에 중요 실패 항목이 있으면 조치 결과 또는 예외 사유를 적는다.
|
||||
- [ ] 단말 증적에는 환경변수 존재 여부만 있고 원문 값은 없다.
|
||||
- [ ] MFA 증빙은 운영 비밀키가 아닌 증빙용 임시 키 또는 마스킹된 키를 사용한다.
|
||||
- [ ] 측정/경보 증빙은 측정값 저장(`leak_sensor_metric`)과 종류별 경보·정상복귀 SMS(`leak_sms_log`)를 함께 보여준다.
|
||||
- [ ] 측정/경보 증빙은 측정값 저장(`sensor_metric`)과 종류별 경보·정상복귀 SMS(`sms_log`)를 함께 보여준다.
|
||||
- [ ] 실제 도메인은 `https://`로 접속되는 화면을 포함한다.
|
||||
- [ ] 네트워크 증적에는 단말로 들어오는 불필요한 포트가 없다는 근거가 포함된다.
|
||||
- [ ] 월간 보고서에는 생성 시각과 보고 범위가 보인다.
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ DB 백업 주기: 매일 / 매주 / 매월
|
|||
| 관리자 인증 | 관리자 로그인 화면, MFA 등록 증빙, 감사로그 | 비밀번호 해시, TOTP MFA, 세션 보호, 실패 제한 코드 설명 |
|
||||
| 측정/임계 | 임계 운영 기준(`METRIC_*`) | 측정값 범위 검증, 서버 임계 판정·쿨다운 설명 |
|
||||
| 개인정보 | SMS 수신자 관리 기준 | SMS 설정 구조, 로그 테이블 설명 |
|
||||
| 로그관리 | 로그 보관기간, 관리자 감사로그 | `leak_sensor_log`, `leak_sensor_metric`, `leak_sms_log`, `php/var/admin_audit.log` 설명 |
|
||||
| 로그관리 | 로그 보관기간, 관리자 감사로그 | `sensor_log`, `sensor_metric`, `sms_log`, `php/var/admin_audit.log` 설명 |
|
||||
| 백업 | DB/설정 백업 주기, 복구 테스트 결과 | `backup_evidence.php` 결과와 백업·복구 문구 |
|
||||
| 패치 | 월간 패치 점검 담당자 | 점검표 양식 |
|
||||
| 물리보안 | 설치 사진, 자산 정보 | 자산대장 양식 |
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
현재 HWP 보안대책서는 사업 목적, 추진체계, 관리적·물리적·기술적 보안대책의 큰 틀은 갖추고 있습니다. 다만 실제 구현된 시스템과 비교하면 본문이 과거 범위(화재·누수·현장 사진·사고 대응)를 기준으로 작성되어 있어, 현재 구현 범위인 **서버실 온습도(SHT30) 모니터링 + 임계 SMS 경보 + 장비 오프라인 알림**으로 정정해야 합니다.
|
||||
|
||||
- 사업 범위가 `화재/누수/장비 전원 이상`으로 넓게 쓰여 있으나, 현재 구현은 `서버실 온습도(SHT30) 측정 + 임계(고온/저온/고습/저습) SMS 경보 + 정상복귀 SMS + 장비 오프라인/복구 SMS + 대시보드`입니다. 누수센서·카메라·현장 사진 기능은 제거되었습니다.
|
||||
- 구성도에는 큰 흐름이 있으나, 실제 구성요소인 STM32 보드, SHT30 온습도 센서, Cafe24 PHP API, MySQL, 측정 이력 테이블(`leak_sensor_metric`), SMS 이력(`leak_sms_log`), 월간 보고서가 명확히 표현되어 있지 않습니다.
|
||||
- 구성도에는 큰 흐름이 있으나, 실제 구성요소인 STM32 보드, SHT30 온습도 센서, Cafe24 PHP API, MySQL, 측정 이력 테이블(`sensor_metric`), SMS 이력(`sms_log`), 월간 보고서가 명확히 표현되어 있지 않습니다.
|
||||
- API 키, 서명 검증, `config.local.php`, `firmware/common/secrets.h`(`APP_API_KEY`), 서버 임계 판정(`config.php`의 `METRIC_*`), 관리자 TOTP MFA, 관리자 감사로그 같은 실제 보안 구현 항목이 본문에 구체적으로 반영되어야 합니다.
|
||||
- 로그 보관기간, SMS 수신자 개인정보 관리, `retention_cleanup.php` 기반 정리 절차, 백업/복구 증빙, 장애 대응 절차가 선언 수준에 머물러 있어 운영 기준표가 필요합니다.
|
||||
- '현장 사진/시설정보'는 더 이상 처리하지 않으므로 해당 절은 삭제하고, 통제 공백처럼 보이지 않도록 **온습도 임계 경보 + 오발송 방지(30분 쿨다운 / 복구 히스테리시스)**로 대체 서술합니다.
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
| 권한 및 계정관리 (L106~L110) | 관리자 계정 최소 부여, 비밀번호 정책 | `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, `MFA_SETUP_TOKEN`, 세션 쿠키 옵션, 로그인 실패 잠금, 감사로그, 비밀번호/TOTP 교체 절차 추가 | 높음 |
|
||||
| 단말기 보안대책 (L135~L143) | 기본 계정 변경, 하드코딩 금지 | 비밀값은 `firmware/common/secrets.h`(`APP_API_KEY`)에 두고 미커밋(.gitignore)으로 빌드 머신에서만 보관, API 키 서버 `config.php`의 `API_KEY`와 일치, 불필요 기능 비활성화 추가. (카메라 권한 관리 문구 불필요) | 높음 |
|
||||
| 플랫폼/서비스 보안대책 (L150~L156) | 인증, 패치, 기본 서비스 제거 | API 서명 검증, 측정값 범위 검증(온도 -40~125℃ / 습도 0~100%), DB prepared statement, TOTP MFA, `config.local.php` 비공개 관리 추가. (사진 업로드/MIME/매직바이트 검증 문구 삭제) | 높음 |
|
||||
| 로그관리 (L94~L95) | 로그 보관기간 정의, 위변조 방지 | `leak_sensor_log`, `leak_sensor_metric`, `leak_sms_log`, 서버 `php/var/admin_audit.log`, `retention_cleanup.php` 기준으로 보관·점검 기준 추가. (`leak_incident`/`leak_photo`/Pi 로그 삭제) | 높음 |
|
||||
| 로그관리 (L94~L95) | 로그 보관기간 정의, 위변조 방지 | `sensor_log`, `sensor_metric`, `sms_log`, 서버 `php/var/admin_audit.log`, `retention_cleanup.php` 기준으로 보관·점검 기준 추가. (`leak_incident`/`leak_photo`/Pi 로그 삭제) | 높음 |
|
||||
| 장비 설치·보호 (L114, L118~L120) | "화재·누수 감지센서, IoT 보드 및 **카메라**" | "SHT30 온습도 센서, STM32 보드"로 교체. 카메라 문구 삭제. 자산대장 등록은 유지 | 높음 |
|
||||
|
||||
## 3. HWP에 추가해야 할 장·절
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
| SHT30 온습도 센서 | I2C 온습도 측정(5분 주기) | 서버실 고정 설치, 임의 탈거 방지, 자산대장 등록 |
|
||||
| PHP API | `api/sensor_data.php` | API 키 서명 검증, HTTPS, 측정값 범위 검증, 서버 임계 판정 |
|
||||
| 임계 판정 | `config.php`의 `METRIC_*` 상수 | 고온30/저온10℃·고습70/저습20% 임계, 복구 히스테리시스(1.0℃/3.0%), 30분 쿨다운 |
|
||||
| MySQL DB | `leak_sensor_log`, `leak_sensor_status`, `leak_sensor_metric`, `leak_sms_log` | 최소 권한 DB 계정, 백업, 보관기간 관리 |
|
||||
| MySQL DB | `sensor_log`, `sensor_status`, `sensor_metric`, `sms_log` | 최소 권한 DB 계정, 백업, 보관기간 관리 |
|
||||
| 대시보드 | `dashboard.php` | 관리자 로그인, TOTP MFA, 세션 보호, 온습도/경보 현황 표시 |
|
||||
| MFA 등록 | `setup_mfa.php` | 임시 등록 토큰, 인증 앱 등록 검증, 외부 QR/API 미사용 |
|
||||
| 설치 점검 | `setup_wizard.php` | DB/권한/SMS/임계 설정 점검 |
|
||||
|
|
@ -69,7 +69,7 @@
|
|||
2. STM32 보드가 측정값을 수집하여 정상 보고(`periodic`/`startup`)를 구성한다.
|
||||
3. 단말이 HTTPS로 PHP API에 측정값을 전송한다(인바운드 포트 미개방, 아웃바운드 POST만).
|
||||
4. 서버가 API 서명, API 키 일치, 필수 필드, 측정값 범위(온도 -40~125℃ / 습도 0~100%)를 검증한다.
|
||||
5. 서버가 운영 임계(`METRIC_*`)로 고온/저온/고습/저습을 재판정하여 `leak_sensor_metric`에 저장하고, 정상 보고는 `leak_sensor_status`를 갱신한다.
|
||||
5. 서버가 운영 임계(`METRIC_*`)로 고온/저온/고습/저습을 재판정하여 `sensor_metric`에 저장하고, 정상 보고는 `sensor_status`를 갱신한다.
|
||||
6. 임계 이탈 시 30분 쿨다운과 복구 히스테리시스를 적용하여 종류별 SMS를 발송하고, 임계 안쪽으로 회복하면 정상복귀 SMS를 발송한다.
|
||||
7. heartbeat 타임아웃이 지나면 장비 오프라인 SMS를, 보고 재개 시 복구 SMS를 발송한다.
|
||||
8. 대시보드와 월간 보고서에서 온습도·경보·SMS·오프라인 현황을 표시한다.
|
||||
|
|
@ -85,12 +85,12 @@
|
|||
|
||||
| 단계 | 처리 내용 | 증적 |
|
||||
|---|---|---|
|
||||
| 측정 | SHT30 측정값 수신, `leak_sensor_metric` 기록, 임계 판정 | `leak_sensor_metric.metric_status` |
|
||||
| 경보 | 임계 이탈 시 종류별 SMS 발송(쿨다운 적용) | `leak_sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) |
|
||||
| 측정 | SHT30 측정값 수신, `sensor_metric` 기록, 임계 판정 | `sensor_metric.metric_status` |
|
||||
| 경보 | 임계 이탈 시 종류별 SMS 발송(쿨다운 적용) | `sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) |
|
||||
| 확인 | 담당자가 대시보드에서 온습도/경보 상태 확인 | 대시보드 캡처 |
|
||||
| 조치 | 공조/환기 등 현장 점검 및 환경 정상화 | 운영 점검 기록 |
|
||||
| 복구 | 임계 안쪽으로 회복 시 정상복귀 SMS | `leak_sms_log` 복구 메시지 |
|
||||
| 오프라인 | heartbeat 타임아웃 시 오프라인 SMS, 복구 시 복구 SMS | `cron_heartbeat.php` 실행 로그, `leak_sms_log` |
|
||||
| 복구 | 임계 안쪽으로 회복 시 정상복귀 SMS | `sms_log` 복구 메시지 |
|
||||
| 오프라인 | heartbeat 타임아웃 시 오프라인 SMS, 복구 시 복구 SMS | `cron_heartbeat.php` 실행 로그, `sms_log` |
|
||||
| 보고 | 월간 보고서에 경보·SMS·오프라인 현황 반영 | `monthly_report.php` |
|
||||
|
||||
### 3.4 설치·운영 점검 절차
|
||||
|
|
@ -100,8 +100,8 @@
|
|||
| 점검 항목 | 확인 방법 | 불합격 시 조치 |
|
||||
|---|---|---|
|
||||
| DB 연결 | 설치 점검 화면 | `config.local.php` DB 설정 수정 |
|
||||
| 필수 테이블 | 설치 점검 화면 | `sql/schema_sht30.sql` 적용 |
|
||||
| 측정 이력 테이블 | 설치 점검 화면 | `leak_sensor_metric` 생성 확인 |
|
||||
| 필수 테이블 | 설치 점검 화면 | `sql/schema_sht30.sql` 또는 `sql/migration_drop_leak.sql` 적용 |
|
||||
| 측정 이력 테이블 | 설치 점검 화면 | `sensor_metric` 생성 확인 |
|
||||
| 임계 설정 | 설치 점검 화면 | `config.php`/`config.local.php`의 `METRIC_*` 값 확인 |
|
||||
| API 키 | 설치 점검 화면 | 서버와 단말의 키 일치 확인 |
|
||||
| 관리자 해시 | 설치 점검 화면 | `php setup_hash.php`로 해시 생성 |
|
||||
|
|
@ -120,9 +120,9 @@ HWP의 "보관기간은 최소한으로 설정" 문구를 아래처럼 구체화
|
|||
|
||||
| 데이터 | 보관 위치 | 권장 보관기간 | 파기/정리 기준 |
|
||||
|---|---|---:|---|
|
||||
| 센서 이벤트 로그 | `leak_sensor_log` | 365일 | 백업 후 기간 초과분 삭제(`retention_cleanup.php`) |
|
||||
| 온습도 측정 이력 | `leak_sensor_metric` | 365일 | 기간 초과분 삭제 |
|
||||
| SMS 발송 로그 | `leak_sms_log` | 365일 | 개인정보 최소화 후 기간 초과분 삭제 |
|
||||
| 센서 이벤트 로그 | `sensor_log` | 365일 | 백업 후 기간 초과분 삭제(`retention_cleanup.php`) |
|
||||
| 온습도 측정 이력 | `sensor_metric` | 365일 | 기간 초과분 삭제 |
|
||||
| SMS 발송 로그 | `sms_log` | 365일 | 개인정보 최소화 후 기간 초과분 삭제 |
|
||||
| 관리자 감사로그 | `php/var/admin_audit.log` | 365일 | 기간 초과분 정리 |
|
||||
| 설정 비밀값 | `config.local.php`, `firmware/common/secrets.h`(`APP_API_KEY`, 미커밋) | 운영 중 | 담당자 변경 또는 유출 의심 시 즉시 교체 |
|
||||
|
||||
|
|
@ -196,7 +196,7 @@ HWP의 "보관기간은 최소한으로 설정" 문구를 아래처럼 구체화
|
|||
|
||||
교체 문구:
|
||||
|
||||
> 본 시스템은 현장 사진 등 시설정보를 수집·저장하지 않으며, 처리하는 개인정보는 SMS 수신자 휴대전화번호로 한정한다. 수신자 번호는 알림 발송 목적에 한하여 최소 수집·이용하고, 담당자 변경 시 즉시 현행화한다. 온습도 임계 경보는 동일 종류 30분 쿨다운과 복구 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. SMS 발송 이력(`leak_sms_log`)은 장애 대응·감사 목적 범위에서만 열람하고, 보관기간 경과 시 삭제 또는 비식별 처리한다.
|
||||
> 본 시스템은 현장 사진 등 시설정보를 수집·저장하지 않으며, 처리하는 개인정보는 SMS 수신자 휴대전화번호로 한정한다. 수신자 번호는 알림 발송 목적에 한하여 최소 수집·이용하고, 담당자 변경 시 즉시 현행화한다. 온습도 임계 경보는 동일 종류 30분 쿨다운과 복구 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. SMS 발송 이력(`sms_log`)은 장애 대응·감사 목적 범위에서만 열람하고, 보관기간 경과 시 삭제 또는 비식별 처리한다.
|
||||
|
||||
## 6. 추가 증적 목록
|
||||
|
||||
|
|
|
|||
|
|
@ -100,10 +100,10 @@ HWP 추가 문구:
|
|||
| 전송구간 | 센서 데이터 탈취·변조 | HTTPS/TLS 사용, API 서명 검증 | `sensor_data.php`, 단말 펌웨어 | 실제 HTTPS 화면, `security_evidence.php?format=md` |
|
||||
| API 인증 | 임의 측정값 주입 | 공유 API 키와 요청 서명 검증 | PHP API, `firmware/common/secrets.h`(`APP_API_KEY`) | 403 테스트 결과, 보안 증적 보고서 |
|
||||
| 측정값 검증 | 비정상 측정값 저장 | 필수 필드·측정값 범위(온도 -40~125℃ / 습도 0~100%) 검증 | `sensor_data.php` | 비정상 요청 차단 테스트 결과 |
|
||||
| 임계 경보 | 오발송·미발송 | 서버 임계 판정(`METRIC_*`), 30분 쿨다운, 복구 히스테리시스 | `config.php`, `sensor_data.php` | `leak_sms_log`, `leak_sensor_metric` 상태 |
|
||||
| 임계 경보 | 오발송·미발송 | 서버 임계 판정(`METRIC_*`), 30분 쿨다운, 복구 히스테리시스 | `config.php`, `sensor_data.php` | `sms_log`, `sensor_metric` 상태 |
|
||||
| 비밀값 | DB/SMS/API 키 유출 | 서버는 `config.local.php`, STM32 단말은 `firmware/common/secrets.h`(`APP_API_KEY`)로 분리, 저장소 미포함(.gitignore) | 서버/단말 설정 | STM32 콘솔 로그, .gitignore 설정, 저장소 검색 결과 |
|
||||
| 관리자 인증 | 대시보드 무단 접근 | 비밀번호 해시, TOTP MFA, 세션 쿠키 보호, 로그인 실패 제한, 감사로그 | `login.php`, `setup_mfa.php`, `admin_security.php`, `dashboard.php`, `security_evidence.php` | 로그인 화면, MFA 등록 증빙, 감사로그, 보안 증적 보고서 |
|
||||
| 개인정보 | SMS 수신자 번호 노출 | 최소 수집, 담당자 변경 시 현행화, 로그 접근 제한 | `config.local.php`, `leak_sms_log` | 수신자 목록 관리대장 |
|
||||
| 개인정보 | SMS 수신자 번호 노출 | 최소 수집, 담당자 변경 시 현행화, 로그 접근 제한 | `config.local.php`, `sms_log` | 수신자 목록 관리대장 |
|
||||
| 로그관리 | 추적 불가 | 센서/측정/SMS 로그와 관리자 감사로그 저장, 보관기간 정리 | MySQL, `php/var/admin_audit.log`, `retention_cleanup.php` | 월간 보고서, 단말 증적 보고서, 감사로그, retention dry-run 결과 |
|
||||
| 백업 | 장애 시 복구 불가 | DB·설정 파일 백업, 복구 점검, 백업 증빙 생성 | 운영 절차, `scripts/backup_evidence.php` | 백업 이력, 복구 테스트 결과, backup-evidence 보고서 |
|
||||
| 패치 | 취약한 OS/PHP 사용 | 단말 OS, PHP, DB 정기 업데이트 | 운영 절차 | `OPERATIONS_SECURITY_CHECKLIST.md`, 월간 점검표 |
|
||||
|
|
@ -126,13 +126,13 @@ HWP에 아래 문구를 추가합니다.
|
|||
|
||||
### 5.2 측정값 검증 및 서버 임계 판정
|
||||
|
||||
> 센서 데이터 API는 POST JSON 요청과 필수 필드(장비 ID·센서 ID·이벤트 유형·타임스탬프)를 검증하고, 온도(-40~125℃)·습도(0~100%) 범위를 벗어난 값은 무효 처리한다. 임계 판정은 펌웨어가 아니라 서버 `config.php`의 운영 임계(`METRIC_*`: 고온30/저온10℃, 고습70/저습20%)로 재수행하여 `leak_sensor_metric`에 상태를 기록한다. 동일 종류 경보는 30분 쿨다운, 복구는 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 SMS 오발송을 방지한다.
|
||||
> 센서 데이터 API는 POST JSON 요청과 필수 필드(장비 ID·센서 ID·이벤트 유형·타임스탬프)를 검증하고, 온도(-40~125℃)·습도(0~100%) 범위를 벗어난 값은 무효 처리한다. 임계 판정은 펌웨어가 아니라 서버 `config.php`의 운영 임계(`METRIC_*`: 고온30/저온10℃, 고습70/저습20%)로 재수행하여 `sensor_metric`에 상태를 기록한다. 동일 종류 경보는 30분 쿨다운, 복구는 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 SMS 오발송을 방지한다.
|
||||
|
||||
확인 증적:
|
||||
|
||||
- 정상 측정값 저장 결과(`leak_sensor_metric`)
|
||||
- 정상 측정값 저장 결과(`sensor_metric`)
|
||||
- 비정상 메소드/필드 누락/범위 초과 차단 결과
|
||||
- 임계 초과 시 종류별 SMS, 회복 시 정상복귀 SMS 발송 이력(`leak_sms_log`)
|
||||
- 임계 초과 시 종류별 SMS, 회복 시 정상복귀 SMS 발송 이력(`sms_log`)
|
||||
|
||||
### 5.3 관리자 페이지 접근통제
|
||||
|
||||
|
|
@ -164,7 +164,7 @@ STM32 단말 firmware/common/secrets.h: 저장소 미커밋(.gitignore), 빌드
|
|||
|
||||
확인 증적:
|
||||
|
||||
- `leak_sensor_status` 마지막 보고 시각
|
||||
- `sensor_status` 마지막 보고 시각
|
||||
- `cron_heartbeat.php` 실행 로그
|
||||
- 오프라인 SMS 발송 이력
|
||||
|
||||
|
|
@ -213,7 +213,7 @@ HWP에 아래 책임표를 추가합니다.
|
|||
|---|---|---:|---|
|
||||
| SMS 수신자 번호 | 개인정보 | 담당자 재직/업무 담당 기간 | 담당자 변경 즉시 삭제·수정 |
|
||||
| SMS 발송 로그 | 개인정보 포함 가능 | 1년 | DB 삭제 또는 비식별 처리 |
|
||||
| 온습도 측정 이력 | 운영 데이터 | 1년 | 기간 초과 DB 삭제(`leak_sensor_metric`) |
|
||||
| 온습도 측정 이력 | 운영 데이터 | 1년 | 기간 초과 DB 삭제(`sensor_metric`) |
|
||||
| 센서 로그 | 운영 로그 | 1년 | 기간 초과 DB 삭제 |
|
||||
| 관리자 감사로그 | 감사/운영 이력 | 1년 | 기간 초과분 정리 |
|
||||
| API 키/SMS 인증키 | 인증정보 | 운영 중 | 교체 후 구 키 폐기 |
|
||||
|
|
@ -266,9 +266,9 @@ HWP에 아래 책임표를 추가합니다.
|
|||
|
||||
| 정보 | 예시 | 목적 | 접근자 | 저장 위치 |
|
||||
|---|---|---|---|---|
|
||||
| 담당자 휴대전화번호 | SMS 수신자 번호 | 이상상태 알림 | 운영 관리자 | `config.local.php`, `leak_sms_log` |
|
||||
| 온습도 측정값 | 온도, 습도, 시각, 임계 상태 | 환경 모니터링 | 운영 관리자 | `leak_sensor_metric`, MySQL |
|
||||
| 센서 이벤트 | 센서 ID, 상태, 시각 | 장애 감지 | 운영 관리자 | `leak_sensor_log`, MySQL |
|
||||
| 담당자 휴대전화번호 | SMS 수신자 번호 | 이상상태 알림 | 운영 관리자 | `config.local.php`, `sms_log` |
|
||||
| 온습도 측정값 | 온도, 습도, 시각, 임계 상태 | 환경 모니터링 | 운영 관리자 | `sensor_metric`, MySQL |
|
||||
| 센서 이벤트 | 센서 ID, 상태, 시각 | 장애 감지 | 운영 관리자 | `sensor_log`, MySQL |
|
||||
| 관리자 계정 | 관리자 ID, 비밀번호 해시, TOTP 비밀키 | 대시보드 접근 | 운영 관리자 | `config.local.php`, `php/var/admin_audit.log` |
|
||||
| 장비 식별자 | Device ID, 위치명 | 장비 관리 | 운영 관리자 | 단말 설정, DB |
|
||||
|
||||
|
|
@ -278,7 +278,7 @@ HWP에 아래 책임표를 추가합니다.
|
|||
|
||||
### 9.3 경보 오발송 방지 문구
|
||||
|
||||
> 본 시스템은 현장 사진 등 시설정보를 수집·저장하지 않는다. 온습도 임계 경보는 서버에서 운영 임계(`METRIC_*`)로 판정하며, 동일 종류 경보는 30분 쿨다운, 정상복귀 판정은 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. 임계 초과·정상복귀·장비 오프라인·복구 이력은 `leak_sms_log`에 기록하며, 보관기간 경과 시 정리한다.
|
||||
> 본 시스템은 현장 사진 등 시설정보를 수집·저장하지 않는다. 온습도 임계 경보는 서버에서 운영 임계(`METRIC_*`)로 판정하며, 동일 종류 경보는 30분 쿨다운, 정상복귀 판정은 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. 임계 초과·정상복귀·장비 오프라인·복구 이력은 `sms_log`에 기록하며, 보관기간 경과 시 정리한다.
|
||||
|
||||
## 10. 보안성 검토 제출 관점의 필수 첨부물
|
||||
|
||||
|
|
@ -317,7 +317,7 @@ HWP 부록에 아래 체크리스트를 넣습니다.
|
|||
| 11 | 장비 오프라인 감지가 동작하는가 | heartbeat 기준 알림 | | |
|
||||
| 12 | DB 백업과 복구 점검을 수행했는가 | 복구 테스트 완료 | | |
|
||||
| 13 | 단말이 부팅 후 자동 보고를 시작하는가 | 전원 인가 시 startup 보고·주기 보고 동작 | | |
|
||||
| 14 | 온습도 측정·저장이 검증되었는가 | `leak_sensor_metric` 측정 이력 저장 | | |
|
||||
| 14 | 온습도 측정·저장이 검증되었는가 | `sensor_metric` 측정 이력 저장 | | |
|
||||
| 15 | 월간 보고서가 생성되는가 | 월별 조회/CSV 가능 | | |
|
||||
| 16 | 관리자 감사로그가 남는가 | 로그인 성공/실패, 로그아웃 기록 | | |
|
||||
| 17 | 보관기간 정리 절차가 있는가 | `retention_cleanup.php --dry-run` 결과 확인 | | |
|
||||
|
|
@ -336,8 +336,8 @@ HWP 부록에 아래 체크리스트를 넣습니다.
|
|||
| 월간 보고서 화면 캡처 | 경보 처리 및 SMS/온습도 현황 확인 |
|
||||
| API 정상/비정상 테스트 로그 | 정상 요청 저장, 비정상 키 거부 확인 |
|
||||
| 테스트 SMS 수신 화면 | 담당자 알림 경로 확인 |
|
||||
| 측정값 저장 결과 | `leak_sensor_metric` 기록 및 임계 상태 확인 |
|
||||
| 임계 경보/복구 SMS 결과 | 종류별 경보·정상복귀 SMS 발송(`leak_sms_log`) 확인 |
|
||||
| 측정값 저장 결과 | `sensor_metric` 기록 및 임계 상태 확인 |
|
||||
| 임계 경보/복구 SMS 결과 | 종류별 경보·정상복귀 SMS 발송(`sms_log`) 확인 |
|
||||
| 관리자 감사로그 | 로그인 성공/실패, 로그아웃 기록 확인 |
|
||||
| 보관기간 정리 dry-run | 측정/로그/감사로그 정리 대상 확인 |
|
||||
| 백업·복구 증빙 보고서 | 백업 파일 목록, 설정/감사로그 존재, 복구 테스트 결과 |
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
# SHT30 온습도 센서 설치 가이드 (2번 모듈, sensor_id=2)
|
||||
# SHT30 온습도 센서 설치 가이드
|
||||
|
||||
서버실 온습도 모니터링을 위해 Sensirion SHT30(한진데이터 P4422-3, I2C) 센서를 설치하고 서버에 연동하는 가이드입니다. **현재 배포 구성은 Raspberry Pi 4 Model B + Python**(`raspberry_pi4_sht30/` = `deploy/03_pi_b_sht30_id2/`, `device_id=rpi4-sht30-01`)입니다. STM32F407 펌웨어(`firmware/`)는 **별도의 마이그레이션 트랙**(하드웨어 미검증)으로, 실제 필드 배포와 무관합니다 — §3 참고.
|
||||
|
||||
> 기준 문서: 루트 [`README.md`](../README.md), [`SYSTEM_OVERVIEW.md`](../SYSTEM_OVERVIEW.md), [`docs/DEPLOYMENT_PLAN_RPI4x3.md`](./DEPLOYMENT_PLAN_RPI4x3.md) §5.
|
||||
> 현장 인쇄용 배선·핀맵 도해: `deploy/03_pi_b_sht30_id2/WIRING.html` (브라우저로 열람 — 자체 완결형, 오프라인 가능).
|
||||
서버실 온습도 모니터링을 위해 Sensirion SHT30(한진데이터 P4422-3, I2C) 센서를 설치하고 서버에 연동하는 가이드입니다. 기본 구성은 STM32F407VGT6 펌웨어(`sht30_fw`, `sensor_id=2`, `device_id=stm32-sht30-01`)이며, 폐쇄망의 다른 환경을 위한 Raspberry Pi I2C 대안도 함께 설명합니다.
|
||||
|
||||
## 1. 서버 준비
|
||||
|
||||
|
|
@ -13,6 +10,12 @@
|
|||
SOURCE sql/schema_sht30.sql;
|
||||
```
|
||||
|
||||
기존 누수 설치를 온습도 전용으로 전환하는 경우에는 전환 마이그레이션을 적용합니다.
|
||||
|
||||
```sql
|
||||
SOURCE sql/migration_drop_leak.sql;
|
||||
```
|
||||
|
||||
2. 서버 파일을 배포합니다.
|
||||
|
||||
```text
|
||||
|
|
@ -23,102 +26,94 @@
|
|||
sql/schema_sht30.sql
|
||||
```
|
||||
|
||||
API는 `sht30` 측정을 수신하면 `leak_sensor_status`를 갱신하고 측정값을 `leak_sensor_metric`에 저장합니다. 서버가 `METRIC_*` 임계로 고온/저온/고습/저습을 판정하고, 초과 시 종류별 30분 쿨다운으로 SMS를 발송합니다.
|
||||
API는 `sht30` 측정을 수신하면 `sensor_status`를 갱신하고 측정값을 `sensor_metric`에 저장합니다. 서버가 `METRIC_*` 임계로 고온/저온/고습/저습을 판정하고, 초과 시 종류별 30분 쿨다운으로 SMS를 발송합니다.
|
||||
|
||||
> 서버는 수집 시 `device_id`/`app_version`/`metric_type` 문자열을 **검증하지 않습니다**(HMAC 서명 + 필수필드 + `sensor_id` 범위 1..255 + 타임스탬프 신선도만 확인). 따라서 라즈베리파이 클라이언트(`rpi4-sht30-01`, `app_version=v2606-sht30-rpi4`)가 그대로 수용됩니다.
|
||||
## 2. 기본 구성 — STM32F407VGT6 (`sht30_fw`)
|
||||
|
||||
## 2. 기본 구성 — Raspberry Pi 4 (I2C)
|
||||
### 2.1 I2C 배선
|
||||
|
||||
소스: `deploy/03_pi_b_sht30_id2/`(= `raspberry_pi4_sht30/`). 근거는 해당 README.md, SETUP.md, WIRING.html, config.py, install.sh, systemd/sht30-sensor.service.
|
||||
|
||||
### 2.1 I2C 배선 (Pi 4 40핀 헤더, 버스 1)
|
||||
|
||||
상세 도해는 `deploy/03_pi_b_sht30_id2/WIRING.html`(SVG 핀맵 + 스키매틱 + 체크리스트)을 브라우저로 여십시오.
|
||||
SHT30을 STM32 I2C1 버스에 연결합니다(상세는 [wiring_diagram.md](./wiring_diagram.md), [firmware/docs/HARDWARE.md](../firmware/docs/HARDWARE.md) §3).
|
||||
|
||||
```text
|
||||
SHT30 VCC -> Raspberry Pi 3.3V (물리 핀 1) ★ Pi 에서는 5V(핀 2·4) 금지 (모듈은 2.4~5.5V OK나, 5V 전원 시 SDA/SCL 5V → 3.3V 전용 Pi GPIO 손상)
|
||||
SHT30 GND -> Raspberry Pi GND (핀 6; 대체 9·14·20·25·30·34·39)
|
||||
SHT30 SDA -> Raspberry Pi GPIO2 / SDA1 (물리 핀 3) + 4.7kΩ 풀업 -> 3.3V
|
||||
SHT30 SCL -> Raspberry Pi GPIO3 / SCL1 (물리 핀 5) + 4.7kΩ 풀업 -> 3.3V
|
||||
SHT30 ADDR -> GND (주소 0x44; VDD면 0x45)
|
||||
SHT30 VDD -> STM32 3.3V
|
||||
SHT30 GND -> STM32 GND
|
||||
SHT30 SCL -> STM32 PB6 (I2C1_SCL, AF4) + 4.7kΩ 풀업 -> 3.3V
|
||||
SHT30 SDA -> STM32 PB7 (I2C1_SDA, AF4) + 4.7kΩ 풀업 -> 3.3V
|
||||
SHT30 ADDR -> GND (주소 0x44; VDD 면 0x45)
|
||||
```
|
||||
|
||||
- 7-bit 주소 `0x44`(ADDR→GND). `config.py`의 `I2C_ADDR`와 반드시 일치.
|
||||
- I2C 풀업 4.7kΩ ×2 — **SHT30 모듈에 온보드 풀업이 있으면 생략**.
|
||||
- 명령 `0x2C06`(high-repeatability) → 약 20ms 대기 → 6바이트 read (드라이버 `sht30_driver.py`).
|
||||
- 7-bit 주소 `0x44`(`APP_SHT30_I2C_ADDR`), I2C 100kHz 권장.
|
||||
- 명령 `0x2C06`(high-repeatability) → 약 20ms 대기 → 6바이트 read.
|
||||
|
||||
### 2.2 설정값 준비 (`config_local.py`)
|
||||
### 2.2 펌웨어 설정·빌드 (폐쇄망)
|
||||
|
||||
```bash
|
||||
cd /home/pi/raspberry_pi4_sht30
|
||||
cp config_local.example.py config_local.py
|
||||
# config_local.py 를 열어 채운다:
|
||||
# API_URL = https://<서버>/raspi_leck_detecter/api/sensor_data.php (1번 모듈과 동일 엔드포인트)
|
||||
# API_KEY = 서버 config.local.php 의 API_KEY 와 바이트 단위로 동일
|
||||
# DEVICE_ID = rpi4-sht30-01
|
||||
# DEVICE_LOCATION = 온습도 측정 위치(예: 서버실)
|
||||
cp firmware/common/secrets.h.example firmware/common/secrets.h
|
||||
# secrets.h 의 APP_API_KEY 를 서버 config.local.php 의 API_KEY 와 동일하게 채운다.
|
||||
```
|
||||
|
||||
`config_local.py`가 없으면 실행 시 **명확한 오류로 즉시 중단**(fail closed)됩니다. `config_local.py`·`API_KEY`는 **절대 커밋하지 마세요**(`.gitignore` 처리됨). `sensor_id`(=2)/`sensor_name`은 `config.py` 기본값을 씁니다.
|
||||
| 위치 | 설정 |
|
||||
|---|---|
|
||||
| `firmware/common/secrets.h` | `APP_API_KEY` = 서버 `API_KEY` |
|
||||
| `firmware/common/app_config.h` | `APP_API_HOST`, `APP_API_PATH`(`/raspi_leck_detecter/api/sensor_data.php`), 네트워크 |
|
||||
| `firmware/common/app_config.h` | `APP_SHT30_REPORT_INTERVAL_SEC`(기본 300초), `APP_SHT30_I2C_ADDR`(0x44) |
|
||||
| `firmware/certs/server_ca.c` | 자리표시자 CA → 실제 Cafe24 루트 CA |
|
||||
|
||||
> ⚠️ 설정은 **Python `config_local.py`** 로만 합니다. 과거 문서에 있던 `/etc/sht30-sensor.env` 환경파일 방식은 이 라즈베리파이 모듈에서 **사용하지 않습니다**.
|
||||
벤더링·빌드·플래시 절차는 [firmware/docs/BUILD_OFFLINE.md](../firmware/docs/BUILD_OFFLINE.md)를 따릅니다. `sht30_fw` 타깃을 빌드해 보드에 플래시합니다.
|
||||
|
||||
### 2.3 설치 (폐쇄망 오프라인 + 온라인 폴백)
|
||||
### 2.3 동작 확인
|
||||
|
||||
원샷:
|
||||
USART3(PD8/PD9, 115200) 콘솔 로그에서 확인합니다.
|
||||
|
||||
- I2C1 초기화 및 SHT30 0x44 응답, 첫 측정값
|
||||
- 네트워크/SNTP/TLS 핸드셰이크 → 서버 200 응답 → `startup` 보고
|
||||
- 이후 5분 주기 `periodic` 측정 보고
|
||||
|
||||
## 3. 대안 구성 — Raspberry Pi I2C (STM32 외 대안)
|
||||
|
||||
STM32 보드를 쓸 수 없는 환경에서는 Raspberry Pi 40핀 헤더 I2C 버스 1을 사용할 수 있습니다.
|
||||
|
||||
```text
|
||||
SHT30 VCC -> Raspberry Pi 3.3V (pin 1)
|
||||
SHT30 GND -> Raspberry Pi GND
|
||||
SHT30 SDA -> Raspberry Pi GPIO2 / SDA1 / pin 3
|
||||
SHT30 SCL -> Raspberry Pi GPIO3 / SCL1 / pin 5
|
||||
SHT30 ADDR -> GND (주소 0x44; 점퍼 변경 시 0x45)
|
||||
```
|
||||
|
||||
I2C를 먼저 활성화합니다.
|
||||
|
||||
```bash
|
||||
cd /home/pi/raspberry_pi4_sht30 && bash setup.sh # 설치→서비스 등록→검증→하드닝 (sudo 붙이지 말 것)
|
||||
sudo raspi-config # Interface Options -> I2C -> enable
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3-venv python3-smbus i2c-tools
|
||||
```
|
||||
|
||||
또는 단계별:
|
||||
센서가 보이는지 확인합니다.
|
||||
|
||||
```bash
|
||||
chmod +x install.sh && ./install.sh # apt(i2c-tools 등) + I2C 자동 활성화 + venv(requests/smbus2) + 로그
|
||||
sudo reboot # I2C 를 새로 켠 경우 1회 (usermod -aG i2c 반영에도 필요)
|
||||
i2cdetect -y 1 # 44 또는 45 가 보여야 함
|
||||
```
|
||||
|
||||
- 폐쇄망: 인터넷 되는 **동일 aarch64** 머신에서 `./vendor.sh`로 `wheelhouse/`+`debs/` 번들 → 타깃 Pi로 복사 후 `./install.sh`(자동 오프라인).
|
||||
환경 파일 `/etc/sht30-sensor.env`를 생성하고 다음 값을 채웁니다.
|
||||
|
||||
### 2.4 systemd 자동 실행
|
||||
|
||||
```bash
|
||||
sudo cp systemd/sht30-sensor.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now sht30-sensor
|
||||
sudo systemctl status sht30-sensor
|
||||
journalctl -u sht30-sensor -f
|
||||
```text
|
||||
API_URL=https://your-domain.example/raspi_leck_detecter/api/sensor_data.php
|
||||
API_KEY=서버와 동일한 API_KEY
|
||||
DEVICE_ID=rpi-sht30-01
|
||||
DEVICE_LOCATION=온습도 측정 위치
|
||||
SHT30_SENSOR_ID=2
|
||||
SHT30_I2C_ADDR=0x44
|
||||
```
|
||||
|
||||
유닛 특성: `User=pi`, **`SupplementaryGroups=i2c`**(`/dev/i2c-1` 접근), `Restart=always`. `install.sh`는 유닛을 자동 설치하지 않으므로 이 단계 누락 금지.
|
||||
|
||||
### 2.5 동작 확인
|
||||
|
||||
```bash
|
||||
i2cdetect -y 1 # 0x44(또는 0x45)가 보여야 함
|
||||
source venv/bin/activate
|
||||
python3 test_sensor.py # CRC/포맷 자가점검 + 측정 1회
|
||||
python3 test_connection.py # 서명 POST 200(연결/서명), 403=API_KEY 불일치, 400=NTP/타임스탬프
|
||||
```
|
||||
|
||||
> ⚠️ `test_sensor.py`는 하드웨어 없이도 **시뮬레이션으로 통과**합니다. 반드시 `i2cdetect`로 0x44를 먼저 확인하고, 서비스 로그에 시뮬레이션 WARN이 없는지 점검하세요(시뮬레이션 폴백이 배선 오류를 가릴 수 있음).
|
||||
|
||||
- Pi 시각은 **NTP 동기** 필수(서버 timestamp 신선도 ±600초 — Raspberry Pi OS 기본 활성).
|
||||
- 이후 5분 주기 `periodic` 측정 보고.
|
||||
|
||||
## 3. 대안(계획) 구성 — STM32F407VGT6 (`sht30_fw`)
|
||||
|
||||
STM32F407 베어메탈 펌웨어(`firmware/`)는 RPi → STM32 전환을 목표로 한 **별도 마이그레이션 트랙**입니다. `docs/stm32f407_migration_plan.md` 기준 **코드는 완성되었으나 실제 보드 빌드·플래시·HIL 시험은 미검증**(서버 host/CA/API키/SNTP가 모두 placeholder)이며, 현재 필드에는 배포되어 있지 않습니다. 이 경로를 실제로 채택하려면 `firmware/docs/HARDWARE.md`·`firmware/docs/BUILD_OFFLINE.md`를 따라 하드웨어를 확정·검증해야 합니다.
|
||||
|
||||
- I2C1 배선(참고): SHT30 SCL → STM32 **PB6**(I2C1_SCL, AF4), SDA → **PB7**(I2C1_SDA, AF4), 각 4.7kΩ 풀업 → 3.3V, ADDR → GND(0x44). VCC는 **3.3V**(5V 금지).
|
||||
- 보고 식별자: `device_id=stm32-sht30-01`, `app_version=v2606-sht30`, `sensor_id=2`. 서버는 이 문자열을 검증하지 않으므로 Pi/STM32 어느 쪽이든 유효 서명이면 동일하게 수용합니다.
|
||||
> 대안 구성에서도 `sensor_id=2`, raw-body 서명(`X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`), 5분 측정 주기는 동일합니다. 임계 판정과 SMS는 모두 서버에서 수행하므로 측정 송신부만 환경에 맞게 구성하면 됩니다.
|
||||
|
||||
## 4. 대시보드 결과
|
||||
|
||||
첫 정상 보고 후:
|
||||
|
||||
- `leak_sensor_status`에 `sensor_id=2` 항목이 생성됩니다.
|
||||
- `leak_sensor_metric`에 온도/습도 이력이 누적됩니다.
|
||||
- `sensor_status`에 `sensor_id=2` 항목이 생성됩니다.
|
||||
- `sensor_metric`에 온도/습도 이력이 누적됩니다.
|
||||
- `dashboard.php`가 센서 카드에 최신 온도/습도와 임계 상태를 표시합니다.
|
||||
|
||||
장비가 `HEARTBEAT_TIMEOUT_SEC`(기본 1200초=20분)을 초과해 보고를 멈추면 서버가 오프라인으로 판정하고 SMS를 발송합니다.
|
||||
|
|
|
|||
|
|
@ -103,14 +103,14 @@ HWP 반영 문구:
|
|||
|
||||
사용자가 제공해야 하는 증적:
|
||||
|
||||
- 정상 측정값 저장 화면 또는 DB 기록(`leak_sensor_metric`)
|
||||
- 정상 측정값 저장 화면 또는 DB 기록(`sensor_metric`)
|
||||
- 비정상 메소드/필드 누락/범위 초과 요청 차단 테스트 결과
|
||||
|
||||
### 2.4 서버 임계 판정 및 SMS 오발송 방지
|
||||
|
||||
HWP 반영 문구:
|
||||
|
||||
> 온습도 임계 판정은 펌웨어가 아니라 서버에서 수행한다. 서버는 `config.php`의 운영 임계(`METRIC_*`: 고온30/저온10℃, 고습70/저습20%)로 고온·저온·고습·저습을 재판정하여 `leak_sensor_metric.metric_status`에 기록하고, 임계 이탈 시 종류별 SMS를 발송한다. 동일 종류 경보는 30분 쿨다운을 적용하고, 정상복귀 판정은 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. 임계 안쪽으로 회복하면 정상복귀 SMS를 발송한다.
|
||||
> 온습도 임계 판정은 펌웨어가 아니라 서버에서 수행한다. 서버는 `config.php`의 운영 임계(`METRIC_*`: 고온30/저온10℃, 고습70/저습20%)로 고온·저온·고습·저습을 재판정하여 `sensor_metric.metric_status`에 기록하고, 임계 이탈 시 종류별 SMS를 발송한다. 동일 종류 경보는 30분 쿨다운을 적용하고, 정상복귀 판정은 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. 임계 안쪽으로 회복하면 정상복귀 SMS를 발송한다.
|
||||
|
||||
코드 근거:
|
||||
|
||||
|
|
@ -119,13 +119,13 @@ HWP 반영 문구:
|
|||
- `METRIC_TEMP_HYSTERESIS_C`(1.0), `METRIC_RH_HYSTERESIS`(3.0)
|
||||
- `METRIC_ALERT_COOLDOWN_SEC`(1800, 30분)
|
||||
- `php/api/sensor_data.php`
|
||||
- `evaluate_metric_thresholds()`로 서버 재판정 → `leak_sensor_metric`
|
||||
- `evaluate_metric_thresholds()`로 서버 재판정 → `sensor_metric`
|
||||
- 경보 종류(`high_temp`/`low_temp`/`high_humidity`/`low_humidity`)별 SMS, 쿨다운/복구 판정
|
||||
- 임계 이탈 시 `send_metric_alert_sms()`, 회복 시 `send_metric_recovery_sms()`
|
||||
|
||||
사용자가 제공해야 하는 증적:
|
||||
|
||||
- 임계 초과 시 종류별 경보 SMS 발송 이력(`leak_sms_log`: `[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`)
|
||||
- 임계 초과 시 종류별 경보 SMS 발송 이력(`sms_log`: `[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`)
|
||||
- 정상복귀 SMS 발송 이력
|
||||
- 쿨다운 적용으로 동일 경보가 30분 내 재발송되지 않는 결과
|
||||
|
||||
|
|
@ -140,15 +140,15 @@ HWP 반영 문구:
|
|||
- `php/config.php`
|
||||
- `HEARTBEAT_TIMEOUT_SEC`(1200), `HEARTBEAT_CHECK_INTERVAL_SEC`(300)
|
||||
- `php/cron_heartbeat.php`
|
||||
- 마지막 보고 시각 기준 오프라인 판정, `leak_sensor_status.offline_alerted` 관리
|
||||
- 마지막 보고 시각 기준 오프라인 판정, `sensor_status.offline_alerted` 관리
|
||||
- `php/api/sensor_data.php`
|
||||
- 오프라인 상태였던 센서가 다시 보고하면 복구 SMS 발송
|
||||
|
||||
사용자가 제공해야 하는 증적:
|
||||
|
||||
- `leak_sensor_status` 마지막 보고 시각
|
||||
- `sensor_status` 마지막 보고 시각
|
||||
- `cron_heartbeat.php` 실행 로그
|
||||
- 오프라인/복구 SMS 발송 이력(`leak_sms_log`)
|
||||
- 오프라인/복구 SMS 발송 이력(`sms_log`)
|
||||
|
||||
### 2.6 비밀값 코드 분리
|
||||
|
||||
|
|
@ -270,6 +270,6 @@ curl -I https://example.com/sht30_monitor/login.php
|
|||
2. `setup_wizard.php` 점검 화면
|
||||
3. API 정상/403 테스트 결과
|
||||
4. 단말 `systemctl status leak-sensor` 화면
|
||||
5. 측정값 저장(`leak_sensor_metric`) 및 임계 경보 SMS(`leak_sms_log`) 화면
|
||||
5. 측정값 저장(`sensor_metric`) 및 임계 경보 SMS(`sms_log`) 화면
|
||||
|
||||
이 5개가 있으면 소스코드 기반 보안대책과 운영 적용 증적을 어느 정도 연결할 수 있습니다.
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
# Claude Code 전역 설정 이동용 사본
|
||||
|
||||
이 폴더 = 이 프로젝트 작업에서 추출한 Claude Code 전역 스킬/규칙의 사본.
|
||||
다른 PC에서 아래 경로로 복사하면 동일하게 동작한다. 비밀값 없음(커밋 안전).
|
||||
|
||||
## 파일
|
||||
|
||||
| 파일 | 종류 | 설치 위치 (다른 PC) |
|
||||
|---|---|---|
|
||||
| `iot-airgap-delivery.SKILL.md` | 스킬 (필요 시 자동/수동 호출) | `~/.claude/skills/iot-airgap-delivery/SKILL.md` |
|
||||
| `iot-verification.rule.md` | 규칙 (전 세션 자동 로드) | `~/.claude/rules/common/iot-verification.md` |
|
||||
|
||||
Windows에서 `~` = `C:\Users\<계정>`.
|
||||
|
||||
## 설치 (다른 PC)
|
||||
|
||||
```bash
|
||||
# Git Bash / WSL
|
||||
mkdir -p ~/.claude/skills/iot-airgap-delivery ~/.claude/rules/common
|
||||
cp iot-airgap-delivery.SKILL.md ~/.claude/skills/iot-airgap-delivery/SKILL.md
|
||||
cp iot-verification.rule.md ~/.claude/rules/common/iot-verification.md
|
||||
```
|
||||
|
||||
```powershell
|
||||
# PowerShell
|
||||
New-Item -ItemType Directory -Force "$env:USERPROFILE\.claude\skills\iot-airgap-delivery", "$env:USERPROFILE\.claude\rules\common" | Out-Null
|
||||
Copy-Item iot-airgap-delivery.SKILL.md "$env:USERPROFILE\.claude\skills\iot-airgap-delivery\SKILL.md"
|
||||
Copy-Item iot-verification.rule.md "$env:USERPROFILE\.claude\rules\common\iot-verification.md"
|
||||
```
|
||||
|
||||
설치 후 새 Claude Code 세션에서 스킬 목록에 `iot-airgap-delivery` 뜨면 성공.
|
||||
|
||||
## 내용 요약
|
||||
|
||||
- **스킬**: 파생키(HMAC) 구조·번들 재생성 워크플로·403/WAF 디버깅 사다리·
|
||||
디스플레이 렌더 오프디바이스 검증·납품 전 병렬 감사 절차.
|
||||
- **규칙**: 위 중 "항상 지켜야 하는" 행동 규칙만 요약(마스터키 취급, deploy/ 수정 금지,
|
||||
requests 커스텀 UA 필수, 렌더 PNG 검증 후 배포, 폴링 엔드포인트 인덱스/LIMIT/try-catch).
|
||||
|
||||
## 갱신 방법
|
||||
|
||||
전역 파일(`~/.claude/...`)을 고친 뒤 이 폴더로 다시 복사해서 커밋.
|
||||
(전역이 원본, 이 폴더는 배포용 사본.)
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
---
|
||||
name: iot-airgap-delivery
|
||||
description: Use when working on IoT sensor systems with air-gapped deploy bundles (Pi nodes + shared-hosting PHP server) — covers derived-key auth, bundle regeneration workflow, 403/WAF debugging, display render verification, and pre-delivery audit. Trigger on deploy bundle work, HMAC key mismatch/403 errors, Pi display rendering, or "납품" (delivery) prep.
|
||||
---
|
||||
|
||||
# IoT Air-gap Delivery Workflow
|
||||
|
||||
Battle-tested workflow for IoT systems shaped like: sensor nodes (Raspberry Pi / STM32)
|
||||
→ shared-hosting PHP server (cafe24-style) → optional mini display node, deployed as
|
||||
self-contained offline bundles generated by a bundle-generator script.
|
||||
|
||||
## 1. Key architecture (get this right first — most repeated confusion)
|
||||
|
||||
- **API_KEY** = master secret. Lives ONLY in: server `config.local.php` + build machine
|
||||
`deploy/.secrets/api_key.txt`. Never on leaf devices.
|
||||
- **Derived keys** = `HMAC-SHA256(message=<label>, key=API_KEY)`:
|
||||
- `STATUS_KEY = hash_hmac('sha256', 'status', API_KEY)` → display node only
|
||||
- `CRON_SECRET = hash_hmac('sha256', 'cron', API_KEY)` → cron HTTP access
|
||||
- The server stores NO derived keys — it recomputes them per request and compares with
|
||||
`hash_equals`. So "server-side status key" does not exist as a stored value.
|
||||
- To print the expected derived key on the server:
|
||||
`php -r 'require "config.php"; echo hash_hmac("sha256","status",API_KEY),"\n";'`
|
||||
- NEVER put the same value in both API_KEY and a derived-key slot. They are different values.
|
||||
- Regenerating `api_key.txt` breaks EVERY deployed node. The generator must reuse an
|
||||
existing secret file; verify the printed key prefix is unchanged after regeneration.
|
||||
|
||||
## 2. Bundle workflow (source is truth)
|
||||
|
||||
1. Edit source files only (never hand-edit files inside `deploy/`).
|
||||
2. Re-run the generator (e.g. `python tools/make_deploy_bundles.py`).
|
||||
3. Confirm output reuses the existing API_KEY (prefix printed unchanged).
|
||||
4. Deploy: server bundle → hosting upload; node bundle → copy to Pi →
|
||||
`sudo systemctl restart <service>`.
|
||||
5. `deploy/` must be gitignored (contains live secrets); remind to back up
|
||||
`deploy/.secrets/` offline — losing it orphans every deployed node.
|
||||
|
||||
## 3. 403 / auth-failure debugging ladder (in order)
|
||||
|
||||
1. Distinguish WHO rejected: `curl -sS -i -H "X-<Key-Header>: <key>" <url>`
|
||||
- JSON error body (`{"error":"forbidden"}`) → app rejected → key mismatch.
|
||||
- HTML 403 → Apache/WAF rejected → NOT a key problem.
|
||||
2. Key mismatch: print server-side derived key (see §1), diff against device config byte-for-byte
|
||||
(watch for pasted whitespace/newlines).
|
||||
3. curl works but device fails with SAME key →
|
||||
a. Service not restarted after config edit (config imported once at process start).
|
||||
b. Service loads a DIFFERENT config file (check systemd `WorkingDirectory`/`ExecStart`,
|
||||
grep the actual file the service imports).
|
||||
c. **WAF blocks default `python-requests/x.y` User-Agent** (very common on shared hosting;
|
||||
curl's UA passes). Fix: always send a custom UA (`"<AppName>/2.0"`) in every
|
||||
`requests` call. Reproduce with `curl -A "python-requests/2.31.0"`.
|
||||
4. 503 `server_not_configured` → server API_KEY still default → upload real config.local.php.
|
||||
5. Placeholder junk keys (e.g. `wnegfjk...`) in a config = never-configured file; find which
|
||||
file the process actually loads before editing anything.
|
||||
|
||||
## 4. Display node changes (small TFT, Pillow rendering)
|
||||
|
||||
- Any render-code change: verify OFF-DEVICE first. Write a scratchpad script that stubs
|
||||
`config` + driver modules via `sys.modules`, feeds synthetic data (including worst cases:
|
||||
all-sections node, missing history, single data point, extreme values), saves PNGs at the
|
||||
panel's exact resolution.
|
||||
- Read the PNGs back (vision) to check: no overlapping text, no clipping at panel edges,
|
||||
no dead zones; then send previews to the user via SendUserFile before deploying.
|
||||
- Layout: slot-based adaptive layout (only sections the node type needs, evenly divided)
|
||||
beats fixed y-coordinates. Guard: clamp page index when sensor count shrinks; fall back
|
||||
to a smaller font when a formatted value exceeds panel width.
|
||||
- SPI display init fail `No such file or directory` → `/dev/spidev*` missing → enable SPI
|
||||
(`sudo raspi-config nonint do_spi 0`) + reboot; then check user in `spi,gpio` groups.
|
||||
|
||||
## 5. Server API endpoints (shared hosting)
|
||||
|
||||
- Poll-heavy endpoints (device polls every ~30s): every time-window query MUST ride an
|
||||
index. Composite `(sensor_id, measured_at)` needs a `sensor_id` predicate — loop
|
||||
per-sensor with a prepared statement instead of one global range scan.
|
||||
- Cap unbounded result sets (`LIMIT`) — a flapping sensor must not inflate the JSON payload.
|
||||
- Wrap the whole handler in try/catch → generic `{"error":"internal"}` + `error_log`;
|
||||
set `ini_set('display_errors','0')` centrally (shared hosting often ships On).
|
||||
- Rate-limit per IP (file-based fixed window, LOCK_EX, same pattern as login throttle);
|
||||
fail-open on storage errors so monitoring devices never die from the throttle itself.
|
||||
- Guard optional tables with a `table_exists` helper before querying.
|
||||
- Additive JSON only: add keys, never rename/remove — old deployed clients keep working.
|
||||
|
||||
## 6. Pre-delivery audit (납품 전)
|
||||
|
||||
1. Launch TWO parallel review agents: security-reviewer + code-reviewer, scoped to the
|
||||
changed surfaces. Verify each finding against actual code before acting.
|
||||
2. Always check FIRST: real secret files (`config_local.py`, `config.local.php`) covered by
|
||||
`.gitignore` AND never committed (`git log --all --full-history -- <path>`); rotate if leaked.
|
||||
3. Fix by severity: CRITICAL (secrets/injection) → HIGH (perf/full scans) → MEDIUM
|
||||
(error disclosure, unbounded payloads) → LOW (render edge cases).
|
||||
4. `php -l` every touched PHP file; re-run render verification; regenerate bundles; then commit.
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
# IoT Delivery Verification Rules
|
||||
|
||||
> Companion to [build-deployment.md](./build-deployment.md) (air-gapped bundles) and the
|
||||
> `iot-airgap-delivery` skill (full workflow). These are always-on behavioral rules.
|
||||
|
||||
## Derived-key discipline (CRITICAL)
|
||||
|
||||
- Master secret (API_KEY) lives only on the server config + build machine secrets dir.
|
||||
Leaf devices get HMAC-derived keys only (`hash_hmac('sha256', '<label>', API_KEY)`).
|
||||
- Servers recompute derived keys per request — never ask "where is the derived key stored
|
||||
on the server"; print it: `php -r 'require "config.php"; echo hash_hmac(...);'`
|
||||
- Never regenerate the master secret casually — it orphans every deployed node. Bundle
|
||||
generators must reuse the existing secret file; verify the key prefix after every run.
|
||||
- Before any commit: real secret files (`config_local.py`, `config.local.php`,
|
||||
`secrets.h`) must be gitignored AND absent from history
|
||||
(`git log --all --full-history -- <path>`). Leak → rotate.
|
||||
|
||||
## Deploy bundle discipline
|
||||
|
||||
- Source tree is truth. Never hand-edit generated `deploy/` files — edit source, re-run
|
||||
the generator, redeploy.
|
||||
- After every source change that ships: regenerate bundles, then state which bundle goes
|
||||
where (server upload vs Pi copy + `systemctl restart <service>`).
|
||||
|
||||
## HTTP client discipline (shared-hosting targets)
|
||||
|
||||
- Every Python `requests` call to a shared-hosting server MUST send a custom User-Agent
|
||||
(WAFs commonly 403 the default `python-requests/x.y` while curl passes).
|
||||
- Debugging 403: first decide WHO rejected (JSON body = app/key; HTML = WAF/Apache),
|
||||
then follow the ladder in the `iot-airgap-delivery` skill. Don't guess — curl it.
|
||||
|
||||
## Render verification (device displays)
|
||||
|
||||
- Never ship display-render changes verified only by reading code. Render synthetic
|
||||
worst-case frames to PNG at exact panel resolution, inspect them visually, show the
|
||||
user previews (SendUserFile), THEN deploy.
|
||||
|
||||
## Poll-heavy endpoint rules
|
||||
|
||||
- Time-window SQL must ride an index (composite indexes need their leading column in
|
||||
WHERE — loop per-entity if needed). Cap result sets with LIMIT. Whole handler in
|
||||
try/catch with generic error JSON. File-based per-IP rate limit that fails OPEN
|
||||
(monitoring must not die from its own throttle). JSON responses evolve additively only.
|
||||
|
|
@ -117,7 +117,7 @@
|
|||
<h2>운영 자가진단</h2>
|
||||
<div class="diag-list">
|
||||
<div class="diag"><i class="dot"></i><div><strong>API 키 · 정상</strong><span>RPi와 PHP API_KEY 일치</span></div></div>
|
||||
<div class="diag"><i class="dot"></i><div><strong>SHT30 metric table · 정상</strong><span>leak_sensor_metric 저장 가능</span></div></div>
|
||||
<div class="diag"><i class="dot"></i><div><strong>SHT30 metric table · 정상</strong><span>sensor_metric 저장 가능</span></div></div>
|
||||
<div class="diag warn"><i class="dot"></i><div><strong>최근 SMS 실패 · 확인 필요</strong><span>최근 7일 실패 1건</span></div></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
@ -142,7 +142,7 @@
|
|||
</article>
|
||||
<article class="sensor">
|
||||
<h3>2번 센서 (SHT30 온습도)</h3>
|
||||
<small>서버실 상단 랙 / rpi4-sht30-01</small><br>
|
||||
<small>서버실 상단 랙 / rpi-sht30-01</small><br>
|
||||
<span class="badge">정상</span>
|
||||
<div class="metric"><span>온도 24.3C</span><span>습도 48.1%</span></div>
|
||||
<div class="last">측정: 2026-05-21 09:29:18</div>
|
||||
|
|
|
|||
|
|
@ -316,7 +316,7 @@
|
|||
<div class="grid">
|
||||
<div class="tile"><strong>상단 상태바</strong><span><span class="state ok">정상</span>이면 운영 경고 없음. <span class="state warn">주의</span>나 <span class="state danger">위험</span>이면 바로 하단 세부 항목을 확인한다.</span></div>
|
||||
<div class="tile"><strong>요약 카드</strong><span>등록 센서, 임계 경보, 오프라인, 30일 임계 SMS, SMS 실패를 빠르게 확인한다.</span></div>
|
||||
<div class="tile"><strong>운영 자가진단</strong><span>API 키, DB 테이블, leak_sensor_metric 저장, SMS 실패 등 운영 준비 상태를 보여준다.</span></div>
|
||||
<div class="tile"><strong>운영 자가진단</strong><span>API 키, DB 테이블, sensor_metric 저장, SMS 실패 등 운영 준비 상태를 보여준다.</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -401,7 +401,7 @@
|
|||
<tr>
|
||||
<td><span class="state warn">센서 온습도 미표시</span></td>
|
||||
<td>SHT30 데이터가 저장되지 않았거나 측정이 실패했다.</td>
|
||||
<td><code>leak_sensor_metric</code> 테이블, <code>sht30-sensor</code> 서비스, I2C 주소 <code>0x44/0x45</code> 확인.</td>
|
||||
<td><code>sensor_metric</code> 테이블, <code>sht30-sensor</code> 서비스, I2C 주소 <code>0x44/0x45</code> 확인.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="state warn">SMS 실패</span></td>
|
||||
|
|
|
|||
|
|
@ -317,7 +317,7 @@
|
|||
<div class="hero-copy">
|
||||
<div class="eyebrow">설치 목표</div>
|
||||
<h2>SHT30 온도와 습도를 5분마다 서버로 보내고 대시보드에 최신값을 표시한다.</h2>
|
||||
<p>두 장비 모두 SHT30 센서를 I2C로 읽어 `leak_sensor_metric`에 온습도 이력을 남기며, 임계 초과 시 서버에서 경보 SMS를 발송한다.</p>
|
||||
<p>두 장비 모두 SHT30 센서를 I2C로 읽어 `sensor_metric`에 온습도 이력을 남기며, 임계 초과 시 서버에서 경보 SMS를 발송한다.</p>
|
||||
<div class="footer-actions">
|
||||
<button class="btn" type="button" onclick="window.print()">인쇄 또는 PDF 저장</button>
|
||||
<a class="btn secondary" href="../SHT30_SENSOR2_SETUP.md">Markdown 설명서</a>
|
||||
|
|
@ -336,18 +336,12 @@
|
|||
<a href="#trouble">문제 대처</a>
|
||||
</nav>
|
||||
|
||||
<div style="margin:16px 0;padding:14px 16px;border:2px solid #e03131;border-radius:10px;background:#fff5f5;color:#7a1f1f;">
|
||||
⚠️ <strong>구버전 예제 안내.</strong> 이 문서는 <code>/etc/sht30-sensor.env</code> 환경파일 방식으로 작성된 <em>구식</em> 예제입니다.
|
||||
실제 배포 구성은 라즈베리파이 <code>config_local.py</code>(Python) 방식이며 장비 ID는 <code>rpi4-sht30-01</code>입니다.
|
||||
최신 정확한 절차는 <a href="../SHT30_SENSOR2_SETUP.md">SHT30_SENSOR2_SETUP.md</a> 및 <code>deploy/03_pi_b_sht30_id2/</code>를 따르세요.
|
||||
</div>
|
||||
|
||||
<section id="prepare">
|
||||
<h2>1. 준비물</h2>
|
||||
<div class="grid">
|
||||
<div class="tile">
|
||||
<strong>라즈베리파이 1대</strong>
|
||||
<span>온습도 전용 장비. 장비 ID 예시는 `rpi4-sht30-01`.</span>
|
||||
<span>온습도 전용 장비. 장비 ID 예시는 `rpi-sht30-01`.</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<strong>SHT30 P4422-3 센서</strong>
|
||||
|
|
@ -527,7 +521,7 @@ journalctl -u sht30-sensor -f</code></pre>
|
|||
</tr>
|
||||
<tr>
|
||||
<td><span class="status warn">metric_saved false</span></td>
|
||||
<td>서버는 받았지만 `leak_sensor_metric` 테이블이 없음.</td>
|
||||
<td>서버는 받았지만 `sensor_metric` 테이블이 없음.</td>
|
||||
<td>`sql/migration_sensor_metric.sql` 실행 후 다시 `--once` 테스트.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
|
|||
|
|
@ -1,445 +0,0 @@
|
|||
# 누수 노드 SHT30 온습도 통합 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 기존 누수 노드(Pi-A=id1, Pi-C=id3)에 SHT30 온습도 센서를 물리적으로 추가하고, 온습도를 같은 sensor_id(1,3)로 서버에 보고 — 대시보드·경보·설치 패키지 전부 반영.
|
||||
|
||||
**Architecture:** 노드마다 기존 누수 서비스는 무변경, `sht30_sensor.py`를 두 번째 systemd 서비스로 얹는다(별도 디렉터리 `/home/pi/sht30`). sht30 코드는 그대로 재사용하고 config_local로 `SENSOR_ID`만 1/3로 지정. 서버는 코드 변경 없이 config.local 값(METRIC_SENSOR_IDS·heartbeat override)만 조정 — 대시보드/보고서는 이미 `METRIC_SENSOR_IDS` 동적 순회.
|
||||
|
||||
**Tech Stack:** Python 3.11(라즈베리 파이 4, Bookworm), smbus2(I2C), PHP(cafe24 서버), systemd, bash 설치 스크립트(폐쇄망 오프라인 번들).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **폐쇄망(air-gapped):** 모든 설치 산출물은 자립형. 네트워크 미사용, 번들 없으면 명확히 FLAG. wheel/deb는 build 머신에서 vendor.sh로 사전 다운로드. (기존 install.sh/vendor.sh 패턴 유지)
|
||||
- **동일 sensor_id:** 온습도는 새 id 없이 누수와 같은 id(Pi-A=1, Pi-C=3). Pi-B(id=2)는 절대 변경 없음.
|
||||
- **누수 코드 무변경:** `leak_sensor.py` 및 누수 런타임 경로(`/home/pi/raspberry_pi4`) 손대지 않음.
|
||||
- **라벨 일치:** 각 노드에서 sht30 config_local의 `DEVICE_LOCATION`은 같은 노드 누수 config_local의 `DEVICE_LOCATION`과 **문자열 동일**(같은 sensor_id upsert 라벨 플랩 방지).
|
||||
- **HMAC 서명:** sht30 config_local의 `API_KEY`는 서버 config.local.php의 `API_KEY`와 완전 동일.
|
||||
- **비밀값:** `config_local.py`/`config.local.php`(실값)는 에이전트가 직접 수정/커밋하지 않음. 예시(`*.example`)와 문서만 반영, 실값 라인은 응답/문서에 제시.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: sht30 config.py — config_local로 SENSOR_ID/SENSOR_NAME 오버라이드 지원
|
||||
|
||||
**목적:** 누수 노드의 sht30가 tracked `config.py`(SENSOR_ID=2 기본) 편집 없이 config_local만으로 id=1/3 보고. 누수 config.py의 SENSORS 오버라이드 패턴과 동일. Pi-B는 config_local에 SENSOR_ID 미지정 → 기본 2 유지(하위호환).
|
||||
|
||||
**Files:**
|
||||
- Modify: `raspberry_pi4_sht30/config.py` (끝에 오버라이드 블록 추가)
|
||||
- Modify: `deploy/03_pi_b_sht30_id2/config.py` (동일 블록 — 소스와 동기화)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `config.SENSOR_ID` / `config.SENSOR_NAME` 가 config_local 값으로 대체 가능(검증 포함). `sht30_sensor.py`가 `from config import SENSOR_ID, SENSOR_NAME` 로 소비(무변경).
|
||||
|
||||
- [ ] **Step 1: `raspberry_pi4_sht30/config.py` 끝(현재 마지막 줄 = config_local import 블록 이후)에 추가**
|
||||
|
||||
```python
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Optional override — per-device values from config_local.py.
|
||||
# Editing the tracked config.py per device causes deployment drift, so a co-located
|
||||
# node (e.g. leak node reporting temp/humidity under the SAME sensor_id 1 or 3)
|
||||
# overrides SENSOR_ID / SENSOR_NAME here instead. If config_local has neither,
|
||||
# the defaults above (SENSOR_ID=2) are kept — backward compatible for Pi-B.
|
||||
# ─────────────────────────────────────────────
|
||||
try:
|
||||
from config_local import SENSOR_ID # noqa: F811 — per-device sensor_id override
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from config_local import SENSOR_NAME # noqa: F811 — per-device display name override
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Validate (fail closed) — a typo must not silently start with a wrong/plausible id.
|
||||
if not (isinstance(SENSOR_ID, int) and 1 <= SENSOR_ID <= 255):
|
||||
raise RuntimeError(f"SENSOR_ID must be int 1..255 (check config_local.py): {SENSOR_ID!r}")
|
||||
if not (isinstance(SENSOR_NAME, str) and SENSOR_NAME):
|
||||
raise RuntimeError("SENSOR_NAME must be a non-empty string (check config_local.py).")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 같은 블록을 `deploy/03_pi_b_sht30_id2/config.py` 끝에도 추가** (소스와 동일 — 두 파일 내용 일치 확인)
|
||||
|
||||
- [ ] **Step 3: 오버라이드 동작 검증(수동 스모크 — 이 repo의 파이썬 테스트 관례는 수동 스크립트)**
|
||||
|
||||
Run (임시 config_local 생성해 import 확인):
|
||||
```bash
|
||||
cd raspberry_pi4_sht30
|
||||
python3 - <<'PY'
|
||||
# 오버라이드 로직 단위 확인(실 config_local 없이): 값 대입+검증 규칙만.
|
||||
ns = {'SENSOR_ID': 2, 'SENSOR_NAME': 'Sensor 2 (SHT30)'}
|
||||
ns['SENSOR_ID'] = 1 # config_local override 흉내
|
||||
assert isinstance(ns['SENSOR_ID'], int) and 1 <= ns['SENSOR_ID'] <= 255
|
||||
assert isinstance(ns['SENSOR_NAME'], str) and ns['SENSOR_NAME']
|
||||
print("override ok:", ns['SENSOR_ID'])
|
||||
PY
|
||||
```
|
||||
Expected: `override ok: 1`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add raspberry_pi4_sht30/config.py deploy/03_pi_b_sht30_id2/config.py
|
||||
git commit -m "feat(sht30): config_local 로 SENSOR_ID/SENSOR_NAME 오버라이드(동일-id 공용 노드 대비)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: deploy/02 (Pi-A) 에 sht30 서브폴더 추가 — SENSOR_ID=1
|
||||
|
||||
**Files:**
|
||||
- Create dir: `deploy/02_pi_a_leak_id1/sht30/`
|
||||
- Create(복사): `sht30/sht30_sensor.py`, `sht30/sht30_driver.py`, `sht30/wire.py`, `sht30/config.py`, `sht30/test_sensor.py`, `sht30/test_connection.py`, `sht30/requirements.txt`, `sht30/vendor.sh`, `sht30/install.sh`, `sht30/systemd/sht30-sensor.service` — 전부 `deploy/03_pi_b_sht30_id2/`에서 **verbatim 복사** (config.py는 Task 1 반영본)
|
||||
- Create: `sht30/config_local.example.py`, `sht30/config_local.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `/home/pi/sht30` 로 설치될 자립형 sht30 installable. Task 4(install.sh 체이닝)가 소비.
|
||||
|
||||
- [ ] **Step 1: deploy/03 sht30 파일 verbatim 복사**
|
||||
|
||||
```bash
|
||||
cd deploy/02_pi_a_leak_id1
|
||||
mkdir -p sht30/systemd
|
||||
cp ../03_pi_b_sht30_id2/sht30_sensor.py sht30/
|
||||
cp ../03_pi_b_sht30_id2/sht30_driver.py sht30/
|
||||
cp ../03_pi_b_sht30_id2/wire.py sht30/
|
||||
cp ../03_pi_b_sht30_id2/config.py sht30/
|
||||
cp ../03_pi_b_sht30_id2/test_sensor.py sht30/
|
||||
cp ../03_pi_b_sht30_id2/test_connection.py sht30/
|
||||
cp ../03_pi_b_sht30_id2/requirements.txt sht30/
|
||||
cp ../03_pi_b_sht30_id2/vendor.sh sht30/
|
||||
cp ../03_pi_b_sht30_id2/install.sh sht30/
|
||||
cp ../03_pi_b_sht30_id2/systemd/sht30-sensor.service sht30/systemd/
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `sht30/systemd/sht30-sensor.service` 의 경로를 `/home/pi/sht30` 로 수정**
|
||||
|
||||
`WorkingDirectory` 와 `ExecStart` 두 줄을 다음으로 변경:
|
||||
```ini
|
||||
WorkingDirectory=/home/pi/sht30
|
||||
ExecStart=/home/pi/sht30/venv/bin/python sht30_sensor.py
|
||||
```
|
||||
(나머지 sandbox/그룹 설정은 그대로 — `SupplementaryGroups=i2c` 유지)
|
||||
|
||||
- [ ] **Step 3: `sht30/config_local.example.py` 작성**
|
||||
|
||||
```python
|
||||
# =============================================================================
|
||||
# config_local.example.py — 누수 노드(Pi-A, sensor_id=1)에 얹는 SHT30 온습도 서비스 설정
|
||||
# cp config_local.example.py config_local.py 후 실값 입력. config_local.py 는 커밋 금지.
|
||||
#
|
||||
# ★ 동일 sensor_id: 이 온습도는 같은 노드 누수 센서와 "같은" sensor_id(=1)로 보고한다.
|
||||
# ★ 라벨 일치: DEVICE_LOCATION 은 같은 노드 누수 config_local.py 의 DEVICE_LOCATION 과
|
||||
# "문자열 동일"해야 한다(같은 id 를 두 서비스가 upsert → 라벨 플랩 방지).
|
||||
# ★ API_KEY 는 서버 config.local.php 의 API_KEY 와 완전 동일(HMAC 서명).
|
||||
# =============================================================================
|
||||
API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php"
|
||||
API_KEY = "CHANGE_ME"
|
||||
|
||||
DEVICE_ID = "rpi4-a-sht30"
|
||||
DEVICE_LOCATION = "Server Room" # ★ 누수(id=1) DEVICE_LOCATION 과 동일하게
|
||||
|
||||
SENSOR_ID = 1 # ★ 누수와 동일 id 로 온습도 보고
|
||||
SENSOR_NAME = "Sensor 1 (SHT30)"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `sht30/config_local.py` 작성(실배포 기본값 — 실값은 현장에서 교체, chmod 600은 install.sh가)**
|
||||
|
||||
Step 3 내용과 동일하게 `config_local.py` 생성. `.gitignore` 가 `config_local.py` 를 제외하는지 확인 — 제외되면 예시만 커밋 대상(실값 파일 미커밋).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy/02_pi_a_leak_id1/sht30
|
||||
git commit -m "feat(pi-a): 누수 노드에 SHT30 온습도 서비스 서브폴더 추가(sensor_id=1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: deploy/04 (Pi-C) 에 sht30 서브폴더 추가 — SENSOR_ID=3
|
||||
|
||||
Task 2와 동일 절차, `deploy/04_pi_c_leak_id3/sht30/` 로. 차이는 config_local(.example).py:
|
||||
|
||||
- [ ] **Step 1: 복사** — Task 2 Step 1을 `cd deploy/04_pi_c_leak_id3` 로 동일 수행.
|
||||
- [ ] **Step 2: service 경로** — Task 2 Step 2와 동일(`/home/pi/sht30`).
|
||||
- [ ] **Step 3+4: config_local(.example).py** — Task 2와 동일하되:
|
||||
|
||||
```python
|
||||
DEVICE_ID = "rpi4-c-sht30"
|
||||
DEVICE_LOCATION = "New Site" # ★ 누수(id=3) DEVICE_LOCATION 과 동일하게
|
||||
SENSOR_ID = 3 # ★ 누수와 동일 id
|
||||
SENSOR_NAME = "Sensor 3 (SHT30)"
|
||||
```
|
||||
(API_URL/API_KEY는 Task 2와 동일 형식)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy/04_pi_c_leak_id3/sht30
|
||||
git commit -m "feat(pi-c): 누수 노드에 SHT30 온습도 서비스 서브폴더 추가(sensor_id=3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: deploy/02 install.sh — sht30 서비스 설치 체이닝 + 서비스 enable
|
||||
|
||||
**Files:**
|
||||
- Modify: `deploy/02_pi_a_leak_id1/install.sh` (마지막 완료 메시지 직전에 단계 추가)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 2의 `sht30/` (자립형 install.sh 포함).
|
||||
- Produces: `install.sh` 1회 실행으로 누수 + 온습도 두 서비스 설치·기동.
|
||||
|
||||
- [ ] **Step 1: `deploy/02_pi_a_leak_id1/install.sh` 의 최종 `echo "==... Installation complete"` 블록 바로 앞에 삽입**
|
||||
|
||||
```bash
|
||||
# 5) SHT30 온습도 co-센서 설치 (동일 sensor_id=1, /home/pi/sht30 로 설치)
|
||||
if [ -d sht30 ]; then
|
||||
echo "[5/5] Installing SHT30 temp/humidity co-sensor (sensor_id 1)..."
|
||||
SHT30_DEST="/home/pi/sht30"
|
||||
sudo mkdir -p "${SHT30_DEST}"
|
||||
# 코드/번들 배치(설치 대상 디렉터리로 복사 — 폐쇄망: 네트워크 미사용)
|
||||
sudo cp -r sht30/. "${SHT30_DEST}"/
|
||||
sudo chown -R "${SERVICE_USER}:${SERVICE_USER}" "${SHT30_DEST}"
|
||||
# sht30 자체 install.sh 로 위임(venv + I2C 활성화 + 로그 + 서비스 설치). OFFLINE 전파.
|
||||
( cd "${SHT30_DEST}" && SERVICE_USER="${SERVICE_USER}" OFFLINE="${OFFLINE}" bash install.sh )
|
||||
# systemd 서비스 등록/기동
|
||||
sudo cp "${SHT30_DEST}/systemd/sht30-sensor.service" /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable sht30-sensor
|
||||
sudo systemctl restart sht30-sensor
|
||||
echo " sht30-sensor 서비스 등록/기동 완료"
|
||||
else
|
||||
echo "[5/5] sht30/ 폴더 없음 — 온습도 co-센서 설치 건너뜀"
|
||||
fi
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 문법 검증**
|
||||
|
||||
Run: `bash -n deploy/02_pi_a_leak_id1/install.sh`
|
||||
Expected: (출력 없음 = 문법 OK)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy/02_pi_a_leak_id1/install.sh
|
||||
git commit -m "feat(pi-a): install.sh 에 SHT30 온습도 서비스 설치 체이닝(누수+온습도 1회 설치)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: deploy/04 install.sh — 동일 체이닝 (sensor_id 3)
|
||||
|
||||
Task 4와 동일. 삽입 스니펫에서 주석 "sensor_id 1"→"sensor_id 3"만 변경.
|
||||
|
||||
- [ ] **Step 1:** `deploy/04_pi_c_leak_id3/install.sh` 에 Task 4 Step 1 스니펫 삽입(주석 id 3).
|
||||
- [ ] **Step 2:** `bash -n deploy/04_pi_c_leak_id3/install.sh` → 문법 OK.
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy/04_pi_c_leak_id3/install.sh
|
||||
git commit -m "feat(pi-c): install.sh 에 SHT30 온습도 서비스 설치 체이닝(sensor_id=3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: deploy/02 배선/문서 — WIRING.html + SETUP.md + README.md
|
||||
|
||||
**Files:**
|
||||
- Modify: `deploy/02_pi_a_leak_id1/WIRING.html` (SHT30 I2C 배선 섹션 추가)
|
||||
- Modify: `deploy/02_pi_a_leak_id1/SETUP.md` (이중 서비스 설치·확인 절차)
|
||||
- Modify: `deploy/02_pi_a_leak_id1/README.md` (개요에 온습도 추가)
|
||||
|
||||
- [ ] **Step 1: `WIRING.html` — 누수 배선 다음에 SHT30 I2C 섹션 삽입**
|
||||
|
||||
`</body>` 직전(또는 마지막 섹션 뒤)에 삽입:
|
||||
```html
|
||||
<section>
|
||||
<h2>SHT30 온습도 (동일 노드, sensor_id 1)</h2>
|
||||
<p>누수 센서와 <b>같은 Pi</b>에 SHT30(P4422-3)을 I2C로 추가. 온습도는 누수와 <b>같은 sensor_id(1)</b>로 보고한다. 누수 GPIO17(핀11)과 <b>핀 충돌 없음</b>.</p>
|
||||
<table>
|
||||
<thead><tr><th>SHT30</th><th>Pi 40핀</th><th>비고</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>VCC</td><td>3.3V (핀 1)</td><td>★ 반드시 3.3V. 5V(핀 2·4) 금지 — I2C 라인 5V로 끌려 Pi 손상</td></tr>
|
||||
<tr><td>SDA</td><td>GPIO2 / SDA1 (핀 3)</td><td>온보드 풀업 없으면 4.7kΩ→3.3V</td></tr>
|
||||
<tr><td>SCL</td><td>GPIO3 / SCL1 (핀 5)</td><td>온보드 풀업 없으면 4.7kΩ→3.3V</td></tr>
|
||||
<tr><td>GND</td><td>GND (핀 6 또는 9)</td><td></td></tr>
|
||||
<tr><td>ADDR</td><td>GND</td><td>0x44 (config.py I2C_ADDR 와 일치)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `SETUP.md` — 온습도 설치/확인 절차 추가**
|
||||
|
||||
다음 섹션을 문서 말미에 추가:
|
||||
```markdown
|
||||
## SHT30 온습도 co-센서 (동일 sensor_id 1)
|
||||
|
||||
이 노드는 누수 + 온습도 두 서비스를 함께 돌린다.
|
||||
|
||||
1. 배선: WIRING.html 의 "SHT30 온습도" 표대로(VCC=3.3V, SDA=핀3, SCL=핀5, GND=핀6, ADDR=GND).
|
||||
2. 온습도 설정: `sht30/config_local.py` 에서 API_URL/API_KEY/DEVICE_LOCATION 을 실값으로.
|
||||
- ★ `DEVICE_LOCATION` 은 누수 `config_local.py` 의 값과 문자열 동일.
|
||||
- `SENSOR_ID=1` 은 그대로(누수와 동일 id).
|
||||
3. 설치: `bash install.sh` 1회 → 누수 + 온습도 서비스 모두 설치·기동(SHT30는 /home/pi/sht30).
|
||||
4. 확인:
|
||||
- `i2cdetect -y 1` → 0x44 표시
|
||||
- `sudo systemctl status leak-sensor sht30-sensor` → 둘 다 active
|
||||
- 서버 대시보드 1번 카드에 누수 상태 + 온습도 표시
|
||||
5. I2C 활성화 직후면 `sudo reboot` 필요할 수 있음(install.sh 안내).
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `README.md`** — 개요 문단에 "이 노드는 누수(id 1) + SHT30 온습도(같은 id 1)를 함께 보고한다" 한 줄 추가.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy/02_pi_a_leak_id1/WIRING.html deploy/02_pi_a_leak_id1/SETUP.md deploy/02_pi_a_leak_id1/README.md
|
||||
git commit -m "docs(pi-a): SHT30 온습도 배선/설치 문서 반영"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: deploy/04 배선/문서 — 동일 (sensor_id 3)
|
||||
|
||||
Task 6과 동일하되 모든 "sensor_id 1"→"3", DEVICE_LOCATION 안내는 누수(id=3) 값 기준.
|
||||
|
||||
- [ ] **Step 1~3:** Task 6 Step 1~3을 `deploy/04_pi_c_leak_id3/` 파일에 id=3으로 반영.
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy/04_pi_c_leak_id3/WIRING.html deploy/04_pi_c_leak_id3/SETUP.md deploy/04_pi_c_leak_id3/README.md
|
||||
git commit -m "docs(pi-c): SHT30 온습도 배선/설치 문서 반영(sensor_id=3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 서버 — config.local.example.php + deploy/01 SETUP 반영
|
||||
|
||||
**Files:**
|
||||
- Modify: `php/config.local.example.php`
|
||||
- Modify: `deploy/01_server_cafe24/asmdkwqdmqwertn/config.local.php`(시크릿 — **에이전트 미수정**, 값만 SETUP·응답에 제시)
|
||||
- Modify: `deploy/01_server_cafe24/SETUP.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 대시보드가 1·3을 온습도 카드로도 표시(METRIC_SENSOR_IDS), 누수 오프라인 감지 속도 유지(heartbeat override).
|
||||
|
||||
- [ ] **Step 1: `php/config.local.example.php` 의 `LEAK_SENSOR_IDS` 아래 `METRIC_SENSOR_IDS` 예시 추가 + heartbeat override 주석 갱신**
|
||||
|
||||
`'LEAK_SENSOR_IDS' => [1],` 다음에 삽입:
|
||||
```php
|
||||
// 온습도(SHT30) sensor_id 목록 — 대시보드 온습도 카드 표시 대상 (기본 [2]).
|
||||
// 누수 노드(1·3)에 SHT30 을 얹어 "같은 sensor_id" 로 온습도를 보고하는 구성에서는
|
||||
// 1·3 도 포함한다: [1, 2, 3]. (누수 카드 + 온습도 카드가 같은 지점에 함께 표시됨)
|
||||
'METRIC_SENSOR_IDS' => [1, 2, 3],
|
||||
```
|
||||
그리고 기존 `SENSOR_HEARTBEAT_TIMEOUT_SEC` 주석 예시를 실효 오버라이드로 교체:
|
||||
```php
|
||||
// 센서별 오프라인 판정 타임아웃(무수신 초). ★ 1·3 은 온습도 목록에도 들어가지만
|
||||
// 누수 노드이므로 metric 기본(7500s)으로 느슨해지면 안 된다 → 누수 타임아웃 유지 위해
|
||||
// 명시 오버라이드한다(30분). (미지정 시 온습도로 간주되어 오프라인 감지가 늦어짐)
|
||||
'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [1 => 1800, 3 => 1800],
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `deploy/01_server_cafe24/SETUP.md` 에 온서버 config.local.php 변경 절차 추가**
|
||||
|
||||
```markdown
|
||||
## 누수 노드 온습도(SHT30) 통합 반영 (동일 sensor_id)
|
||||
|
||||
누수 노드(1·3)에 SHT30 를 얹어 같은 sensor_id 로 온습도를 보고하면, 서버 config.local.php 에
|
||||
아래를 반영한다(코드 변경 없음 — 설정만):
|
||||
|
||||
'METRIC_SENSOR_IDS' => [1, 2, 3],
|
||||
'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [1 => 1800, 3 => 1800],
|
||||
|
||||
- METRIC_SENSOR_IDS: 1·3 을 온습도 카드 대상에 포함(대시보드/월간보고 자동 반영).
|
||||
- SENSOR_HEARTBEAT_TIMEOUT_SEC: 1·3 을 누수 타임아웃(30분)으로 고정(온습도 완화 방지).
|
||||
- 온습도 임계 경보 수신자는 기존 SENSOR_RECIPIENTS[1]/[3] 그대로 사용.
|
||||
반영 후 대시보드에서 1·3 카드에 누수 + 온습도가 함께 뜨는지 확인.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add php/config.local.example.php deploy/01_server_cafe24/SETUP.md
|
||||
git commit -m "docs(server): 누수 노드 동일-id 온습도 통합 — METRIC_SENSOR_IDS/heartbeat 예시·SETUP 반영"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 서버 테스트 — heartbeat override(1·3) 케이스 추가
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/heartbeat_timeout_test.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `resolve_heartbeat_timeout($sid, $overrides, $metricIds, $metricDefault, $globalDefault)` (config.php 순수 함수).
|
||||
|
||||
- [ ] **Step 1: 기존 테스트 파일 문법 확인**
|
||||
|
||||
Run: `php -l tests/heartbeat_timeout_test.php`
|
||||
Expected: `No syntax errors detected`
|
||||
|
||||
- [ ] **Step 2: 실패 테스트 추가 — 1·3 이 METRIC 목록에 있어도 override 로 누수 타임아웃 반환**
|
||||
|
||||
`tests/heartbeat_timeout_test.php` 에 다음 검증 추가(파일의 assert 패턴에 맞춰 삽입):
|
||||
```php
|
||||
// 동일-id 온습도 통합: 1·3 이 METRIC_SENSOR_IDS 에 포함돼도, 명시 오버라이드가
|
||||
// metric 기본(7500)보다 우선하여 누수 타임아웃(1800)을 반환해야 한다.
|
||||
$overrides = [1 => 1800, 3 => 1800];
|
||||
$metricIds = [1, 2, 3];
|
||||
assert(resolve_heartbeat_timeout(1, $overrides, $metricIds, 7500, 1200) === 1800); // 누수 유지
|
||||
assert(resolve_heartbeat_timeout(3, $overrides, $metricIds, 7500, 1200) === 1800); // 누수 유지
|
||||
assert(resolve_heartbeat_timeout(2, $overrides, $metricIds, 7500, 1200) === 7500); // 순수 온습도
|
||||
echo "[OK] heartbeat override(1,3) with metric-membership\n";
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 실행 → 통과 확인**
|
||||
|
||||
Run: `php tests/heartbeat_timeout_test.php`
|
||||
Expected: 기존 출력 + `[OK] heartbeat override(1,3) with metric-membership`, 종료코드 0
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/heartbeat_timeout_test.php
|
||||
git commit -m "test(server): 동일-id 온습도 통합 시 1·3 누수 heartbeat 타임아웃 유지 검증"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 통합 검증 (대시보드 중복집합 + 라벨/스키마) — 코드 수정은 발견 시만
|
||||
|
||||
**Files:**
|
||||
- Verify: `php/dashboard_data.php`(≈208–223, 503–506), `php/monthly_report_print.php`, `php/forecast.php`
|
||||
- Modify(발견 시만): 위 파일 중 동일 id가 LEAK∩METRIC 일 때 깨지는 부분
|
||||
|
||||
- [ ] **Step 1: 카드 맵 로직 확인 — sensor_id 가 LEAK·METRIC 양쪽에 있어도 정상**
|
||||
|
||||
`php/dashboard_data.php` 208–230행을 읽고, `LEAK_SENSOR_IDS`와 `METRIC_SENSOR_IDS` 순회가 서로 독립적으로 카드를 만드는지(중복 집합 1·3에서 예외/누락 없는지) 확인. 누수 카드와 온습도 카드가 **각각** 생성되면 정상.
|
||||
|
||||
- [ ] **Step 2: 월간보고/예보에서 동일 id 취급 확인**
|
||||
|
||||
`php/monthly_report_print.php`(LEAK_SENSOR_IDS·METRIC_SENSOR_IDS 사용부), `php/forecast.php`(METRIC 순회)에서 1·3이 온습도로도 집계될 때 누수 데이터와 충돌/중복 표기가 없는지 확인.
|
||||
|
||||
- [ ] **Step 3: 문제 발견 시에만 최소 수정** — 없으면 수정 없이 다음.
|
||||
|
||||
- [ ] **Step 4: 통합 스모크(로컬 서버 있으면) — sensor_id=1 metric POST → 저장·표시·임계 경로**
|
||||
|
||||
가능하면 로컬/스테이징에서 sensor_id=1 로 sht30 periodic POST(서명 포함) → `leak_sensor_metric`에 1행 저장 + `leak_sensor_status`의 is_leak 보존 + 대시보드 1번 카드에 온습도 표시 확인. (운영 서버 직접 검증은 사용자 몫 — SETUP 절차로 안내.)
|
||||
|
||||
- [ ] **Step 5: Commit(수정 있었을 때만)**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix(server): 동일-id LEAK∩METRIC 대시보드/보고 표기 정합(발견 시)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (작성자 체크)
|
||||
|
||||
- **Spec 커버리지:** A(서버 설정/문서)=Task 8·9·10, B(노드 sht30 서비스)=Task 1·2·3·4·5, 배선/설치문서=Task 6·7. 스펙의 모든 절 대응 태스크 존재. ✓
|
||||
- **Placeholder:** "적절히 처리" 류 없음. 각 스텝 실제 코드/명령/기대출력 포함. ✓
|
||||
- **타입/이름 정합:** `resolve_heartbeat_timeout` 시그니처, `SENSOR_ID`/`SENSOR_NAME`/`DEVICE_LOCATION`/`METRIC_SENSOR_IDS`/`SENSOR_HEARTBEAT_TIMEOUT_SEC` 명칭 전 태스크 일관. ✓
|
||||
- **비밀값:** 실 config_local.py/config.local.php 는 예시·문서만, 실값 라인은 제시(에이전트 미커밋). ✓
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
# 대시보드 다크 UI 전면 개편 구현 계획
|
||||
|
||||
날짜: 2026-07-16
|
||||
목업: `review/ui_mockup_v2.html` (사용자 승인 완료)
|
||||
브랜치: feat/pi-c-sensor3-leak-node (또는 신규 feat/dashboard-dark-ui)
|
||||
|
||||
## 배경·목표
|
||||
|
||||
목업(다크 관제 테마, 글래스 툴바, 결합 센서 카드, 카드형 테이블)을 실제
|
||||
`php/dashboard.php` + `php/assets/app.css` + `php/tabs/*.php` 에 반영한다.
|
||||
폐쇄망 제약 유지: 외부 CDN/폰트/JS 0, 차트는 서버 렌더 인라인 SVG.
|
||||
|
||||
## 센서 토폴로지 (표현의 핵심)
|
||||
|
||||
| id | 구성 | 보고 주기 | 특징 |
|
||||
|----|------|-----------|------|
|
||||
| 1 | 누수 + 온습도 결합 (Pi-A) | 10분 | 누수 사진 촬영·업로드 |
|
||||
| 2 | 온습도 단독 (Pi-B) | 1시간 | 오프라인 판정 임계 김(≈125분) |
|
||||
| 3 | 누수 + 온습도 결합 (Pi-C) | 10분 | 누수 사진 촬영·업로드 |
|
||||
|
||||
### 발견된 기존 UX 결함 (이번 개편에서 반드시 해결)
|
||||
|
||||
`tabs/live.php` 는 id 중복 시 metric 카드가 leak 카드를 가린다
|
||||
(`if (!isset($sensor_cards[$sid]))`). → 1·3번 센서의 **누수 상태가
|
||||
실시간 화면에 표시되지 않는다.** 결합 카드로 통합해 해결한다.
|
||||
|
||||
## 카드 설계 (센서 특징별)
|
||||
|
||||
### A. 결합 카드 (id 1·3) — 한 장에 두 계통
|
||||
- 헤더: 센서명 + 칩 2개 `누수` `온습도` (또는 단일 칩 `누수+온습도`)
|
||||
- 상태 라인 우선순위: 오프라인 > 누수(확정) > 확인중(디바운스) > 온습도 경보 > 정상
|
||||
- 누수와 온습도 경보 동시면: 상태 라인은 누수, 온습도 칩에 경보색 유지
|
||||
- 본문 2행:
|
||||
1. 누수 행: 상태 점 + 텍스트. 누수 중이면 경과 시간·SMS 발송 수·**최근 사진 썸네일**(photo.php 링크)
|
||||
2. 온습도 행: 온도/습도 큰 숫자 + 24h 스파크라인 + 예보 칩(여유/접근)
|
||||
- 누수 중 시각 효과: 카드 glow 펄스 + 하단 물결 SVG (reduced-motion 시 정지)
|
||||
- 푸터: 최종확인 시각 · `10분 주기` 표기
|
||||
|
||||
### B. 온습도 단독 카드 (id 2)
|
||||
- 결합 카드에서 누수 행 제거한 형태. `1시간 주기` 표기.
|
||||
- 오프라인 elapsed 표기는 기존 로직 유지.
|
||||
|
||||
### C. 데이터 없음/오프라인 placeholder — 기존 로직 유지, 새 스타일만.
|
||||
|
||||
## 구현 페이즈
|
||||
|
||||
### Phase 1 — 디자인 토큰·셸 (dash-dark.css 신설 + dashboard.php)
|
||||
0. 확인 완료: app.css 는 login/monthly_report/setup_wizard 등 7개 페이지 공용
|
||||
→ 직접 교체 금지. **`assets/dash-dark.css` 신설**, dashboard.php 만
|
||||
app.css 뒤에 추가 로드해 토큰·컴포넌트를 오버라이드. 타 페이지 무변경.
|
||||
1. `dash-dark.css` 토큰: 다크 팔레트(--bg/--surface/--ink/--ok/--warn/--err/--leak/--temp/--hum),
|
||||
spring easing 변수, 라운드/간격 토큰. 기존 클래스명 유지(마크업 변경 최소화).
|
||||
2. 글래스 툴바(sticky + backdrop-filter), 새로고침 카운트다운 링(기존 30초 리로드
|
||||
JS 그대로, 표시만 교체).
|
||||
3. 히어로/알림 스트립/KPI 스트립/세그먼트 탭바 스타일 교체.
|
||||
4. `prefers-reduced-motion`, `prefers-reduced-transparency` 블록.
|
||||
5. 인쇄(tab=all) 대비: `@media print` 에서 다크 배경 → 흰 배경·검정 잉크로 강제
|
||||
(기존 인쇄·보안 증적 워크플로 보존). monthly_report*·login 등 타 페이지는 건드리지 않음.
|
||||
|
||||
### Phase 2 — live 탭: 결합 센서 카드
|
||||
1. `tabs/live.php` 카드 병합 로직 재작성: id별로 metric+leak 상태 모두 보유하는
|
||||
구조(`['metric' => s|null, 'leak' => s|null]`)로 변경.
|
||||
2. 결합/단독/누수단독 3분기 렌더. 상태 우선순위 규칙 위 설계대로.
|
||||
3. 누수 중 카드에 최근 사진 썸네일: `load_tab_live()` 에서 미복구 사고의
|
||||
최신 photo id 1~2건 조회 추가 (`leak_photos` 테이블, 기존 history 쿼리 재사용).
|
||||
4. 24h 스파크라인: `load_tab_live()` 에 센서별 최근 24h 측정 다운샘플(≤48pt) 쿼리
|
||||
추가, 인라인 SVG polyline 서버 렌더(트렌드 차트 헬퍼 축소판).
|
||||
5. 보고 주기 표기: 설정 상수에서 도출(하드코딩 금지).
|
||||
|
||||
### Phase 3 — trend 탭 스타일 이식
|
||||
1. 기존 SVG 차트 구조 유지(임계선·피크 플래그 이미 있음), 클래스 → 새 토큰 매핑.
|
||||
2. 통계 위젯(7일 추이/도넛/게이지/리더보드) 카드 스타일 교체. 마크업 거의 그대로.
|
||||
|
||||
### Phase 4 — history 탭 카드형 테이블
|
||||
1. 테이블 4종을 `tablecard`(헤더+건수 필) 래핑, 행 hover·경보 행 강조.
|
||||
2. 사진 썸네일 hover 확대. details 접힘(모바일) 동작 유지.
|
||||
3. 30일 평균·SMS 통계 그리드 새 스타일.
|
||||
|
||||
### Phase 5 — system 탭 + 마무리
|
||||
1. system 탭 진단 목록 새 스타일(구조 변경 없음).
|
||||
2. `tests/` PHP 테스트 통과 확인(헬퍼 시그니처 불변이므로 영향 최소 예상).
|
||||
3. 반응형(모바일 768px) + 인쇄(tab=all) 실기 확인.
|
||||
4. `deploy/01_server_cafe24` 에 app.css·tabs 복사본 있으면 동기화 확인.
|
||||
|
||||
## 지키는 것 (불변)
|
||||
|
||||
- 서버 렌더 구조·URL 스킴(?tab=, ?range=)·30초 리로드·details 복원 JS
|
||||
- 임계 판정/SMS 로직 (표시 전용 개편)
|
||||
- WCAG: 새로고침 일시정지 버튼, aria-current, 명도 대비(다크에서 4.5:1 재검증)
|
||||
- 폐쇄망: 외부 리소스 0
|
||||
|
||||
## 리스크
|
||||
|
||||
- 다크 배경 인쇄: tab=all 인쇄 증적이 검정 배경으로 나가면 안 됨 → Phase 1 의
|
||||
@media print 강제 라이트가 방어. 실기 인쇄 미리보기 확인 필수.
|
||||
- app.css 651줄 전면 교체 → 페이지별(login, setup_wizard 등) 공용 여부 확인 후
|
||||
dashboard 전용 분리 또는 공용 토큰만 교체 결정 (Phase 1 착수 시 grep).
|
||||
|
|
@ -1,606 +0,0 @@
|
|||
# 연기(화재) 감지 노드 시범 — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 저가 광전식 연기 감지기 개조품의 감지 이벤트를 기존 PHP 서버까지 도달시켜 `[화재감지]` SMS를 발송한다.
|
||||
|
||||
**Architecture:** 기존 Pi4 누수 노드(`leak_sensor.py`)에 연기 채널을 `sensor_id=4`로 추가. 옵토커플러로 절연된 GPIO27 입력을 기존 엣지검출+confirm-read 디바운스가 처리하고, 확정 전이에서 `smoke_alarm`/`smoke_recovery` event_type를 HMAC 서명 POST. 서버(`sensor_data.php`)는 additive로 새 event_type를 수용해 별도 SMS 태그·수신자 그룹으로 라우팅. `is_leak`·누수 로직은 불변.
|
||||
|
||||
**Tech Stack:** Python 3.11 (RPi.GPIO/rpi-lgpio), PHP 8 + MySQL 8, HMAC-SHA256 raw-body 서명, systemd, 에어갭 배포 번들.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **에어갭(폐쇄망):** 런타임/설치 시 네트워크 없음. 이 시범은 신규 스택·ADC 없음 → 추가 벤더링 불필요(기존 rpi-lgpio/requests 그대로).
|
||||
- **HMAC raw-body 바이트 동일성:** body는 손직렬화 고정 필드순서 문자열. `is_leak`는 0/1 정수, 필드순서 불변. 서버 검증(`verify_signature_raw`, config.php:663)은 event_type 무관.
|
||||
- **additive-only:** JSON/DB/응답 스키마는 추가만. 기존 필드 의미 변경 금지(특히 `is_leak`·누수 상태).
|
||||
- **event_type 이중 게이트:** `sensor_data.php:208`(PHP 화이트리스트) + `sql/schema_sht30.sql:30`(DB ENUM) 둘 다 넓혀야 함. 하나만 하면 조용히 `periodic` 강등(로그·SMS 없음) 또는 DB insert 실패.
|
||||
- **소스가 진실:** `deploy/` 생성물 손편집 금지. `make_deploy_bundles.py` 소스 편집 → 재생성.
|
||||
- **커밋 전 전체 테스트 통과 수 명시**(`./run_tests.sh`), 커밋은 사용자 지시 시.
|
||||
- **인증 경계:** 개조 저가 감지기는 인증품 아님. 보조 통보용. README/대시보드 배너 유지.
|
||||
|
||||
---
|
||||
|
||||
## 하드웨어 — 구매 목록 + 배선도
|
||||
|
||||
### 구매 (AliExpress, ≈ ₩5,000 + 공구)
|
||||
| 부품 | 링크 / 검색 | 수량 | 가격 |
|
||||
|---|---|---|---|
|
||||
| **광전식 단독형 연기알람** (광전식 명시) | AliExpress item **1005010719685508** ("스마트 광전식 화재 경보기 배터리 85dB") | 2 (1개 실습용) | ₩1,507/개 |
|
||||
| 대안 감지기 | item 1005011575755691 (EN14604 CE) / 1005010724878980 (PGST) | — | ₩1,507 |
|
||||
| **PC817 옵토커플러** | 검색 `PC817` 10-pack | 1 | ~₩1,000 |
|
||||
| 저항 (1kΩ, 10kΩ) | 검색 `resistor kit 1/4W` | 1 | ~₩1,000 |
|
||||
| 점퍼선 F-F + 미니 브레드보드 | 검색 `dupont female female` / `mini breadboard 400` | 1 | ~₩2,000 |
|
||||
|
||||
**필수 공구:** 멀티미터(DC 전압), 인두기+납. 없으면 개조 불가 — 먼저 확보.
|
||||
|
||||
### 배선도 (개조, PC817 절연, 감지=HIGH)
|
||||
|
||||
```
|
||||
저가 광전식 알람 (배터리 내장, 개조) 라즈베리파이 4 (40핀 헤더)
|
||||
┌───────────────────────────┐
|
||||
│ [알람 IC] │ 3V3 ── pin 1 ─────┐
|
||||
│ │ 알람 시 LED 점등 │ │
|
||||
│ LED 애노드 ●──┐ │ ┌────────────────┘
|
||||
│ (1kΩ) │ PC817 │4 (collector)
|
||||
│ └──────────────► 1핀(A) ▷|─────► │
|
||||
│ GND ●──────────────────────► 2핀(K) │ 3핀(E)
|
||||
│ │ [PC817 내부 포토트랜지스터] │
|
||||
└───────────────────────────┘ └───► pin 13 (GPIO27) ──┬── 감지 입력
|
||||
│
|
||||
10kΩ 풀다운 ────┤
|
||||
│
|
||||
GND ── pin 9 ───────┘
|
||||
|
||||
동작: 평상시 → 알람 LED off → 옵토 off → GPIO27 = LOW (풀다운, 정상)
|
||||
연기 → 알람 LED on → 옵토 도통 → GPIO27 = HIGH (감지)
|
||||
```
|
||||
|
||||
**핀 매핑 (BCM/물리):**
|
||||
| 신호 | 연결 | Pi 물리핀 |
|
||||
|---|---|---|
|
||||
| 3.3V (PC817 collector 측) | PC817 4핀 → 3V3 | pin 1 |
|
||||
| SMOKE 입력 | PC817 3핀(emitter) → GPIO27 | pin 13 |
|
||||
| 풀다운 | GPIO27 → 10kΩ → GND | pin 13 ↔ pin 9 |
|
||||
| GND 공통 | 감지기 GND ↔ Pi GND(pin 9) | — |
|
||||
|
||||
**주의:**
|
||||
1. LED 애노드 노드 전압을 멀티미터 DC로 먼저 측정(평상시 vs 테스트버튼). V면 직렬저항 = (V−1.2)/2mA. ~2V면 470Ω, 애매하면 1kΩ.
|
||||
2. PC817을 하이사이드(collector→3V3, emitter→GPIO+풀다운)로 배선해야 감지=HIGH → 기존 `ACTIVE_LOW=False`와 극성 일치(코드 무변경).
|
||||
3. 감지기 GND와 Pi GND 공통 접지 필수(옵토 LED 리턴 경로).
|
||||
4. **한계:** 배터리死·단선은 무음(시범 감수). 실배포 승격 시 릴레이+NC(스펙 §9).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**서버(PHP):**
|
||||
- `php/config.php` — SMS 태그 2개 + 쿨다운 상수 + summary SQL 2줄 (수정)
|
||||
- `php/api/sensor_data.php` — event_type 화이트리스트 + is_leak 가드 + smoke 디스패치 분기 (수정)
|
||||
- `php/sms_send.php` — `send_smoke_alert_sms()` / `send_smoke_recovery_sms()` (추가)
|
||||
- `sql/schema_sht30.sql` — ENUM 확장 (수정, 신규 설치용)
|
||||
- `sql/migration_leak_smoke.sql` — 기존 운영 DB ALTER (신규)
|
||||
- `tests/smoke_alert_test.php` — 순수 로직 테스트 (신규)
|
||||
|
||||
**Pi 노드(Python):**
|
||||
- `raspberry_pi4/leak_sensor.py` — 순수 헬퍼 `event_type_for()` + `_confirm_state_change` kind 분기 (수정)
|
||||
- `raspberry_pi4/tests/test_smoke_event.py` — 순수 헬퍼 테스트 (신규)
|
||||
|
||||
**배포:**
|
||||
- `tools/make_deploy_bundles.py` — Pi-C PI_META sensors + 서버 `SENSOR_LABELS[4]`/`SMOKE_SENSOR_IDS` (수정)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 서버 — event_type 수용 + is_leak 가드
|
||||
|
||||
**Files:**
|
||||
- Modify: `php/api/sensor_data.php:208-218`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 서버가 `smoke_alarm`/`smoke_recovery` event_type를 `periodic`으로 강등하지 않고 로깅. `$is_smoke_event` 불리언. smoke 이벤트는 `is_leak=0` 강제.
|
||||
|
||||
- [ ] **Step 1: 화이트리스트 확장**
|
||||
|
||||
`php/api/sensor_data.php:208-209` 를 아래로 교체:
|
||||
```php
|
||||
$event_type = in_array($data['event_type'], ['startup', 'periodic', 'leak_alert', 'leak_recovery', 'smoke_alarm', 'smoke_recovery'], true)
|
||||
? $data['event_type'] : 'periodic';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: is_leak 가드 + is_smoke_event (라인 213-218 블록 교체)**
|
||||
|
||||
```php
|
||||
if ($event_type === 'leak_alert') {
|
||||
$is_leak = 1;
|
||||
} elseif ($event_type === 'leak_recovery') {
|
||||
$is_leak = 0;
|
||||
} elseif ($event_type === 'smoke_alarm' || $event_type === 'smoke_recovery') {
|
||||
$is_leak = 0; // 연기 이벤트는 누수 상태를 건드리지 않음
|
||||
}
|
||||
$is_leak_event = ($event_type === 'leak_alert' || $event_type === 'leak_recovery');
|
||||
$is_smoke_event = ($event_type === 'smoke_alarm' || $event_type === 'smoke_recovery');
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
git add php/api/sensor_data.php
|
||||
git commit -m "feat(smoke): ingest에 smoke_alarm/recovery event_type 수용 (is_leak 불변)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 서버 — DB ENUM 확장 + 마이그레이션
|
||||
|
||||
**Files:**
|
||||
- Modify: `sql/schema_sht30.sql:30-31`
|
||||
- Create: `sql/migration_leak_smoke.sql`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `leak_sensor_log.event_type` ENUM이 smoke 값 수용. 없으면 Task1의 INSERT가 strict 모드에서 실패/절단.
|
||||
|
||||
- [ ] **Step 1: 신규 설치 스키마 ENUM 확장**
|
||||
|
||||
`sql/schema_sht30.sql:30` 의 event_type 컬럼을:
|
||||
```sql
|
||||
event_type ENUM('startup','periodic','leak_alert','leak_recovery','smoke_alarm','smoke_recovery') NOT NULL DEFAULT 'periodic',
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 기존 운영 DB용 마이그레이션 작성**
|
||||
|
||||
`sql/migration_leak_smoke.sql` 생성:
|
||||
```sql
|
||||
-- 연기 감지 이벤트 타입 추가 (additive). MySQL 8은 ADD IF NOT EXISTS 미지원 —
|
||||
-- 재실행 시 동일 정의라 에러 없이 통과(값이 이미 있으면 그대로).
|
||||
ALTER TABLE leak_sensor_log
|
||||
MODIFY COLUMN event_type
|
||||
ENUM('startup','periodic','leak_alert','leak_recovery','smoke_alarm','smoke_recovery')
|
||||
NOT NULL DEFAULT 'periodic';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 로컬/스테이징 DB에 적용 후 insert 검증**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
mysql <db> < sql/migration_leak_smoke.sql
|
||||
mysql <db> -e "INSERT INTO leak_sensor_log (device_id,sensor_id,sensor_name,event_type,is_leak,sensor_time,created_at) VALUES ('t',4,'연기','smoke_alarm',0,NOW(),NOW()); SELECT event_type FROM leak_sensor_log WHERE sensor_id=4 ORDER BY id DESC LIMIT 1;"
|
||||
```
|
||||
Expected: `smoke_alarm` 반환(절단/'' 아님).
|
||||
|
||||
- [ ] **Step 4: 커밋**
|
||||
|
||||
```bash
|
||||
git add sql/schema_sht30.sql sql/migration_leak_smoke.sql
|
||||
git commit -m "feat(smoke): leak_sensor_log.event_type ENUM에 smoke 추가 + 마이그레이션"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 서버 — 화재 SMS 태그·발송 함수·디스패치
|
||||
|
||||
**Files:**
|
||||
- Modify: `php/config.php:157` (태그), `php/config.php:186` (summary SQL), `php/config.php` (쿨다운 상수)
|
||||
- Modify: `php/sms_send.php` (함수 2개 추가, `send_leak_recovery_sms` 뒤 ~740)
|
||||
- Modify: `php/api/sensor_data.php:496` (디스패치 분기)
|
||||
- Create: `tests/smoke_alert_test.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `sms_dispatch_to_recipients()`, `sensor_label()`, `alert_cooldown_suppresses()`, `SMS_TAG_SMOKE_*`.
|
||||
- Produces: `send_smoke_alert_sms(array $sensor_data): bool`, `send_smoke_recovery_sms(array $sensor_data): bool`. `sms_summary_select_sql()`가 `smoke_alarm`,`smoke_recovery` 컬럼 포함.
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 작성 (순수 로직 — 태그·summary SQL 존재)**
|
||||
|
||||
`tests/smoke_alert_test.php` 생성 (harness는 `recipients_group_test.php` 패턴):
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
require_once $root . '/php/config.php';
|
||||
|
||||
$failures = [];
|
||||
function check(bool $cond, string $msg): void {
|
||||
global $failures;
|
||||
if (!$cond) { $failures[] = $msg; }
|
||||
}
|
||||
|
||||
// 태그 상수 정의 + 문구
|
||||
check(defined('SMS_TAG_SMOKE_ALARM') && SMS_TAG_SMOKE_ALARM === '[화재감지]', 'SMS_TAG_SMOKE_ALARM = [화재감지]');
|
||||
check(defined('SMS_TAG_SMOKE_RECOVERY') && SMS_TAG_SMOKE_RECOVERY === '[화재복구]', 'SMS_TAG_SMOKE_RECOVERY = [화재복구]');
|
||||
|
||||
// summary SQL이 smoke 집계 컬럼 포함(대시보드/월간보고서 SSOT)
|
||||
$sql = sms_summary_select_sql();
|
||||
check(str_contains($sql, 'AS smoke_alarm'), 'summary SQL has smoke_alarm');
|
||||
check(str_contains($sql, 'AS smoke_recovery'), 'summary SQL has smoke_recovery');
|
||||
|
||||
// 발송 함수 존재(누수 함수와 시그니처 동일)
|
||||
check(function_exists('send_smoke_alert_sms'), 'send_smoke_alert_sms() exists');
|
||||
check(function_exists('send_smoke_recovery_sms'), 'send_smoke_recovery_sms() exists');
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Smoke alert test failures:\n");
|
||||
foreach ($failures as $f) { fwrite(STDERR, "- {$f}\n"); }
|
||||
exit(1);
|
||||
}
|
||||
echo "Smoke alert checks passed.\n";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `php tests/smoke_alert_test.php`
|
||||
Expected: FAIL (상수·함수 미정의).
|
||||
|
||||
- [ ] **Step 3: config.php — 태그 + 쿨다운 상수**
|
||||
|
||||
`php/config.php:157` (`SMS_TAG_LEAK_RECOVERY` 정의 줄) 바로 뒤에 추가:
|
||||
```php
|
||||
const SMS_TAG_SMOKE_ALARM = '[화재감지]'; // 4번 채널(연기·광전식) 감지
|
||||
const SMS_TAG_SMOKE_RECOVERY = '[화재복구]'; // 연기 해소(복구)
|
||||
```
|
||||
그리고 누수 쿨다운 상수(`LEAK_ALERT_COOLDOWN_SEC`) 근처(config.php:205 부근)에 추가:
|
||||
```php
|
||||
const SMOKE_ALERT_COOLDOWN_SEC = 300; // 화재 경보 SMS 재발신 최소 간격(초)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: config.php — summary SQL에 smoke 집계 추가**
|
||||
|
||||
`sms_summary_select_sql()` 의 `sms_fail` 줄(config.php:186) **앞에** 두 줄 삽입:
|
||||
```php
|
||||
. " SUM(message LIKE '" . SMS_TAG_SMOKE_ALARM . "%') AS smoke_alarm,\n"
|
||||
. " SUM(message LIKE '" . SMS_TAG_SMOKE_RECOVERY . "%') AS smoke_recovery,\n"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: sms_send.php — 발송 함수 2개 (send_leak_recovery_sms 뒤 ~740 라인)**
|
||||
|
||||
`send_leak_alert_sms`(sms_send.php:625) 구조를 그대로 미러(락·쿨다운·에피소드 인지). 태그/쿨다운상수/문구만 교체:
|
||||
```php
|
||||
/**
|
||||
* 화재(연기) 경보 SMS. send_leak_alert_sms 구조 동일(락+쿨다운+에피소드 인지).
|
||||
*/
|
||||
function send_smoke_alert_sms(array $sensor_data): bool {
|
||||
$db = get_db();
|
||||
$sensor_id = (int)$sensor_data['sensor_id'];
|
||||
|
||||
$lockName = substr("sms_smoke_alert_{$sensor_id}", 0, 64);
|
||||
$lockState = 'unsupported';
|
||||
try {
|
||||
$lk = $db->prepare('SELECT GET_LOCK(:n, 0)');
|
||||
$lk->execute([':n' => $lockName]);
|
||||
$res = $lk->fetchColumn();
|
||||
$lockState = ($res === null || $res === false) ? 'unsupported' : (((int)$res === 1) ? 'acquired' : 'busy');
|
||||
} catch (Throwable $e) {
|
||||
$lockState = 'unsupported';
|
||||
error_log("[SMS] GET_LOCK 사용 불가({$lockName}): " . $e->getMessage());
|
||||
}
|
||||
if ($lockState === 'busy') {
|
||||
error_log("[SMS] " . SMS_TAG_SMOKE_ALARM . " 동시 처리 감지 - 센서{$sensor_id} 중복 발송 생략");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$recent = $db->prepare("
|
||||
SELECT message, sent_at FROM leak_sms_log
|
||||
WHERE sensor_id = :sensor_id AND status = 'success'
|
||||
AND (message LIKE :alert OR message LIKE :recovery)
|
||||
ORDER BY sent_at DESC, id DESC LIMIT 1
|
||||
");
|
||||
$recent->execute([
|
||||
':sensor_id' => $sensor_id,
|
||||
':alert' => SMS_TAG_SMOKE_ALARM . '%',
|
||||
':recovery' => SMS_TAG_SMOKE_RECOVERY . '%',
|
||||
]);
|
||||
$row = $recent->fetch();
|
||||
$lastMsg = ($row === false) ? null : (string)$row['message'];
|
||||
$lastAt = ($row === false) ? null : (string)$row['sent_at'];
|
||||
if (alert_cooldown_suppresses($lastMsg, $lastAt, SMS_TAG_SMOKE_ALARM, SMOKE_ALERT_COOLDOWN_SEC, time())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dt = date('Y-m-d H:i:s');
|
||||
[$loc, $name] = sensor_label($sensor_id, $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
|
||||
$message = SMS_TAG_SMOKE_ALARM . "\n"
|
||||
. "위치: {$loc}\n"
|
||||
. "관리주체: {$name}\n"
|
||||
. "연기가 감지되었습니다. 즉시 확인하세요!\n"
|
||||
. "시각: {$dt}";
|
||||
return sms_dispatch_to_recipients($db, $sensor_data, $message, "화재감지");
|
||||
} finally {
|
||||
if ($lockState === 'acquired') {
|
||||
try { $db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => $lockName]); }
|
||||
catch (Throwable $e) { error_log("[SMS] RELEASE_LOCK 실패({$lockName}): " . $e->getMessage()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화재(연기) 복구 SMS. send_leak_recovery_sms 구조 동일(직전 경보 있을 때만 1회).
|
||||
*/
|
||||
function send_smoke_recovery_sms(array $sensor_data): bool {
|
||||
$db = get_db();
|
||||
$sensor_id = (int)$sensor_data['sensor_id'];
|
||||
|
||||
$stmt = $db->prepare("
|
||||
SELECT message FROM leak_sms_log
|
||||
WHERE sensor_id = :sid AND status = 'success'
|
||||
AND (message LIKE :alert OR message LIKE :recovery)
|
||||
ORDER BY sent_at DESC, id DESC LIMIT 1
|
||||
");
|
||||
$stmt->execute([
|
||||
':sid' => $sensor_id,
|
||||
':alert' => SMS_TAG_SMOKE_ALARM . '%',
|
||||
':recovery' => SMS_TAG_SMOKE_RECOVERY . '%',
|
||||
]);
|
||||
$last = $stmt->fetchColumn();
|
||||
if ($last === false || !str_starts_with((string)$last, SMS_TAG_SMOKE_ALARM)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dt = date('Y-m-d H:i:s');
|
||||
[$loc, $name] = sensor_label($sensor_id, $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
|
||||
$message = SMS_TAG_SMOKE_RECOVERY . "\n"
|
||||
. "위치: {$loc}\n"
|
||||
. "관리주체: {$name}\n"
|
||||
. "연기 감지가 해소(복구)되었습니다.\n"
|
||||
. "시각: {$dt}";
|
||||
return sms_dispatch_to_recipients($db, $sensor_data, $message, "화재복구");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: sensor_data.php — 디스패치 분기 (누수 분기 뒤, 라인 496 다음)**
|
||||
|
||||
```php
|
||||
// ── 화재(연기) 경보 / 복구 SMS (4번 채널) ──
|
||||
// Pi가 confirm-read 디바운스로 확정 엣지에서만 보내므로 서버 디바운스 없이 즉시 발송.
|
||||
// 재발신 폭주는 send_smoke_alert_sms 내부 쿨다운(SMOKE_ALERT_COOLDOWN_SEC)으로 억제.
|
||||
if ($is_smoke_event) {
|
||||
if ($event_type === 'smoke_alarm' && function_exists('send_smoke_alert_sms')) {
|
||||
if (send_smoke_alert_sms($sensor_info)) { $sms_sent = true; }
|
||||
} elseif ($event_type === 'smoke_recovery' && function_exists('send_smoke_recovery_sms')) {
|
||||
if (send_smoke_recovery_sms($sensor_info)) { $sms_sent = true; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 테스트 통과 확인 + 전체 스위트**
|
||||
|
||||
Run: `php tests/smoke_alert_test.php` → Expected: PASS
|
||||
Run: `./run_tests.sh` → Expected: 기존 전체 통과 + smoke 1개 추가 (통과 수 명시).
|
||||
|
||||
- [ ] **Step 8: 커밋**
|
||||
|
||||
```bash
|
||||
git add php/config.php php/sms_send.php php/api/sensor_data.php tests/smoke_alert_test.php
|
||||
git commit -m "feat(smoke): [화재감지]/[화재복구] SMS 태그·발송·디스패치 + 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 서버 — 라벨·연기 센서 목록 (config)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/make_deploy_bundles.py` (생성 소스 — 서버 config.local.php 산출)
|
||||
- 운영: `php/config.local.php` (SENSOR_RECIPIENTS[4], git 미추적)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 서버가 `sensor_id=4`를 "연기" 라벨로 표시, 4번 SMS를 화재팀 그룹으로 라우팅.
|
||||
|
||||
- [ ] **Step 1: 생성기에 4번 라벨 + (선택) SMOKE_SENSOR_IDS 추가**
|
||||
|
||||
`tools/make_deploy_bundles.py` 의 `SENSOR_LABELS`(~344-348) 에 4번 추가:
|
||||
```python
|
||||
4: {"location": "<설치 위치>", "name": "연기(광전식)"},
|
||||
```
|
||||
(선택) 대시보드 카드용 `SMOKE_SENSOR_IDS = [4]` 상수를 서버 config 생성부에 추가.
|
||||
|
||||
- [ ] **Step 2: 운영 config.local.php에 수신자 그룹 (수동, git 미추적)**
|
||||
|
||||
`php/config.local.php` 의 `SENSOR_RECIPIENTS` 에:
|
||||
```php
|
||||
4 => ['010XXXXYYYY', '010XXXXZZZZ'], // 화재(연기) 수신자
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 수신자 해소 검증 (추측 금지 — 실제 출력)**
|
||||
|
||||
Run: `php -r 'require "php/config.php"; var_export(recipients_for_sensor(4));'`
|
||||
Expected: 위 화재 그룹 반환(전역 폴백 아님).
|
||||
|
||||
- [ ] **Step 4: 커밋 (config.local.php는 커밋 대상 아님)**
|
||||
|
||||
```bash
|
||||
git add tools/make_deploy_bundles.py
|
||||
git commit -m "feat(smoke): 서버 4번 연기 라벨 + SMOKE_SENSOR_IDS"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Pi — 순수 헬퍼 `event_type_for()` (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `raspberry_pi4/leak_sensor.py` (헬퍼 추가, `_confirm_state_change` 위 ~417)
|
||||
- Create: `raspberry_pi4/tests/test_smoke_event.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `event_type_for(sensor: dict, candidate: bool) -> str` — kind=="smoke"면 smoke_alarm/smoke_recovery, 아니면 leak_alert/leak_recovery. GPIO 불필요한 순수 함수.
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 작성**
|
||||
|
||||
`raspberry_pi4/tests/test_smoke_event.py` 생성:
|
||||
```python
|
||||
from leak_sensor import event_type_for
|
||||
|
||||
|
||||
def test_smoke_kind_maps_to_smoke_events():
|
||||
smoke = {"id": 4, "gpio": 27, "name": "연기", "kind": "smoke"}
|
||||
assert event_type_for(smoke, True) == "smoke_alarm"
|
||||
assert event_type_for(smoke, False) == "smoke_recovery"
|
||||
|
||||
|
||||
def test_default_kind_maps_to_leak_events():
|
||||
leak = {"id": 1, "gpio": 17, "name": "누수"}
|
||||
assert event_type_for(leak, True) == "leak_alert"
|
||||
assert event_type_for(leak, False) == "leak_recovery"
|
||||
```
|
||||
> 실행 전제: `event_type_for` 는 config/GPIO import 부작용 없는 순수 함수여야 테스트가 import 가능. leak_sensor.py 상단 import가 하드웨어에 의존하면, pytest 실행 시 `conftest.py` 에서 `RPi.GPIO`/`config` 를 스텁하거나(기존 테스트 하네스 확인), 헬퍼를 import-safe한 위치(부작용 전)로 둔다.
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `cd raspberry_pi4 && python -m pytest tests/test_smoke_event.py -v`
|
||||
Expected: FAIL (`event_type_for` 미정의 / ImportError).
|
||||
|
||||
- [ ] **Step 3: 헬퍼 구현 (leak_sensor.py, `_confirm_state_change` 정의 앞)**
|
||||
|
||||
```python
|
||||
def event_type_for(sensor: dict, candidate: bool) -> str:
|
||||
"""Map a confirmed transition to its event_type by sensor kind.
|
||||
kind=='smoke' → smoke_alarm/smoke_recovery; default (leak) → leak_alert/leak_recovery."""
|
||||
if sensor.get("kind") == "smoke":
|
||||
return "smoke_alarm" if candidate else "smoke_recovery"
|
||||
return "leak_alert" if candidate else "leak_recovery"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `cd raspberry_pi4 && python -m pytest tests/test_smoke_event.py -v`
|
||||
Expected: PASS (2 passed).
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add raspberry_pi4/leak_sensor.py raspberry_pi4/tests/test_smoke_event.py
|
||||
git commit -m "feat(smoke): event_type_for() 순수 헬퍼 + 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Pi — 확정 전이에서 kind 분기
|
||||
|
||||
**Files:**
|
||||
- Modify: `raspberry_pi4/leak_sensor.py:434-448` (`_confirm_state_change` 의 candidate 분기)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `event_type_for()` (Task 5), `post_to_server()`, `_handle_leak_with_photo()`.
|
||||
- Produces: 연기 센서(kind=smoke)는 확정 감지 시 `smoke_alarm`(사진 없음), 복구 시 `smoke_recovery` POST. 누수 센서 경로 불변.
|
||||
|
||||
- [ ] **Step 1: candidate 분기 교체 (leak_sensor.py:434-448)**
|
||||
|
||||
```python
|
||||
if candidate:
|
||||
evt = event_type_for(sensor, True)
|
||||
if sensor.get("kind") == "smoke":
|
||||
log.warning(f"[SMOKE DETECTED] sensor{sensor['id']} ({sensor['name']}) !!!")
|
||||
threading.Thread(target=post_to_server, args=(evt, sensor, False), daemon=True).start()
|
||||
else:
|
||||
log.warning(f"[LEAK DETECTED] sensor{sensor['id']} ({sensor['name']}) !!!")
|
||||
threading.Thread(target=_handle_leak_with_photo, args=(sensor,), daemon=True).start()
|
||||
else:
|
||||
evt = event_type_for(sensor, False)
|
||||
log.info(f"[{'SMOKE' if sensor.get('kind')=='smoke' else 'LEAK'} RECOVERED] "
|
||||
f"sensor{sensor['id']} ({sensor['name']}) recovered to normal")
|
||||
threading.Thread(target=post_to_server, args=(evt, sensor, False), daemon=True).start()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 회귀 — 기존 Pi 테스트 통과 확인**
|
||||
|
||||
Run: `cd raspberry_pi4 && python -m pytest tests/ -v`
|
||||
Expected: 기존 누수/디바운스 테스트 + smoke 헬퍼 테스트 전부 PASS (통과 수 명시).
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
git add raspberry_pi4/leak_sensor.py
|
||||
git commit -m "feat(smoke): 확정 전이 kind 분기 — 연기는 smoke_alarm/recovery(사진 없음)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 배포 — 번들 재생성 + 마이그레이션 + 재시작
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/make_deploy_bundles.py` (Pi-C PI_META sensors)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1-6 소스 변경, `deploy/.secrets/api_key.txt`.
|
||||
- Produces: Pi-C 번들에 연기 채널(id4) config_local override. 서버 config.local.php에 라벨.
|
||||
|
||||
- [ ] **Step 1: PI_META에 연기 센서 추가**
|
||||
|
||||
`tools/make_deploy_bundles.py` 의 Pi-C(`04_pi_c_leak_id3`) `sensors` 에 추가:
|
||||
```python
|
||||
{"id": 4, "gpio": 27, "name": "연기(광전식)", "kind": "smoke"},
|
||||
```
|
||||
(config.py 검증은 id/gpio/name 필수만 확인 → 추가 키 `kind` 허용됨. 검증 로직 변경 불필요. 실행 전 config.py 검증부에서 추가 키를 거부하지 않는지 1회 확인.)
|
||||
|
||||
- [ ] **Step 2: 번들 재생성 + API_KEY prefix 불변 확인**
|
||||
|
||||
Run: `python tools/make_deploy_bundles.py`
|
||||
Expected: 실행 로그에 기존 API_KEY prefix 재사용(신규 키 생성 안 됨).
|
||||
|
||||
- [ ] **Step 3: 서버 반영**
|
||||
|
||||
- `deploy/01_server_cafe24/<web_dir>/config.local.php` 서버 업로드 (SENSOR_LABELS[4], SENSOR_RECIPIENTS[4]).
|
||||
- 서버에서 `mysql <db> < sql/migration_leak_smoke.sql` 실행.
|
||||
|
||||
- [ ] **Step 4: Pi 반영**
|
||||
|
||||
- Pi-C 번들 폴더 scp → `sudo systemctl restart leak-sensor` (연기 채널은 기존 leak-sensor 서비스에 편입).
|
||||
- harden된 노드는 물리접근/USB 마이그레이션 번들.
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add tools/make_deploy_bundles.py
|
||||
git commit -m "feat(smoke): Pi-C 번들에 연기 채널(id4) + 배포 절차"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 하드웨어 조립 + 엔드투엔드 검증
|
||||
|
||||
**Files:** 없음 (물리 작업 + 검증)
|
||||
|
||||
- [ ] **Step 1: 개조**
|
||||
|
||||
1. 감지기 뚜껑 열고 멀티미터 DC로 알람 LED 애노드 확정(평상시 0V, 테스트버튼 ~2V).
|
||||
2. LED노드 → 1kΩ → PC817 1핀, 2핀 → 감지기 GND.
|
||||
3. PC817 4핀 → Pi 3V3(pin1), 3핀 → GPIO27(pin13) + 10kΩ 풀다운→GND(pin9).
|
||||
|
||||
- [ ] **Step 2: 유휴 레벨 확인**
|
||||
|
||||
Run (Pi): `python3 -c "import RPi.GPIO as g; g.setmode(g.BCM); g.setup(27, g.IN, pull_up_down=g.PUD_DOWN); import time; [print(g.input(27)) or time.sleep(1) for _ in range(3)]"`
|
||||
Expected: 평상시 `0`(LOW).
|
||||
|
||||
- [ ] **Step 3: 엔드투엔드 (HTTP 200 아닌 SMS 실제 발송까지)**
|
||||
|
||||
1. 감지기 테스트버튼(또는 연기) → GPIO27 HIGH.
|
||||
2. Pi 로그 `[SMOKE DETECTED] sensor4` 확인.
|
||||
3. 서버 `leak_sensor_log` 에 `event_type='smoke_alarm'` 행 확인.
|
||||
4. **화재팀 휴대폰에 `[화재감지]` SMS 도착 확인** (HTTP 200만으로 판정 금지).
|
||||
5. 해제 → `[화재복구]` 1회 확인.
|
||||
|
||||
- [ ] **Step 4: 회귀 확인**
|
||||
|
||||
기존 누수(1/3)·온습도(2) 이벤트 정상 + `leak_sensor_status` 의 누수 `is_leak` 미오염 확인.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec 커버리지:**
|
||||
- §2 하드웨어/배선 → 하드웨어 섹션 + Task 8. ✓
|
||||
- §3 Pi 소프트(SENSORS+kind, 극성) → Task 5·6·7 (극성=감지HIGH, ACTIVE_LOW 불변; 풀다운은 외부 10kΩ=옵션 A). ✓
|
||||
- §4 서버 additive(화이트리스트·ENUM·태그·발송·라우팅·라벨) → Task 1·2·3·4. ✓
|
||||
- §5 상태모델(CLEAR/ALARM) → 기존 상태머신 재사용, Task 6 전이. ✓
|
||||
- §6 배포 → Task 7. ✓
|
||||
- §7 테스트 → Task 3(PHP)·5(Py)·8(E2E). ✓
|
||||
|
||||
**미결(스펙 §8) 반영:** 극성 풀다운은 외부 10kΩ(옵션 A, 코드변경 0)로 확정 — 내부 PUD_DOWN(옵션 B)은 setup_gpio 승격 필요하나 이번 시범은 A 채택. 카메라 OFF(연기 분기에서 `_handle_leak_with_photo` 미호출). LED노드 전압은 Task 8 Step1에서 실측.
|
||||
|
||||
**Placeholder 스캔:** `<설치 위치>`, `010XXXX…`(운영 비밀값), `<web_dir>`, `<db>`는 운영 환경별 실제값 — 코드 placeholder 아님. 나머지 코드 스텝은 실코드.
|
||||
|
||||
**타입 일관성:** `event_type_for(sensor, candidate)`(Task5) ↔ Task6 호출 시그니처 일치. `send_smoke_alert_sms/recovery(array): bool`(Task3) ↔ sensor_data.php 호출 일치. 태그 상수명 `SMS_TAG_SMOKE_ALARM/RECOVERY` 전 태스크 동일.
|
||||
|
||||
---
|
||||
|
||||
## 진행 순서 메모
|
||||
|
||||
- Task 1~4(서버) + Task 5(Pi 헬퍼)는 **하드웨어 없이 지금 가능** — 물건 오는 동안 선행.
|
||||
- Task 6은 Task 5 의존. Task 7·8은 하드웨어 도착 후.
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
# 누수 디바운스 · 온습도 1시간 주기 · 센서별 수신자 그룹 설계
|
||||
|
||||
- 날짜: 2026-07-13
|
||||
- 브랜치: feat/pi-c-sensor3-leak-node
|
||||
- 대상: leak_sms_v3 (통합 모니터링 — 누수 1번 모듈 / SHT30 온습도 2번 모듈, 단일 Cafe24 서버)
|
||||
|
||||
## 배경 · 목표
|
||||
|
||||
운영 중 세 가지 요구가 나왔다.
|
||||
|
||||
1. **누수 감지↔복구 플래핑 SMS 폭탄 방지.** 센서에 닿는 물이 애매하면 GPIO가 leak/dry 사이를 진동한다(펌웨어 500ms 바운스로도 못 막는 초 단위 채터링). 현재 서버는 `[누수복구]` 뒤 재감지를 "새 에피소드"로 보고 쿨다운을 무시해 즉시 발송하므로, 사이클마다 `[누수감지]`+`[누수복구]` 2건이 폭탄처럼 쏟아진다.
|
||||
2. **온습도(SHT30) 측정 주기를 1시간으로.** 현재 5분(`REPORT_INTERVAL=300`).
|
||||
3. **센서별 SMS 수신자 그룹 지정.** 현재 `SMS_RECIPIENTS` 전역 하나뿐.
|
||||
|
||||
**폐쇄망 제약:** 모든 운영 설정은 `config.local.php`(또는 환경변수)로 주입한다. 관리자용 설정 UI는 없다(읽기 전용 표시만). DB 스키마 변경은 마이그레이션 SQL 파일로 전달하고, 컬럼 부재 시 graceful degrade(구배포에서 자동 비활성)를 유지한다.
|
||||
|
||||
**공통 원칙:** `leak_sms_log.message`의 태그 문자열이 사실상의 경보 상태 키다(별도 상태 테이블 없음). 태그는 `config.php`의 `SMS_TAG_*` 상수 단일 출처. 원천 이벤트 로그(`leak_sensor_log`)는 진단을 위해 raw 그대로 보존한다.
|
||||
|
||||
---
|
||||
|
||||
## 기능 1 — 누수 감지/복구 디바운스 (서버 전용, 펌웨어 무변경)
|
||||
|
||||
### 결정된 접근
|
||||
|
||||
**디바운스/확인 지연.** 상태 전이를 즉시 발송하지 않고, 확인창 동안 지속되어야 확정한다. 펌웨어는 이미 누수 지속 중 `periodic`을 10초 주기로 재보고(`LEAK_REPORT_INTERVAL=10`)하므로, 서버가 이 신호로 확정 타이머를 전진시킨다. **펌웨어 변경 불필요.**
|
||||
|
||||
### 상태 저장 (leak_sensor_status에 컬럼 2개 추가)
|
||||
|
||||
| 컬럼 | 타입 | 의미 |
|
||||
|---|---|---|
|
||||
| `leak_confirmed` | `TINYINT(1) NOT NULL DEFAULT 0` | 디바운스로 **확정**된 누수 상태. SMS·`leak_incident`·대시보드 헤드라인의 기준. |
|
||||
| `leak_pending_since` | `DATETIME NULL DEFAULT NULL` | raw 상태(`is_leak`)가 확정 상태와 어긋나기 시작한 시각. `NULL`=안정(raw==confirmed). |
|
||||
|
||||
`is_leak`(raw 최신)은 그대로 두고, 확정 상태를 별도로 둔다.
|
||||
|
||||
### 순수 판정 함수 (단위 테스트 대상)
|
||||
|
||||
`alert_cooldown_suppresses()`와 같은 위치(`sms_send.php`)에 DB/네트워크 없는 순수 함수를 둔다.
|
||||
|
||||
```
|
||||
leak_debounce_decide(
|
||||
int $raw, // 0/1 현재 raw 상태
|
||||
int $confirmed, // 0/1 현재 확정 상태
|
||||
?int $pendingSinceTs, // 미확정 전이 시작 Unix (없으면 null)
|
||||
int $alertConfirmSec, // raw=1 확정 임계
|
||||
int $recoveryConfirmSec,// raw=0 확정 임계
|
||||
int $now // 현재 Unix (테스트 주입)
|
||||
): array // ['confirmed'=>int, 'pending_since'=>?int, 'sms'=>null|'leak'|'recovery']
|
||||
```
|
||||
|
||||
판정표 (단일 타이머, 대칭):
|
||||
|
||||
| 조건 | confirmed | pending_since | sms |
|
||||
|---|---|---|---|
|
||||
| `raw == confirmed` | 유지 | `null`(해제) | `null` |
|
||||
| `raw != confirmed`, pending 없음 | 유지 | `now`(시작) | `null` |
|
||||
| `raw != confirmed`, 경과 `< threshold` | 유지 | 유지 | `null` |
|
||||
| `raw != confirmed`, 경과 `≥ threshold` | `raw`(확정) | `null`(해제) | raw=1→`leak`, raw=0→`recovery` |
|
||||
|
||||
`threshold = (raw==1) ? alertConfirmSec : recoveryConfirmSec`.
|
||||
|
||||
**왜 이 설계가 폭탄을 원천 차단하나:** 누수 지속 중 짧은 dry 블립이 와도(raw 0→1→0…), dry 구간이 `recoveryConfirmSec`(기본 180s)보다 짧으면 복구가 확정되지 않는다 → 확정 상태는 계속 `leak` 유지 → **재감지 SMS도, 복구 SMS도 안 나간다.** 진짜 해소(연속 dry ≥ 180s) 때만 `[누수복구]` 1건. 진짜 신규 누수(연속 leak ≥ 60s)만 `[누수감지]` 1건.
|
||||
|
||||
### DB 래퍼 · 통합 지점
|
||||
|
||||
`apply_leak_debounce(PDO $db, int $sensor_id, int $raw, array $sensor_info): bool`
|
||||
1. `(센서)` 단위 네임드 락 `GET_LOCK("leak_debounce_{sid}", 0)`으로 read-decide-update 직렬화(동시 보고 경쟁 방지, 미지원 시 락 없이 진행).
|
||||
2. `leak_sensor_status`에서 `leak_confirmed`, `UNIX_TIMESTAMP(leak_pending_since)` 읽기.
|
||||
3. `leak_debounce_decide(...)` 호출.
|
||||
4. `UPDATE leak_sensor_status SET leak_confirmed=?, leak_pending_since=?`.
|
||||
5. `sms==='leak'` → `leak_incident` open(미개봉 시, 최근 `leak_alert` log_id 링크) + `send_leak_alert_sms()`.
|
||||
`sms==='recovery'` → `leak_incident` 최근 미복구 close + `send_leak_recovery_sms()`.
|
||||
6. SMS 발송 여부 반환.
|
||||
|
||||
기존 `send_leak_alert_sms()`/`send_leak_recovery_sms()`(포맷·발송·로그·자체 쿨다운 백스톱)는 그대로 재사용한다. 이제 이들을 호출하는 **결정**만 raw 이벤트가 아니라 확정 엣지에서 내려온다.
|
||||
|
||||
**호출 게이트 (api/sensor_data.php):** `leak_alert`/`leak_recovery` 이벤트이거나 `sensor_id ∈ LEAK_SENSOR_IDS`인 보고에서 `apply_leak_debounce($db, $sid, $is_leak, $sensor_info)` 호출. SHT30(비누수) 센서는 스킵. 누수 센서의 `periodic`(is_leak=1)이 확정 타이머를 전진시킨다.
|
||||
|
||||
**cron sweep:** `sweep_pending_leak_transitions(PDO $db)` — `leak_pending_since IS NOT NULL`인 센서를 raw=`is_leak`로 재평가해 보고가 끊겨도(예: 복구 대기 중 10분 주기, 장비 무응답) 확정을 해소한다. `check_offline_sensors_throttled()`의 실행 블록(5분 쓰로틀)에 추가.
|
||||
|
||||
### leak_incident (사고 추적)을 확정 기준으로
|
||||
|
||||
기존은 raw `leak_alert`에 open, raw `leak_recovery`에 close → 플래핑 시 1초짜리 사고가 다수 생겨 월간보고서 누수 건수가 부풀었다. 사고 open/close를 **확정 엣지**로 옮겨(위 5단계) 사고 이력이 확정 에피소드만 담게 한다. raw 이벤트는 `leak_sensor_log`에 그대로 남아 플래핑 진단이 가능하다.
|
||||
|
||||
### graceful degrade
|
||||
|
||||
`leak_status_has_debounce_cols(PDO $db): bool`(1회 캐시, information_schema 조회)로 컬럼 존재를 확인한다. **없으면 레거시 즉시 발송 경로**(현행 `leak_alert`→즉시 SMS + raw 기준 incident)를 그대로 탄다. 즉 마이그레이션 미적용 배포에서도 동작은 종전과 동일하고 디바운스만 비활성.
|
||||
|
||||
배포 시 이미 누수 중(`is_leak=1`)인 센서: `leak_confirmed` 초기 0 → 다음 보고에서 pending 시작 → 60s 후 `[누수감지]` 1회 재확정. (허용 가능한 엣지, 사실상 재확인.)
|
||||
|
||||
### 설정 상수 (config.php)
|
||||
|
||||
```
|
||||
LEAK_DEBOUNCE_ENABLED (bool, 기본 true) // 마스터 스위치 (false면 레거시 즉시)
|
||||
LEAK_ALERT_CONFIRM_SEC (int, 기본 60) // 감지 확인창
|
||||
LEAK_RECOVERY_CONFIRM_SEC (int, 기본 180) // 복구 확인창
|
||||
```
|
||||
|
||||
### 대시보드 표시
|
||||
|
||||
- `dashboard_data.php`: `leak_active_count`·히어로 판정을 컬럼 존재 시 `leak_confirmed` 기준으로(=SMS와 헤드라인 일치). 컬럼 부재 시 기존 `is_leak` 폴백.
|
||||
- 누수 상태 카드: `leak_pending_since`가 세팅되고 raw가 확정과 어긋나면 **"확인중(디바운스)"** 배지 노출 — 관리자가 "감지됐는데 왜 SMS 안 왔나"를 즉시 이해.
|
||||
- 시스템 탭 `config_rows`: `['누수 확인 지연', "감지 60s / 복구 180s"]` 행 추가.
|
||||
|
||||
---
|
||||
|
||||
## 기능 2 — 온습도 1시간 주기 + 센서별 히트비트 타임아웃
|
||||
|
||||
### 펌웨어
|
||||
|
||||
`raspberry_pi4_sht30/config.py`: `REPORT_INTERVAL = 300 → 3600` (기본값 자체 변경, 주석 갱신). `config_local.py`로 현장 오버라이드 가능.
|
||||
|
||||
### 센서별 오프라인 타임아웃
|
||||
|
||||
전역 단일 `HEARTBEAT_TIMEOUT_SEC`이 5분 주기 전제였으므로 1시간 온습도 센서를 매시간 오프라인 오탐한다. 히트비트 타임아웃을 센서별로 해소한다.
|
||||
|
||||
`heartbeat_timeout_for(int $sid): int` (config.php):
|
||||
1. `SENSOR_HEARTBEAT_TIMEOUT_SEC[$sid]` 존재 → 그 값 (명시 오버라이드)
|
||||
2. `$sid ∈ METRIC_SENSOR_IDS` → `METRIC_HEARTBEAT_TIMEOUT_SEC` (기본 7500 ≈ 1시간 2회 누락 + 여유)
|
||||
3. 그 외(누수 등) → `HEARTBEAT_TIMEOUT_SEC` (기본 1200 유지)
|
||||
|
||||
`check_offline_sensors()` 수정: 단일 인터벌 WHERE 대신 센서를 순회하며 센서별 타임아웃으로 판정·선점한다. 선점 UPDATE의 `last_seen < NOW() - INTERVAL :timeout SECOND`도 해당 센서 타임아웃을 바인딩. (원자적 0→1 선점·복귀 시 오탐 방지 로직은 유지.)
|
||||
|
||||
`dashboard_data.php` freshness(현 line 334 `HEARTBEAT_TIMEOUT_SEC`)도 `heartbeat_timeout_for($sid)`로 교체.
|
||||
|
||||
### 부수 영향 정리
|
||||
|
||||
- `STUCK_DETECT_SAMPLES`: 6(×5분=30분) → 1시간에선 6시간. 기본 **3**(=3시간 평탄)으로 낮추고 주석 갱신. config로 조정 가능.
|
||||
- `FORECAST_RECENT_POINTS`(주석 "≈1시간")·급변 기준 등 예보 상수 주석을 1시간 주기 기준으로 갱신(예보는 대시보드 전용·SMS 무관 — 로직 변경 없이 주석·의미만 정정, YAGNI).
|
||||
- config.php 히트비트 섹션 주석(5분/10분 서술)을 1시간/센서별로 정정.
|
||||
- 누수 오프라인은 여전히 20분 내 감지(중요도 유지).
|
||||
|
||||
### 설정 상수 (config.php)
|
||||
|
||||
```
|
||||
METRIC_HEARTBEAT_TIMEOUT_SEC (int, 기본 7500) // 온습도 센서 오프라인 판정
|
||||
SENSOR_HEARTBEAT_TIMEOUT_SEC (map, 기본 []) // sid=>sec 명시 오버라이드
|
||||
STUCK_DETECT_SAMPLES (int, 기본 3) // 6→3
|
||||
```
|
||||
|
||||
### 대시보드 표시
|
||||
|
||||
시스템 탭 `config_rows`의 `['오프라인 판정', ...]`을 센서별로: 예 `누수 20분 / 온습도 125분 무수신`.
|
||||
|
||||
---
|
||||
|
||||
## 기능 3 — 센서별 수신자 그룹 (config.local.php 맵)
|
||||
|
||||
### 설정 파싱 (config.php)
|
||||
|
||||
`SENSOR_LABELS`와 동일 패턴으로 `SENSOR_RECIPIENTS` 맵을 파싱·검증한다.
|
||||
|
||||
```
|
||||
'SENSOR_RECIPIENTS' => [
|
||||
1 => ['010-1111-1111', '010-2222-2222'], // 누수 → 시설팀
|
||||
3 => ['010-3333-3333'], // 3번 → 고객센터
|
||||
]
|
||||
```
|
||||
|
||||
- 키: 1..255 정수 sid. 값: 문자열 배열 또는 쉼표문자열(정제: `preg_replace('/\D+/','')`, 빈 항목 제거).
|
||||
- 유효 번호가 하나도 없는 그룹은 버림(전역 폴백되도록).
|
||||
- `define('SENSOR_RECIPIENTS', $parsed)`.
|
||||
|
||||
### 해소 헬퍼
|
||||
|
||||
`recipients_for_sensor(int $sid): array` (config.php) → `SENSOR_RECIPIENTS[$sid] ?? SMS_RECIPIENTS`. `sid=0`(헬스/잔액/시스템)은 그룹 없음 → 전역 폴백.
|
||||
|
||||
### 발송 통합 (sms_send.php)
|
||||
|
||||
`sms_dispatch_to_recipients(...)`의 `$targets = $recipients ?? SMS_RECIPIENTS`를 `$targets = $recipients ?? recipients_for_sensor((int)($sensor_data['sensor_id'] ?? 0))`로 변경. 명시 전달(에스컬레이션 TIER2)은 그대로 우선. 잔액경고(`maybe_warn_sms_balance`)·일일헬스는 시스템 전역이라 `SMS_RECIPIENTS` 유지.
|
||||
|
||||
**에스컬레이션 TIER2:** 전역 `SMS_RECIPIENTS_TIER2` 유지(YAGNI — 센서별 2차 그룹은 이번 범위 밖).
|
||||
|
||||
### 대시보드 표시
|
||||
|
||||
시스템 탭 `config_rows`: 센서별 수신자 그룹 요약 행 추가(번호는 `mask_phone()`로 마스킹). 예 `['수신자 · 1번', '010-****-1111 외 1명']`, 미지정 센서는 `전역(N명)` 표기.
|
||||
|
||||
---
|
||||
|
||||
## DB 마이그레이션
|
||||
|
||||
`sql/migration_leak_debounce.sql` 신규 + canonical `sql/schema_sht30.sql` & `deploy/01_server_cafe24/sql/schema.sql`의 `leak_sensor_status` 정의 갱신.
|
||||
|
||||
```sql
|
||||
ALTER TABLE `leak_sensor_status`
|
||||
ADD COLUMN `leak_confirmed` TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT '디바운스 확정 누수 상태(SMS/사고추적 기준)',
|
||||
ADD COLUMN `leak_pending_since` DATETIME NULL DEFAULT NULL
|
||||
COMMENT '미확정 전이 시작 시각(NULL=안정)';
|
||||
```
|
||||
|
||||
멱등(`ADD COLUMN IF NOT EXISTS` 미지원 MySQL 대비 재실행 시 오류 무시 안내 주석 포함). 컬럼 부재 배포는 graceful degrade로 계속 동작.
|
||||
|
||||
## 테스트 (무프레임워크 PHP — 기존 tests/ 스타일)
|
||||
|
||||
- `tests/leak_debounce_test.php`: `leak_debounce_decide` 순수함수.
|
||||
- 플래핑(leak→dry→leak, 각 구간 < 확인창) → sms 없음, 확정 불변.
|
||||
- 지속 누수(연속 leak ≥ 60s) → 1회 `leak` 확정.
|
||||
- 확정 누수 중 짧은 dry(<180s) 블립 → 복구 확정 안 됨, sms 없음.
|
||||
- 진짜 복구(연속 dry ≥ 180s) → 1회 `recovery`.
|
||||
- pending 시작/해제/유지 경계값.
|
||||
- `tests/recipients_group_test.php`: `recipients_for_sensor` (그룹 히트/전역 폴백/sid=0/빈 그룹 버림).
|
||||
- `tests/heartbeat_timeout_test.php`: `heartbeat_timeout_for` (명시>METRIC>전역 우선순위).
|
||||
- 기존 `tests/cooldown_episode_test.php`·`threshold_sms_test.php` 회귀 확인(레거시 경로·metric 경보 불변).
|
||||
|
||||
## 배포 · 폐쇄망 노트
|
||||
|
||||
1. SQL 마이그레이션을 서버에서 1회 실행(`SOURCE .../migration_leak_debounce.sql`).
|
||||
2. `php/config.local.php`에 필요 시 `SENSOR_RECIPIENTS`, `SENSOR_HEARTBEAT_TIMEOUT_SEC` 등 추가(선택 — 미설정 시 전역/기본 폴백).
|
||||
3. 온습도 Pi 노드: `raspberry_pi4_sht30/config.py` 갱신 반영(재배포) 또는 `config_local.py`에 `REPORT_INTERVAL=3600`.
|
||||
4. `deploy/01_server_cafe24/asmdkwqdmqwertn/` 미러(php 배포본)에 동일 변경 반영.
|
||||
|
||||
## 범위 밖 (YAGNI)
|
||||
|
||||
- 온습도(metric) 경보 디바운스 — 이미 히스테리시스+쿨다운 보유, 1시간 주기로 채터링 사실상 소멸.
|
||||
- 센서별 TIER2 에스컬레이션 그룹.
|
||||
- 관리자 설정 편집 UI(폐쇄망 — config.local.php 유지).
|
||||
- 플래핑 횟수 기반 별도 억제(디바운스로 충분).
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
# 설계: 누수 노드에 SHT30 온습도 통합 (동일 sensor_id)
|
||||
|
||||
**날짜:** 2026-07-15
|
||||
**브랜치:** feat/pi-c-sensor3-leak-node
|
||||
**상태:** 설계 승인됨 (사용자 확인 완료)
|
||||
|
||||
## 목표
|
||||
|
||||
기존 누수 노드(Pi-A=sensor_id 1, Pi-C=sensor_id 3)에 SHT30(P4422-3) 온습도 센서를
|
||||
물리적으로 추가하고, 온습도를 **같은 sensor_id(1, 3)** 로 서버에 보고한다. 대시보드·경보·
|
||||
월간보고·설치 패키지까지 전부 반영한다. Pi-B(온습도 전용, sensor_id 2)는 건드리지 않는다.
|
||||
|
||||
## 핵심 결정 (확정)
|
||||
|
||||
1. **동일 sensor_id.** 온습도는 새 id를 만들지 않고 누수와 같은 id(1, 3)로 보고한다.
|
||||
- `leak_sensor_status`(sensor_id 키)와 `leak_sensor_metric`(sensor_id+측정시각 키)는
|
||||
테이블이 분리돼 있어, 한 sensor_id가 누수 상태 + 온습도 측정을 동시에 가질 수 있다.
|
||||
- 새 id/재번호 없음. Pi-B(2) 유지.
|
||||
2. **노드: 별도 서비스.** 불안정한 누수 코드(`leak_sensor.py`)는 건드리지 않는다.
|
||||
기존 `sht30_sensor.py`를 **두 번째 systemd 서비스**로 얹는다. sht30 코드는 무변경 —
|
||||
config의 `SENSOR_ID`만 노드별(1/3)로 설정.
|
||||
3. **라벨:** 서버 SENSOR_LABELS 오버라이드 없이, 노드가 보고하는 device_location/
|
||||
sensor_name을 그대로 쓴다. 단, 같은 sensor_id를 두 서비스가 upsert하므로 라벨이
|
||||
흔들리지 않게 **두 서비스의 device_location을 노드별로 동일하게** 맞춘다.
|
||||
4. **경보/수신자:** 전역 METRIC_* 임계로 서버가 고온/고습 등 판정 → SMS. 수신자는
|
||||
기존 SENSOR_RECIPIENTS의 1·3 그룹을 그대로 사용(추가 매핑 불필요).
|
||||
|
||||
## 아키텍처 / 데이터 흐름
|
||||
|
||||
```
|
||||
Pi-A (한 대)
|
||||
├ leak-sensor.service → leak_sensor.py → POST (sensor_id=1) leak_alert/recovery/periodic (누수, 온습도 없음)
|
||||
└ sht30-sensor.service → sht30_sensor.py → POST (sensor_id=1) periodic (metric_type=sht30, 온·습도)
|
||||
Pi-C (한 대): 위와 동일, sensor_id=3
|
||||
Pi-B: 변경 없음 (sensor_id=2)
|
||||
|
||||
서버 sensor_data.php:
|
||||
- sensor_id=1 상태 upsert: 누수 이벤트만 is_leak 갱신, sht30 periodic은 is_leak 보존(기존 로직)
|
||||
- sht30 payload → leak_sensor_metric(sensor_id=1) 저장 + 임계 판정 → 온습도 SMS(SENSOR_RECIPIENTS[1])
|
||||
- last_seen 은 두 서비스가 모두 갱신
|
||||
대시보드: sensor_id=1 → 누수 카드(LEAK_SENSOR_IDS) + 온습도 카드(METRIC_SENSOR_IDS)
|
||||
```
|
||||
|
||||
## 변경 범위
|
||||
|
||||
### A. 서버 (코드 변경 없음 — 설정/문서/예시만)
|
||||
|
||||
서버 코드는 이미 동적(대시보드/추이/월간보고 전부 `METRIC_SENSOR_IDS` 순회, 스키마는
|
||||
임의 sensor_id 수용)이라 **코드 수정 불필요.** 설정과 예시/문서만 반영.
|
||||
|
||||
1. `php/config.local.example.php`
|
||||
- `METRIC_SENSOR_IDS` 예시를 `[1, 2, 3]` 로(누수 노드 1·3도 온습도 카드 표시).
|
||||
- `SENSOR_HEARTBEAT_TIMEOUT_SEC` 예시에 `[1 => 1800, 3 => 1800]` 추가 — 1·3을 온습도
|
||||
목록에 넣으면 오프라인 타임아웃이 metric 기본(7500s)으로 느슨해지므로, 누수 노드는
|
||||
빠른 오프라인 감지를 유지하도록 명시 오버라이드.
|
||||
- 동일 sensor_id에 누수+온습도를 함께 두는 구성 설명 주석 추가.
|
||||
2. `deploy/01_server_cafe24/asmdkwqdmqwertn/config.local.php`(온서버 시크릿 — 접근 불가)
|
||||
- 실제 반영 값을 사용자에게 제시:
|
||||
`METRIC_SENSOR_IDS => [1,2,3]`, `SENSOR_HEARTBEAT_TIMEOUT_SEC => [1=>1800, 3=>1800]`.
|
||||
- 파일이 시크릿이라 에이전트가 직접 수정하지 않고, 추가할 라인을 SETUP 문서/응답에 명시.
|
||||
3. `deploy/01_server_cafe24/SETUP.md`: 위 config.local 변경 절차 문서화.
|
||||
|
||||
**검증 항목(구현 중 확인):** `php/dashboard_data.php`의 카드 맵(≈208–223행)이 sensor_id를
|
||||
LEAK/METRIC 양쪽에 두어도(중복 집합) 정상 렌더되는지, `monthly_report*`/`forecast.php`가
|
||||
동일 id를 누수+온습도로 다뤄도 깨지지 않는지 확인. 문제 시 최소 수정.
|
||||
|
||||
### B. 노드 — 각 누수 노드에 sht30 서비스 추가 (deploy/02, deploy/04)
|
||||
|
||||
각 노드 배포 폴더에 `sht30/` 하위 폴더를 추가한다(기존 누수 파일/경로는 불변 =
|
||||
`/home/pi/raspberry_pi4` 그대로). sht30 파일은 `raspberry_pi4_sht30/`에서 복사(코드 무변경).
|
||||
|
||||
노드별 `sht30/` 구성 파일:
|
||||
- `sht30_sensor.py`, `sht30_driver.py`, `wire.py` — 소스에서 복사(무변경)
|
||||
- `config.py` — 소스에서 복사(무변경; SENSOR_ID 기본값은 config_local에서 오버라이드)
|
||||
- `config_local.example.py` — 템플릿
|
||||
- `config_local.py` — 노드별 실값:
|
||||
- Pi-A(02): `SENSOR_ID=1`, `SENSOR_NAME`=누수와 동일, `DEVICE_ID="rpi4-a-sht30"`,
|
||||
`DEVICE_LOCATION`=Pi-A 누수 노드와 **동일 문자열**, API_URL/API_KEY=서버와 동일
|
||||
- Pi-C(04): `SENSOR_ID=3`, 나머지 동일 패턴(`DEVICE_ID="rpi4-c-sht30"`)
|
||||
- `requirements.txt` — `smbus2` 포함(소스와 동일)
|
||||
- `systemd/sht30-sensor.service` — `WorkingDirectory=/home/pi/sht30`,
|
||||
`ExecStart=/home/pi/sht30/venv/bin/python sht30_sensor.py`, `SupplementaryGroups=i2c`
|
||||
- `test_sensor.py`, `test_connection.py` — 소스에서 복사
|
||||
- `vendor.sh` — 오프라인 번들(smbus2 휠 + i2c-tools deb)
|
||||
|
||||
각 누수 노드 폴더의 설치/문서 갱신:
|
||||
- `install.sh` — 기존 누수 설치에 더해 **sht30 서비스 설치 단계 추가**:
|
||||
- `sht30/`를 `/home/pi/sht30`로 설치(venv 생성, requirements 설치, 로그파일
|
||||
`/var/log/sht30_sensor.log` 생성, config_local 권한 600)
|
||||
- **I2C 활성화**: `sudo raspi-config nonint do_i2c 0` (오프라인 가능). 실패 시
|
||||
`/boot/firmware/config.txt`(Bookworm) 또는 `/boot/config.txt`에 `dtparam=i2c_arm=on`
|
||||
보장. i2c 그룹에 SERVICE_USER 추가.
|
||||
- `sht30-sensor.service` 설치 + enable + start
|
||||
- 폐쇄망 규칙 준수: 네트워크 미사용, 번들 없으면 명확히 FLAG(기존 install.sh 패턴 유지)
|
||||
- `WIRING.html` — 기존 누수 배선에 **SHT30 I2C 배선 추가**:
|
||||
VCC→3.3V(핀1), SDA→GPIO2(핀3), SCL→GPIO3(핀5), GND→핀6/9, ADDR→GND=0x44.
|
||||
누수 GPIO17(핀11)과 무충돌 명시. "반드시 3.3V, 5V 금지" 경고 포함.
|
||||
- `SETUP.md` / `README.md` — 이중 서비스(누수+온습도) 설치·확인 절차, 동일 sensor_id 설명.
|
||||
|
||||
### C. 소스 트리 (참고 — 정합성)
|
||||
|
||||
`raspberry_pi4_sht30/`(sht30 소스)와 `raspberry_pi4/`(누수 소스)는 코드 변경 없음.
|
||||
`php/config.local.example.php`만 소스에서 갱신(위 A-1). deploy가 1차 산출물이므로
|
||||
deploy 폴더를 정본으로 반영하고, 소스 예시 파일은 동기화한다.
|
||||
|
||||
## 에러 처리 / 엣지
|
||||
|
||||
- **라벨 플랩:** 두 서비스가 같은 sensor_id로 sensor_name/device_location upsert →
|
||||
마지막 쓰기가 이김. 노드별로 두 config의 device_location을 동일 문자열로 맞춰 방지.
|
||||
(sensor_name은 서버 표시에 영향 최소지만 역시 일치 권장.)
|
||||
- **오프라인 타임아웃:** A-1/A-2의 `SENSOR_HEARTBEAT_TIMEOUT_SEC` 오버라이드로 1·3의
|
||||
누수 오프라인 감지 속도 유지(온습도 목록 편입에 의한 완화 방지).
|
||||
- **is_leak 보존:** sht30 periodic(비-누수 이벤트)은 서버 upsert에서 is_leak 보존(기존
|
||||
`IF(:is_leak_event, VALUES(is_leak), is_leak)` 로직) → 온습도 보고가 누수 상태를 덮지 않음.
|
||||
- **SHT30 통신 실패:** `sht30_sensor.py`가 크래시 없이 재시도(기존 동작). 누수 서비스와
|
||||
독립 프로세스라 상호 영향 없음.
|
||||
- **I2C 미활성:** smbus2/I2C 불가 시 sht30는 시뮬레이션 폴백(기존). install.sh가 I2C를
|
||||
활성화하고, 미활성 시 로그로 안내.
|
||||
|
||||
## 테스트
|
||||
|
||||
- **서버(PHP 단위/통합):**
|
||||
- recipients: sensor_id 1·3의 온습도 경보가 SENSOR_RECIPIENTS[1]/[3]로 발송되는지.
|
||||
- heartbeat: `heartbeat_timeout_for(1)`/(3)이 오버라이드로 누수 타임아웃(1800s)을
|
||||
반환(metric 7500s 아님)하는지 — `resolve_heartbeat_timeout` 단위 테스트 케이스 추가.
|
||||
- 통합: sensor_id=1 metric POST(periodic) → leak_sensor_metric 저장 + is_leak 보존 +
|
||||
임계 초과 시 SMS 경로.
|
||||
- 대시보드: LEAK∩METRIC(=1,3)에서 카드 렌더 정상(중복 집합) 스모크.
|
||||
- **노드:**
|
||||
- `sht30/test_sensor.py`: I2C 판독(또는 시뮬).
|
||||
- `sht30/test_connection.py`: SENSOR_ID=1/3로 서버 POST 200 + 서명 검증.
|
||||
- **오프라인 설치:** 네트워크 차단 상태에서 install.sh가 두 서비스 + I2C 활성화까지
|
||||
무network 완료되는지(폐쇄망 검증).
|
||||
|
||||
## 비목표 (YAGNI)
|
||||
|
||||
- Pi-B(sensor_id 2) 재번호/변경 없음.
|
||||
- 누수 라인 100nF 콘덴서/하드웨어 필터 — 별개 이슈(이번 범위 밖).
|
||||
- 센서별 경보 on/off 토글 등 신규 서버 기능 없음(전역 임계 그대로).
|
||||
- 누수 4~10분 복구 지연(리포트 굶주림) 수정 — 별개 이슈(이번 범위 밖).
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
> **⚠️ SUPERSEDED 2026-07-19:** 화재/연기 감지는 PMS7003 미세먼지 metric 통합으로 대체됨.
|
||||
> 개조/불꽃/CO 경로 폐기. 아래는 이력 보존용.
|
||||
> 대체 설계: [`2026-07-19-node-sensor-integration-design.md`](./2026-07-19-node-sensor-integration-design.md)
|
||||
|
||||
# 연기(화재) 감지 노드 — 시범(Pilot) 설계
|
||||
|
||||
- **작성일:** 2026-07-18
|
||||
- **브랜치:** feat/pi-c-sensor3-leak-node
|
||||
- **범위:** 기존 Pi4 누수+온습도 노드에 저가 광전식 연기 감지를 곁다리로 추가 (시범/개념검증)
|
||||
- **상태:** 설계 확정, 구현 계획 대기
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표와 비목표
|
||||
|
||||
### 목표
|
||||
- 저가 광전식 연기 감지기의 **감지 이벤트 1건**을 기존 PHP 서버까지 도달시켜 SMS 알림 발송.
|
||||
- 기존 노드 구조(엣지검출 + confirm-read 디바운스 + HMAC 보고 + 센서별 SMS 그룹) **재사용**.
|
||||
- **최대한 저가.** 시범(개념검증)이므로 인증·감시·로컬사이렌은 범위 밖.
|
||||
|
||||
### 비목표 (이번 시범에서 제외)
|
||||
- 로컬 알람/사이렌 (필요 없음 — 감지 신호 도달만).
|
||||
- 감시(FAULT)/단선검출 2선 루프, EOL 전원감시릴레이.
|
||||
- KFI/UL 인증, 법정 자동화재탐지설비 대체.
|
||||
- FAULT event_type, 정비 SMS 그룹.
|
||||
|
||||
> **인증 경계 (문서/대시보드 배너 필수):** 이 시범 노드는 **보조 통보용**이며 법정 소방 화재경보설비를 대체하지 않는다. 개조된 저가 감지기는 인증품이 아니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 하드웨어 (개조 시범 경로 — 어댑터 불필요)
|
||||
|
||||
> 확정 근거: 사용자가 12~24V 어댑터 없이 가는 개조를 명시 선택. 단독형 배터리 알람이라 **외부 전원 0.** 대가는 납땜 + 멀티미터 프로빙(§ 트레이드오프).
|
||||
|
||||
### BOM (1노드 ≈ ₩5,000, 외부 전원 없음)
|
||||
| 부품 | 사양 | 가격 |
|
||||
|---|---|---|
|
||||
| 저가 광전식 단독형 연기알람 (AliExpress) | DC3V 배터리, 예비 포함 2개(1개 실습용). PGST 등 | ₩1,507~4,000/개 |
|
||||
| PC817 옵토커플러 | 10팩 | ~₩1,000 |
|
||||
| 저항 (1kΩ, 10kΩ) | 키트 | ~₩1,000 |
|
||||
| 점퍼선 F-F + 미니 브레드보드 | 납땜 최소화용 | ~₩2,000 |
|
||||
| (피에조 탭 시만) 1N4148 + 1~10µF | | ~₩100 |
|
||||
|
||||
**필요 공구(부품 아님, 개조 전제):** 멀티미터(DC전압) + 인두기+납. 없으면 개조가 릴레이 경로보다 오히려 번거로움.
|
||||
|
||||
### 탭 지점 (감지 시 전압이 스위칭하는 노드) — 멀티미터 DC로 확정
|
||||
평상시 vs 테스트버튼/연기 상태 비교:
|
||||
1. **알람 LED 애노드 (추천)** — 0V → ~2V 안정 DC.
|
||||
2. 연동단자(연동형 모델만) — 0 → ~9VDC 안정.
|
||||
3. 피에조 구동선 — 0 → 9~12Vpp 교류 (정류 필요, 채터링 → 비추천).
|
||||
|
||||
> Pi GPIO는 아날로그 저항을 못 읽는다(ADC 없음). 감지는 노드의 **전압 레벨 변화를 디지털 HIGH/LOW로** 읽는다.
|
||||
|
||||
### 배선 (PC817 절연, 감지=HIGH로 누수와 극성 통일)
|
||||
```
|
||||
[감지기측] LED노드(+) → 1kΩ → PC817 1핀(LED anode), PC817 2핀 → 감지기 GND
|
||||
[Pi측] PC817 4핀(collector) → 3.3V, PC817 3핀(emitter) → GPIO27(BCM, 물리 pin13) + 10kΩ 풀다운→GND
|
||||
동작: 연기 → LED 점등 → 옵토 도통 → GPIO27 = HIGH (감지, active-high)
|
||||
```
|
||||
- 옵토를 **하이사이드**(collector→3.3V, emitter→GPIO+풀다운)로 배선 → 감지=HIGH → 누수센서(ACTIVE_LOW=False)와 동일 극성 → **극성 코드 무변경.**
|
||||
- LED 노드 전압 V일 때 직렬저항 = (V − 1.2) / 2mA. ~2V면 470Ω, 애매하면 1kΩ.
|
||||
- PC817 이유: 노드가 3.3V 초과/교류여도 안전 + 프로빙 실수로부터 Pi 보호 + 접지 공유 불필요.
|
||||
|
||||
### GPIO 배정
|
||||
| 신호 | Pi 핀 | 풀 | idle | 감지 |
|
||||
|---|---|---|---|---|
|
||||
| SMOKE | GPIO27 (물리 pin13) | 외부 10kΩ 풀다운→GND (또는 per-sensor PUD_DOWN) | LOW | HIGH(옵토 도통) |
|
||||
|
||||
- 기존: GPIO17=누수센서1, I2C(GPIO2/3)=SHT30, CSI=카메라. GPIO27은 config의 주석 처리된 여유 슬롯.
|
||||
- **극성:** 감지=HIGH → `ACTIVE_LOW=False`(누수와 동일) 그대로. **풀다운만** 필요(옵토 off 시 LOW 확정) → §8 미결 A/B.
|
||||
- **개조의 안전 한계:** 배터리 죽으면 Pi가 모름(무음 실패). 단선도 무음. 시범이라 감수 — 실배포 승격 시 릴레이+NC로 교체.
|
||||
|
||||
### fallback (어댑터 확보되면 권장 승격)
|
||||
4선 광전 릴레이출력 감지기(₩4,250) + 12V 어댑터 → 무전압 접점 직결(납땜·옵토·멀티미터 불필요), NC=단선 페일세이프, 광전 확정, 부저 없음. 작업량은 개조보다 적으나 어댑터 필요.
|
||||
|
||||
---
|
||||
|
||||
## 3. 소프트웨어 — Pi 노드 (거의 신규 코드 없음)
|
||||
|
||||
### 접근: 기존 emitter 재사용
|
||||
연기 채널을 `SENSORS` 목록의 한 항목으로 추가. 기존 엣지검출 + confirm-read 디바운스 + `_post_event` HMAC 파이프라인이 그대로 처리.
|
||||
|
||||
### 변경점
|
||||
1. **`raspberry_pi4/config_local.py` (배포 생성물, 소스는 make_deploy_bundles.py):**
|
||||
```python
|
||||
SENSORS += [{"id": 4, "gpio": 27, "name": "연기(광전식)"}]
|
||||
```
|
||||
- `sensor_id = 4` (사용중 1/2/3와 겹치지 않음 → 누수 status 덮어쓰기 방지 + 독립 SMS 그룹).
|
||||
2. **극성:** 옵토 하이사이드 배선이라 idle=LOW(옵토 off, 풀다운), 감지=HIGH(옵토 도통). `_read_is_leak` 관점에서 "감지=HIGH"이므로 전역 `ACTIVE_LOW=False`(누수와 동일)를 그대로 쓴다 → **극성 코드 변경 불필요.** 단 옵토 off 시 LOW를 확정하려면 GPIO27에 **풀다운**이 필요(전역은 PUD_OFF):
|
||||
- 옵션 A (코드변경 0): 외부 10kΩ 풀다운 저항을 GPIO27→GND에 물림. config 무변경.
|
||||
- 옵션 B (권장, 부품 0): `setup_gpio`를 per-sensor `pull` 키 지원으로 소폭 승격해 GPIO27만 내부 PUD_DOWN 적용. `ACTIVE_LOW`는 건드리지 않음.
|
||||
> 구현 계획에서 A/B 확정. 부품 안 늘리려면 B, 코드 안 건드리려면 A.
|
||||
3. **이벤트 방출:** 감지 확정 시 `smoke_alarm`, 해제 시 `smoke_recovery` event_type로 POST. 기존 `_post_event`/`post_to_server` 그대로(event_type 문자열만 추가).
|
||||
4. **디바운스:** 기존 `CONFIRM_READS`/`CONFIRM_INTERVAL_SEC` 재사용. (시범 단계에선 별도 비대칭 디바운스 미도입.)
|
||||
5. **카메라:** 기본 OFF(저가·단순). `smoke_alarm`에 캡처를 붙이려면 기존 `_handle_leak_with_photo` 재사용 가능(log_id 키만 맞으면 무관) — 옵션.
|
||||
|
||||
### 페이로드 (additive, 서명 바이트 순서 고정)
|
||||
누수 body 재사용 + event_type만 smoke. 서버가 `is_leak`를 event_type로 강제하므로 `is_leak`는 그대로 두고 event_type로 구분.
|
||||
```json
|
||||
{"device_id":"…","device_location":"…","sensor_id":4,"sensor_name":"연기(광전식)","event_type":"smoke_alarm","is_leak":0,"timestamp":<int>}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 소프트웨어 — 서버 (PHP, additive만)
|
||||
|
||||
> **필수:** `sensor_data.php:208` 화이트리스트 + `schema_sht30.sql:30` ENUM이 모르는 event_type을 조용히 `periodic`으로 강등(로그·SMS 없음). 아래 additive 변경 없으면 감지가 HTTP 200만 뜨고 사라진다.
|
||||
|
||||
1. **`php/api/sensor_data.php:208`** — event_type 화이트리스트에 `'smoke_alarm'`, `'smoke_recovery'` 추가.
|
||||
2. **`php/api/sensor_data.php:212-218`** — is_leak 강제 블록에 smoke 분기 추가(연기 상태 별도 플래그가 필요하면 additive 컬럼, 아니면 event_type만으로 로깅). **`is_leak`·누수 로직 불변.**
|
||||
3. **`sql/schema_sht30.sql:30`** — `leak_sensor_log.event_type` ENUM에 `'smoke_alarm'`,`'smoke_recovery'` 추가 + **기존 운영 DB용 ALTER 마이그레이션**(`migration_leak_smoke.sql`, MySQL8 비멱등 주의).
|
||||
4. **`php/config.php:144-157`** — `SMS_TAG_SMOKE_ALARM = '[화재감지]'`, `SMS_TAG_SMOKE_RECOVERY = '[화재복구]'` 추가 + `sms_summary_select_sql()`(175-187)에 SUM(message LIKE …) 라인 추가.
|
||||
5. **`php/sms_send.php`** — `send_smoke_alert_sms()` / `send_smoke_recovery_sms()` (leak 함수 미러, 쿨다운 ~300s). `sms_dispatch_to_recipients()`가 sensor_id로 자동 라우팅 → id4 그룹. `sensor_data.php`의 leak 디스패치 분기(480-496) 근처에 smoke 분기 추가(즉시 발송; Pi가 디바운스하므로 서버 디바운스 미적용, SMS 재발신만 쿨다운).
|
||||
6. **`php/config.php`** — `SENSOR_LABELS[4]`, (선택) `SMOKE_SENSOR_IDS = [4]`(대시보드 카드용). `config.local.php`에 `SENSOR_RECIPIENTS[4] => [화재 수신자…]`.
|
||||
7. **대시보드(선택):** `status_summary.php`/`dashboard_data.php`에 연기 카드 additive. 미적용 시 기존 per-sensor 행에 편승.
|
||||
|
||||
### 최소 대안 (서버 코드 변경 0)
|
||||
`sensor_id 4`를 누수 센서로 취급(`LEAK_SENSOR_IDS += 4`) → 감지가 `leak_alert` 파이프라인으로 흐름. **단 SMS가 "[누수감지]"로 떠 화재를 오인** → display-vs-policy 위반. 채택 안 함(시범이라도 문구 구분 유지).
|
||||
|
||||
---
|
||||
|
||||
## 5. 상태 모델 (시범 = 2상태)
|
||||
|
||||
```
|
||||
CLEAR ──(연기 확정: confirm-read 통과)──▶ ALARM → smoke_alarm POST
|
||||
ALARM ──(연속 clear 확정)──────────────▶ CLEAR → smoke_recovery POST
|
||||
```
|
||||
- FAULT/감시 없음(시범). 실배포 승격 시 릴레이+NC 또는 2선 감시로 CLEAR/ALARM/FAULT 확장.
|
||||
- 보고 주기: CLEAR heartbeat = 기존 `REPORT_INTERVAL(600s)`, ALARM 재보고 = `LEAK_REPORT_INTERVAL(10s)` 재사용, SMS 재발신은 서버 쿨다운.
|
||||
|
||||
---
|
||||
|
||||
## 6. 배포 (기존 절차 재사용)
|
||||
|
||||
1. 소스 편집: `tools/make_deploy_bundles.py` PI_META의 Pi-C sensors에 `{"id":4,"gpio":27,"name":"연기(광전식)"}` 추가; 서버측 `SENSOR_LABELS[4]`, (선택) `SMOKE_SENSOR_IDS`.
|
||||
2. 서버 PHP additive 변경(3~7항) + `config.local.php`에 `SENSOR_RECIPIENTS[4]`.
|
||||
3. `python tools/make_deploy_bundles.py` → **API_KEY prefix 불변 확인**(.secrets 재사용).
|
||||
4. 서버: `config.local.php` 업로드 + ENUM 마이그레이션(`migration_leak_smoke.sql`) 실행.
|
||||
5. Pi: 번들 scp → `sudo systemctl restart leak-sensor` (연기 채널은 기존 leak-sensor 서비스에 편입 — 별도 서비스 불필요).
|
||||
- harden_pi.sh로 SSH 잠긴 노드는 물리접근 또는 USB 마이그레이션 번들.
|
||||
6. 에어갭: ADC·신규 스택 없음 → 추가 벤더링 불필요(기존 rpi-lgpio/requests 그대로).
|
||||
|
||||
---
|
||||
|
||||
## 7. 테스트 (TDD 대상)
|
||||
|
||||
- **순수함수:** smoke event_type body 빌더 바이트 동일성 + HMAC 서명 검증.
|
||||
- **서버:** `smoke_alarm` 수용(화이트리스트), ENUM insert 성공, `[화재감지]` 태그 카운트, SMS 라우팅(id4 그룹).
|
||||
- **통합:** 테스트버튼 눌러 실제 GPIO27 LOW → confirm-read 통과 → `smoke_alarm` POST → 서버 로그 + SMS 발송 **엔드투엔드 확인**(HTTP 200만으로 판정 금지 — SMS 실제 발송까지).
|
||||
- **회귀:** 기존 누수/온습도 이벤트 불변 확인(`is_leak`·leak_sensor_status 미오염).
|
||||
|
||||
---
|
||||
|
||||
## 8. 미해결/구현계획에서 결정
|
||||
|
||||
- [ ] GPIO27 풀다운 방식 A(외부 10kΩ 저항) vs B(per-sensor 내부 PUD_DOWN 키) 확정.
|
||||
- [ ] 카메라 캡처를 smoke_alarm에 붙일지(옵션, 기본 OFF).
|
||||
- [ ] Ali 단독형 알람 실물 확인: 광전식 여부, 알람 LED 노드 전압(멀티미터 DC, 평상시 vs 테스트버튼), 탭 지점 확정.
|
||||
- [ ] 저배터리 무음 실패 완화책(주기 교체 일정 or 저배터리 칩 패턴 감지) 필요 여부.
|
||||
|
||||
---
|
||||
|
||||
## 9. 승격 경로 (시범 → 실배포)
|
||||
|
||||
| 항목 | 시범(현재) | 실배포 승격 |
|
||||
|---|---|---|
|
||||
| 감지기 | Ali 저가 단독형 개조(₩1,507~) | Ali 4선 릴레이출력(₩4,250) → 누멘스 SNC-300-SR(KFI/UL 인증) |
|
||||
| 전원 | 배터리(어댑터 불필요) | 12~24V 어댑터/DIN 전원 |
|
||||
| 배선 | LED 탭 + PC817 옵토 | 무전압 접점 직결(옵토 불필요), NC=페일세이프 |
|
||||
| 감시 | 없음(배터리死·단선 무음) | NC=단선검출 → MY2N EOL 릴레이(정식 FAULT) |
|
||||
| 소프트웨어 | 동일 (SENSORS +1, 서버 additive) | 동일 + (선택)FAULT event_type |
|
||||
|
||||
소프트웨어가 하드웨어와 무관하게 동일하므로, 시범→실배포는 **하드웨어 교체(개조→릴레이→인증) + (선택)FAULT 추가**로 끝난다. Pi/서버 코드 구조는 그대로.
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
# 설계: 누수 노드 다중센서 통합 (누수 + 온습도 + 미세먼지) · 상시 카메라 조명
|
||||
|
||||
**날짜:** 2026-07-19
|
||||
**브랜치:** feat/pi-c-sensor3-leak-node
|
||||
**상태:** 설계 확정, 구현 진행
|
||||
**대체:** [`2026-07-18-smoke-node-pilot-design.md`](./2026-07-18-smoke-node-pilot-design.md) (연기 개조/불꽃/CO 경로 폐기)
|
||||
**참조 계획:** [`../plans/2026-07-18-smoke-node-pilot.md`](../plans/2026-07-18-smoke-node-pilot.md) (연기 시범 → PMS7003 metric 통합으로 전환)
|
||||
|
||||
## 목표
|
||||
|
||||
기존 누수 노드(Pi-A = sensor_id 1, Pi-C = sensor_id 3)를 **한 노드에 3종 센서**를 얹은
|
||||
통합 노드로 확장한다:
|
||||
|
||||
1. **누수** — 기존 leak GPIO(GPIO17, active-high). 변경 없음.
|
||||
2. **온습도(SHT30)** — I2C metric(`metric_type=sht30`). 이미 통합됨(2026-07-15 설계).
|
||||
3. **미세먼지(PMS7003)** — UART metric(`metric_type=pms7003`). **이번 신규.**
|
||||
연기(화재)의 **대리 지표(smoke proxy)** — 입자 유입 시 PM 농도가 급등한다.
|
||||
|
||||
세 센서 모두 **그 노드의 같은 sensor_id(1 또는 3)로 보고**하고, `metric_type` 문자열로만
|
||||
구분한다. SHT30 동일-id 통합 방식을 그대로 미러(새 id·재번호 없음).
|
||||
|
||||
추가로, 누수 확인 카메라(NoIR)용 **상시점등 백색 LED**를 배선한다(스위칭·코드 없음).
|
||||
|
||||
## 핵심 결정 (확정)
|
||||
|
||||
1. **동일 sensor_id · metric_type 구분.** `leak_sensor_metric`(sensor_id + 측정시각 키)이
|
||||
온습도·미세먼지를 한 테이블에서 `metric_type`으로 구분 → 한 sensor_id가 누수 상태 +
|
||||
온습도 측정 + 미세먼지 측정을 동시에 가진다. PM 행은 `pm1_0/pm2_5/pm10`만 채우고
|
||||
`temperature_c/humidity_percent`는 NULL(반대도 동일) — additive 컬럼.
|
||||
2. **노드: 별도 서비스.** 불안정한 누수 코드는 건드리지 않는다. PMS7003 는 SHT30 처럼
|
||||
**독립 systemd 서비스**(`pms7003-sensor`)로 얹는다. config 의 `SENSOR_ID`만 노드별(1/3).
|
||||
3. **event_type:** 첫 성공 판독 = `startup`, 이후 `periodic`(SHT30 와 동일). PMS 는 누수/알람
|
||||
이벤트를 절대 방출하지 않는다 — 임계 판정·SMS 는 전적으로 서버 몫.
|
||||
4. **Pi metric_status = 물리 타당성만**(`normal` | `out_of_range`). 서버가 운영 임계를 재평가
|
||||
하므로 Pi 의 status 를 경보 근거로 신뢰하지 않는다.
|
||||
5. **경보/수신자:** 전역 `METRIC_PM25_HIGH`(75)·`METRIC_PM10_HIGH`(150) 초과 시 서버가
|
||||
판정 → `[미세먼지경보]` SMS. 수신자는 기존 sensor_id 1·3 그룹 그대로(추가 매핑 불필요).
|
||||
6. **카메라 조명: 상시점등.** 스위칭·GPIO 제어 없음. 백색 LED 모듈을 Pi 5V 에 직결해 항상 켠다.
|
||||
|
||||
## 데이터 흐름 / 페이로드
|
||||
|
||||
```
|
||||
Pi-A (한 대) Pi-C: 동일, sensor_id=3
|
||||
├ leak-sensor.service → POST(sensor_id=1) leak_alert/recovery/periodic (누수)
|
||||
├ sht30-sensor.service → POST(sensor_id=1) startup/periodic (metric_type=sht30, 온·습도)
|
||||
└ pms7003-sensor.service → POST(sensor_id=1) startup/periodic (metric_type=pms7003, PM)
|
||||
|
||||
서버 sensor_data.php: sensor_id=1 metric 저장(metric_type 구분) + 임계 재평가
|
||||
→ high_pm25/high_pm10 breach → [미세먼지경보] SMS (SENSOR_RECIPIENTS[1])
|
||||
→ is_leak 보존(비-누수 이벤트는 누수 상태를 덮지 않음)
|
||||
대시보드: sensor_id=1 → 누수 칩 + 온습도 칩 + 미세먼지 칩
|
||||
```
|
||||
|
||||
**PMS7003 보고 바디 (Pi 가 이 바이트 순서 그대로 만들어 HMAC 서명; KEY 이름 서버와 일치):**
|
||||
```json
|
||||
{"device_id":"..","device_location":"..","sensor_id":1,"sensor_name":"..","event_type":"periodic",
|
||||
"timestamp":<int>,"metric_type":"pms7003","pm1_0":<int>,"pm2_5":<int>,"pm10":<int>,
|
||||
"metric_status":"normal","app_version":".."}
|
||||
```
|
||||
- `pm1_0/pm2_5/pm10` = **맨 정수 µg/m³**(대기환산), 범위 0..1000.
|
||||
- HTTP User-Agent: `PMS7003Sensor/2.0` (WAF 우회용 커스텀 UA).
|
||||
|
||||
## 서버 (이미 반영됨 — 참고)
|
||||
|
||||
- 스키마 `leak_sensor_metric`: `pm1_0/pm2_5/pm10 SMALLINT UNSIGNED NULL` additive.
|
||||
- `config.php`: `METRIC_PM25_HIGH`(75) · `METRIC_PM10_HIGH`(150) · `METRIC_PM_HYSTERESIS`(10),
|
||||
쿨다운은 `METRIC_ALERT_COOLDOWN_SEC` 재사용.
|
||||
- 새 breach kind `high_pm25` · `high_pm10` → 둘 다 `SMS_TAG_HIGH_PM = '[미세먼지경보]'` 매핑.
|
||||
|
||||
## 하드웨어 배선
|
||||
|
||||
### PMS7003 (UART, 5V) — TXD 만 읽으면 됨
|
||||
| 신호 | PMS7003 | Pi 4 | 비고 |
|
||||
|---|---|---|---|
|
||||
| 전원 | VCC | **5V (물리 pin2)** | 레이저 팬 구동, 5V 필수 |
|
||||
| 접지 | GND | **GND (물리 pin6)** | |
|
||||
| 데이터 | TXD | **RXD = GPIO15 (물리 pin10)** | PMS → Pi 단방향(읽기) |
|
||||
|
||||
- **UART 활성화 필수:** `enable_uart=1` + 직렬 로그인 콘솔 분리(포트 점유 해제). `install.sh`가
|
||||
자동 처리, 첫 활성화면 재부팅 필요(migrate.sh 가 안내). 포트 = `/dev/serial0`.
|
||||
- **버스 무충돌:** UART(GPIO14/15)는 누수 GPIO17·SHT30 I2C(GPIO2/3)와 겹치지 않는다 →
|
||||
세 센서 병행 가능.
|
||||
|
||||
### 카메라 조명 (상시점등 백색 LED)
|
||||
| 신호 | LED 모듈 | Pi 4 | 비고 |
|
||||
|---|---|---|---|
|
||||
| 전원 | VCC | **5V** | |
|
||||
| 접지 | GND | **GND** | |
|
||||
|
||||
- **상시 켜짐 · 스위칭/코드 없음.** 저전력 모듈이라 발열 무해, 상시점등해도 문제 없음.
|
||||
- **NoIR + 백색광 주의:** NoIR 카메라 + 백색 LED 는 화면 색이 **분홍빛**으로 나온다(누수 여부
|
||||
식별에는 지장 없음). 색을 정상으로 원하면 **일반 카메라(IR-cut 필터 내장)**를 쓴다.
|
||||
- 자세한 배선/주의는 `raspberry_pi4/WIRING.html` "Camera Lighting (Always-On)" 절 참조.
|
||||
|
||||
## 배포 (기존 절차 재사용)
|
||||
|
||||
- 소스 트리: PMS7003 Pi 코드는 `raspberry_pi4_pms7003/`(SHT30 소스 미러 —
|
||||
`pms7003_sensor.py`/`pms7003_driver.py`/`install.sh`/`vendor.sh`/`systemd/pms7003-sensor.service`).
|
||||
- 기존 하드닝 노드(SSH off) 대상 **USB 원샷 마이그레이션**: `tools/migration_leak_add_pms7003/`
|
||||
→ 생성물 `deploy/07_migration_leak_add_pms7003/`. `migrate.sh`가 노드의 기존 `config_local.py`
|
||||
에서 API_URL/API_KEY/위치/sensor_id 를 자동 추출해 같은 id 로 통합(시크릿 미포함 번들).
|
||||
- 폐쇄망: 빌드 PC 에서 `cd pms7003 && bash vendor.sh` → `debs/`·`wheelhouse/` 동봉.
|
||||
- 서버: `config.local.php`의 `METRIC_SENSOR_IDS` 에 1·3 포함 확인(온습도와 동일 목록).
|
||||
|
||||
## 비목표 (YAGNI)
|
||||
|
||||
- Pi-B(sensor_id 2, 온습도 전용) 변경 없음.
|
||||
- 연기 개조/불꽃/CO/릴레이 감지 경로 — 전면 폐기(smoke proxy = PM 농도로 대체).
|
||||
- PM 기반 자동 카메라 캡처 트리거 — 별개 이슈(이번 범위 밖).
|
||||
- 카메라 조명 밝기 제어/스위칭 — 상시점등이라 불필요.
|
||||
|
|
@ -1,13 +1,8 @@
|
|||
# SHT30 온습도 센서 I2C 배선도 (Raspberry Pi 4 기본 / STM32 변형)
|
||||
# SHT30 온습도 센서 ↔ STM32F407VGT6 I2C 배선도
|
||||
|
||||
Sensirion SHT30(한진데이터 P4422-3 모듈) I2C 배선 요약입니다.
|
||||
Sensirion SHT30(한진데이터 P4422-3 모듈)을 STM32F407VGT6의 I2C1 버스에 연결합니다. 단일 보드 `sht30_fw`(`sensor_id=2`, `device_id=stm32-sht30-01`)가 5분 주기로 온습도를 측정해 유선 Ethernet/TLS로 서버에 보고합니다.
|
||||
|
||||
> **현재 배포 = Raspberry Pi 4 Model B.** Pi 에서는 SHT30 을 40핀 헤더 I2C 버스 1에 연결합니다:
|
||||
> **VCC→3.3V(핀 1), GND→핀 6, SDA→GPIO2/핀 3, SCL→GPIO3/핀 5, ADDR→GND(0x44)**, SDA/SCL 4.7kΩ 풀업→3.3V(모듈 내장 시 생략).
|
||||
> 상세 도해는 `deploy/03_pi_b_sht30_id2/WIRING.html`(SVG 핀맵) 및 [SHT30_SENSOR2_SETUP.md](./SHT30_SENSOR2_SETUP.md) §2.1 을 보세요.
|
||||
> `sensor_id=2`, `device_id=rpi4-sht30-01`, `raspberry_pi4_sht30/config.py REPORT_INTERVAL=300`(5분).
|
||||
>
|
||||
> **아래 §는 STM32F407VGT6 변형**(마이그레이션 트랙 `firmware/`, 하드웨어 미검증·현재 배포와 무관)의 I2C1(PB6/PB7) 배선입니다. 상세 핀맵·전원·클럭원은 [firmware/docs/HARDWARE.md](../firmware/docs/HARDWARE.md) §3.
|
||||
상세 핀맵·전원·클럭원은 [firmware/docs/HARDWARE.md](../firmware/docs/HARDWARE.md) §3을 따릅니다. 본 문서는 SHT30 I2C 결선 요약입니다.
|
||||
|
||||
## 센서 동작 개요
|
||||
|
||||
|
|
@ -74,7 +69,7 @@ SHT30 모듈은 3.3V 단일 전원으로 동작합니다. SCL/SDA는 오픈드
|
|||
|
||||
정상 측정 범위(벗어나면 펌웨어가 `metric_status="out_of_range"` 로 표시): 온도 `[-40, 125]°C`, 상대습도 `[0, 100]%`.
|
||||
|
||||
> 참고: `out_of_range` 는 **펌웨어 전용 타당성(plausibility) 분류**입니다. 서버는 이 값을 무시하고 운영 임계(`METRIC_*`)로 재판정하므로, DB `leak_sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장되며 `out_of_range` 는 저장되지 않습니다.
|
||||
> 참고: `out_of_range` 는 **펌웨어 전용 타당성(plausibility) 분류**입니다. 서버는 이 값을 무시하고 운영 임계(`METRIC_*`)로 재판정하므로, DB `sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장되며 `out_of_range` 는 저장되지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,280 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>서버실 통합 모니터링 시스템 · 프로젝트 소개서</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#eef1f5; --card:#fff; --ink:#1f2733; --ink2:#51607a; --ink3:#8a95a6;
|
||||
--line:#e3e8ef; --head:#0b2545; --accent:#134074; --accent2:#1971c2;
|
||||
--fire:#e8590c; --leak:#1971c2; --th:#7048e8; --off:#868e96; --ok:#2b8a3e;
|
||||
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Malgun Gothic","Apple SD Gothic Neo","Noto Sans KR",sans-serif;
|
||||
--mono:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0}
|
||||
body{font-family:var(--sans);background:var(--bg);color:var(--ink);line-height:1.65;
|
||||
-webkit-font-smoothing:antialiased;padding:26px 16px 70px}
|
||||
.wrap{max-width:940px;margin:0 auto}
|
||||
|
||||
header.hero{background:linear-gradient(135deg,#0b2545 0%,#134074 55%,#1971c2 100%);
|
||||
color:#fff;border-radius:16px;padding:30px 30px;box-shadow:0 10px 28px rgba(11,37,69,.18)}
|
||||
.hero .eyebrow{font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:#a9c6e8;font-weight:700;margin:0 0 8px}
|
||||
.hero h1{margin:0 0 12px;font-size:27px;line-height:1.25;font-weight:800}
|
||||
.hero p{margin:0;font-size:15px;color:#dce7f5;max-width:74ch}
|
||||
.hero .meta{margin-top:16px;display:flex;flex-wrap:wrap;gap:8px}
|
||||
.hero .meta span{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.24);
|
||||
border-radius:999px;padding:5px 13px;font-size:12.5px;font-weight:600}
|
||||
|
||||
section{background:var(--card);border:1px solid var(--line);border-radius:14px;
|
||||
padding:24px 26px;margin-top:20px;box-shadow:0 2px 8px rgba(16,24,40,.05)}
|
||||
h2{display:flex;align-items:center;gap:12px;margin:0 0 8px;font-size:20px;font-weight:800;color:var(--ink)}
|
||||
h2 .n{flex:none;width:30px;height:30px;border-radius:9px;background:var(--accent);color:#fff;
|
||||
font-size:15px;display:flex;align-items:center;justify-content:center;font-weight:800}
|
||||
h2 + .lead{margin:0 0 18px;color:var(--ink2);font-size:14.5px}
|
||||
h3{margin:24px 0 10px;font-size:15.5px;font-weight:800;color:var(--accent)}
|
||||
|
||||
.flow{display:flex;flex-wrap:wrap;align-items:stretch;gap:10px;margin:6px 0 4px}
|
||||
.flow .step{flex:1 1 150px;min-width:140px;background:#f7f9fc;border:1px solid var(--line);
|
||||
border-radius:11px;padding:13px 14px}
|
||||
.flow .step b{display:block;font-size:14px;margin-bottom:3px}
|
||||
.flow .step span{font-size:12.5px;color:var(--ink2)}
|
||||
.flow .arrow{align-self:center;color:var(--ink3);font-weight:800;font-size:18px}
|
||||
|
||||
table{width:100%;border-collapse:collapse;font-size:13.5px;margin-top:6px}
|
||||
th,td{padding:10px 12px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
thead th{background:#f3f6fa;color:var(--accent);font-weight:800;font-size:12.5px;border-bottom:2px solid #d6e0ec}
|
||||
td.mono,th.mono{font-family:var(--mono);white-space:nowrap;font-size:12.5px}
|
||||
tbody tr:nth-child(even){background:#fbfcfe}
|
||||
.scroll{overflow-x:auto}
|
||||
|
||||
.tag{display:inline-block;font-family:var(--mono);font-size:12px;font-weight:700;
|
||||
padding:2px 8px;border-radius:6px;white-space:nowrap}
|
||||
.t-fire{background:#fff1e6;color:var(--fire)}
|
||||
.t-th{background:#efeafe;color:var(--th)}
|
||||
.t-leak{background:#e7f1fb;color:var(--leak)}
|
||||
.t-off{background:#eef0f2;color:#495661}
|
||||
.t-ok{background:#e7f6ec;color:var(--ok)}
|
||||
.t-sys{background:#f0f2f5;color:#51607a}
|
||||
|
||||
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px;margin-top:6px}
|
||||
.box{border:1px solid var(--line);border-radius:11px;padding:14px 15px;background:#fbfcfe}
|
||||
.box h4{margin:0 0 6px;font-size:14px;color:var(--ink)}
|
||||
.box p{margin:0;font-size:13px;color:var(--ink2)}
|
||||
.box .k{font-family:var(--mono);font-size:12px;color:var(--ink3)}
|
||||
|
||||
.note{border:1px solid #ffd8a8;background:#fff7ed;border-radius:11px;padding:14px 16px;margin-top:16px}
|
||||
.note b{color:var(--fire)}
|
||||
.note p{margin:0;font-size:13.5px;color:#7a4a1a}
|
||||
|
||||
.pills{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0 2px}
|
||||
.pill{background:#f3f6fa;border:1px solid var(--line);border-radius:999px;padding:5px 12px;font-size:12.5px;font-weight:600;color:var(--ink2)}
|
||||
|
||||
code,.ic{font-family:var(--mono);background:#eef2f7;padding:1px 6px;border-radius:5px;font-size:12.5px}
|
||||
ul.tight{margin:6px 0 0;padding-left:20px}
|
||||
ul.tight li{margin:4px 0;font-size:13.5px}
|
||||
.muted{color:var(--ink3);font-size:12.5px}
|
||||
footer{color:var(--ink3);font-size:12px;text-align:center;margin-top:26px}
|
||||
|
||||
@media (max-width:640px){ .hero h1{font-size:22px} }
|
||||
@media print{
|
||||
body{background:#fff;padding:0;font-size:12px}
|
||||
.wrap{max-width:100%}
|
||||
.hero{background:#fff!important;color:#000;border:2px solid var(--accent);box-shadow:none}
|
||||
.hero .eyebrow{color:var(--accent)} .hero p{color:#333} .hero .meta span{background:#fff;border-color:#999;color:#000}
|
||||
section{box-shadow:none;border:1px solid #bbb;page-break-inside:avoid}
|
||||
h2 .n{background:var(--accent)!important;-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||||
table,tr,td,th,.box,.note{page-break-inside:avoid}
|
||||
*{-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header class="hero">
|
||||
<p class="eyebrow">서버실 통합 모니터링 · leak_sms_v3</p>
|
||||
<h1>서버실 통합 모니터링 시스템 — 프로젝트 소개서</h1>
|
||||
<p>센서가 감지한 이상을 <b>담당자 문자(SMS)</b>로 즉시 알리고, <b>웹 대시보드</b>에서 전체 현황을
|
||||
한눈에 보는 시스템입니다. 이 문서는 시스템이 어떻게 동작하는지, <b>언제 누구에게 문자가 가는지</b>,
|
||||
<b>웹사이트에서 무엇을 볼 수 있는지</b>를 정리합니다.</p>
|
||||
<div class="meta">
|
||||
<span>대상: 서버실 · 문서고 (항온항습)</span>
|
||||
<span>감시: 누수 · 화재의심(PM) · 온습도 · 결로 · 장비상태</span>
|
||||
<span>버전 v2606 · 2026-07</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 1. 시스템 개요 -->
|
||||
<section>
|
||||
<h2><span class="n">1</span>시스템 개요 — 무엇을, 어떻게</h2>
|
||||
<p class="lead">현장 센서 장비(라즈베리파이)가 측정·감지한 값을 중앙 서버로 보내면, 서버가 이상 여부를
|
||||
판정해 담당자에게 문자를 보내고 대시보드에 표시합니다. 이상 판정 기준은 모두 <b>서버</b>에 있어,
|
||||
장비를 다시 만지지 않고 값만 바꿀 수 있습니다.</p>
|
||||
|
||||
<div class="flow">
|
||||
<div class="step"><b>① 현장 센서 노드</b><span>누수·온습도·미세먼지(PM)·카메라. 측정/감지 후 서버로 전송.</span></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="step"><b>② 중앙 서버</b><span>서명 검증 → 저장(MySQL) → 이상 판정(임계값) → 문자·대시보드.</span></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="step"><b>③ 통지</b><span>담당자 SMS 발송.</span></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="step"><b>④ 웹 대시보드</b><span>전 현장 현황·추이·이력 표시.</span></div>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:12px">전송 구간은 전 노드 공통으로 <code>HMAC-SHA256</code> 서명해 위·변조를 차단합니다.
|
||||
판정 기준(임계값)은 서버 설정 파일 <code>config.local.php</code> 에서 현장에 맞춰 조정합니다.</p>
|
||||
|
||||
<h3>노드 구성</h3>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>노드</th><th>탑재</th><th>측정/감지</th><th>보고 주기(기본)</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><b>누수 노드</b></td>
|
||||
<td>누수 프로브 (필요 시 카메라·SHT30·PMS7003 확장)</td>
|
||||
<td>누수 유무 (확장 시 온습도·미세먼지·현장 사진)</td>
|
||||
<td>정상 10분 / 누수 중 10초</td></tr>
|
||||
<tr><td><b>온습도 노드</b></td>
|
||||
<td>SHT30 (온습도 모듈)</td>
|
||||
<td>온도 · 습도</td>
|
||||
<td>1시간</td></tr>
|
||||
<tr><td><b>상태 표시기</b> <span class="muted">(선택)</span></td>
|
||||
<td>소형 디스플레이 (Zero 2 W)</td>
|
||||
<td>센서 아님 — 현황을 현장에 표시</td>
|
||||
<td>—</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:10px">확장 시 미세먼지(PM)·온습도는 같은 노드에 함께 실려 하나의 현장 번호로 보고되며,
|
||||
서버가 종류를 구분해 별도로 판정합니다. 배선 상세는 <b>센서 형태·내용 보고서</b>를 참조하세요.</p>
|
||||
</section>
|
||||
|
||||
<!-- 2. 언제 문자가 가나 -->
|
||||
<section>
|
||||
<h2><span class="n">2</span>언제 · 누구에게 문자가 가나</h2>
|
||||
<p class="lead">이상이 생기면 해당 <b>현장 담당자</b>에게 문자가 즉시 발송됩니다. 문자는 짧은 단문(1건)으로
|
||||
<code>[종류] 위치 / 값·상태 / 시각</code> 형식입니다.</p>
|
||||
|
||||
<h3>수신자 — 담당자 지정 방식</h3>
|
||||
<div class="cards">
|
||||
<div class="box"><h4>현장별 담당 그룹</h4><p>현장(센서)마다 받을 담당자를 따로 지정합니다. 그 현장의 경보는 지정된 담당자에게만 갑니다.</p><p class="k">SENSOR_RECIPIENTS</p></div>
|
||||
<div class="box"><h4>기본(전역) 수신자</h4><p>현장별 지정이 없으면 기본 담당자에게 갑니다. 시스템 공통 통지(정기점검·잔액)도 여기로.</p><p class="k">SMS_RECIPIENTS</p></div>
|
||||
<div class="box"><h4>2차(야간) 담당</h4><p>경보가 오래 미복구되면 상급/야간 담당에게 <b>추가로</b> 한 번 더 알립니다(에스컬레이션).</p><p class="k">SMS_RECIPIENTS_TIER2</p></div>
|
||||
</div>
|
||||
|
||||
<h3>문자 종류 · 발송 조건</h3>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>문자(태그)</th><th>언제 가나 (기본값)</th><th>수신</th><th>반복 억제</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="tag t-fire">[화재의심]</span></td>
|
||||
<td>미세먼지 <b>PM2.5 > 35</b> 또는 <b>PM10 > 75</b> ㎍/㎥ (연기 급증 = 화재 조기징후)</td>
|
||||
<td>현장 담당</td><td>30분</td></tr>
|
||||
<tr><td><span class="tag t-th">[고온경보]</span></td><td>온도 <b>> 30℃</b></td><td>현장 담당</td><td>30분</td></tr>
|
||||
<tr><td><span class="tag t-th">[저온경보]</span></td><td>온도 <b>< 10℃</b></td><td>현장 담당</td><td>30분</td></tr>
|
||||
<tr><td><span class="tag t-th">[고습경보]</span></td><td>습도 <b>> 70%</b></td><td>현장 담당</td><td>30분</td></tr>
|
||||
<tr><td><span class="tag t-th">[저습경보]</span></td><td>습도 <b>< 20%</b></td><td>현장 담당</td><td>30분</td></tr>
|
||||
<tr><td><span class="tag t-th">[결로주의]</span></td><td>결로마진(온도−이슬점) <b>≤ 3℃</b> — 표면 결로·장비 손상 위험</td><td>현장 담당</td><td>30분</td></tr>
|
||||
<tr><td><span class="tag t-leak">[누수감지]</span></td><td>누수 확정(<b>60초</b> 지속 확인 후). 물 튐 오탐 방지</td><td>현장 담당</td><td>30분</td></tr>
|
||||
<tr><td><span class="tag t-leak">[누수복구]</span></td><td>누수 해소 확정(<b>180초</b> 지속 확인). 직전 감지가 있었을 때만</td><td>현장 담당</td><td>1회</td></tr>
|
||||
<tr><td><span class="tag t-ok">[정상복귀]</span></td><td>온습도가 임계 안쪽으로 회복(여유 확보) 시</td><td>현장 담당</td><td>1회</td></tr>
|
||||
<tr><td><span class="tag t-off">[장비 오프라인]</span></td><td>보고가 끊김 — 누수/PM 노드 <b>약 20분</b>, 온습도 노드 <b>약 125분</b> 무응답</td><td>현장 담당</td><td>재시도 보장</td></tr>
|
||||
<tr><td><span class="tag t-off">[장비 복구]</span></td><td>끊겼던 통신이 재개될 때</td><td>현장 담당</td><td>1회</td></tr>
|
||||
<tr><td><span class="tag t-fire">[에스컬레이션]</span></td><td>임계 경보가 <b>30분</b> 이상 미복구로 지속 → <b>2차(야간) 담당</b>에게 추가 통지</td><td>2차(TIER2)</td><td>에피소드당 1회</td></tr>
|
||||
<tr><td><span class="tag t-sys">[SMS 잔액부족]</span></td><td>문자 발송 잔여가 <b>30건 이하</b> — 경보 채널이 조용히 죽는 것 사전 방지</td><td>기본(전역)</td><td>6시간</td></tr>
|
||||
<tr><td><span class="tag t-ok">[정기점검]</span></td><td>매일 <b>09시</b> 이후 1회 — 센서 정상수·최근수신·잔여 요약(생존신호)</td><td>기본(전역)</td><td>1일 1회</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<p><b>“정기점검” 문자의 뜻:</b> 매일 1통 오는 안부 문자입니다. <b>이 문자가 안 오면</b> 경보 채널
|
||||
전체(서버·문자망)에 문제가 있다는 신호이니 점검하세요. 즉, 담당자는 <b>“이상 문자가 오면 대응, 안부 문자가 안 오면 점검”</b>만 기억하면 됩니다.</p>
|
||||
</div>
|
||||
|
||||
<h3>같은 경보가 문자 폭탄이 되지 않는 이유</h3>
|
||||
<ul class="tight">
|
||||
<li><b>재발송 억제(쿨다운):</b> 같은 종류 경보는 30분에 1번만. 이상이 지속돼도 계속 울리지 않습니다.</li>
|
||||
<li><b>누수 떨림 방지(디바운스):</b> 감지 60초·복구 180초를 지속해야 “확정”해 발송 — 물기 튐으로 감지↔복구가 반복되는 것을 차단.</li>
|
||||
<li><b>에피소드 인지:</b> 정상 복귀 후 다시 이상이 생기면 별개 사건으로 보고 즉시 재발송(놓치지 않음).</li>
|
||||
<li><b>미복구 에스컬레이션:</b> 30분 지나도 안 풀리면 2차(야간) 담당에게 한 번 더.</li>
|
||||
</ul>
|
||||
<p class="muted" style="margin-top:8px">항온항습 환경 특성상 값이 원래 평탄하므로 <b>“센서 값 고착” 경보는 꺼 두었습니다</b>(정상 평탄값 오탐 방지).</p>
|
||||
</section>
|
||||
|
||||
<!-- 3. 웹사이트에서 보는 것 -->
|
||||
<section>
|
||||
<h2><span class="n">3</span>웹사이트(대시보드)에서 보는 것</h2>
|
||||
<p class="lead">브라우저로 접속해 로그인(2단계 인증)하면 전 현장 현황을 한 화면에서 봅니다. 화면은 자동 새로고침됩니다.</p>
|
||||
|
||||
<h3>화면 상단 — 항상 고정</h3>
|
||||
<div class="cards">
|
||||
<div class="box"><h4>시스템 상태 배너</h4><p>지금 가장 중요한 상태를 큰 글씨로. 경보 시 <b>어느 센서·값·언제부터·문자 발송 결과</b>까지 명시. 우선순위: 누수 → 화재의심 → 온습도 → 오프라인 → 정상.</p></div>
|
||||
<div class="box"><h4>핵심 KPI 6타일</h4><p>경보 센서 · 오프라인(온라인 수) · 미복구 누수 · 오늘 경보 · SMS 잔여 · 중요 진단. 타일을 누르면 해당 탭으로.</p></div>
|
||||
<div class="box"><h4>알림 스트립 · 대응 패널</h4><p>즉시 조치가 필요한 것만 1줄씩. 위험 시 상황별 <b>대응 체크리스트</b>와 담당 연락처를 함께 표시.</p></div>
|
||||
</div>
|
||||
|
||||
<h3>탭 — 상세 화면</h3>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>탭</th><th>보는 내용</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><b>실시간 현황</b> <span class="muted">live</span></td>
|
||||
<td>현장별 카드 — 온습도·화재감시(PM)·누수 상태와 최신 측정값, 오프라인/경보 배지.</td></tr>
|
||||
<tr><td><b>추이·통계</b> <span class="muted">trend</span></td>
|
||||
<td>온습도 추이 차트, 화재감시(PM) 추이 차트, 7일 경보 추이, 경보 종류 도넛(30일), 경보 최다 센서 순위.</td></tr>
|
||||
<tr><td><b>이력 조회</b> <span class="muted">history</span></td>
|
||||
<td>최근 온습도 측정 30건, 최근 화재감시(PM) 측정 30건, 문자 발송 이력.</td></tr>
|
||||
<tr><td><b>시스템 점검</b> <span class="muted">system</span></td>
|
||||
<td>자가진단 결과, 적용 중인 임계·설정 요약, SMS 잔여·발송 실패 현황.</td></tr>
|
||||
<tr><td><b>전체(인쇄용)</b> <span class="muted">all</span></td>
|
||||
<td>위 탭을 한 페이지로 합본 — 출력·보고용.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>예방 신호 — “터지기 전”</h3>
|
||||
<p>측정 추세를 분석해 <b>경보가 발생하기 전</b> 여유·도달 예상 시간(ETA)·이슬점 마진을 대시보드에 미리 표시하고,
|
||||
위험 시 대응 절차를 안내합니다(추가 장비·DB 변경 없이 기존 측정만으로 산출).</p>
|
||||
|
||||
<div class="pills">
|
||||
<span class="pill">설치 점검 (테스트 문자 발송)</span>
|
||||
<span class="pill">월간 보고</span>
|
||||
<span class="pill">보안 증적</span>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:8px">상단 메뉴에서 위 부가 페이지로 이동할 수 있습니다.</p>
|
||||
</section>
|
||||
|
||||
<!-- 4. 운영 요약 -->
|
||||
<section>
|
||||
<h2><span class="n">4</span>운영 요약 · 유의사항</h2>
|
||||
|
||||
<h3>기본 임계값 (조정 가능)</h3>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>항목</th><th>기본값</th><th>항목</th><th>기본값</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>화재의심 PM2.5</td><td class="mono">> 35 ㎍/㎥</td><td>고온</td><td class="mono">> 30 ℃</td></tr>
|
||||
<tr><td>화재의심 PM10</td><td class="mono">> 75 ㎍/㎥</td><td>저온</td><td class="mono">< 10 ℃</td></tr>
|
||||
<tr><td>고습 / 저습</td><td class="mono">> 70% / < 20%</td><td>결로마진</td><td class="mono">≤ 3 ℃</td></tr>
|
||||
<tr><td>경보 재발송 쿨다운</td><td class="mono">30 분</td><td>에스컬레이션</td><td class="mono">30 분 미복구</td></tr>
|
||||
<tr><td>오프라인 판정(누수/PM)</td><td class="mono">약 20 분</td><td>오프라인 판정(온습도)</td><td class="mono">약 125 분</td></tr>
|
||||
<tr><td>SMS 잔액 경고</td><td class="mono">≤ 30 건</td><td>정기점검 발송</td><td class="mono">매일 09 시</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:8px">모든 값은 서버 설정(<code>config.local.php</code>)에서 현장에 맞게 조정할 수 있으며, 현장 장비를 다시 만질 필요가 없습니다.</p>
|
||||
|
||||
<div class="note">
|
||||
<p><b>화재 감지 관련 유의:</b> 미세먼지 센서(PMS7003)는 <b>광학 먼지 센서</b>로, 연기·먼지를 완벽히
|
||||
구분하지 못합니다. 항온항습 서버실은 평상시 먼지가 거의 없어 <b>먼지 급증을 화재의 조기 징후</b>로
|
||||
활용하는 것이며, <b>인증 화재감지기가 아닙니다.</b> 보조 조기경보로 사용하고, 법정 소방 설비(인증
|
||||
연기/열 감지기)는 별도로 병행해야 합니다.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>서버실 통합 모니터링 시스템 (leak_sms_v3) · 프로젝트 소개서 · v2606</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
| 프로세스 관리 | systemd 재시작 | IWDG 워치독 + 재연결 | 변경(이식) |
|
||||
| 로깅 | 로테이팅 파일 로그 | UART 콘솔 | 변경(축소) |
|
||||
|
||||
> **임계 판정의 위치:** 펌웨어는 `temperature_c`/`humidity_percent` 원값과 `metric_status`(센서 물리/타당성 범위 기준 `normal`/`out_of_range`)만 보고한다. 고온/저온/고습/저습 경보와 SMS 트리거는 **서버 `config.php` 의 `METRIC_*` 임계**가 담당한다. 폐쇄망에서 임계값을 조정할 때 펌웨어 재플래시가 불필요하도록 의도된 분리다. `out_of_range` 는 **펌웨어 전용 타당성 분류**이며 서버가 무시·재판정하므로, DB `leak_sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장된다(`out_of_range` 는 DB에 저장되지 않음).
|
||||
> **임계 판정의 위치:** 펌웨어는 `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) 참고.
|
||||
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ Sensirion SHT30 (한진데이터 P4422-3 모듈), I2C. RPi `sht30_monitor.py`
|
|||
- 풀업: 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)).
|
||||
운영 경보 임계(고온/저온/고습/저습 → SMS)는 **서버 `php/config.php` 의 `METRIC_*` 상수**가 판정한다(펌웨어는 원값만 보고).
|
||||
`out_of_range` 는 **펌웨어 전용 타당성 분류**이며 서버가 무시·재판정하므로, DB `leak_sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장된다(`out_of_range` 는 DB에 남지 않음).
|
||||
`out_of_range` 는 **펌웨어 전용 타당성 분류**이며 서버가 무시·재판정하므로, DB `sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장된다(`out_of_range` 는 DB에 남지 않음).
|
||||
|
||||
```
|
||||
SHT30 모듈 STM32
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ SHT30 측정에서 SMS 발송까지의 책임 분담:
|
|||
센서 물리/타당성 범위(`APP_SHT30_TEMP/RH_LOW/HIGH`) 기준으로 `metric_status`(`normal`/`out_of_range`)만 부여해
|
||||
`sensor_data.php` 로 POST 한다. 펌웨어는 **고온/저온/고습/저습 같은 운영 임계를 판정하지 않는다.**
|
||||
여기서 `out_of_range` 는 **펌웨어 전용 타당성 분류**다. 서버는 이 값을 무시하고 운영 임계로 재판정하므로
|
||||
DB `leak_sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장되고 `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_*` 임계와 비교해
|
||||
고온/저온/고습/저습 조건이면 **SMS 게이트웨이로 문자**를 발송한다.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# =============================================================================
|
||||
# .htaccess - 누수감지 시스템 보안 설정
|
||||
# 배포 경로: pores.kr/raspi_leck_detecter/
|
||||
# =============================================================================
|
||||
|
||||
# ─── HTTPS 강제 리다이렉트 ─────────────────────────────────
|
||||
|
|
@ -21,10 +22,7 @@
|
|||
</IfModule>
|
||||
|
||||
# ─── 민감 PHP 파일 직접 접근 차단 ─────────────────────────
|
||||
# (dashboard_data·forecast·live·trend·history·system·monthly_report_data·
|
||||
# monthly_report_print·report_charts 는 include 전용 — tabs/.htaccess 와 이중 차단.
|
||||
# monthly_report.php 본체는 진입 페이지이므로 차단 대상 아님)
|
||||
<FilesMatch "^(config|config\.local|config\.local\.example|sms_send|incidents|ops_checks|setup_hash|test_mobile|dashboard_data|forecast|live|trend|history|system|monthly_report_data|monthly_report_print|report_charts)\.php$">
|
||||
<FilesMatch "^(config|config\.local|config\.local\.example|sms_send|incidents|ops_checks|setup_hash|test_mobile)\.php$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
|
|
@ -39,11 +37,7 @@
|
|||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set X-Frame-Options "DENY"
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
# 내부 관리 시스템 — 검색엔진 색인 금지(HTTP 레벨, 전 응답 공통).
|
||||
# robots.txt 는 쓰지 않는다: 공개 파일이라 난독화 경로를 오히려 광고하게 됨.
|
||||
Header always set X-Robots-Tag "noindex, nofollow, noarchive"
|
||||
# 외부로 Referer 자체를 보내지 않음(도메인 확인 유출 차단). 내부 이동은 영향 없음.
|
||||
Header always set Referrer-Policy "no-referrer"
|
||||
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'"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@
|
|||
// [응답] 200 OK application/json {"status":"ok","photo_id":..,"filename":".."}
|
||||
// =============================================================================
|
||||
|
||||
// CORS 헤더 없음 — 기기 서버측 POST 전용(브라우저 미관여), Allow-Origin 은 도메인 지문만 노출.
|
||||
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);
|
||||
|
|
@ -99,7 +101,7 @@ function photo_nonce_claim(PDO $db, string $sig): string {
|
|||
return 'unavailable';
|
||||
}
|
||||
try {
|
||||
$ins = $db->prepare("INSERT INTO leak_ingest_nonce (signature, seen_at) VALUES (:sig, NOW())");
|
||||
$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();
|
||||
|
|
@ -113,7 +115,7 @@ function photo_nonce_claim(PDO $db, string $sig): string {
|
|||
}
|
||||
try {
|
||||
$ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2);
|
||||
$db->exec("DELETE FROM leak_ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
|
||||
$db->exec("DELETE FROM ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
|
||||
} catch (Throwable $e) {
|
||||
/* 정리는 실패해도 무방 */
|
||||
}
|
||||
|
|
@ -125,7 +127,7 @@ function photo_nonce_release(PDO $db, string $sig): void {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
$db->prepare("DELETE FROM leak_ingest_nonce WHERE signature = :sig")
|
||||
$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());
|
||||
|
|
@ -246,9 +248,9 @@ $filename = '';
|
|||
try {
|
||||
$db = get_db();
|
||||
|
||||
// log_id 가 leak_sensor_log 에 실제로 존재하는지 + 어느 센서/디바이스의 로그인지 확인한다
|
||||
// log_id 가 sensor_log 에 실제로 존재하는지 + 어느 센서/디바이스의 로그인지 확인한다
|
||||
// (임의 log_id 로 사진 누적 방지, 교차 센서 결속). 권위값은 로그 행의 sensor_id/device_id.
|
||||
$chk = $db->prepare("SELECT sensor_id, device_id FROM leak_sensor_log WHERE id = :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) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
//
|
||||
// [요청 형식 — 온습도(SHT30, 2번 모듈)] POST application/json
|
||||
// {
|
||||
// "device_id": "rpi4-sht30-01", // 배포=Raspberry Pi 4 (STM32 펌웨어면 "stm32-sht30-01")
|
||||
// "device_id": "stm32-sht30-01",
|
||||
// "device_location": "서버실",
|
||||
// "sensor_id": 2,
|
||||
// "sensor_name": "2번 센서 (SHT30)",
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
// "temperature_c": 24.50,
|
||||
// "humidity_percent": 48.30,
|
||||
// "metric_status": "normal", // 펌웨어 plausibility 판정(참고용; 서버가 재판정)
|
||||
// "app_version": "v2606-sht30-rpi4" // Pi 배포 (STM32 펌웨어면 "v2606-sht30"); 서버는 검증 안 함
|
||||
// "app_version": "v2606-sht30"
|
||||
// }
|
||||
//
|
||||
// [요청 형식 — 누수(1번 모듈)] POST application/json
|
||||
|
|
@ -45,8 +45,10 @@
|
|||
// {"status":"ok","message":"저장 완료", ...}
|
||||
// =============================================================================
|
||||
|
||||
// CORS 헤더 없음 — 기기(서버측 cURL/requests) → 서버 POST 전용이라 브라우저
|
||||
// same-origin 정책 대상이 아니며, Allow-Origin 헤더는 도메인 지문만 노출한다.
|
||||
// CORS 허용 (기기 → 서버 POST 전용, 브라우저 접근 불필요)
|
||||
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);
|
||||
|
|
@ -107,7 +109,7 @@ function ingest_nonce_claim(PDO $db, string $sig): string {
|
|||
return 'unavailable';
|
||||
}
|
||||
try {
|
||||
$ins = $db->prepare("INSERT INTO leak_ingest_nonce (signature, seen_at) VALUES (:sig, NOW())");
|
||||
$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();
|
||||
|
|
@ -122,7 +124,7 @@ function ingest_nonce_claim(PDO $db, string $sig): string {
|
|||
// 선점 성공 후, 신선도 창의 2배보다 오래된 nonce 정리(베스트 에포트, 인덱스로 가벼움).
|
||||
try {
|
||||
$ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2);
|
||||
$db->exec("DELETE FROM leak_ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
|
||||
$db->exec("DELETE FROM ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
|
||||
} catch (Throwable $e) {
|
||||
/* 정리는 실패해도 무방 */
|
||||
}
|
||||
|
|
@ -136,7 +138,7 @@ function ingest_nonce_release(PDO $db, string $sig): void {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
$db->prepare("DELETE FROM leak_ingest_nonce WHERE signature = :sig")
|
||||
$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());
|
||||
|
|
@ -178,9 +180,7 @@ if ($has_raw_sig) {
|
|||
}
|
||||
if (!$sig_ok) {
|
||||
error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?') . " (mode={$sig_mode})");
|
||||
// 출처 식별: 서명모드(raw=키불일치 / none=헤더스트립)와 본문 device_id(신뢰불가)를 함께
|
||||
// 링에 남긴다 → 대시보드에서 '우리 노드 오설정 vs 외부 스캐너' 판별.
|
||||
record_ingest_rejection('signature', ['mode' => $sig_mode, 'device_id' => (string)($data['device_id'] ?? '')]);
|
||||
record_ingest_rejection('signature');
|
||||
json_response(['status' => 'error', 'message' => '인증 실패'], 403);
|
||||
}
|
||||
|
||||
|
|
@ -242,46 +242,29 @@ if ($humidity_percent !== null && ($humidity_percent < 0 || $humidity_percent >
|
|||
$humidity_percent = null;
|
||||
}
|
||||
|
||||
// PMS7003 미세먼지(µg/m³ 대기환산 정수). 누수 노드에 온습도와 동일 sensor_id 로 통합되며
|
||||
// metric_type='pms7003' 로 구분된다(온습도 행에서는 NULL, PM 행에서는 온습도가 NULL).
|
||||
// 물리 범위(0..1000)를 벗어난 값은 결측(null)으로 처리한다(온도와 동일한 방어).
|
||||
$pm1_0 = isset($data['pm1_0']) && is_numeric($data['pm1_0']) ? (int)$data['pm1_0'] : null;
|
||||
$pm2_5 = isset($data['pm2_5']) && is_numeric($data['pm2_5']) ? (int)$data['pm2_5'] : null;
|
||||
$pm10 = isset($data['pm10']) && is_numeric($data['pm10']) ? (int)$data['pm10'] : null;
|
||||
if ($pm1_0 !== null && ($pm1_0 < 0 || $pm1_0 > 1000)) { $pm1_0 = null; }
|
||||
if ($pm2_5 !== null && ($pm2_5 < 0 || $pm2_5 > 1000)) { $pm2_5 = null; }
|
||||
if ($pm10 !== null && ($pm10 < 0 || $pm10 > 1000)) { $pm10 = null; }
|
||||
|
||||
$has_metric_payload = ($metric_type !== '')
|
||||
&& ($temperature_c !== null || $humidity_percent !== null
|
||||
|| $pm1_0 !== null || $pm2_5 !== null || $pm10 !== null);
|
||||
$has_metric_payload = ($metric_type !== '') && ($temperature_c !== null || $humidity_percent !== null);
|
||||
|
||||
// ── 서버측 임계 판정 (config.php METRIC_*) ─────────────────
|
||||
// 펌웨어 metric_status 는 물리 plausibility 판정이라 운영 경보로는 신뢰하지 않고,
|
||||
// 서버가 운영 임계로 다시 판정하여 leak_sensor_metric.metric_status 에 저장한다.
|
||||
// 서버가 운영 임계로 다시 판정하여 sensor_metric.metric_status 에 저장한다.
|
||||
$metric_eval = $has_metric_payload
|
||||
? evaluate_metric_thresholds($temperature_c, $humidity_percent, $pm2_5, $pm10)
|
||||
? evaluate_metric_thresholds($temperature_c, $humidity_percent)
|
||||
: ['breaches' => [], 'status' => 'normal'];
|
||||
$metric_status = $metric_eval['status'];
|
||||
|
||||
$metric_saved = false;
|
||||
|
||||
// ── DB 저장 ───────────────────────────────────────────────
|
||||
// 주기(periodic) 보고는 순수 하트비트 — leak_sensor_log 에 남기지 않고 상태 upsert
|
||||
// (+온습도면 leak_sensor_metric)만 갱신한다. 누수 노드가 10분 주기로 늘어나면
|
||||
// periodic 행이 이벤트 로그 창을 밀어내므로(지점 2개 = 일 288행) 전부 스킵.
|
||||
// startup 은 1회 기록. 누수 이벤트(leak_alert/leak_recovery)는 항상 기록한다
|
||||
// 주기(periodic) 온습도 측정 보고는 sensor_log 에 남기지 않고 sensor_metric/상태만 갱신
|
||||
// (대시보드 로그 정리). startup 은 1회 sensor_log 기록.
|
||||
// 누수 이벤트(leak_alert/leak_recovery)는 항상 sensor_log 에 기록한다
|
||||
// (leak_alert 는 log_id 가 사진 업로드에 필요하고, 누수 이력 추적 대상).
|
||||
$skip_log = ($event_type === 'periodic');
|
||||
$skip_log = ($event_type === 'periodic' && $has_metric_payload);
|
||||
$log_id = null;
|
||||
|
||||
try {
|
||||
$db = get_db();
|
||||
|
||||
// 누수 디바운스 사용 여부(마스터 스위치 + 컬럼 존재). 컬럼 없는 구배포는 레거시
|
||||
// 즉시 발송 경로로 graceful degrade. 아래 사고추적/누수 SMS 분기에서 참조.
|
||||
$use_debounce = LEAK_DEBOUNCE_ENABLED && leak_status_has_debounce_cols($db);
|
||||
|
||||
// ── 재전송(replay) 멱등 선점 ──
|
||||
// 인증·신선도를 통과한 서명을 nonce 로 원자적 기록한다. 같은 서명 본문이 시계오차
|
||||
// 창 안에서 다시 오면(펌웨어 재시도/캡처 재생) 중복 저장·중복 SMS 없이 멱등 200.
|
||||
|
|
@ -297,7 +280,7 @@ try {
|
|||
|
||||
if (!$skip_log) {
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO leak_sensor_log
|
||||
INSERT INTO sensor_log
|
||||
(device_id, device_location, sensor_id, sensor_name,
|
||||
event_type, is_leak, sensor_time, created_at)
|
||||
VALUES
|
||||
|
|
@ -319,7 +302,7 @@ try {
|
|||
|
||||
// ── 오프라인 복구 감지: UPSERT 전에 offline_alerted 확인 ──
|
||||
$was_offline = false;
|
||||
$chk = $db->prepare("SELECT offline_alerted FROM leak_sensor_status WHERE sensor_id = :sensor_id");
|
||||
$chk = $db->prepare("SELECT offline_alerted FROM sensor_status WHERE sensor_id = :sensor_id");
|
||||
$chk->execute([':sensor_id' => $sensor_id]);
|
||||
$offline_flag = $chk->fetchColumn();
|
||||
if ($offline_flag === '1' || $offline_flag === 1) {
|
||||
|
|
@ -331,7 +314,7 @@ try {
|
|||
// (startup/periodic)는 누수 상태를 건드리지 않도록 기존 값을 보존한다(통합 서버에서
|
||||
// 2번 모듈 보고가 1번 모듈 누수 상태를 덮어쓰지 않게).
|
||||
$stmt2 = $db->prepare("
|
||||
INSERT INTO leak_sensor_status
|
||||
INSERT INTO sensor_status
|
||||
(sensor_id, device_id, sensor_name, device_location,
|
||||
last_event_type, is_leak, last_seen, offline_alerted)
|
||||
VALUES
|
||||
|
|
@ -356,17 +339,15 @@ try {
|
|||
':is_leak_event' => $is_leak_event ? 1 : 0,
|
||||
]);
|
||||
|
||||
if ($has_metric_payload && api_table_exists($db, 'leak_sensor_metric')) {
|
||||
if ($has_metric_payload && api_table_exists($db, 'sensor_metric')) {
|
||||
$metric_stmt = $db->prepare("
|
||||
INSERT INTO leak_sensor_metric
|
||||
INSERT INTO sensor_metric
|
||||
(device_id, device_location, sensor_id, sensor_name,
|
||||
metric_type, temperature_c, humidity_percent,
|
||||
pm1_0, pm2_5, pm10, metric_status,
|
||||
metric_type, temperature_c, humidity_percent, metric_status,
|
||||
measured_at, created_at)
|
||||
VALUES
|
||||
(:device_id, :device_location, :sensor_id, :sensor_name,
|
||||
:metric_type, :temperature_c, :humidity_percent,
|
||||
:pm1_0, :pm2_5, :pm10, :metric_status,
|
||||
:metric_type, :temperature_c, :humidity_percent, :metric_status,
|
||||
:measured_at, NOW())
|
||||
");
|
||||
$metric_stmt->execute([
|
||||
|
|
@ -377,22 +358,17 @@ try {
|
|||
':metric_type' => $metric_type,
|
||||
':temperature_c' => $temperature_c,
|
||||
':humidity_percent' => $humidity_percent,
|
||||
':pm1_0' => $pm1_0,
|
||||
':pm2_5' => $pm2_5,
|
||||
':pm10' => $pm10,
|
||||
':metric_status' => $metric_status,
|
||||
':measured_at' => $sensor_dt,
|
||||
]);
|
||||
$metric_saved = true;
|
||||
}
|
||||
|
||||
// ── 누수 사고(에피소드) 추적 (leak_incident) — 레거시(디바운스 미사용) 경로 ──
|
||||
// ── 누수 사고(에피소드) 추적 (leak_incident) ──────────
|
||||
// leak_alert: 미복구 사고가 없으면 새 사고를 연다(중복 leak_alert 는 새로 열지 않음).
|
||||
// leak_recovery: 같은 센서의 가장 최근 미복구 사고를 닫는다(recovered_at/복구 log_id 채움).
|
||||
// leak_incident 테이블이 없으면(누수 미통합 서버) graceful 하게 건너뛴다.
|
||||
// 디바운스 사용 시엔 사고 open/close 를 '확정 엣지'(apply_leak_debounce)로 옮겨 처리하므로
|
||||
// 여기서는 건너뛴다(raw 이벤트마다 사고가 열려 월간보고서가 부풀지 않게).
|
||||
if (!$use_debounce && $is_leak_event && api_table_exists($db, 'leak_incident')) {
|
||||
if ($is_leak_event && api_table_exists($db, 'leak_incident')) {
|
||||
if ($event_type === 'leak_alert') {
|
||||
$open = $db->prepare("
|
||||
SELECT id FROM leak_incident
|
||||
|
|
@ -472,10 +448,6 @@ if ($has_metric_payload) {
|
|||
$value = condensation_margin_c($temperature_c, $humidity_percent); // 표시값=결로마진(℃)
|
||||
} elseif (in_array($kind, ['high_temp', 'low_temp'], true)) {
|
||||
$value = $temperature_c;
|
||||
} elseif ($kind === 'high_pm25') {
|
||||
$value = $pm2_5; // 표시값=PM2.5(µg/m³)
|
||||
} elseif ($kind === 'high_pm10') {
|
||||
$value = $pm10; // 표시값=PM10(µg/m³)
|
||||
} else {
|
||||
$value = $humidity_percent;
|
||||
}
|
||||
|
|
@ -483,14 +455,8 @@ if ($has_metric_payload) {
|
|||
$sms_sent = true;
|
||||
}
|
||||
}
|
||||
} elseif (($temperature_c !== null || $humidity_percent !== null)
|
||||
&& metric_was_alerting($db, $sensor_id)
|
||||
&& metric_is_recovered($temperature_c, $humidity_percent)) {
|
||||
// 직전까지 경보 상태였고 이제 (히스테리시스 포함) 정상으로 복귀 → 복귀 SMS 1회.
|
||||
// 복귀 판정/메시지는 온습도(SHT30)에 한정한다. PMS 보고(온습도 NULL)는 이 분기를
|
||||
// 타면 안 된다 — 온습도 결측을 '정상 복귀'로 오인해 온습도 경보 중에도 거짓 복귀
|
||||
// SMS 가 나가는 것을 막는다(같은 sensor_id 에 PMS·SHT30 이 별도 행으로 공존하므로).
|
||||
// 미세먼지 경보에는 별도 복귀 SMS 가 없다(값이 임계 아래로 내려가면 재발송이 멈출 뿐).
|
||||
} elseif (metric_was_alerting($db, $sensor_id) && metric_is_recovered($temperature_c, $humidity_percent)) {
|
||||
// 직전까지 경보 상태였고 이제 (히스테리시스 포함) 정상으로 복귀 → 복귀 SMS 1회
|
||||
$sms_sent = send_metric_recovery_sms(
|
||||
$sensor_info,
|
||||
(float)($temperature_c ?? 0.0),
|
||||
|
|
@ -500,17 +466,10 @@ if ($has_metric_payload) {
|
|||
}
|
||||
|
||||
// ── 누수 경보 / 복구 SMS (1번 모듈) ────────────────────────
|
||||
// [디바운스 경로] 감지/복구 전이를 확인창 동안 지속돼야 확정하고, 확정 엣지에서만 SMS 1회.
|
||||
// 감지↔복구 채터링(플래핑)이 SMS 폭탄으로 번지는 것을 원천 차단한다. 누수 센서의 주기
|
||||
// 보고(periodic, is_leak 포함)도 확정 타이머를 전진시키므로 이벤트/주기 모두에서 호출한다.
|
||||
// [레거시 경로] 디바운스 컬럼이 없거나 비활성이면 종전대로 즉시 발송(쿨다운/복구 판정).
|
||||
if ($use_debounce) {
|
||||
if ($is_leak_event || in_array($sensor_id, LEAK_SENSOR_IDS, true)) {
|
||||
if (apply_leak_debounce($db, $sensor_id, $is_leak, $sensor_info)) {
|
||||
$sms_sent = true;
|
||||
}
|
||||
}
|
||||
} elseif ($is_leak_event) {
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -1,261 +0,0 @@
|
|||
<?php
|
||||
// =============================================================================
|
||||
// api/status_summary.php — 미니 상태 표시기(Pi Zero 2 W + ST7789V)용 읽기 전용 요약 JSON
|
||||
//
|
||||
// 인증: X-Status-Key 헤더 = hash_hmac('sha256', 'status', API_KEY)
|
||||
// · cron_heartbeat.php 의 CRON_SECRET 과 동일한 파생 방식(별도 시크릿 배포 불필요,
|
||||
// 표시기는 API_KEY 로 로컬 계산). 헤더 방식이라 access log 에 키가 남지 않는다.
|
||||
// · 기본 API_KEY 미교체 상태면 파생키도 예측 가능 → 접근 자체를 차단(fail closed).
|
||||
// 쓰기 없음(SELECT 전용) · 세션/쿠키 없음 · GET 전용.
|
||||
//
|
||||
// 응답(요약): { ts, system:{state,online,total,unrecovered,sms_balance,today_alerts},
|
||||
// sensors:[{id,name,location,leak_node,metric_node,pm_node,offline,leak,pending,
|
||||
// th_status,metric_status(=th_status 하위호환 별칭),pm_status,
|
||||
// temp,hum,pm25,pm10,pm1_0,last_seen,history,pm_history,leak_events}] }
|
||||
// · state 어휘(우선순위): leak > pm(미세먼지) > alert(온습도) > offline > ok
|
||||
// · 온습도/미세먼지는 같은 sensor_id 아래 metric_type('sht30' vs 'pms7003')로 분리 —
|
||||
// temp/hum/th_status 는 sht30 행에서만, pm25/pm10/pm1_0/pm_status 는 pms7003 행에서만.
|
||||
// =============================================================================
|
||||
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'GET') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'method_not_allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── 레이트 리밋 (IP별 고정창 60초/30회 — 표시기 정상 폴링은 분당 2회) ─────────
|
||||
// login.php 와 동일하게 php/var JSON + LOCK_EX. 파일 오류 시엔 차단하지 않는다
|
||||
// (모니터링 표시기가 스로틀 저장소 문제로 죽는 것보다 서비스 지속이 우선).
|
||||
$rl_file = __DIR__ . '/../var/status_rate.json';
|
||||
$rl_ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
$rl_now = time();
|
||||
$rl_win = $rl_now - ($rl_now % 60);
|
||||
$rl = @json_decode((string)@file_get_contents($rl_file), true);
|
||||
$rl = is_array($rl) ? $rl : [];
|
||||
$rec = $rl[$rl_ip] ?? ['n' => 0, 'win' => $rl_win];
|
||||
if (($rec['win'] ?? 0) !== $rl_win) { $rec = ['n' => 0, 'win' => $rl_win]; }
|
||||
$rec['n']++;
|
||||
// 창이 바뀐 다른 IP 기록은 청소(파일 무한 성장 방지)
|
||||
$rl = array_filter($rl, fn($r) => ($r['win'] ?? 0) === $rl_win);
|
||||
$rl[$rl_ip] = $rec;
|
||||
@file_put_contents($rl_file, json_encode($rl), LOCK_EX);
|
||||
if ($rec['n'] > 30) {
|
||||
http_response_code(429);
|
||||
header('Retry-After: ' . (60 - ($rl_now % 60)));
|
||||
echo json_encode(['error' => 'rate_limited']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── 인증 (fail closed) ────────────────────────────────────────────────────────
|
||||
if (!defined('API_KEY') || API_KEY === '' || API_KEY === 'change-this-api-key') {
|
||||
http_response_code(503);
|
||||
echo json_encode(['error' => 'server_not_configured']);
|
||||
exit;
|
||||
}
|
||||
$status_secret = hash_hmac('sha256', 'status', API_KEY);
|
||||
$given = (string)($_SERVER['HTTP_X_STATUS_KEY'] ?? '');
|
||||
if ($given === '' || !hash_equals($status_secret, $given)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'forbidden']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── 데이터 수집 (대시보드 판정 규칙과 동일 기준) ─────────────────────────────
|
||||
// DB 예외는 전부 잡아 generic 500 으로 — display_errors=On 호스팅에서도 내부정보 비노출.
|
||||
try {
|
||||
$db = get_db();
|
||||
|
||||
$status_rows = $db->query("SELECT * FROM leak_sensor_status ORDER BY sensor_id")->fetchAll();
|
||||
$sensor_ids = array_map(fn($r) => (int)$r['sensor_id'], $status_rows);
|
||||
|
||||
// 최신 측정을 (sensor_id, metric_type) 단위로 가져온다 — 누수 노드(1·3)는 온습도(sht30)와
|
||||
// 미세먼지(pms7003)를 같은 sensor_id 로 보고하므로, metric_type 무시하고 MAX(id) 로 뽑으면
|
||||
// 두 행이 서로를 덮어써(sht30 행은 pm NULL, pms 행은 temp/hum NULL) 한쪽이 사라진다.
|
||||
// → metric_type 별 MAX(id) 로 뽑아 sht30/pms 버킷에 각각 담는다. 레거시 metric_type='' 은 sht30.
|
||||
$latest_sht30 = []; // sensor_id => 최신 sht30(온습도) 행
|
||||
$latest_pms = []; // sensor_id => 최신 pms7003(미세먼지) 행
|
||||
foreach ($db->query("
|
||||
SELECT sm.*
|
||||
FROM leak_sensor_metric sm
|
||||
INNER JOIN (
|
||||
SELECT sensor_id, metric_type, MAX(id) AS latest_id
|
||||
FROM leak_sensor_metric GROUP BY sensor_id, metric_type
|
||||
) latest ON latest.latest_id = sm.id
|
||||
")->fetchAll() as $mr) {
|
||||
$sid = (int)$mr['sensor_id'];
|
||||
$mt = (string)($mr['metric_type'] ?? '');
|
||||
if ($mt === 'pms7003') {
|
||||
$latest_pms[$sid] = $mr;
|
||||
} else {
|
||||
// 'sht30' 또는 레거시 '' — 온습도로 취급. sht30·'' 가 둘 다 있으면 최신(id 큰) 것 유지.
|
||||
if (!isset($latest_sht30[$sid]) || (int)$mr['id'] > (int)$latest_sht30[$sid]['id']) {
|
||||
$latest_sht30[$sid] = $mr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 24시간 추세(시간당 평균, 최대 24포인트) — 센서별 쿼리로
|
||||
// (sensor_id, measured_at) 복합 인덱스를 태워 풀스캔 방지(30초 폴링 엔드포인트) ──
|
||||
$history = []; // sensor_id => [ ['t'=>'HH:MM','temp'=>float|null,'hum'=>float|null], ... ]
|
||||
$pm_history = []; // sensor_id => [ ['t'=>'HH:MM','pm25'=>int], ... ] (pms7003 시간당 평균)
|
||||
// AVG 는 컬럼별로 NULL 을 무시하므로, 같은 시간버킷에 온습도(sht30 행)·미세먼지(pms 행)가
|
||||
// 섞여 있어도 서로를 오염시키지 않는다(sht30 행은 pm2_5=NULL, pms 행은 temp/hum=NULL).
|
||||
// pm2_5 를 같은 쿼리에 얹어 추가 쿼리 없이 (sensor_id, measured_at) 인덱스를 그대로 태운다.
|
||||
$hist_stmt = $db->prepare("
|
||||
SELECT DATE_FORMAT(measured_at, '%Y-%m-%d %H:00:00') AS bucket,
|
||||
AVG(temperature_c) AS temp,
|
||||
AVG(humidity_percent) AS hum,
|
||||
AVG(pm2_5) AS pm25
|
||||
FROM leak_sensor_metric
|
||||
WHERE sensor_id = :sid AND measured_at >= NOW() - INTERVAL 24 HOUR
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
LIMIT 25
|
||||
");
|
||||
foreach ($sensor_ids as $sid) {
|
||||
$hist_stmt->execute([':sid' => $sid]);
|
||||
foreach ($hist_stmt->fetchAll() as $h) {
|
||||
$t = substr($h['bucket'], 11, 5); // HH:MM
|
||||
$history[$sid][] = [
|
||||
't' => $t,
|
||||
'temp' => $h['temp'] !== null ? round((float)$h['temp'], 1) : null,
|
||||
'hum' => $h['hum'] !== null ? round((float)$h['hum'], 0) : null,
|
||||
];
|
||||
if ($h['pm25'] !== null) { // 미세먼지 값이 있는 버킷만
|
||||
$pm_history[$sid][] = ['t' => $t, 'pm25' => (int)round((float)$h['pm25'])];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 누수 이벤트 타임라인 — 플래핑 센서가 응답을 무한정 키우지 않도록 상한(최신 우선)
|
||||
$leak_events = []; // sensor_id => ['HH:MM', ...] — 최근 24h leak_alert 발생 시각
|
||||
foreach ($db->query("
|
||||
SELECT sensor_id, sensor_time
|
||||
FROM leak_sensor_log
|
||||
WHERE event_type = 'leak_alert' AND sensor_time >= NOW() - INTERVAL 24 HOUR
|
||||
ORDER BY sensor_time DESC
|
||||
LIMIT 200
|
||||
")->fetchAll() as $e) {
|
||||
$leak_events[(int)$e['sensor_id']][] = substr((string)$e['sensor_time'], 11, 5);
|
||||
}
|
||||
foreach ($leak_events as &$ev_list) { $ev_list = array_reverse($ev_list); } // 시간순 복원
|
||||
unset($ev_list);
|
||||
|
||||
$now = time();
|
||||
$sensors = [];
|
||||
$offline_count = 0;
|
||||
foreach ($status_rows as $s) {
|
||||
$sid = (int)$s['sensor_id'];
|
||||
$elapsed = $now - strtotime($s['last_seen'] ?? '2000-01-01');
|
||||
$offline = ((int)($s['offline_alerted'] ?? 0) === 1) || ($elapsed >= heartbeat_timeout_for($sid));
|
||||
if ($offline) { $offline_count++; }
|
||||
|
||||
// 누수 확정(디바운스) 기준 — 대시보드/SMS 와 동일. 컬럼 부재 구배포는 raw 폴백.
|
||||
$raw_leak = (int)($s['is_leak'] ?? 0);
|
||||
$leak = array_key_exists('leak_confirmed', $s) ? (int)$s['leak_confirmed'] : $raw_leak;
|
||||
$pending = array_key_exists('leak_confirmed', $s)
|
||||
&& (($s['leak_pending_since'] ?? null) !== null) && ($raw_leak !== $leak);
|
||||
|
||||
[$location, $name] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null);
|
||||
// 최신 측정을 metric_type 별로 분리 — 온습도(sht30 행)와 미세먼지(pms 행)가 같은
|
||||
// sensor_id 아래 서로를 덮어쓰지 않게 한다. temp/hum/th_status 는 sht30 행에서만,
|
||||
// pm25/pm10/pm1_0/pm_status 는 pms 행에서만 취한다.
|
||||
$sht = $latest_sht30[$sid] ?? null;
|
||||
$pms = $latest_pms[$sid] ?? null;
|
||||
|
||||
// 온습도 대표 상태(결로 포함) — sht30 행의 저장 metric_status.
|
||||
$th_status = $sht['metric_status'] ?? 'normal';
|
||||
// 미세먼지 상태 — pms 행의 저장 metric_status(정의상 pms 행은 temp/hum NULL 이라
|
||||
// high_pm25/high_pm10/normal 만 나온다). 계약 보장을 위해 PM 값만 허용, 그 외는 normal.
|
||||
$pm_status_raw = $pms['metric_status'] ?? 'normal';
|
||||
$pm_status = in_array($pm_status_raw, ['high_pm25', 'high_pm10'], true) ? $pm_status_raw : 'normal';
|
||||
|
||||
$sensors[] = [
|
||||
'id' => $sid,
|
||||
'name' => $name,
|
||||
'location' => $location,
|
||||
'leak_node' => in_array($sid, LEAK_SENSOR_IDS, true),
|
||||
'metric_node' => in_array($sid, METRIC_SENSOR_IDS, true),
|
||||
'pm_node' => in_array($sid, PM_SENSOR_IDS, true),
|
||||
'offline' => $offline,
|
||||
'leak' => $leak,
|
||||
'pending' => $pending,
|
||||
'th_status' => $th_status, // 온습도(+결로) 상태(sht30 행)
|
||||
'metric_status' => $th_status, // 하위호환 별칭(= th_status)
|
||||
'pm_status' => $pm_status, // 미세먼지 상태(normal|high_pm25|high_pm10)
|
||||
'temp' => isset($sht['temperature_c']) && $sht['temperature_c'] !== null ? (float)$sht['temperature_c'] : null,
|
||||
'hum' => isset($sht['humidity_percent']) && $sht['humidity_percent'] !== null ? (float)$sht['humidity_percent'] : null,
|
||||
'pm25' => isset($pms['pm2_5']) && $pms['pm2_5'] !== null ? (int)$pms['pm2_5'] : null,
|
||||
'pm10' => isset($pms['pm10']) && $pms['pm10'] !== null ? (int)$pms['pm10'] : null,
|
||||
'pm1_0' => isset($pms['pm1_0']) && $pms['pm1_0'] !== null ? (int)$pms['pm1_0'] : null,
|
||||
'last_seen' => $s['last_seen'] ?? null,
|
||||
'history' => $history[$sid] ?? [], // 24h 시간당 추세(온/습도)
|
||||
'pm_history' => $pm_history[$sid] ?? [], // 24h 시간당 추세(PM2.5)
|
||||
'leak_events' => $leak_events[$sid] ?? [], // 24h 누수 이벤트 시각
|
||||
];
|
||||
}
|
||||
|
||||
$unrecovered = 0;
|
||||
if (table_exists_api($db, 'leak_incident')) {
|
||||
$unrecovered = (int)$db->query("SELECT COUNT(*) FROM leak_incident WHERE recovered_at IS NULL")->fetchColumn();
|
||||
}
|
||||
|
||||
$today_alerts = (int)$db->query("
|
||||
SELECT COUNT(*) FROM leak_sensor_metric
|
||||
WHERE metric_status <> 'normal' AND measured_at >= CURDATE()
|
||||
")->fetchColumn()
|
||||
+ (int)$db->query("
|
||||
SELECT COUNT(*) FROM leak_sensor_log
|
||||
WHERE event_type = 'leak_alert' AND sensor_time >= CURDATE()
|
||||
")->fetchColumn();
|
||||
|
||||
// 시스템 종합 상태 — 표시기 배경색 판정용. 5단 우선순위: 누수 > 미세먼지 > 온습도 > 오프라인 > 정상
|
||||
// (leak > pm > alert(온습도 th) > offline > ok). 오프라인 센서의 경보는 신선하지 않으므로 제외.
|
||||
$leak_active = count(array_filter($sensors, fn($x) => $x['leak'] === 1 && !$x['offline']));
|
||||
$pm_alert = count(array_filter($sensors, fn($x) => $x['pm_status'] !== 'normal' && !$x['offline']));
|
||||
$th_alert = count(array_filter($sensors, fn($x) => $x['th_status'] !== 'normal' && !$x['offline']));
|
||||
if ($leak_active > 0 || $unrecovered > 0) { $state = 'leak'; }
|
||||
elseif ($pm_alert > 0) { $state = 'pm'; }
|
||||
elseif ($th_alert > 0) { $state = 'alert'; }
|
||||
elseif ($offline_count > 0) { $state = 'offline'; }
|
||||
else { $state = 'ok'; }
|
||||
|
||||
echo json_encode([
|
||||
'ts' => date('Y-m-d H:i:s'),
|
||||
'system' => [
|
||||
'state' => $state,
|
||||
'online' => count($sensors) - $offline_count,
|
||||
'total' => count($sensors),
|
||||
'unrecovered' => $unrecovered,
|
||||
'sms_balance' => latest_sms_balance($db),
|
||||
'today_alerts' => $today_alerts,
|
||||
],
|
||||
'sensors' => $sensors,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
// 내부 오류 상세(쿼리/테이블명 등)는 응답에 싣지 않는다 — 로그로만.
|
||||
error_log('[status_summary] ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'internal']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 로컬 헬퍼 — dashboard_data.php(관리 화면 전용)를 끌어오지 않기 위한 최소 복제.
|
||||
function table_exists_api(PDO $db, string $table): bool {
|
||||
try {
|
||||
$st = $db->prepare("
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = :t
|
||||
");
|
||||
$st->execute([':t' => $table]);
|
||||
return (int)$st->fetchColumn() > 0;
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,651 +0,0 @@
|
|||
/* =============================================================================
|
||||
* app.css — 통합 모니터링 관리자 디자인 시스템 "Slate NOC Console" (Dark)
|
||||
* 다크 우선 관제 콘솔. 초대형 tabular 수치가 히어로. 7개 페이지 공용.
|
||||
* 상태 3중 인코딩(색+도형+텍스트) 유지. 인쇄는 강제 라이트.
|
||||
* ============================================================================= */
|
||||
|
||||
:root {
|
||||
--bg: #0f1533;
|
||||
--surface: #191f42;
|
||||
--surface-2: #232a55;
|
||||
--raise: #2b3366;
|
||||
--ink: #e9edff;
|
||||
--ink-2: #98a2cf;
|
||||
--ink-3: #6f7cae;
|
||||
--line: #2b3364;
|
||||
--line-2: #212850;
|
||||
|
||||
--primary: #6c7bff;
|
||||
--primary-600: #8f9bff;
|
||||
--on-primary: #0b1030;
|
||||
--primary-tint: #1d2450;
|
||||
|
||||
--header-bg: #141a3d;
|
||||
--header-ink: var(--ink);
|
||||
|
||||
--normal: #35d6a0; --normal-ink: #74ecc0; --normal-bg: rgba(53,214,160,.13);
|
||||
--leak: #ff5470; --leak-ink: #ff90a5; --leak-bg: rgba(255,84,112,.14);
|
||||
--warn: #ffb23e; --warn-ink: #ffcd7b; --warn-bg: rgba(255,178,62,.13);
|
||||
--offline:#7885b8; --offline-ink:#9aa6d4; --offline-bg:rgba(120,133,184,.14);
|
||||
|
||||
--hot-ink: #ff9a7a; --hot-bg: rgba(255,120,90,.14);
|
||||
--cold-ink: #5fd6ee; --cold-bg: rgba(63,199,230,.13);
|
||||
--humid-ink: var(--warn-ink); --humid-bg: var(--warn-bg);
|
||||
--dry-ink: #b0b8ff; --dry-bg: rgba(120,130,255,.14);
|
||||
|
||||
--chart-temp: #ff8a5c;
|
||||
--chart-hum: #3fc7e6;
|
||||
--chart-grid: var(--line);
|
||||
|
||||
--font: "Malgun Gothic","Apple SD Gothic Neo",system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans KR",sans-serif;
|
||||
--mono: ui-monospace,SFMono-Regular,Consolas,"D2Coding",monospace;
|
||||
|
||||
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px; --sp-8: 32px;
|
||||
--r-sm: 8px; --r: 12px; --r-lg: 18px; --r-pill: 999px;
|
||||
/* 소프트·확산형 그림자(참고 대시보드처럼 카드가 표면 위에 살짝 떠 보이게) */
|
||||
--shadow: 0 2px 10px rgba(0,0,0,.30);
|
||||
--shadow-lg: 0 16px 40px rgba(0,0,0,.48);
|
||||
--ring: 0 0 0 3px rgba(108,123,255,.45);
|
||||
/* 장식 골드 액센트(참고 2.png 인디고+골드) — 상태색 --warn 과 별개의 장식 역할 */
|
||||
--accent-gold: #ffc24b;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background:
|
||||
radial-gradient(1200px 500px at 15% -8%, rgba(108,123,255,.10), transparent 60%),
|
||||
radial-gradient(1000px 460px at 92% -4%, rgba(63,199,230,.07), transparent 60%),
|
||||
var(--bg);
|
||||
background-attachment: fixed;
|
||||
color: var(--ink); line-height: 1.55; font-size: 1rem; -webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.val, .metric-chip, .report-stat strong, td.num, .num, .status-clock,
|
||||
.card .val, .quick-tile strong { font-variant-numeric: tabular-nums; }
|
||||
a { color: var(--primary-600); }
|
||||
:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: var(--r-sm); }
|
||||
img { max-width: 100%; }
|
||||
|
||||
h1 { font-size: 1.375rem; font-weight: 800; line-height: 1.25; letter-spacing: -.01em; }
|
||||
h2 { font-size: 1.0625rem; font-weight: 700; color: var(--ink); margin: var(--sp-6) 0 var(--sp-3); letter-spacing: .01em; }
|
||||
.panel h2, .status-hero h2 { margin-top: 0; }
|
||||
label, .label { font-size: .875rem; font-weight: 600; }
|
||||
.caption, small { font-size: .8125rem; color: var(--ink-3); }
|
||||
.container > h2 { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
.container > h2::before { content: ""; width: 3px; height: 1.05em; border-radius: 2px; background: linear-gradient(180deg, var(--primary), var(--chart-hum)); }
|
||||
|
||||
header {
|
||||
background: var(--header-bg); color: var(--header-ink); padding: var(--sp-4) var(--sp-6);
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4);
|
||||
border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20;
|
||||
}
|
||||
header h1 { font-size: 1.25rem; color: var(--header-ink); display: flex; align-items: center; gap: var(--sp-3); }
|
||||
header h1::before { content: ""; width: 10px; height: 10px; border-radius: 50%; background: var(--normal); box-shadow: 0 0 0 4px rgba(53,214,160,.18); }
|
||||
.top-actions { display: flex; gap: var(--sp-2); align-items: center; flex-wrap: wrap; }
|
||||
.top-actions a { color: var(--ink-2); font-size: .8125rem; font-weight: 600; text-decoration: none; background: var(--surface-2); border: 1px solid var(--line); padding: 7px 14px; border-radius: var(--r-sm); }
|
||||
.top-actions a:hover { background: var(--raise); color: var(--ink); border-color: var(--primary); }
|
||||
|
||||
.container { max-width: 1360px; margin: var(--sp-6) auto var(--sp-8); padding: 0 var(--sp-5); }
|
||||
.ops-grid { display: grid; grid-template-columns: minmax(0,1.5fr) minmax(300px,.9fr); gap: var(--sp-4); align-items: start; margin-bottom: var(--sp-6); }
|
||||
.cards { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: var(--sp-4); margin-bottom: var(--sp-4); }
|
||||
.cards.flush { margin-bottom: var(--sp-6); }
|
||||
.sensor-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px,1fr)); gap: var(--sp-4); margin-bottom: var(--sp-6); }
|
||||
|
||||
.panel, .card, .chart-panel, .leak-status-card, .empty-note { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); }
|
||||
.panel { padding: var(--sp-5); }
|
||||
.chart-panel { padding: var(--sp-5); margin-bottom: var(--sp-6); }
|
||||
|
||||
.card { padding: var(--sp-5) var(--sp-5) var(--sp-6); position: relative; }
|
||||
.card > div:first-child { color: var(--ink-2); font-size: .78rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.card span { color: var(--ink-2); font-size: .8125rem; }
|
||||
.card .val { font-size: clamp(2.4rem, 4.2vw, 3.4rem); font-weight: 800; line-height: 1.02; margin-top: var(--sp-2); color: var(--ink); letter-spacing: -.02em; }
|
||||
.card .val.compact { font-size: clamp(1.6rem, 2.6vw, 2rem); }
|
||||
.card .val.clock { font-size: clamp(1.1rem, 2vw, 1.45rem); font-weight: 700; color: var(--ink-2); letter-spacing: 0; }
|
||||
.card .val.ok-color { color: var(--normal-ink); }
|
||||
.card .val.err-color { color: var(--leak-ink); }
|
||||
.card .val.warn-color { color: var(--warn-ink); }
|
||||
/* 상태색은 .val 색상으로 인코딩(상단 스트립 제거 — 참고 대시보드 스타일의 평평한 카드) */
|
||||
.card.ok, .card:has(.val.ok-color) { border-color: rgba(53,214,160,.32); }
|
||||
.card.err, .card:has(.val.err-color) { border-color: rgba(255,84,112,.34); }
|
||||
.card.warn, .card:has(.val.warn-color) { border-color: rgba(255,178,62,.32); }
|
||||
.card .val.ok-color::before, .card .val.err-color::before, .card .val.warn-color::before { content: none; }
|
||||
|
||||
.status-hero { border-radius: var(--r-lg); padding: var(--sp-6); margin-bottom: var(--sp-5); display: grid; grid-template-columns: minmax(0,1fr) auto; gap: var(--sp-4); align-items: center; color: var(--ink); background: linear-gradient(180deg, rgba(53,214,160,.08), transparent 70%), var(--surface); border: 1px solid var(--line); position: relative; overflow: hidden; }
|
||||
.status-hero::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: linear-gradient(90deg, var(--normal), var(--chart-hum)); }
|
||||
.status-hero h2 { font-size: 1.4rem; font-weight: 800; display: flex; align-items: center; gap: var(--sp-3); }
|
||||
.status-hero h2::before { content: ""; width: .78em; height: .78em; border-radius: 50%; background: var(--normal); flex: none; box-shadow: 0 0 0 5px rgba(53,214,160,.16); }
|
||||
.status-hero p { color: var(--ink-2); font-size: .95rem; margin-top: 4px; }
|
||||
.status-hero.warning { background: linear-gradient(180deg, rgba(255,178,62,.09), transparent 70%), var(--surface); }
|
||||
.status-hero.danger { background: linear-gradient(180deg, rgba(255,84,112,.12), transparent 72%), var(--surface); }
|
||||
.status-hero.warning::before { background: var(--warn); }
|
||||
.status-hero.danger::before { background: var(--leak); }
|
||||
.status-hero.warning h2::before { background: var(--warn); box-shadow: 0 0 0 5px rgba(255,178,62,.16); }
|
||||
.status-hero.danger h2::before { background: var(--leak); box-shadow: 0 0 0 5px rgba(255,84,112,.18); animation: hero-pulse 1.6s ease-in-out infinite; }
|
||||
@keyframes hero-pulse { 0%,100% { box-shadow: 0 0 0 4px rgba(255,84,112,.10); } 50% { box-shadow: 0 0 0 8px rgba(255,84,112,.24); } }
|
||||
@media (prefers-reduced-motion: reduce) { .status-hero.danger h2::before { animation: none; } }
|
||||
.status-clock { text-align: right; font-size: .8125rem; color: var(--ink-3); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.sensor-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); padding: var(--sp-5); box-shadow: var(--shadow); transition: transform .12s ease, border-color .12s ease; }
|
||||
.sensor-card:hover { transform: translateY(-2px); border-color: var(--raise); }
|
||||
.sensor-card strong { font-size: 1rem; font-weight: 700; }
|
||||
.sensor-card small { color: var(--ink-3); }
|
||||
.sensor-card.alert { border-color: var(--leak); background: linear-gradient(180deg, var(--leak-bg), transparent 60%), var(--surface); }
|
||||
.sensor-card.offline { border-color: var(--offline); background: linear-gradient(180deg, var(--offline-bg), transparent 60%), var(--surface); }
|
||||
.sensor-card.pending { border-color: var(--warn); background: linear-gradient(180deg, var(--warn-bg), transparent 60%), var(--surface); }
|
||||
.badge-row { margin-top: var(--sp-3); }
|
||||
|
||||
.badge, .pill { display: inline-flex; align-items: center; gap: .4em; border-radius: var(--r-pill); padding: 4px 11px; font-size: .75rem; font-weight: 700; line-height: 1.4; background: var(--normal-bg); color: var(--normal-ink); border: 1px solid transparent; }
|
||||
.badge::before, .pill::before { content: ""; width: .5em; height: .5em; border-radius: 50%; background: currentColor; flex: none; }
|
||||
.badge.ok, .pill.ok { background: var(--normal-bg); color: var(--normal-ink); }
|
||||
.badge.alert,.pill.alert { background: var(--leak-bg); color: var(--leak-ink); }
|
||||
.badge.offline,.pill.offline{ background: var(--offline-bg); color: var(--offline-ink); }
|
||||
.badge.pending,.pill.pending{ background: var(--warn-bg); color: var(--warn-ink); }
|
||||
.pill.high_temp { background: var(--hot-bg); color: var(--hot-ink); }
|
||||
.pill.low_temp { background: var(--cold-bg); color: var(--cold-ink); }
|
||||
.pill.high_humidity{ background: var(--humid-bg); color: var(--humid-ink); }
|
||||
.pill.low_humidity { background: var(--dry-bg); color: var(--dry-ink); }
|
||||
.pill.leak, .pill.leak_alert { background: var(--leak-bg); color: var(--leak-ink); }
|
||||
.pill.leak_recovery { background: var(--normal-bg); color: var(--normal-ink); }
|
||||
|
||||
.metric-line { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-3); }
|
||||
.metric-chip { display: inline-flex; align-items: center; border-radius: var(--r-pill); background: var(--surface-2); color: var(--ink-2); padding: 5px 12px; font-size: .82rem; font-weight: 700; }
|
||||
.metric-chip.hot { background: var(--hot-bg); color: var(--hot-ink); }
|
||||
.metric-chip.cold { background: var(--cold-bg); color: var(--cold-ink); }
|
||||
.metric-chip.humid { background: var(--humid-bg); color: var(--humid-ink); }
|
||||
.metric-chip.dry { background: var(--dry-bg); color: var(--dry-ink); }
|
||||
.metric-time { color: var(--ink-3); font-size: .75rem; margin-top: var(--sp-2); font-variant-numeric: tabular-nums; }
|
||||
.metric-time.stale { color: var(--offline-ink); }
|
||||
|
||||
.diag-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); margin: 0 0 var(--sp-3); }
|
||||
.diag-head h2 { margin: 0; }
|
||||
.diag-pill { font-size: .75rem; font-weight: 700; padding: 4px 11px; border-radius: var(--r-pill); white-space: nowrap; }
|
||||
.diag-pill.ok { background: var(--normal-bg); color: var(--normal-ink); }
|
||||
.diag-pill.warn { background: var(--warn-bg); color: var(--warn-ink); }
|
||||
.diag-pill.crit { background: var(--leak-bg); color: var(--leak-ink); }
|
||||
.diag-counts { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-3); }
|
||||
.dc { flex: 1; text-align: center; font-size: .72rem; color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r); padding: 8px 4px; }
|
||||
.dc b { display: block; font-size: 1.3rem; font-weight: 800; color: var(--ink); line-height: 1.2; margin-bottom: 1px; font-variant-numeric: tabular-nums; }
|
||||
.dc.ok b { color: var(--normal-ink); }
|
||||
.dc.warn b { color: var(--warn-ink); }
|
||||
.dc.crit b { color: var(--leak-ink); }
|
||||
.diag-issues { list-style: none; margin: 0 0 var(--sp-2); padding: 0; display: grid; gap: var(--sp-2); }
|
||||
.diag-issue { display: grid; grid-template-columns: auto minmax(0,1fr); gap: var(--sp-2); align-items: start; border: 1px solid var(--line); border-radius: var(--r); padding: 9px 11px; background: var(--surface-2); }
|
||||
.diag-issue.critical { background: var(--leak-bg); border-color: rgba(255,84,112,.3); }
|
||||
.diag-issue.warn { background: var(--warn-bg); border-color: rgba(255,178,62,.28); }
|
||||
.diag-dot { width: 9px; height: 9px; border-radius: 50%; margin-top: 5px; background: var(--warn); flex: none; }
|
||||
.diag-issue.critical .diag-dot { background: var(--leak); }
|
||||
.diag-issue strong { display: block; font-size: .8125rem; color: var(--ink); }
|
||||
.diag-issue span { color: var(--ink-2); font-size: .78rem; line-height: 1.4; }
|
||||
.diag-ok { border-top: 1px solid var(--line); margin-top: var(--sp-1); padding-top: var(--sp-2); }
|
||||
.diag-ok > summary { cursor: pointer; font-size: .8125rem; color: var(--ink-2); font-weight: 600; list-style: none; display: flex; align-items: center; gap: 6px; padding: 2px 0; }
|
||||
.diag-ok > summary::-webkit-details-marker { display: none; }
|
||||
.diag-ok > summary::before { content: "\25B8"; color: var(--ink-3); font-size: .7rem; }
|
||||
.diag-ok[open] > summary::before { content: "\25BE"; }
|
||||
.diag-ok-grid { list-style: none; margin: var(--sp-2) 0 0; padding: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 7px 12px; }
|
||||
.diag-ok-grid li { display: flex; align-items: center; gap: 6px; font-size: .75rem; color: var(--ink-2); min-width: 0; }
|
||||
.diag-ok-grid li span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.diag-ok-grid i { width: 7px; height: 7px; border-radius: 50%; background: var(--normal); flex: none; }
|
||||
.panel-note { color: var(--ink-3); font-size: .78rem; margin-top: var(--sp-3); line-height: 1.5; }
|
||||
|
||||
.report-grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: var(--sp-3); }
|
||||
.report-stat { border: 1px solid var(--line); border-radius: var(--r); padding: var(--sp-4); background: var(--surface-2); }
|
||||
.report-stat span { display: block; color: var(--ink-2); font-size: .75rem; margin-bottom: 4px; }
|
||||
.report-stat strong { font-size: 1.35rem; font-weight: 800; }
|
||||
|
||||
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; margin-bottom: var(--sp-6); border-radius: var(--r-lg); }
|
||||
table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; }
|
||||
.table-wrap table { margin-bottom: 0; }
|
||||
th { background: var(--surface-2); font-size: .8125rem; font-weight: 700; color: var(--ink-2); padding: 11px 14px; text-align: left; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||||
td { padding: 10px 14px; border-bottom: 1px solid var(--line-2); font-size: .875rem; color: var(--ink); }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover td { background: var(--surface-2); }
|
||||
.alert-row td { background: var(--leak-bg); }
|
||||
.alert-row:hover td { background: var(--leak-bg); }
|
||||
|
||||
.ok-color, .err-color, .warn-color { font-weight: 700; }
|
||||
.ok-color { color: var(--normal-ink); }
|
||||
.err-color { color: var(--leak-ink); }
|
||||
.warn-color{ color: var(--warn-ink); }
|
||||
.check.ok, .check-ok { color: var(--normal-ink); font-weight: 700; }
|
||||
.check.fail, .check-fail { color: var(--leak-ink); font-weight: 700; }
|
||||
.check.ok::before, .check-ok::before { content: "\2713\00a0"; }
|
||||
.check.fail::before, .check-fail::before { content: "\2715\00a0"; }
|
||||
|
||||
.chart-legend { display: flex; gap: var(--sp-4); flex-wrap: wrap; margin: var(--sp-2) 0 var(--sp-3); font-size: .8125rem; color: var(--ink-2); }
|
||||
.chart-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.legend-swatch { width: 16px; height: 4px; border-radius: 2px; display: inline-block; }
|
||||
.legend-swatch.temp { background: var(--chart-temp); }
|
||||
.legend-swatch.hum { background: var(--chart-hum); }
|
||||
.chart-svg-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.chart-empty, .empty-note { color: var(--ink-3); padding: var(--sp-6); text-align: center; }
|
||||
.empty-note { padding: var(--sp-5); }
|
||||
|
||||
.leak-status-card { padding: var(--sp-5); transition: transform .12s ease, border-color .12s ease; }
|
||||
.leak-status-card:hover { transform: translateY(-2px); border-color: var(--raise); }
|
||||
.leak-status-card.leak { border-color: var(--leak); background: linear-gradient(180deg, var(--leak-bg), transparent 60%), var(--surface); }
|
||||
.leak-status-card.offline { border-color: var(--offline); background: linear-gradient(180deg, var(--offline-bg), transparent 60%), var(--surface); }
|
||||
.leak-thumbs { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
||||
.leak-thumbs a { display: inline-block; line-height: 0; }
|
||||
.leak-thumbs img { width: 64px; height: 64px; object-fit: cover; border-radius: var(--r-sm); border: 1px solid var(--line); background: var(--surface-2); }
|
||||
|
||||
.mobile-quick { display: none; }
|
||||
.quick-grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: var(--sp-2); margin-bottom: var(--sp-3); }
|
||||
.quick-tile { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r); padding: 12px; text-decoration: none; color: var(--ink); min-height: 74px; }
|
||||
.quick-tile span { display: block; color: var(--ink-2); font-size: .75rem; margin-bottom: 5px; }
|
||||
.quick-tile strong { font-size: 1.5rem; font-weight: 800; line-height: 1; }
|
||||
.quick-tile.danger { border-color: var(--leak); background: var(--leak-bg); }
|
||||
.quick-tile.warn { border-color: var(--warn); background: var(--warn-bg); }
|
||||
.quick-tile.ok { border-color: var(--normal); background: var(--normal-bg); }
|
||||
.quick-links { display: flex; gap: var(--sp-2); overflow-x: auto; -webkit-overflow-scrolling: touch; padding-bottom: 6px; margin-bottom: var(--sp-4); }
|
||||
.quick-links a { flex: 0 0 auto; color: var(--ink); background: var(--surface-2); border: 1px solid var(--line); text-decoration: none; border-radius: var(--r-pill); padding: 8px 13px; font-size: .8125rem; font-weight: 700; }
|
||||
|
||||
.mobile-detail { margin-bottom: var(--sp-6); }
|
||||
.mobile-detail summary { display: none; }
|
||||
.refresh { float: right; font-size: .8125rem; color: var(--ink-2); font-weight: 400; }
|
||||
.refresh-toggle { background: var(--surface-2); color: var(--ink-2); border: 1px solid var(--line); border-radius: var(--r-pill); padding: 3px 10px; font-size: .75rem; cursor: pointer; margin-left: 8px; }
|
||||
|
||||
.forecast { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-3); padding-top: var(--sp-3); border-top: 1px solid var(--line-2); }
|
||||
.forecast-chip { display: inline-flex; align-items: center; gap: .35em; border-radius: var(--r-pill); padding: 3px 10px; font-size: .75rem; font-weight: 700; line-height: 1.5; background: var(--warn-bg); color: var(--warn-ink); }
|
||||
.forecast-chip::before { content: ""; width: .45em; height: .45em; border-radius: 50%; background: currentColor; flex: none; }
|
||||
.forecast-chip.near { background: var(--warn-bg); color: var(--warn-ink); }
|
||||
.forecast-chip.over { background: var(--leak-bg); color: var(--leak-ink); }
|
||||
.forecast-note { font-size: .75rem; color: var(--ink-3); }
|
||||
|
||||
.response-panel { background: linear-gradient(180deg, var(--leak-bg), transparent 60%), var(--surface); border: 1px solid var(--leak); border-radius: var(--r-lg); padding: var(--sp-5); margin-bottom: var(--sp-5); }
|
||||
.response-panel h2 { margin: 0 0 var(--sp-3); display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.response-count { font-size: .8125rem; font-weight: 700; color: var(--leak-ink); background: var(--leak-bg); border-radius: var(--r-pill); padding: 3px 11px; }
|
||||
.response-item { border-top: 1px solid var(--line-2); padding: var(--sp-3) 0; }
|
||||
.response-item:first-of-type { border-top: none; padding-top: 0; }
|
||||
.response-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-2); }
|
||||
.response-head strong { font-size: .95rem; }
|
||||
.response-detail { color: var(--ink-2); font-size: .875rem; }
|
||||
.playbook { margin-top: var(--sp-2); background: var(--surface-2); border-radius: var(--r-sm); padding: var(--sp-3) var(--sp-4); }
|
||||
.playbook-title { font-size: .8125rem; font-weight: 700; color: var(--ink-2); margin-bottom: 4px; }
|
||||
.playbook-steps { margin: 0; padding-left: 1.4em; }
|
||||
.playbook-steps li { font-size: .875rem; line-height: 1.7; }
|
||||
.response-contacts { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); margin-top: var(--sp-4); padding-top: var(--sp-3); border-top: 1px solid var(--line-2); }
|
||||
.contacts-label { font-size: .8125rem; font-weight: 700; color: var(--ink-2); }
|
||||
.contact { font-size: .875rem; }
|
||||
.contact a { font-weight: 700; }
|
||||
|
||||
.login-box { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); padding: var(--sp-8) var(--sp-6); box-shadow: var(--shadow-lg); width: 100%; max-width: 400px; position: relative; overflow: hidden; }
|
||||
.login-box::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: linear-gradient(90deg, var(--primary), var(--chart-hum)); }
|
||||
.login-box h1 { color: var(--ink); margin-bottom: var(--sp-2); text-align: center; }
|
||||
.login-box p.sub { font-size: .875rem; color: var(--ink-2); text-align: center; margin-bottom: var(--sp-6); }
|
||||
.form-group { margin-bottom: var(--sp-4); }
|
||||
.form-group label { display: block; color: var(--ink-2); margin-bottom: 6px; }
|
||||
.form-group input, input[type=text], input[type=password], textarea { width: 100%; padding: 11px 14px; border: 1px solid var(--line); border-radius: var(--r); font-size: .95rem; font-family: inherit; color: var(--ink); background: var(--surface-2); outline: none; }
|
||||
.form-group input:focus, input:focus, textarea:focus { border-color: var(--primary); box-shadow: var(--ring); }
|
||||
@media (forced-colors: active) { input:focus, textarea:focus { outline: 2px solid; outline-offset: 1px; } }
|
||||
.btn { display: inline-block; width: 100%; padding: 12px; background: var(--primary); color: var(--on-primary); border: none; border-radius: var(--r); font-size: 1rem; font-weight: 800; cursor: pointer; text-align: center; }
|
||||
.btn:hover { background: var(--primary-600); }
|
||||
.btn:disabled { background: var(--offline); cursor: not-allowed; opacity: .6; }
|
||||
.btn.secondary { background: var(--surface-2); color: var(--ink); border: 1px solid var(--line); }
|
||||
|
||||
.error, .expired, .banner-ok { padding: 11px 14px; border-radius: var(--r); font-size: .875rem; margin-bottom: var(--sp-4); border: 1px solid var(--line); border-left-width: 4px; }
|
||||
.error { background: var(--leak-bg); color: var(--leak-ink); border-left-color: var(--leak); }
|
||||
.expired { background: var(--warn-bg); color: var(--warn-ink); border-left-color: var(--warn); }
|
||||
.banner-ok { background: var(--normal-bg); color: var(--normal-ink); border-left-color: var(--normal); }
|
||||
|
||||
.secret, code, .mono { font-family: var(--mono); background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r-sm); padding: 2px 6px; word-break: break-all; color: var(--ink); }
|
||||
.secret { display: block; padding: 12px; font-size: 1.05rem; letter-spacing: .06em; }
|
||||
|
||||
/* ===== 개요 벤토(존) — 관련 위젯 그룹핑으로 섹션 관계·위치 시각화 (샘플 콘텐츠 반영) ===== */
|
||||
.zone { margin-top: var(--sp-8); }
|
||||
.zone-label { display: flex; align-items: center; gap: var(--sp-3); font-size: .72rem; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; color: var(--ink-3); margin-bottom: var(--sp-4); }
|
||||
.zone-label::before { content: ""; width: 16px; height: 3px; border-radius: 2px; flex: none; background: linear-gradient(90deg, var(--primary), var(--chart-hum)); }
|
||||
.zone-label b { color: var(--ink-2); font-weight: 800; letter-spacing: .04em; }
|
||||
.zone-label .en { color: var(--ink-3); font-weight: 700; }
|
||||
.zone-label::after { content: ""; flex: 1; height: 1px; background: linear-gradient(90deg, var(--line) 0%, var(--line-2) 60%, transparent 100%); }
|
||||
.zone > h2:first-of-type { margin-top: var(--sp-2); }
|
||||
|
||||
.overview { display: grid; grid-template-columns: repeat(12, minmax(0,1fr)); gap: var(--sp-4); align-items: stretch; margin-bottom: var(--sp-4); }
|
||||
.overview > * { min-width: 0; }
|
||||
.ov-gauge { grid-column: span 3; }
|
||||
.ov-kpis { grid-column: span 2; display: grid; grid-template-rows: 1fr 1fr; gap: var(--sp-4); }
|
||||
.ov-donut { grid-column: span 3; }
|
||||
.ov-board { grid-column: span 4; }
|
||||
.ov-trend { grid-column: span 8; }
|
||||
.ov-diag { grid-column: span 4; }
|
||||
.ov-diag.panel { margin: 0; }
|
||||
|
||||
.ov-tile { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); padding: var(--sp-5); display: flex; flex-direction: column; }
|
||||
.ov-tile h3 { font-size: .74rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-2); margin: 0 0 var(--sp-3); display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
|
||||
.ov-tile h3 .sub { font-size: .68rem; font-weight: 600; color: var(--ink-3); text-transform: none; letter-spacing: 0; }
|
||||
|
||||
/* 가동률 게이지 */
|
||||
.gauge-body { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; justify-content: center; }
|
||||
.gauge-ring { position: relative; width: 138px; max-width: 100%; }
|
||||
.gauge-ring svg { width: 100%; height: auto; display: block; }
|
||||
.gauge-ring .g-track { fill: none; stroke: var(--surface-2); stroke-width: 12; }
|
||||
.gauge-ring .g-val { fill: none; stroke: var(--normal); stroke-width: 12; stroke-linecap: round; }
|
||||
.gauge-ring.warn .g-val { stroke: var(--warn); }
|
||||
.gauge-ring.crit .g-val { stroke: var(--leak); }
|
||||
.gauge-num { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||
.gauge-num b { font-size: 2rem; font-weight: 800; line-height: 1; letter-spacing: -.02em; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||
.gauge-num small { font-size: .68rem; color: var(--ink-3); margin-top: 3px; }
|
||||
.gauge-foot { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); padding-top: var(--sp-3); margin-top: var(--sp-1); border-top: 1px solid var(--line-2); }
|
||||
.gauge-foot .lbl { font-size: .7rem; color: var(--ink-3); }
|
||||
.spark { width: 84px; height: 30px; display: block; }
|
||||
.spark polyline { fill: none; stroke: var(--warn); stroke-width: 1.6; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.spark .sp-dot { fill: var(--warn); }
|
||||
.g-badge { display: inline-flex; align-items: center; gap: .3em; font-size: .7rem; font-weight: 800; padding: 2px 9px; border-radius: var(--r-pill); background: var(--warn-bg); color: var(--warn-ink); }
|
||||
.g-badge.ok { background: var(--normal-bg); color: var(--normal-ink); }
|
||||
.g-badge.crit { background: var(--leak-bg); color: var(--leak-ink); }
|
||||
|
||||
/* 개요 KPI 소형 타일(듀얼) */
|
||||
.ov-kpi { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); padding: var(--sp-4) var(--sp-5); position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; }
|
||||
.ov-kpi::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: var(--primary); }
|
||||
.ov-kpi.err::before { background: var(--leak); }
|
||||
.ov-kpi.warn::before { background: var(--warn); }
|
||||
.ov-kpi.ok::before { background: var(--normal); }
|
||||
.ov-kpi .k-lbl { font-size: .72rem; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--ink-2); }
|
||||
.ov-kpi .k-val { font-size: clamp(1.7rem, 2.6vw, 2.2rem); font-weight: 800; line-height: 1.05; letter-spacing: -.02em; font-variant-numeric: tabular-nums; margin-top: 2px; }
|
||||
.ov-kpi .k-val u { text-decoration: none; font-size: .9rem; color: var(--ink-3); font-weight: 700; margin-left: 1px; }
|
||||
.ov-kpi.err .k-val { color: var(--leak-ink); }
|
||||
.ov-kpi.warn .k-val { color: var(--warn-ink); }
|
||||
.ov-kpi.ok .k-val { color: var(--normal-ink); }
|
||||
|
||||
/* 도넛 (경보 종류 구성) */
|
||||
.donut-body { display: flex; align-items: center; gap: var(--sp-4); flex: 1; }
|
||||
.donut-ring { position: relative; flex: none; width: 116px; }
|
||||
.donut-ring svg { width: 100%; height: auto; display: block; }
|
||||
.donut-ring .d-track { fill: none; stroke: var(--surface-2); stroke-width: 15; }
|
||||
.donut-ring circle.seg { fill: none; stroke-width: 15; }
|
||||
.donut-num { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||
.donut-num b { font-size: 1.5rem; font-weight: 800; line-height: 1; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||
.donut-num small { font-size: .64rem; color: var(--ink-3); }
|
||||
.donut-legend { display: grid; gap: 6px; flex: 1; min-width: 0; }
|
||||
.dl { display: flex; align-items: center; gap: 7px; font-size: .76rem; color: var(--ink-2); }
|
||||
.dl i { width: 9px; height: 9px; border-radius: 3px; flex: none; }
|
||||
.dl .dl-c { margin-left: auto; font-weight: 800; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||
.donut-empty { color: var(--ink-3); font-size: .82rem; text-align: center; flex: 1; align-self: center; }
|
||||
|
||||
/* 리더보드 (경보 최다 센서) */
|
||||
.board { display: grid; gap: 9px; flex: 1; align-content: center; }
|
||||
.board-row { display: grid; grid-template-columns: 1.1em minmax(0,1fr) auto; align-items: center; gap: var(--sp-3); }
|
||||
.board-rank { font-size: .74rem; font-weight: 800; color: var(--ink-3); font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.board-row:nth-child(1) .board-rank { color: var(--warn-ink); }
|
||||
.board-main { min-width: 0; }
|
||||
.board-name { font-size: .82rem; font-weight: 600; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.board-bar { height: 6px; border-radius: var(--r-pill); background: var(--surface-2); margin-top: 4px; overflow: hidden; }
|
||||
.board-bar > span { display: block; height: 100%; border-radius: var(--r-pill); background: linear-gradient(90deg, var(--primary), var(--chart-hum)); }
|
||||
.board-row:nth-child(1) .board-bar > span { background: linear-gradient(90deg, var(--warn), var(--chart-temp)); }
|
||||
.board-cnt { font-size: .95rem; font-weight: 800; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||
.board-cnt small { font-size: .68rem; color: var(--ink-3); font-weight: 600; }
|
||||
.board-empty { color: var(--ink-3); font-size: .82rem; text-align: center; align-self: center; flex: 1; }
|
||||
|
||||
/* 개요 트렌드 (경보 발생 vs 복구) */
|
||||
.ov-trend .trend-svg { width: 100%; height: auto; display: block; }
|
||||
.ov-trend .plot-bg { fill: var(--surface-2); stroke: var(--line); }
|
||||
.ov-trend .grid-line { stroke: var(--chart-grid); }
|
||||
.ov-trend .ax { fill: var(--ink-3); }
|
||||
.ov-trend .line-alert { stroke: var(--leak); }
|
||||
.ov-trend .line-recover { stroke: var(--normal); }
|
||||
.ov-trend .line-metric { stroke: var(--warn); }
|
||||
.ov-trend .dot-alert { fill: var(--leak); }
|
||||
.ov-trend .dot-metric { fill: var(--warn); }
|
||||
.mini-legend { display: flex; gap: var(--sp-4); flex-wrap: wrap; font-size: .78rem; color: var(--ink-2); margin-bottom: var(--sp-2); }
|
||||
.mini-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.mini-legend i { width: 15px; height: 3px; border-radius: 2px; }
|
||||
|
||||
/* 추세 차트 기간 토글(링크) + 피크 마커 */
|
||||
.chart-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-3); }
|
||||
.range-toggle { display: inline-flex; gap: 2px; background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r-pill); padding: 2px; }
|
||||
.range-toggle a { font-size: .76rem; font-weight: 700; text-decoration: none; color: var(--ink-2); padding: 4px 13px; border-radius: var(--r-pill); }
|
||||
.range-toggle a[aria-current="true"] { background: var(--primary); color: var(--on-primary); }
|
||||
.chart-svg-wrap .peak-marker { fill: var(--warn); }
|
||||
.chart-svg-wrap .peak-label { fill: var(--warn-ink); font-weight: 700; }
|
||||
.chart-svg-wrap .peak-flag { fill: var(--surface-2); stroke: var(--warn); }
|
||||
|
||||
/* 개요 벤토 반응형 */
|
||||
@media (max-width: 1080px) {
|
||||
.ov-gauge, .ov-donut { grid-column: span 6; }
|
||||
.ov-kpis { grid-column: span 6; grid-template-rows: none; grid-template-columns: 1fr 1fr; }
|
||||
.ov-board { grid-column: span 6; }
|
||||
.ov-trend, .ov-diag { grid-column: span 12; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.overview > * { grid-column: span 12 !important; }
|
||||
.ov-kpis { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
header { flex-direction: column; align-items: flex-start; position: static; }
|
||||
.top-actions { margin-top: var(--sp-3); overflow-x: auto; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; }
|
||||
.top-actions a { flex: 0 0 auto; }
|
||||
.status-hero { grid-template-columns: 1fr; }
|
||||
.status-clock { text-align: left; }
|
||||
.mobile-quick { display: block; }
|
||||
.ops-grid { grid-template-columns: 1fr; }
|
||||
.cards { grid-template-columns: 1fr; }
|
||||
.report-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
.card, .sensor-card { min-width: 0; }
|
||||
header h1 { font-size: 1.1rem; }
|
||||
th, td { padding: 9px 10px; }
|
||||
td { font-size: .8125rem; }
|
||||
.mobile-detail { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; }
|
||||
.mobile-detail summary { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 14px; color: var(--ink); font-weight: 700; cursor: pointer; list-style: none; }
|
||||
.mobile-detail summary::-webkit-details-marker { display: none; }
|
||||
.mobile-detail summary span { color: var(--ink-2); font-size: .8125rem; font-weight: 600; }
|
||||
.mobile-detail .table-wrap { margin-bottom: 0; border-top: 1px solid var(--line); border-radius: 0; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.container { padding: 0 var(--sp-3); }
|
||||
header { padding: var(--sp-3) var(--sp-4); }
|
||||
.quick-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
.sensor-grid { grid-template-columns: 1fr; }
|
||||
.card { padding: var(--sp-4); }
|
||||
.col-hide-xs { display: none; }
|
||||
.report-grid { grid-template-columns: 1fr; }
|
||||
.login-box { padding: var(--sp-6) var(--sp-5); margin: 0 var(--sp-3); }
|
||||
th, td { font-size: .8125rem; }
|
||||
}
|
||||
|
||||
@media print {
|
||||
:root {
|
||||
--bg: #fff; --surface: #fff; --surface-2: #fff; --raise: #fff;
|
||||
--ink: #000; --ink-2: #333; --ink-3: #444; --line: #999; --line-2: #ccc;
|
||||
--primary: #14315e; --on-primary: #fff; --primary-tint: #fff;
|
||||
--normal-bg: #fff; --leak-bg: #fff; --warn-bg: #fff; --offline-bg: #fff;
|
||||
--cold-bg: #fff; --dry-bg: #fff; --hot-bg: #fff; --humid-bg: #fff;
|
||||
--shadow: none; --shadow-lg: none;
|
||||
--normal-ink:#0a6b39; --leak-ink:#b11f1f; --warn-ink:#8a5a00;
|
||||
}
|
||||
body { background: #fff !important; color: #000; }
|
||||
header, .top-actions, .refresh, .mobile-quick, .btn { display: none !important; }
|
||||
.card::before, .status-hero::before, .login-box::before { display: none; }
|
||||
.panel, .card, table, .chart-panel, .status-hero { box-shadow: none; border: 1px solid #999; page-break-inside: avoid; break-inside: avoid; }
|
||||
.badge, .pill { border: 1px solid currentColor; }
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 대시보드 탭 재개편 추가 컴포넌트 (기존 규칙 불변 — 추가만)
|
||||
* 탭바 / 탭 배지 / 알림 스트립 / 통합 KPI 스트립 / 히어로 상세줄 / 미니 패널 /
|
||||
* 범주형 차트 토큰(--cat-*) / 시스템 점검 그리드 / 인쇄 보강
|
||||
* ============================================================================= */
|
||||
|
||||
:root {
|
||||
/* 범주형 차트 토큰 — 경보 종류 도넛·범례(인라인 hex 치환, 인쇄에서 진한 값으로 교체) */
|
||||
--cat-hot: #ff8a5c;
|
||||
--cat-humid: #ffb23e;
|
||||
--cat-leak: #ff5470;
|
||||
--cat-cold: #5fd6ee;
|
||||
--cat-dry: #b0b8ff;
|
||||
}
|
||||
|
||||
/* ── 탭바 (?tab= 서버 렌더 링크 — nav + aria-current) ───────────────────────── */
|
||||
.tabbar {
|
||||
display: flex; align-items: stretch; gap: var(--sp-1);
|
||||
background: var(--header-bg); border: 1px solid var(--line); border-radius: var(--r-lg);
|
||||
padding: var(--sp-1); margin: var(--sp-5) 0 var(--sp-4);
|
||||
overflow-x: auto; -webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.tabbar .tab-link {
|
||||
flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px;
|
||||
color: var(--ink-2); font-size: .9rem; font-weight: 700; text-decoration: none;
|
||||
padding: 10px 18px; border-radius: var(--r); position: relative; white-space: nowrap;
|
||||
}
|
||||
.tabbar .tab-link:hover { color: var(--ink); background: var(--surface-2); }
|
||||
.tabbar .tab-link.active {
|
||||
color: var(--ink); background: var(--surface-2);
|
||||
}
|
||||
.tabbar .tab-link.active::after {
|
||||
content: ""; position: absolute; left: 14px; right: 14px; bottom: 4px; height: 2px;
|
||||
border-radius: 2px; background: linear-gradient(90deg, var(--primary), var(--chart-hum));
|
||||
}
|
||||
.tab-badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 1.5em; padding: 1px 7px; border-radius: var(--r-pill);
|
||||
font-size: .72rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.5;
|
||||
}
|
||||
.tab-badge.warn { background: var(--warn-bg); color: var(--warn-ink); }
|
||||
.tab-badge.crit { background: var(--leak-bg); color: var(--leak-ink); }
|
||||
.tabbar .tab-print {
|
||||
flex: 0 0 auto; margin-left: auto; align-self: center;
|
||||
color: var(--ink-3); font-size: .78rem; font-weight: 600; text-decoration: none;
|
||||
padding: 8px 12px; border-radius: var(--r); white-space: nowrap;
|
||||
}
|
||||
.tabbar .tab-print:hover, .tabbar .tab-print.active { color: var(--ink); background: var(--surface-2); }
|
||||
.print-note { color: var(--ink-2); font-size: .8125rem; margin-bottom: var(--sp-4); }
|
||||
|
||||
/* ── 알림 스트립 (즉시 조치 1줄 — 상세는 해당 탭 딥링크) ────────────────────── */
|
||||
.alert-strip {
|
||||
display: flex; align-items: baseline; gap: var(--sp-3); flex-wrap: wrap;
|
||||
border: 1px solid var(--line); border-left-width: 4px; border-radius: var(--r);
|
||||
padding: 10px 14px; margin-bottom: var(--sp-3);
|
||||
text-decoration: none; color: var(--ink); background: var(--surface);
|
||||
}
|
||||
.alert-strip strong { font-size: .9rem; }
|
||||
.alert-strip span { color: var(--ink-2); font-size: .8125rem; flex: 1 1 auto; min-width: 0; }
|
||||
.alert-strip em { font-style: normal; font-size: .8125rem; font-weight: 700; color: var(--primary-600); white-space: nowrap; }
|
||||
.alert-strip.danger { border-left-color: var(--leak); background: linear-gradient(90deg, var(--leak-bg), transparent 45%), var(--surface); }
|
||||
.alert-strip.danger strong { color: var(--leak-ink); }
|
||||
.alert-strip.warn { border-left-color: var(--warn); background: linear-gradient(90deg, var(--warn-bg), transparent 45%), var(--surface); }
|
||||
.alert-strip.warn strong { color: var(--warn-ink); }
|
||||
.alert-strip:hover { border-color: var(--primary); }
|
||||
|
||||
/* ── 히어로 상세줄 (경보 중 센서별 값·시작 시각·SMS 발송 결과) ──────────────── */
|
||||
.hero-lines { list-style: none; margin: var(--sp-3) 0 0; padding: 0; display: grid; gap: 6px; }
|
||||
.hero-lines li {
|
||||
font-size: .875rem; font-weight: 600; color: var(--ink);
|
||||
display: flex; align-items: baseline; gap: 8px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hero-lines li::before { content: "\25B8"; color: var(--leak-ink); flex: none; }
|
||||
|
||||
/* ── 통합 KPI 스트립 (6타일 · 탭 딥링크 · 색+액센트+텍스트 3중 인코딩) ──────── */
|
||||
.kpi-strip {
|
||||
display: grid; grid-template-columns: repeat(6, minmax(0,1fr)); gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-2);
|
||||
}
|
||||
.kpi {
|
||||
display: flex; flex-direction: column; justify-content: center; gap: 6px;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow); padding: var(--sp-4) var(--sp-5); min-height: 104px;
|
||||
text-decoration: none; color: var(--ink); transition: border-color .12s ease, transform .12s ease;
|
||||
}
|
||||
.kpi:hover { border-color: var(--primary); transform: translateY(-2px); }
|
||||
/* 상태 3중 인코딩: 라벨 앞 상태 dot(도형+색) + 값 색 + 텍스트 — 상단 스트립 없이 */
|
||||
.kpi .kpi-lbl { display: flex; align-items: center; gap: 7px; color: var(--ink-2); font-size: .72rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.kpi .kpi-lbl::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--normal); flex: none; }
|
||||
.kpi .kpi-val { font-size: clamp(1.6rem, 2.8vw, 2.15rem); font-weight: 800; line-height: 1.05; letter-spacing: -.025em; font-variant-numeric: tabular-nums; }
|
||||
.kpi .kpi-sub { color: var(--ink-3); font-size: .72rem; font-variant-numeric: tabular-nums; }
|
||||
.kpi.warn .kpi-lbl::before { background: var(--warn); }
|
||||
.kpi.err .kpi-lbl::before { background: var(--leak); }
|
||||
.kpi.ok .kpi-val { color: var(--ink); }
|
||||
.kpi.warn .kpi-val { color: var(--warn-ink); }
|
||||
.kpi.err .kpi-val { color: var(--leak-ink); }
|
||||
|
||||
/* ── 미니 패널 (live 탭: SMS 채널 상태 / 오늘 경보 요약) ────────────────────── */
|
||||
.mini-panels { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--sp-4); margin-bottom: var(--sp-6); }
|
||||
.mini-panel h3, .sub-h3 { font-size: .875rem; font-weight: 700; color: var(--ink); margin: 0 0 var(--sp-3); }
|
||||
.sub-h3 { margin-top: var(--sp-4); }
|
||||
.mini-rows { display: grid; gap: var(--sp-2); margin: 0; }
|
||||
.mini-rows > div {
|
||||
display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-3);
|
||||
border-bottom: 1px solid var(--line-2); padding-bottom: var(--sp-2);
|
||||
}
|
||||
.mini-rows > div:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.mini-rows dt { color: var(--ink-2); font-size: .8125rem; font-weight: 600; }
|
||||
.mini-rows dd { margin: 0; font-size: .95rem; font-weight: 800; font-variant-numeric: tabular-nums; text-align: right; }
|
||||
|
||||
/* ── 예보 칩: 여유(safe) 상시 표시용 중립 스타일 ────────────────────────────── */
|
||||
.forecast-chip.safe { background: var(--surface-2); color: var(--ink-2); }
|
||||
|
||||
/* ── 통합 센서 카드: 헤더(이름+유형칩) / 카드 내부 세로 리듬 정렬 ─────────────── */
|
||||
.sensor-card { display: flex; flex-direction: column; }
|
||||
.sc-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
|
||||
.sc-head strong { font-size: 1rem; font-weight: 700; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sc-type { flex: none; font-size: .68rem; font-weight: 800; letter-spacing: .03em; padding: 2px 8px; border-radius: var(--r-pill); background: var(--surface-2); color: var(--ink-2); border: 1px solid var(--line); }
|
||||
.sc-type.metric { background: rgba(63,199,230,.13); color: var(--cold-ink); }
|
||||
.sc-type.leak { background: var(--offline-bg); color: var(--offline-ink); }
|
||||
.sensor-card small { display: block; margin-top: 2px; }
|
||||
/* 예보줄을 카드 하단에 고정(카드 높이가 그리드 행 기준으로 늘어나도 자연스럽게) */
|
||||
.sensor-card .forecast { margin-top: auto; }
|
||||
|
||||
/* ── 범주형 차트 클래스 (도넛 세그먼트 stroke / 범례 스와치 background) ─────── */
|
||||
.donut-ring circle.seg.cat-hot { stroke: var(--cat-hot); }
|
||||
.donut-ring circle.seg.cat-humid { stroke: var(--cat-humid); }
|
||||
.donut-ring circle.seg.cat-leak { stroke: var(--cat-leak); }
|
||||
.donut-ring circle.seg.cat-cold { stroke: var(--cat-cold); }
|
||||
.donut-ring circle.seg.cat-dry { stroke: var(--cat-dry); }
|
||||
.dl i.cat-hot { background: var(--cat-hot); }
|
||||
.dl i.cat-humid { background: var(--cat-humid); }
|
||||
.dl i.cat-leak { background: var(--cat-leak); }
|
||||
.dl i.cat-cold { background: var(--cat-cold); }
|
||||
.dl i.cat-dry { background: var(--cat-dry); }
|
||||
.donut-note { margin-top: var(--sp-2); }
|
||||
|
||||
/* ── trend 탭 위젯 그리드 (기존 .overview 12열 위에서 배치) ──────────────────
|
||||
1행: 경보 추이·도넛·게이지 각 span 4 (=12). 2행: 리더보드 span 12 (가로 리더보드
|
||||
— 타일 하나만 다음 행에 덩그러니 남는 것 방지). */
|
||||
.trend-widgets > .ov-tile { grid-column: span 4; }
|
||||
.trend-widgets > .ov-board { grid-column: span 12; }
|
||||
.trend-widgets > .ov-board .board { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
|
||||
@media (max-width: 1080px) {
|
||||
.trend-widgets > .ov-tile { grid-column: span 6; }
|
||||
.trend-widgets > .ov-board { grid-column: span 12; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.trend-widgets > .ov-tile, .trend-widgets > .ov-board { grid-column: span 12; }
|
||||
}
|
||||
|
||||
/* ── system 탭 패널 그리드 ──────────────────────────────────────────────────── */
|
||||
.system-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--sp-4); align-items: start; }
|
||||
.system-grid > .panel { margin: 0; }
|
||||
.playbook-item { border-top: 1px solid var(--line-2); padding: var(--sp-2) 0; }
|
||||
.playbook-item:first-of-type { border-top: none; padding-top: 0; }
|
||||
.playbook-item summary { cursor: pointer; font-size: .875rem; font-weight: 700; color: var(--ink); padding: 4px 0; }
|
||||
.playbook-item .playbook-steps { margin: var(--sp-2) 0 var(--sp-2); padding-left: 1.6em; }
|
||||
.playbook-item .playbook-steps li { font-size: .875rem; line-height: 1.7; }
|
||||
.link-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); }
|
||||
.link-list li { font-size: .875rem; color: var(--ink-2); }
|
||||
.link-list a { font-weight: 700; }
|
||||
|
||||
/* ── 반응형: KPI 3×2 / 탭바 모바일 sticky(모바일은 header 가 static) ─────────── */
|
||||
@media (max-width: 900px) {
|
||||
.kpi-strip { grid-template-columns: repeat(3, minmax(0,1fr)); }
|
||||
.mini-panels { grid-template-columns: 1fr; }
|
||||
.system-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.tabbar { position: sticky; top: 0; z-index: 18; border-radius: 0; margin-left: calc(-1 * var(--sp-5)); margin-right: calc(-1 * var(--sp-5)); border-left: none; border-right: none; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.kpi-strip { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
.tabbar { margin-left: calc(-1 * var(--sp-3)); margin-right: calc(-1 * var(--sp-3)); }
|
||||
}
|
||||
|
||||
/* ── 인쇄 보강: 탐색 요소 숨김 + 범주 토큰 고대비 값 ─────────────────────────── */
|
||||
@media print {
|
||||
:root {
|
||||
--cat-hot: #b34a1f; --cat-humid: #8a5a00; --cat-leak: #b11f1f;
|
||||
--cat-cold: #0f5e74; --cat-dry: #3b4aa0;
|
||||
}
|
||||
.tabbar, .print-note { display: none !important; }
|
||||
.alert-strip, .kpi { box-shadow: none; border: 1px solid #999; }
|
||||
.kpi::before { display: none; }
|
||||
.alert-strip em { display: none; }
|
||||
}
|
||||
|
|
@ -1,237 +0,0 @@
|
|||
/* =============================================================================
|
||||
* dash-v2.css — dashboard.php 전용 오버레이 (app.css 뒤에 로드)
|
||||
* 목업 review/ui_mockup_v2.html 반영: 글래스 툴바 · 스프링 모션 · 세그먼트 탭 ·
|
||||
* 결합 센서 카드(누수+온습도) · 카운트다운 링 · 카드형 테이블 헤더.
|
||||
* app.css 는 7페이지 공용이므로 건드리지 않는다. 인쇄 규칙은 app.css 것을 계승.
|
||||
* 폐쇄망: 외부 리소스 0.
|
||||
* ============================================================================= */
|
||||
|
||||
:root {
|
||||
--spring: cubic-bezier(.32,.72,.28,1); /* 감쇠 스프링 근사 */
|
||||
}
|
||||
|
||||
/* ── 글래스 툴바: 반투명 재질 + sticky, 콘텐츠가 아래로 스크롤 ─────────────── */
|
||||
header {
|
||||
background: rgba(20,26,61,.78);
|
||||
backdrop-filter: blur(18px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(160%);
|
||||
}
|
||||
.top-actions a {
|
||||
transition: background .18s var(--spring), color .15s, border-color .15s, transform .15s var(--spring);
|
||||
}
|
||||
.top-actions a:active { transform: scale(.96); }
|
||||
|
||||
/* ── 새로고침 카운트다운 링 ─────────────────────────────────────────────────── */
|
||||
.refresh { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.refresh-ring { width: 15px; height: 15px; flex: none; }
|
||||
.refresh-ring circle {
|
||||
fill: none; stroke: var(--primary-600); stroke-width: 2.5;
|
||||
stroke-dasharray: 38; stroke-linecap: round;
|
||||
transform: rotate(-90deg); transform-origin: center;
|
||||
animation: dash-countdown 30s linear infinite;
|
||||
}
|
||||
@keyframes dash-countdown { from { stroke-dashoffset: 0; } to { stroke-dashoffset: 38; } }
|
||||
.refresh.paused .refresh-ring circle { animation-play-state: paused; opacity: .35; }
|
||||
.refresh-toggle { transition: transform .15s var(--spring), background .15s; }
|
||||
.refresh-toggle:active { transform: scale(.93); }
|
||||
|
||||
/* ── 세그먼트 탭바(iOS segmented control) ───────────────────────────────────── */
|
||||
.tabbar { border-radius: var(--r-pill); padding: 4px; gap: 3px; }
|
||||
.tabbar .tab-link {
|
||||
border-radius: var(--r-pill); padding: 8px 18px;
|
||||
transition: background .22s var(--spring), color .15s, transform .15s var(--spring);
|
||||
}
|
||||
.tabbar .tab-link:active { transform: scale(.96); }
|
||||
.tabbar .tab-link.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--primary), #8f6bff);
|
||||
box-shadow: 0 4px 14px rgba(108,123,255,.35);
|
||||
}
|
||||
.tabbar .tab-link.active::after { content: none; }
|
||||
.tabbar .tab-link.active .tab-badge.crit { background: rgba(255,255,255,.22); color: #fff; }
|
||||
.tabbar .tab-print { border-radius: var(--r-pill); }
|
||||
|
||||
/* ── 인터랙션 공통: 즉각 반응(:active) + 부상(hover) 스프링 ─────────────────── */
|
||||
.kpi, .sensor-card, .leak-status-card, .alert-strip, .quick-tile {
|
||||
transition: transform .2s var(--spring), border-color .2s, box-shadow .2s, background .2s;
|
||||
}
|
||||
.kpi:hover, .sensor-card:hover, .leak-status-card:hover {
|
||||
transform: translateY(-3px); box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.kpi:active, .sensor-card:active, .alert-strip:active { transform: translateY(-1px) scale(.985); }
|
||||
.alert-strip:hover { transform: translateX(3px); }
|
||||
|
||||
/* ── 결합 센서 카드 (누수+온습도) ────────────────────────────────────────────
|
||||
상태 우선순위: 오프라인 > 누수 확정 > 확인중 > 온습도 경보 > 정상 */
|
||||
.sensor-card { position: relative; overflow: hidden; }
|
||||
.sensor-card::before {
|
||||
content: ""; position: absolute; inset: 0 0 auto 0; height: 3px;
|
||||
background: var(--normal); opacity: .85;
|
||||
}
|
||||
.sensor-card.alert::before { background: var(--leak); }
|
||||
.sensor-card.leak::before { background: var(--chart-hum); }
|
||||
.sensor-card.pending::before { background: var(--warn); }
|
||||
.sensor-card.offline::before { background: var(--offline); }
|
||||
.sensor-card.offline { opacity: .78; }
|
||||
.sensor-card.leak {
|
||||
border-color: rgba(63,199,230,.4);
|
||||
background: linear-gradient(180deg, rgba(63,199,230,.10), transparent 60%), var(--surface);
|
||||
animation: dash-leak-glow 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes dash-leak-glow {
|
||||
50% { box-shadow: 0 0 26px rgba(63,199,230,.30); }
|
||||
}
|
||||
|
||||
/* 유형 칩 2개(결합) 나란히 */
|
||||
.sc-types { display: flex; gap: 4px; flex: none; }
|
||||
|
||||
/* 상태 라인: 점 + 텍스트 (badge-row 대체 — 기존 배지도 유지 호환) */
|
||||
.status-line { display: flex; align-items: center; gap: 7px; font-size: .8125rem; font-weight: 700; margin-top: var(--sp-2); }
|
||||
.status-line .sdot { width: 8px; height: 8px; border-radius: 50%; background: var(--normal); box-shadow: 0 0 8px var(--normal); flex: none; }
|
||||
.status-line.ok { color: var(--normal-ink); }
|
||||
.status-line.err { color: var(--leak-ink); }
|
||||
.status-line.err .sdot { background: var(--leak); box-shadow: 0 0 8px var(--leak); animation: dash-pulse 1.2s ease-in-out infinite; }
|
||||
.status-line.leakst { color: var(--cold-ink); }
|
||||
.status-line.leakst .sdot { background: var(--chart-hum); box-shadow: 0 0 8px var(--chart-hum); animation: dash-pulse 1.2s ease-in-out infinite; }
|
||||
.status-line.pend { color: var(--warn-ink); }
|
||||
.status-line.pend .sdot { background: var(--warn); box-shadow: 0 0 8px var(--warn); }
|
||||
.status-line.off { color: var(--offline-ink); }
|
||||
.status-line.off .sdot { background: var(--offline); box-shadow: none; }
|
||||
@keyframes dash-pulse { 50% { opacity: .4; } }
|
||||
|
||||
/* 온습도 큰 수치 2열 */
|
||||
.vals { display: flex; gap: var(--sp-5); margin-top: var(--sp-3); }
|
||||
.vals .v { flex: 1; min-width: 0; }
|
||||
.vals .v .n {
|
||||
font-size: 1.7rem; font-weight: 800; letter-spacing: -.03em; line-height: 1.08;
|
||||
font-variant-numeric: tabular-nums; color: var(--ink);
|
||||
}
|
||||
.vals .v .n small { font-size: .85rem; color: var(--ink-2); font-weight: 600; letter-spacing: 0; }
|
||||
.vals .v .l { font-size: .7rem; color: var(--ink-3); margin-top: 2px; }
|
||||
.vals .v.temp .n { color: var(--chart-temp); }
|
||||
.vals .v.hum .n { color: var(--chart-hum); }
|
||||
.vals .v .n.hot { text-shadow: 0 0 14px rgba(255,138,92,.5); }
|
||||
.vals .v .n.cold { color: var(--cold-ink); }
|
||||
.vals .v .n.humid { text-shadow: 0 0 14px rgba(255,178,62,.5); }
|
||||
.vals .v .n.dry { color: var(--dry-ink); }
|
||||
|
||||
/* 24h 스파크라인 */
|
||||
.spark24 { width: 100%; height: 34px; display: block; margin-top: var(--sp-2); }
|
||||
.spark24 polyline { fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.spark24 .sp-t { stroke: var(--chart-temp); opacity: .85; }
|
||||
.spark24 .sp-h { stroke: var(--chart-hum); opacity: .85; stroke-dasharray: 5 3; }
|
||||
|
||||
/* 누수 행(결합 카드 내부): 경과·SMS·사진 */
|
||||
.leak-row {
|
||||
display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
|
||||
margin-top: var(--sp-3); padding-top: var(--sp-3); border-top: 1px solid var(--line-2);
|
||||
}
|
||||
.leak-row .leak-fact { font-size: .8125rem; color: var(--ink-2); font-variant-numeric: tabular-nums; }
|
||||
.leak-row .leak-fact b { color: var(--ink); font-weight: 800; }
|
||||
.leak-row .leak-thumbs { margin-left: auto; }
|
||||
.leak-row .leak-thumbs img {
|
||||
width: 46px; height: 36px; object-fit: cover;
|
||||
transition: transform .18s var(--spring);
|
||||
}
|
||||
.leak-row .leak-thumbs a:hover img { transform: scale(1.5); }
|
||||
|
||||
/* 누수 카드 하단 물결(장식) */
|
||||
.leak-wave { position: absolute; left: 0; right: 0; bottom: 0; height: 42px; opacity: .14; pointer-events: none; }
|
||||
.leak-wave path { fill: var(--chart-hum); }
|
||||
|
||||
/* 카드 푸터: 최종확인 + 보고 주기 */
|
||||
.sc-foot {
|
||||
display: flex; justify-content: space-between; gap: var(--sp-2);
|
||||
font-size: .72rem; color: var(--ink-3); margin-top: var(--sp-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sc-foot .stale { color: var(--leak-ink); font-weight: 700; }
|
||||
|
||||
/* ── 이력 테이블: 카드형 헤더(테이블 위 캡션 스트립) ────────────────────────── */
|
||||
h2.table-title { margin-bottom: 0; font-size: .9375rem; }
|
||||
.table-title {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
background: var(--surface-2); border: 1px solid var(--line); border-bottom: none;
|
||||
border-radius: var(--r-lg) var(--r-lg) 0 0; padding: 12px 16px;
|
||||
font-size: .9375rem; font-weight: 700;
|
||||
}
|
||||
.table-title .count-pill {
|
||||
margin-left: auto; font-size: .7rem; font-weight: 700; color: var(--ink-2);
|
||||
background: var(--surface); border: 1px solid var(--line);
|
||||
padding: 2px 10px; border-radius: var(--r-pill); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.table-title + .table-wrap,
|
||||
.table-title + details.mobile-detail .table-wrap { border-radius: 0 0 var(--r-lg) var(--r-lg); }
|
||||
.table-title + .table-wrap table,
|
||||
.table-title + details.mobile-detail table { border-radius: 0 0 var(--r-lg) var(--r-lg); border-top: none; }
|
||||
.table-title .h2-sub { margin-left: auto; font-size: .72rem; font-weight: 600; color: var(--ink-3); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── 히어로 미세 조정: 큰 제목 음수 트래킹 ──────────────────────────────────── */
|
||||
.status-hero h2 { letter-spacing: -.02em; }
|
||||
.kpi .kpi-val, .card .val, .gauge-num b { letter-spacing: -.03em; }
|
||||
|
||||
/* ── 미세먼지(PM) 계통 색상·요소 (온습도 orange 와 구분되는 magenta/violet 계열) ──────
|
||||
PMS7003 미세먼지 경보가 온습도 경보로 오독되지 않도록 별도 색 계통을 부여한다. PM 토큰은
|
||||
app.css 에 없으므로 여기서 정의(dash-v2.css 는 app.css 뒤 로드 — 재정의 아님, additive). */
|
||||
:root {
|
||||
--pm: #d24fd6; /* 미세먼지 대표 액센트(도넛·칩·라인) */
|
||||
--pm-ink: #eca3ea; /* 어두운 배경 위 텍스트용 밝은 변형 */
|
||||
--pm-bg: rgba(210,79,214,.15);
|
||||
--pm25: #e85fbf; /* PM2.5 계열(magenta-pink) */
|
||||
--pm10: #a06bff; /* PM10 계열(violet) */
|
||||
}
|
||||
|
||||
/* 유형 칩(미세먼지) — sc-type.metric(청록)·sc-type.leak(회색)과 구분 */
|
||||
.sc-type.pm { background: var(--pm-bg); color: var(--pm-ink); }
|
||||
|
||||
/* 카드 상태(미세먼지 경보) — 상단 바 + 배경 그라디언트(온습도 alert 와 별개 색) */
|
||||
.sensor-card.pm { border-color: rgba(210,79,214,.4); background: linear-gradient(180deg, var(--pm-bg), transparent 60%), var(--surface); }
|
||||
.sensor-card.pm::before { background: var(--pm); }
|
||||
|
||||
/* 상태 라인(미세먼지 경보) */
|
||||
.status-line.pmst { color: var(--pm-ink); }
|
||||
.status-line.pmst .sdot { background: var(--pm); box-shadow: 0 0 8px var(--pm); animation: dash-pulse 1.2s ease-in-out infinite; }
|
||||
|
||||
/* 미세먼지 큰 수치 2열 — 온습도 vals 재사용, 색만 PM 계열. 위반 시 hot 글로우(magenta) */
|
||||
.vals.pm-vals { margin-top: var(--sp-2); }
|
||||
.vals .v.pm25 .n { color: var(--pm25); }
|
||||
.vals .v.pm10 .n { color: var(--pm10); }
|
||||
.pm-vals .v .n.hot { text-shadow: 0 0 14px rgba(210,79,214,.55); }
|
||||
|
||||
/* 상태 pill(미세먼지/결로) — 이력·측정 표의 상태 배지 */
|
||||
.pill.high_pm25 { background: var(--pm-bg); color: var(--pm-ink); }
|
||||
.pill.high_pm10 { background: var(--pm-bg); color: var(--pm-ink); }
|
||||
.pill.condensation_risk { background: var(--warn-bg); color: var(--warn-ink); }
|
||||
|
||||
/* 추이 차트(미세먼지) — dashboard.php 인라인 <style> 에 PM 규칙이 없어 여기서 정의 */
|
||||
.chart-svg-wrap .axis-pm { fill: var(--pm); }
|
||||
.chart-svg-wrap .line-pm25 { stroke: var(--pm25); }
|
||||
.chart-svg-wrap .line-pm10 { stroke: var(--pm10); }
|
||||
.legend-swatch.pm25 { background: var(--pm25); }
|
||||
.legend-swatch.pm10 { background: var(--pm10); }
|
||||
|
||||
/* 7일 경보 추이 라인(미세먼지) + 도넛 세그먼트·범례(미세먼지) */
|
||||
.ov-trend .line-pm { stroke: var(--pm); }
|
||||
.donut-ring circle.seg.cat-pm { stroke: var(--pm); }
|
||||
.dl i.cat-pm { background: var(--pm); }
|
||||
|
||||
/* ── 접근성 ─────────────────────────────────────────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sensor-card.leak { animation: none; }
|
||||
.status-line .sdot { animation: none !important; }
|
||||
.refresh-ring circle { animation: none; }
|
||||
.kpi, .sensor-card, .leak-status-card, .alert-strip, .quick-tile,
|
||||
.tabbar .tab-link, .top-actions a, .refresh-toggle { transition: none; }
|
||||
.kpi:hover, .sensor-card:hover, .leak-status-card:hover, .alert-strip:hover { transform: none; }
|
||||
}
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
header { background: var(--header-bg); backdrop-filter: none; -webkit-backdrop-filter: none; }
|
||||
}
|
||||
|
||||
/* ── 인쇄: 장식·모션 요소 제거(app.css 강제 라이트 계승) ─────────────────────── */
|
||||
@media print {
|
||||
.leak-wave, .refresh-ring, .spark24 { display: none !important; }
|
||||
.sensor-card, .sensor-card.leak { animation: none; box-shadow: none; }
|
||||
.status-line .sdot { animation: none; box-shadow: none; border: 1px solid currentColor; }
|
||||
.vals .v .n { text-shadow: none; }
|
||||
}
|
||||
|
|
@ -1,285 +0,0 @@
|
|||
/* =============================================================================
|
||||
* report-print.css — 월간 운영 보고서(PDF 저장 전용) A4 화이트 문서 스타일
|
||||
*
|
||||
* monthly_report_print.php 만 링크한다(app.css 미링크 — 다크 셸 토큰과 격리).
|
||||
* 폐쇄망: 자기호스팅 로컬 파일. 외부 폰트/CDN 0 — 시스템 폰트만.
|
||||
* 화면(@media screen)에서는 회색 캔버스 위에 뜬 흰 A4 종이로 미리보기(WYSIWYG),
|
||||
* 인쇄(@media print)에서는 캔버스·툴바가 벗겨지고 종이가 곧 PDF 가 된다.
|
||||
*
|
||||
* 대상 브라우저: Chrome / Edge(Chromium) — 고정 러닝 헤더/푸터·색 보존 안정.
|
||||
* FLAG: Chromium print-to-PDF 는 @page 마진박스 counter(page) 미지원 + 폐쇄망
|
||||
* JS 폴리필 금지 → 라이브 페이지번호 미탑재. 고정 푸터에 문서번호·생성일시·대외비를
|
||||
* 매 장 반복해 추적성을 확보하고, 화면 노트로 인쇄 대화상자의 머리글/바닥글 옵션을 안내.
|
||||
* ============================================================================= */
|
||||
|
||||
.report-paper {
|
||||
/* 라이트 고정 팔레트 — app.css @media print 토큰과 값 정합(흑백/컬러 안전) */
|
||||
--paper-ink: #1a1f2b;
|
||||
--paper-sub: #5b6472;
|
||||
--paper-navy: #14315e;
|
||||
--paper-gold: #9a7b28;
|
||||
--hair: #d9dee6;
|
||||
--hair-strong: #c2cad6;
|
||||
--ok: #0a6b39;
|
||||
--warn: #8a5a00;
|
||||
--alert: #b11f1f;
|
||||
--band-safe: #e7f0ea;
|
||||
--band-hot: #f6e3dc;
|
||||
--band-cold: #dfeef2;
|
||||
--paper-bg-soft: #f4f6f9;
|
||||
}
|
||||
|
||||
/* 색 보존 강제(네이비 밴드·골드 룰·판정 씰·차트 획 유실 방지) */
|
||||
.report-paper,
|
||||
.report-paper * {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
/* ── @page: A4 세로 + 여백 ─────────────────────────────────────────────────── */
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 18mm 16mm 20mm;
|
||||
}
|
||||
@page :first {
|
||||
margin-top: 22mm; /* 표지 상단 여유 */
|
||||
}
|
||||
|
||||
/* ── 종이 본체 ─────────────────────────────────────────────────────────────── */
|
||||
.report-paper {
|
||||
font-family: "Malgun Gothic", "맑은 고딕", "Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
|
||||
color: var(--paper-ink);
|
||||
background: #fff;
|
||||
line-height: 1.6;
|
||||
font-size: 10.5pt;
|
||||
font-variant-numeric: tabular-nums;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.report-paper h1, .report-paper h2, .report-paper h3 { margin: 0; font-weight: 800; }
|
||||
.report-paper p { margin: 0; }
|
||||
.report-paper .num { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── 러닝 헤더/푸터(position:fixed — Chromium 이 매 장 반복 렌더) ─────────────── */
|
||||
.run-header, .run-footer {
|
||||
position: fixed;
|
||||
left: 0; right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 8pt;
|
||||
color: var(--paper-sub);
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.run-header { top: 0; border-bottom: 0.6pt solid var(--hair); padding-bottom: 3pt; }
|
||||
.run-footer { bottom: 0; border-top: 0.6pt solid var(--hair); padding-top: 3pt; }
|
||||
.run-header .cls, .run-footer .cls { color: var(--alert); font-weight: 700; }
|
||||
.run-header b { color: var(--paper-navy); font-weight: 800; }
|
||||
|
||||
/* ── 표지 마스트헤드 ───────────────────────────────────────────────────────── */
|
||||
.cover { position: relative; padding-top: 4mm; }
|
||||
.cover-eyebrow {
|
||||
font-size: 9.5pt; font-weight: 700; letter-spacing: .35em;
|
||||
color: var(--paper-navy); margin-bottom: 6mm;
|
||||
}
|
||||
.cover-eyebrow::before {
|
||||
content: ""; display: inline-block; width: 26pt; height: 3pt; vertical-align: middle;
|
||||
background: var(--paper-gold); margin-right: 8pt; border-radius: 2pt;
|
||||
}
|
||||
.cover-title {
|
||||
font-size: 27pt; line-height: 1.15; color: var(--paper-navy);
|
||||
letter-spacing: -.01em; margin-bottom: 3mm; max-width: 70%;
|
||||
}
|
||||
.cover-sub { font-size: 11pt; color: var(--paper-sub); margin-bottom: 10mm; }
|
||||
.cover-month {
|
||||
font-size: 58pt; font-weight: 800; color: var(--paper-navy);
|
||||
line-height: 1; letter-spacing: -.02em;
|
||||
}
|
||||
.cover-period { font-size: 10.5pt; color: var(--paper-sub); margin-top: 2mm; }
|
||||
.cover-seal { position: absolute; top: 0; right: 0; text-align: center; }
|
||||
.cover-seal .seal-cap { font-size: 8pt; color: var(--paper-sub); margin-top: 2pt; }
|
||||
|
||||
/* 관제 대상 3개소 스트립 */
|
||||
.cover-scope {
|
||||
display: flex; gap: 6mm; margin-top: 10mm; padding-top: 6mm;
|
||||
border-top: 1pt solid var(--hair-strong);
|
||||
}
|
||||
.scope-item { flex: 1; }
|
||||
.scope-item .k { font-size: 8.5pt; color: var(--paper-sub); letter-spacing: .1em; }
|
||||
.scope-item .v { font-size: 12pt; font-weight: 800; color: var(--paper-navy); }
|
||||
.scope-item .t { font-size: 8.5pt; color: var(--paper-sub); }
|
||||
|
||||
/* 문서 통제 블록 */
|
||||
.doc-control {
|
||||
margin-top: 12mm; border: 1pt solid var(--hair-strong); border-radius: 2pt;
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); overflow: hidden;
|
||||
}
|
||||
.doc-control > div { padding: 3.2mm 4mm; border-right: 0.6pt solid var(--hair); border-bottom: 0.6pt solid var(--hair); }
|
||||
.doc-control > div:nth-child(3n) { border-right: none; }
|
||||
.doc-control .dk { font-size: 8pt; color: var(--paper-sub); letter-spacing: .08em; margin-bottom: 1pt; }
|
||||
.doc-control .dv { font-size: 10pt; font-weight: 700; color: var(--paper-ink); }
|
||||
.doc-control .dv.cls { color: var(--alert); }
|
||||
|
||||
/* ── 섹션 제목 ─────────────────────────────────────────────────────────────── */
|
||||
.section { margin-top: 8mm; }
|
||||
.section-title {
|
||||
font-size: 13pt; color: var(--paper-navy); padding-bottom: 2mm;
|
||||
border-bottom: 1.6pt solid var(--paper-navy); margin-bottom: 4mm;
|
||||
display: flex; align-items: baseline; gap: 8pt;
|
||||
break-after: avoid; page-break-after: avoid;
|
||||
}
|
||||
.section-title .en { font-size: 8.5pt; font-weight: 600; color: var(--paper-sub); letter-spacing: .12em; }
|
||||
.section-title .no {
|
||||
font-size: 9pt; font-weight: 800; color: #fff; background: var(--paper-navy);
|
||||
border-radius: 2pt; padding: 1pt 6pt; letter-spacing: .05em;
|
||||
}
|
||||
|
||||
/* ── 경영 요약 KPI ─────────────────────────────────────────────────────────── */
|
||||
.kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4mm; }
|
||||
.kpi-card {
|
||||
border: 1pt solid var(--hair-strong); border-radius: 2pt; padding: 3.5mm 4mm;
|
||||
break-inside: avoid; page-break-inside: avoid; position: relative;
|
||||
}
|
||||
.kpi-card::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3pt; background: var(--paper-navy); }
|
||||
.kpi-card.is-alert::before { background: var(--alert); }
|
||||
.kpi-card.is-warn::before { background: var(--warn); }
|
||||
.kpi-card.is-ok::before { background: var(--ok); }
|
||||
.kpi-card .kpi-k { font-size: 8.5pt; color: var(--paper-sub); letter-spacing: .04em; }
|
||||
.kpi-card .kpi-v { font-size: 21pt; font-weight: 800; color: var(--paper-navy); line-height: 1.1; }
|
||||
.kpi-card.is-alert .kpi-v { color: var(--alert); }
|
||||
.kpi-card .kpi-s { font-size: 8.5pt; color: var(--paper-sub); }
|
||||
.kpi-gauge-wrap { text-align: center; }
|
||||
|
||||
.exec-summary {
|
||||
margin-top: 4mm; border: 1pt solid var(--hair-strong); border-left: 3pt solid var(--paper-navy);
|
||||
border-radius: 2pt; padding: 3.5mm 4.5mm; background: var(--paper-bg-soft);
|
||||
break-inside: avoid; page-break-inside: avoid;
|
||||
}
|
||||
.exec-summary .verdict-line { font-size: 11pt; font-weight: 800; color: var(--paper-navy); }
|
||||
.exec-summary .verdict-line.is-alert { color: var(--alert); }
|
||||
.exec-summary .verdict-line.is-warn { color: var(--warn); }
|
||||
.exec-summary .verdict-line.is-ok { color: var(--ok); }
|
||||
.exec-summary p { font-size: 10pt; color: var(--paper-ink); margin-top: 1.5mm; }
|
||||
|
||||
/* ── 정의형 지표 표(경보·이벤트 / 온습도) ──────────────────────────────────── */
|
||||
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6mm; }
|
||||
.metric-block { break-inside: avoid; page-break-inside: avoid; }
|
||||
.metric-block h3 { font-size: 10pt; color: var(--paper-navy); margin-bottom: 2mm; }
|
||||
table.def { width: 100%; border-collapse: collapse; font-size: 9.5pt; }
|
||||
table.def th, table.def td { padding: 2mm 3mm; border-bottom: 0.6pt solid var(--hair); text-align: left; }
|
||||
table.def th { color: var(--paper-sub); font-weight: 600; width: 55%; }
|
||||
table.def td { text-align: right; font-weight: 700; color: var(--paper-ink); font-variant-numeric: tabular-nums; }
|
||||
table.def td.is-alert { color: var(--alert); }
|
||||
|
||||
/* ── 일반 데이터 표(지점별 등) ─────────────────────────────────────────────── */
|
||||
table.grid { width: 100%; border-collapse: collapse; font-size: 9pt; }
|
||||
table.grid thead { display: table-header-group; } /* 페이지 넘김 시 머리행 반복 */
|
||||
table.grid th, table.grid td { padding: 2mm 2.5mm; border: 0.6pt solid var(--hair); text-align: center; }
|
||||
table.grid th { background: var(--paper-navy); color: #fff; font-weight: 700; font-size: 8.5pt; }
|
||||
table.grid td.l { text-align: left; }
|
||||
table.grid tbody tr:nth-child(even) td { background: var(--paper-bg-soft); }
|
||||
table.grid td.is-alert { color: var(--alert); font-weight: 800; }
|
||||
|
||||
/* ── 지점별 카드 ───────────────────────────────────────────────────────────── */
|
||||
.loc-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4mm; }
|
||||
.loc-card {
|
||||
border: 1pt solid var(--hair-strong); border-radius: 2pt; padding: 3.5mm 4mm;
|
||||
break-inside: avoid; page-break-inside: avoid;
|
||||
}
|
||||
.loc-card .lc-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2mm; padding-bottom: 2mm; border-bottom: 0.8pt solid var(--hair); }
|
||||
.loc-card .lc-name { font-size: 11.5pt; font-weight: 800; color: var(--paper-navy); }
|
||||
.loc-card .lc-loc { font-size: 8.5pt; color: var(--paper-sub); }
|
||||
.pill {
|
||||
font-size: 8pt; font-weight: 800; padding: 1pt 6pt; border-radius: 8pt;
|
||||
border: 0.8pt solid currentColor;
|
||||
}
|
||||
.pill.is-ok { color: var(--ok); }
|
||||
.pill.is-warn { color: var(--warn); }
|
||||
.pill.is-alert { color: var(--alert); }
|
||||
.loc-card dl { display: grid; grid-template-columns: auto 1fr; gap: 1mm 3mm; margin: 0; font-size: 9pt; }
|
||||
.loc-card dt { color: var(--paper-sub); }
|
||||
.loc-card dd { margin: 0; text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.loc-card dd.is-alert { color: var(--alert); }
|
||||
|
||||
/* ── 차트 패널 ─────────────────────────────────────────────────────────────── */
|
||||
.chart-panel {
|
||||
border: 1pt solid var(--hair-strong); border-radius: 2pt; padding: 4mm;
|
||||
margin-top: 3mm; break-inside: avoid; page-break-inside: avoid;
|
||||
}
|
||||
.chart-panel h3 { font-size: 10pt; color: var(--paper-navy); margin-bottom: 1mm; }
|
||||
.chart-panel .chart-cap { font-size: 8.5pt; color: var(--paper-sub); margin-bottom: 2mm; }
|
||||
.chart-empty { padding: 8mm; text-align: center; color: var(--paper-sub); font-size: 9.5pt; border: 0.8pt dashed var(--hair-strong); border-radius: 2pt; }
|
||||
.chart-legend-row { display: flex; flex-wrap: wrap; gap: 4mm; margin-top: 2mm; font-size: 8.5pt; color: var(--paper-sub); }
|
||||
.chart-legend-row span { display: inline-flex; align-items: center; gap: 4pt; }
|
||||
.lg-sw { width: 11pt; height: 8pt; border-radius: 1pt; display: inline-block; }
|
||||
.lg-sw.hatch { background: repeating-linear-gradient(45deg, var(--alert) 0 2pt, #fff 2pt 3.5pt); border: 0.5pt solid var(--alert); }
|
||||
.lg-line { width: 16pt; height: 3pt; border-radius: 2pt; display: inline-block; }
|
||||
.lg-line.dash { background: repeating-linear-gradient(90deg, currentColor 0 4pt, transparent 4pt 7pt) !important; }
|
||||
.lg-band { width: 12pt; height: 9pt; border-radius: 1pt; display: inline-block; border: 0.5pt solid var(--hair-strong); }
|
||||
|
||||
/* ── 누수 사고 로그 ────────────────────────────────────────────────────────── */
|
||||
.incident-none {
|
||||
border: 1pt solid var(--ok); border-radius: 2pt; padding: 4mm 4.5mm;
|
||||
color: var(--ok); font-weight: 700; font-size: 10pt; background: var(--band-safe);
|
||||
}
|
||||
|
||||
/* ── 기준·주석 + 서명란 ────────────────────────────────────────────────────── */
|
||||
.basis-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6mm; }
|
||||
.basis-block { break-inside: avoid; page-break-inside: avoid; }
|
||||
.basis-block h3 { font-size: 9.5pt; color: var(--paper-navy); margin-bottom: 2mm; }
|
||||
.basis-block ul { margin: 0; padding-left: 4.5mm; font-size: 9pt; color: var(--paper-ink); }
|
||||
.basis-block li { margin-bottom: 1mm; }
|
||||
.basis-block li b { color: var(--paper-navy); }
|
||||
.sign-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6mm; margin-top: 8mm; break-inside: avoid; page-break-inside: avoid; }
|
||||
.sign-box { border: 1pt solid var(--hair-strong); border-radius: 2pt; }
|
||||
.sign-box .sk { font-size: 8.5pt; color: var(--paper-sub); padding: 2mm 3mm; border-bottom: 0.6pt solid var(--hair); background: var(--paper-bg-soft); }
|
||||
.sign-box .sv { height: 16mm; }
|
||||
.sign-box .sn { font-size: 8pt; color: var(--paper-sub); padding: 1.5mm 3mm; border-top: 0.6pt solid var(--hair); text-align: right; }
|
||||
|
||||
/* ── 페이지 브레이크 ───────────────────────────────────────────────────────── */
|
||||
.page-break { break-before: page; page-break-before: always; }
|
||||
|
||||
/* ── 화면 미리보기(회색 캔버스 위 흰 종이) ─────────────────────────────────── */
|
||||
@media screen {
|
||||
body { margin: 0; background: #e9eaee; }
|
||||
.report-canvas { padding: 24px 12px 64px; }
|
||||
.report-paper {
|
||||
width: 210mm; max-width: 100%; min-height: 297mm; margin: 0 auto;
|
||||
padding: 22mm 16mm 20mm; background: #fff; box-sizing: border-box;
|
||||
box-shadow: 0 6px 28px rgba(20, 30, 60, .18);
|
||||
}
|
||||
/* 화면에서는 고정 헤더/푸터를 종이 안쪽에 흐름 배치(겹침 방지) */
|
||||
.run-header, .run-footer { position: static; margin: 0 auto; width: 210mm; max-width: 100%; }
|
||||
.run-header { margin-bottom: 6mm; }
|
||||
.run-footer { margin-top: 6mm; }
|
||||
.print-note {
|
||||
width: 210mm; max-width: 100%; margin: 0 auto 16px; padding: 10px 14px;
|
||||
background: #fff8e6; border: 1px solid #e6c766; border-radius: 6px;
|
||||
font-size: 12px; color: #6b5410; box-sizing: border-box;
|
||||
font-family: "Malgun Gothic", "맑은 고딕", "Apple SD Gothic Neo", sans-serif;
|
||||
}
|
||||
.print-note b { color: #8a5a00; }
|
||||
.print-note a { color: #14315e; }
|
||||
.screen-toolbar {
|
||||
width: 210mm; max-width: 100%; margin: 0 auto 12px; display: flex; gap: 8px; flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.screen-toolbar a, .screen-toolbar button {
|
||||
font: inherit; font-size: 13px; font-weight: 700; padding: 8px 14px; border-radius: 6px;
|
||||
border: 1px solid #14315e; background: #14315e; color: #fff; cursor: pointer; text-decoration: none;
|
||||
font-family: "Malgun Gothic", "맑은 고딕", "Apple SD Gothic Neo", sans-serif;
|
||||
}
|
||||
.screen-toolbar a.ghost { background: #fff; color: #14315e; }
|
||||
}
|
||||
|
||||
/* ── 인쇄(종이가 곧 PDF) ───────────────────────────────────────────────────── */
|
||||
@media print {
|
||||
html, body { margin: 0; background: #fff; }
|
||||
.report-canvas { padding: 0; background: #fff; }
|
||||
.report-paper {
|
||||
width: auto; max-width: none; min-height: 0; margin: 0; padding: 0;
|
||||
background: #fff; box-shadow: none;
|
||||
}
|
||||
.print-note, .screen-toolbar, .screen-only { display: none !important; }
|
||||
/* 고정 헤더/푸터와 본문 겹침 방지 여백 */
|
||||
.report-body { padding-top: 6mm; padding-bottom: 6mm; }
|
||||
}
|
||||
|
|
@ -7,15 +7,10 @@ $time = date('c');
|
|||
?>
|
||||
<!doctype html><html lang="ko"><head>
|
||||
<meta charset="utf-8"><title>접근 거부</title>
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<meta name="robots" content="noindex"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
/* 페이지 전용: 단독 안내 카드 중앙 정렬(대시보드 셸 없음) */
|
||||
body { min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4); }
|
||||
.blocked-card { text-align: center; }
|
||||
</style>
|
||||
</head><body><div class="login-box blocked-card">
|
||||
<style>body{margin:0;display:grid;place-items:center;min-height:100vh;background:#faf7f2;font-family:system-ui,-apple-system,"Noto Sans KR",sans-serif;color:#222}.card{max-width:520px;background:#fff;border:1px solid #eee;border-radius:16px;padding:28px;box-shadow:0 10px 24px rgba(0,0,0,.06);text-align:center}h1{margin:0 0 8px;font-size:22px}.small{color:#666;font-size:13px;margin-top:14px}code{background:#f0f0f0;padding:2px 6px;border-radius:6px}</style>
|
||||
</head><body><div class="card">
|
||||
<h1>접근이 거부되었습니다</h1>
|
||||
<p class="sub">관리자에게 연락하세요.</p>
|
||||
<p class="caption">요청 ID: <code><?=htmlspecialchars($id)?></code></p>
|
||||
<p>관리자에게 연락하세요.</p>
|
||||
<p class="small">요청 ID: <code><?=htmlspecialchars($id)?></code></p>
|
||||
</div></body></html>
|
||||
|
|
|
|||
|
|
@ -28,14 +28,6 @@ return [
|
|||
'01000000000',
|
||||
],
|
||||
|
||||
// 센서별 수신자 그룹 — 지정한 센서의 경보 SMS 는 그 그룹으로만 발송된다. 지정 없는
|
||||
// 센서는 위 전역 SMS_RECIPIENTS 로 폴백한다(헬스/잔액 등 시스템 통지도 전역 사용).
|
||||
// 예: 누수(1번)는 시설팀, 3번은 고객센터로. 비우면(기본) 전역만 사용.
|
||||
'SENSOR_RECIPIENTS' => [
|
||||
// 1 => ['010-1111-1111', '010-2222-2222'],
|
||||
// 3 => ['010-3333-3333'],
|
||||
],
|
||||
|
||||
'ADMIN_USER' => 'admin',
|
||||
'ADMIN_PASSWORD_HASH' => '$2y$10$replace.with.password_hash.output',
|
||||
// Base32 TOTP secret. MUST be left EMPTY here and set to a UNIQUE generated value in
|
||||
|
|
@ -49,67 +41,19 @@ return [
|
|||
// Temporary bootstrap token for setup_mfa.php. Remove or empty after MFA enrollment.
|
||||
'MFA_SETUP_TOKEN' => 'replace-with-temporary-random-token',
|
||||
|
||||
// 누수 모듈 sensor_id 목록 — 대시보드 누수 상태 카드 표시 대상 (기본 [1]).
|
||||
// 신규 누수 지점(예: Pi-C, sensor_id 3) 투입 시: [1, 3]
|
||||
'LEAK_SENSOR_IDS' => [1],
|
||||
|
||||
// 온습도(SHT30) sensor_id 목록 — 대시보드 온습도 카드 표시 대상 (기본 [2]).
|
||||
// 누수 노드(1·3)에 SHT30 을 얹어 "같은 sensor_id" 로 온습도를 보고하는 구성에서는
|
||||
// 1·3 도 포함한다: [1, 2, 3]. (같은 지점에 누수 카드 + 온습도 카드가 함께 표시됨)
|
||||
'METRIC_SENSOR_IDS' => [1, 2, 3],
|
||||
|
||||
// 미세먼지(PMS7003) sensor_id 목록 — 대시보드가 해당 센서 카드에 미세먼지 블록
|
||||
// (PM2.5/PM10 µg/m³)을 표시할 대상 (기본 [1, 3]).
|
||||
// ★ 동일-id 통합 토폴로지: 누수 노드(1·3)는 하나의 sensor_id 아래 누수 + 온습도(SHT30)
|
||||
// + 미세먼지(PMS7003)를 metric_type('sht30' vs 'pms7003')로 구분해 보고한다. 2번은
|
||||
// 온습도 단독. 따라서 운영 토폴로지는:
|
||||
// LEAK_SENSOR_IDS = [1, 3] (누수)
|
||||
// METRIC_SENSOR_IDS = [1, 2, 3] (온습도)
|
||||
// PM_SENSOR_IDS = [1, 3] (미세먼지)
|
||||
// 대시보드는 metric_type 별 최신값을 각각 버킷팅하므로, 같은 sensor_id 에서 온습도와
|
||||
// 미세먼지가 서로를 덮어쓰지 않는다. 신규 미세먼지 지점은 여기서 오버라이드(문자열 "1,3" 허용).
|
||||
'PM_SENSOR_IDS' => [1, 3],
|
||||
|
||||
// 누수 감지/복구 디바운스(감지↔복구 플래핑 SMS 폭탄 방지). 상태가 확인창 동안 지속돼야
|
||||
// 확정하고 확정 엣지에서만 SMS 1회. 아래는 기본값(필요 시 조정). sql/migration_leak_debounce.sql
|
||||
// 적용 필요(컬럼 없으면 레거시 즉시 발송으로 자동 폴백).
|
||||
// 'LEAK_DEBOUNCE_ENABLED' => true,
|
||||
// 'LEAK_ALERT_CONFIRM_SEC' => 60, // 감지 확인창(초)
|
||||
// 'LEAK_RECOVERY_CONFIRM_SEC' => 180, // 복구 확인창(초)
|
||||
|
||||
// 센서별 오프라인 판정 타임아웃(무수신 초). 온습도(SHT30)는 1시간 주기라 전역 20분으론
|
||||
// 매시간 오탐 → 온습도 센서는 METRIC_HEARTBEAT_TIMEOUT_SEC(기본 7500s≈125분) 자동 적용.
|
||||
// 특정 센서만 다르게 하려면 SENSOR_HEARTBEAT_TIMEOUT_SEC 로 명시 오버라이드.
|
||||
// 'METRIC_HEARTBEAT_TIMEOUT_SEC' => 7500,
|
||||
// ★ 동일-id 온습도 통합: 1·3 은 위 METRIC_SENSOR_IDS 에도 들어가지만 누수 노드이므로
|
||||
// metric 기본(7500s)으로 느슨해지면 안 됨 → 누수 타임아웃(30분) 유지하도록 명시 오버라이드.
|
||||
'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [1 => 1800, 3 => 1800],
|
||||
|
||||
// 온습도 임계 경보 (서버 판정 → SMS). 운영 환경에 맞게 조정.
|
||||
'METRIC_TEMP_HIGH_C' => 27, // 고온 임계(℃)
|
||||
'METRIC_TEMP_HIGH_C' => 30, // 고온 임계(℃)
|
||||
'METRIC_TEMP_LOW_C' => 10, // 저온 임계(℃)
|
||||
'METRIC_RH_HIGH' => 60, // 고습 임계(%)
|
||||
'METRIC_RH_LOW' => -1, // 저습 경보 비활성(RH 는 항상 ≥0 → 절대 발화 안 함)
|
||||
'METRIC_DEWPOINT_MARGIN_C' => 0, // 결로(이슬점) 경보 비활성(0 이면 판정 분기 스킵)
|
||||
'METRIC_RH_HIGH' => 70, // 고습 임계(%)
|
||||
'METRIC_RH_LOW' => 20, // 저습 임계(%)
|
||||
'METRIC_ALERT_COOLDOWN_SEC' => 1800, // 동일 종류 경보 재발송 억제(초, 기본 30분)
|
||||
|
||||
// 예보·대응(대시보드 예방 신호 + 조치 가이드). 전부 기본값으로 동작 — 필요 시만 override.
|
||||
// FORECAST_ENABLED(true), FORECAST_WARN_MARGIN_TEMP_C(2.0), FORECAST_WARN_MARGIN_RH(8.0),
|
||||
// FORECAST_HORIZON_MIN(180), FORECAST_RATE_TEMP_C_PER_10MIN(3.0) 등. config.php 참고.
|
||||
// 대응 담당자(대시보드 '대응 필요' 섹션에 표시). 비우면 담당자 블록 생략.
|
||||
'RESPONSE_CONTACTS' => [
|
||||
// ['role' => '시설담당', 'name' => '홍길동', 'phone' => '010-0000-0000'],
|
||||
// ['role' => '야간당직', 'name' => '김철수', 'phone' => '010-0000-0000'],
|
||||
],
|
||||
// 상태별 대응 체크리스트는 config.php 의 기본 플레이북을 사용. 현장 절차로 바꾸려면
|
||||
// 'RESPONSE_PLAYBOOKS' => ['high_temp' => ['title'=>'고온 대응','steps'=>['...']], ...] 로 통째 override.
|
||||
|
||||
// 미복귀 경보 2차(야간) 에스컬레이션 수신자. 비우면 비활성(opt-in).
|
||||
'SMS_RECIPIENTS_TIER2' => [],
|
||||
'ESCALATE_AFTER_SEC' => 1800, // 경보 미복귀 30분 후 2차 통지
|
||||
// 아래는 기본값으로 동작(필요 시에만 override):
|
||||
// SMS_BALANCE_WARN_THRESHOLD(30건), HEALTH_SMS_HOUR(9시),
|
||||
// STUCK_DETECT_SAMPLES(3 = 온습도 1시간 주기 기준 ≈3시간), SMS_RETRY_OFFLINE(true)
|
||||
// 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,
|
||||
'SENSOR_LOG_RETENTION_DAYS' => 365,
|
||||
|
|
|
|||
370
php/config.php
370
php/config.php
|
|
@ -9,11 +9,6 @@
|
|||
|
||||
define('APP_VERSION', 'v2606');
|
||||
|
||||
// ─── 운영 오류 노출 차단 (공유호스팅은 display_errors=On 이 흔함) ───
|
||||
// 스택트레이스/쿼리/경로가 HTTP 응답에 실리지 않도록 전 엔드포인트 공통 차단.
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
$local_config_file = __DIR__ . '/config.local.php';
|
||||
$local_config = is_file($local_config_file) ? require $local_config_file : [];
|
||||
|
||||
|
|
@ -48,8 +43,6 @@ function api_key_is_default(): bool {
|
|||
// 수신 본문 timestamp 허용 시계 오차(초). 이 범위를 벗어난(과거/미래) 서명 요청은
|
||||
// 재전송(replay) 방어를 위해 거부한다. SHT30 보고 주기(5분) 대비 넉넉히 둔다.
|
||||
define('INGEST_MAX_CLOCK_SKEW_SEC', (int)cfg('INGEST_MAX_CLOCK_SKEW_SEC', 600));
|
||||
// 최근 거부 이벤트 링버퍼 크기(출처 IP 로그). 무단 접근 출처 식별용, 파일 무한증가 방지.
|
||||
define('INGEST_REJECT_EVENTS_MAX', (int)cfg('INGEST_REJECT_EVENTS_MAX', 50));
|
||||
|
||||
// 수신 본문 최대 크기(바이트). 정상 디바이스 본문은 ~1KB 미만. 인증 전 대용량
|
||||
// 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 를 차단한다.
|
||||
|
|
@ -58,7 +51,7 @@ define('INGEST_MAX_BODY_BYTES', (int)cfg('INGEST_MAX_BODY_BYTES', 8192));
|
|||
// 재전송(replay) 멱등 방어. 서명(X-Signature)을 nonce 로 기록해, 시계오차 창
|
||||
// (INGEST_MAX_CLOCK_SKEW_SEC) 안에서 동일 서명 본문이 재수신되면 멱등 처리(재저장/
|
||||
// 재발송 생략)한다. 펌웨어 재시도(같은 본문)·캡처 재생 모두 한 번만 반영된다.
|
||||
// leak_ingest_nonce 테이블이 없는 기존 배포에서는 자동으로 비활성(graceful degrade).
|
||||
// ingest_nonce 테이블이 없는 기존 배포에서는 자동으로 비활성(graceful degrade).
|
||||
define('INGEST_NONCE_ENABLED', (bool)cfg('INGEST_NONCE_ENABLED', true));
|
||||
|
||||
// 레거시 본문 필드 서명(verify_signature: 키-접두 sha256) 수용 여부. 기본 비활성.
|
||||
|
|
@ -77,59 +70,7 @@ $sms_recipients = cfg('SMS_RECIPIENTS', ['01000000000']);
|
|||
if (is_string($sms_recipients)) {
|
||||
$sms_recipients = array_filter(array_map('trim', explode(',', $sms_recipients)));
|
||||
}
|
||||
define('SMS_RECIPIENTS', array_values($sms_recipients));
|
||||
|
||||
// ─── 센서별 수신자 그룹 (config.local.php 맵) ────────────────────────────────
|
||||
// 센서(sensor_id)별로 SMS 를 받을 수신자 그룹을 지정한다. 지정이 없는 센서는 전역
|
||||
// SMS_RECIPIENTS 로 폴백한다(SENSOR_LABELS 와 동일 원칙 — Pi 재배포 없이 config.local.php
|
||||
// 만 바꾸면 됨). 값은 문자열 배열 또는 쉼표문자열(숫자만 정제). 예:
|
||||
// 'SENSOR_RECIPIENTS' => [
|
||||
// 1 => ['010-1111-1111', '010-2222-2222'], // 누수 → 시설팀
|
||||
// 3 => ['010-3333-3333'], // 3번 → 고객센터
|
||||
// ]
|
||||
$sensor_recipients_raw = cfg('SENSOR_RECIPIENTS', []);
|
||||
$sensor_recipients = [];
|
||||
if (is_array($sensor_recipients_raw)) {
|
||||
foreach ($sensor_recipients_raw as $sid => $list) {
|
||||
$sid = (int)$sid;
|
||||
if ($sid < 1 || $sid > 255) {
|
||||
continue; // 유효 sensor_id(1..255) 만
|
||||
}
|
||||
if (is_string($list)) {
|
||||
$list = explode(',', $list);
|
||||
}
|
||||
if (!is_array($list)) {
|
||||
continue;
|
||||
}
|
||||
$nums = [];
|
||||
foreach ($list as $num) {
|
||||
$d = preg_replace('/\D+/', '', (string)$num);
|
||||
if ($d !== '') {
|
||||
$nums[] = $d;
|
||||
}
|
||||
}
|
||||
if ($nums !== []) { // 유효 번호가 하나도 없으면 버림(전역 폴백되게)
|
||||
$sensor_recipients[$sid] = $nums;
|
||||
}
|
||||
}
|
||||
}
|
||||
define('SENSOR_RECIPIENTS', $sensor_recipients);
|
||||
|
||||
/**
|
||||
* 센서별 수신자 그룹 해소(순수 로직). 그룹 지정 시 그 그룹, 없으면 전역 폴백.
|
||||
* 테스트 용이성을 위해 상수 의존 없는 순수 함수로 분리한다.
|
||||
*/
|
||||
function resolve_sensor_recipients(int $sid, array $groups, array $global): array {
|
||||
return $groups[$sid] ?? $global;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 센서의 SMS 수신자 목록. SENSOR_RECIPIENTS[sid] 우선, 없으면 전역 SMS_RECIPIENTS.
|
||||
* sid=0(헬스/잔액/시스템 통지)은 그룹이 없으므로 전역으로 폴백한다.
|
||||
*/
|
||||
function recipients_for_sensor(int $sid): array {
|
||||
return resolve_sensor_recipients($sid, SENSOR_RECIPIENTS, SMS_RECIPIENTS);
|
||||
}
|
||||
define('SMS_RECIPIENTS', $sms_recipients);
|
||||
|
||||
// Cafe24 SMS('S' 타입)는 EUC-KR 기준 약 90바이트까지만 단문으로 발송된다. 초과하면
|
||||
// 게이트웨이가 잘라/거부하므로, 발송 직전에 EUC-KR 바이트 기준으로 안전하게 자른다
|
||||
|
|
@ -137,7 +78,7 @@ function recipients_for_sensor(int $sid): array {
|
|||
define('SMS_MAX_EUCKR_BYTES', (int)cfg('SMS_MAX_EUCKR_BYTES', 90));
|
||||
|
||||
// ─── SMS 메시지 태그 (경보 상태머신의 단일 출처) ──────────────────────────────
|
||||
// leak_sms_log.message 의 접두 태그로 쿨다운/복구/대시보드/월간보고서가 경보 상태를
|
||||
// sms_log.message 의 접두 태그로 쿨다운/복구/대시보드/월간보고서가 경보 상태를
|
||||
// 재구성한다. 별도 상태 테이블을 두지 않으므로 이 태그 문자열이 사실상의 상태 키다.
|
||||
// 여러 파일(sms_send/dashboard/monthly_report)의 LIKE 문자열이 서로 어긋나 무성
|
||||
// 장애가 나는 것을 막기 위해, 태그는 반드시 여기서만 정의하고 상수로 참조한다.
|
||||
|
|
@ -155,7 +96,6 @@ const SMS_TAG_SENSOR_STUCK = '[센서고착]'; // 측정값 고착(live-bu
|
|||
const SMS_TAG_ESCALATION = '[에스컬레이션]'; // 미복귀 경보 2차(야간) 통지
|
||||
const SMS_TAG_LEAK_ALERT = '[누수감지]'; // 1번 모듈(누수) 누수 발생
|
||||
const SMS_TAG_LEAK_RECOVERY = '[누수복구]'; // 1번 모듈(누수) 누수 해소(복구)
|
||||
const SMS_TAG_HIGH_PM = '[화재의심]'; // PMS7003 PM 급상승(연기 추정) — 화재 조기징후. 서버실/항온항습 화재 프록시
|
||||
|
||||
// 경보 종류(metric kind) → 태그 매핑(단일 출처). evaluate_metric_thresholds 의
|
||||
// breach 종류와 1:1 대응한다.
|
||||
|
|
@ -164,16 +104,13 @@ const SMS_METRIC_TAGS = [
|
|||
'low_temp' => SMS_TAG_LOW_TEMP,
|
||||
'high_humidity' => SMS_TAG_HIGH_HUMIDITY,
|
||||
'low_humidity' => SMS_TAG_LOW_HUMIDITY,
|
||||
// PMS7003 화재의심(PM): PM2.5·PM10 두 종류가 같은 태그([화재의심])로 매핑된다.
|
||||
'high_pm25' => SMS_TAG_HIGH_PM,
|
||||
'high_pm10' => SMS_TAG_HIGH_PM,
|
||||
];
|
||||
|
||||
/**
|
||||
* leak_sms_log 태그별 건수 집계 SELECT 절(단일 출처). 대시보드/월간보고서가 공유한다.
|
||||
* sms_log 태그별 건수 집계 SELECT 절(단일 출처). 대시보드/월간보고서가 공유한다.
|
||||
* 태그 문자열은 위 SMS_TAG_* 상수에서만 가져오므로, 태그를 바꿔도 모든 집계가 한
|
||||
* 곳에서 따라온다(여러 파일의 LIKE 문자열이 어긋나 무성 장애 나는 것 방지).
|
||||
* 반환 컬럼: high_temp, low_temp, high_humidity, low_humidity, high_pm, recovery,
|
||||
* 반환 컬럼: 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 {
|
||||
|
|
@ -182,7 +119,6 @@ function sms_summary_select_sql(): string {
|
|||
. " SUM(message LIKE '" . SMS_TAG_LOW_TEMP . "%') AS low_temp,\n"
|
||||
. " SUM(message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%') AS high_humidity,\n"
|
||||
. " SUM(message LIKE '" . SMS_TAG_LOW_HUMIDITY . "%') AS low_humidity,\n"
|
||||
. " SUM(message LIKE '" . SMS_TAG_HIGH_PM . "%') AS high_pm,\n"
|
||||
. " SUM(message LIKE '" . SMS_TAG_RECOVERY . "%') AS recovery,\n"
|
||||
. " SUM(message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%') AS device_offline,\n"
|
||||
. " SUM(message LIKE '" . SMS_TAG_DEVICE_ONLINE . "%') AS device_online,\n"
|
||||
|
|
@ -205,37 +141,10 @@ define('METRIC_RH_HYSTERESIS', (float)cfg('METRIC_RH_HYSTERESIS', 3.0));
|
|||
// 동일 종류 경보 재발송 억제 쿨다운(초). 기본 30분.
|
||||
define('METRIC_ALERT_COOLDOWN_SEC', (int)cfg('METRIC_ALERT_COOLDOWN_SEC', 1800));
|
||||
|
||||
// ─── 미세먼지(PMS7003) 임계 경보 (PM2.5/PM10 → SMS) ────────────────────────────
|
||||
// PMS7003 은 누수 노드(sensor_id 1·3)에 온습도(SHT30)와 함께 동일 sensor_id 로 통합되어
|
||||
// metric_type='pms7003' 로 보고한다. 값은 대기환산 µg/m³ 정수(0..1000). 아래 운영 임계를
|
||||
// 초과하면 api/sensor_data.php 가 [화재의심] SMS 를 보낸다(종류별 쿨다운은 온습도와
|
||||
// 동일하게 METRIC_ALERT_COOLDOWN_SEC 재사용). 임계 판정은 Pi 가 아니라 서버에서 하므로
|
||||
// 폐쇄망에서도 재배포 없이 config.local.php 로 조정할 수 있다.
|
||||
//
|
||||
// 화재 프록시 기준(서버실/항온항습): 여과·양압 환경이라 평상 PM ≈ 0. PM 급상승 = 외부
|
||||
// 유입 아니면 연소 연기 → 화재 조기징후로 본다. 대기질 "나쁨"(75/150)까지 기다리면 연기가
|
||||
// 이미 자욱해 늦으므로, 조기감지용으로 낮춘 값을 쓴다. (PMS7003 은 광학 먼지센서지 인증
|
||||
// 화재감지기가 아니다 — 보조 조기징후로만 쓰고 인증 연기/열 감지기를 병행할 것.)
|
||||
define('METRIC_PM25_HIGH', (float)cfg('METRIC_PM25_HIGH', 35)); // PM2.5 화재의심 임계(µg/m³) — 조기감지용 하향
|
||||
define('METRIC_PM10_HIGH', (float)cfg('METRIC_PM10_HIGH', 75)); // PM10 화재의심 임계(µg/m³) — 조기감지용 하향
|
||||
// 복구(정상 회복) 히스테리시스: 임계 안쪽으로 이 여유만큼 들어와야 복구로 간주(경계 채터링 방지)
|
||||
define('METRIC_PM_HYSTERESIS', (float)cfg('METRIC_PM_HYSTERESIS', 10)); // µg/m³
|
||||
|
||||
// 누수(leak) 경보 재발송 억제 쿨다운(초). 1번 모듈은 누수 지속 중 주기(periodic) 보고를
|
||||
// 계속 보내므로, 같은 누수 에피소드에서 [누수감지] SMS 가 반복 발송되지 않도록 한다. 기본 30분.
|
||||
define('LEAK_ALERT_COOLDOWN_SEC', (int)cfg('LEAK_ALERT_COOLDOWN_SEC', 1800));
|
||||
|
||||
// ─── 누수 감지/복구 디바운스 (감지↔복구 플래핑 SMS 폭탄 방지) ──────────────────
|
||||
// 센서에 닿는 물이 애매하면 GPIO 가 leak/dry 사이를 초 단위로 진동(채터링)한다. 상태
|
||||
// 전이를 즉시 발송하지 않고, 확인창(감지/복구) 동안 raw 상태가 지속돼야 "확정"하고
|
||||
// 확정 엣지에서만 SMS 를 1회 보낸다. 누수 지속 중 짧은 dry 블립(< 복구 확인창)은 복구를
|
||||
// 확정하지 못하므로, 확정 상태가 유지되어 재감지·복구 SMS 가 둘 다 나가지 않는다(폭탄 차단).
|
||||
// leak_sensor_status.leak_confirmed / leak_pending_since 컬럼으로 상태를 저장한다.
|
||||
// 해당 컬럼이 없는 구배포에서는 자동으로 레거시(즉시 발송) 경로로 graceful degrade.
|
||||
define('LEAK_DEBOUNCE_ENABLED', (bool)cfg('LEAK_DEBOUNCE_ENABLED', true));
|
||||
define('LEAK_ALERT_CONFIRM_SEC', (int)cfg('LEAK_ALERT_CONFIRM_SEC', 60)); // 감지 확인창(초)
|
||||
define('LEAK_RECOVERY_CONFIRM_SEC', (int)cfg('LEAK_RECOVERY_CONFIRM_SEC', 180)); // 복구 확인창(초)
|
||||
|
||||
// ─── 결로(이슬점) 경보 ────────────────────────────────────────────────────────
|
||||
// 온도·습도가 각각 정상이어도 (현재온도 − 이슬점)=결로마진 이 좁으면 차가운 표면에
|
||||
// 결로가 생긴다(장비 손상). 마진이 이 임계 이하이면 결로주의 경보를 보낸다.
|
||||
|
|
@ -243,37 +152,6 @@ define('LEAK_RECOVERY_CONFIRM_SEC', (int)cfg('LEAK_RECOVERY_CONFIRM_SEC', 180))
|
|||
define('METRIC_DEWPOINT_MARGIN_C', (float)cfg('METRIC_DEWPOINT_MARGIN_C', 3.0));
|
||||
define('METRIC_DEWPOINT_HYSTERESIS_C', (float)cfg('METRIC_DEWPOINT_HYSTERESIS_C', 1.0));
|
||||
|
||||
// ─── 예보·대응 (대시보드 예방 신호 + 조치 가이드) ─────────────────────────────
|
||||
// 기존 측정 데이터만으로 "경보가 터지기 전"을 대시보드에 표시한다(php/forecast.php).
|
||||
// 임계 여유·추세 ETA·이슬점 마진·변화율을 계산하고, 위험 시 상태별 대응 체크리스트를
|
||||
// 보여준다. DB 스키마 변경 없음(전부 기존 leak_sensor_metric/status 로 산출).
|
||||
define('FORECAST_ENABLED', (bool)cfg('FORECAST_ENABLED', true));
|
||||
define('FORECAST_RECENT_POINTS', (int)cfg('FORECAST_RECENT_POINTS', 12)); // 회귀에 쓸 최근 측정 수(≈1시간)
|
||||
define('FORECAST_MIN_POINTS', (int)cfg('FORECAST_MIN_POINTS', 6)); // ETA 산출 최소 점수
|
||||
define('FORECAST_MIN_R2', (float)cfg('FORECAST_MIN_R2', 0.5)); // 추세 신뢰(결정계수) 하한
|
||||
define('FORECAST_HORIZON_MIN', (int)cfg('FORECAST_HORIZON_MIN', 180)); // ETA 표시 지평(분)
|
||||
define('FORECAST_WARN_MARGIN_TEMP_C', (float)cfg('FORECAST_WARN_MARGIN_TEMP_C', 2.0)); // 온도 여유 근접 기준(℃)
|
||||
define('FORECAST_WARN_MARGIN_RH', (float)cfg('FORECAST_WARN_MARGIN_RH', 8.0)); // 습도 여유 근접 기준(%)
|
||||
define('FORECAST_RATE_TEMP_C_PER_10MIN', (float)cfg('FORECAST_RATE_TEMP_C_PER_10MIN', 3.0)); // 급변 기준(℃/10분)
|
||||
define('FORECAST_RATE_RH_PER_10MIN', (float)cfg('FORECAST_RATE_RH_PER_10MIN', 10.0)); // 급변 기준(%/10분)
|
||||
|
||||
// 상태별 대응 체크리스트(플레이북). 조건 키 → ['title'=>, 'steps'=>[...]].
|
||||
// config.local.php 에서 'RESPONSE_PLAYBOOKS' 로 통째 오버라이드 가능(현장 절차 반영).
|
||||
$default_playbooks = [
|
||||
'leak' => ['title' => '누수 대응', 'steps' => ['현장 즉시 확인', '해당 구역 급수 밸브 차단', '누수 지점·범위 사진 확인', '전기 설비 침수 여부 안전 점검', '조치 후 복구 확인']],
|
||||
'high_temp' => ['title' => '고온 대응', 'steps' => ['냉방·환기 동작 확인', '실외기/흡배기·필터 막힘 점검', '발열 장비·부하 확인', '필요 시 임시 냉각']],
|
||||
'low_temp' => ['title' => '저온 대응', 'steps' => ['난방 동작 확인', '외기 유입·틈새 점검', '동결 위험 배관 확인']],
|
||||
'high_humidity' => ['title' => '고습 대응', 'steps' => ['제습기 동작 확인', '누수·침수 여부 확인', '환기 상태 점검']],
|
||||
'low_humidity' => ['title' => '저습 대응', 'steps' => ['가습 설비 확인', '정전기 민감 장비 점검']],
|
||||
'condensation' => ['title' => '결로 위험 대응', 'steps' => ['차가운 표면·배관 결로 확인', '제습·환기로 이슬점 마진 확보', '장비 표면 물기 점검']],
|
||||
'offline' => ['title' => '오프라인 대응', 'steps' => ['장비 전원·상태 표시등 확인', '유선/무선 네트워크 연결 확인', 'NTP 시각 동기 확인', '필요 시 장비 재부팅', '복구 후 수신 재개 확인']],
|
||||
];
|
||||
define('RESPONSE_PLAYBOOKS', cfg('RESPONSE_PLAYBOOKS', $default_playbooks));
|
||||
|
||||
// 대응 담당자 연락처: [['role'=>'시설담당','name'=>'홍길동','phone'=>'010-...'], ...].
|
||||
// 비면(기본) 대응 섹션에서 연락처 블록을 생략한다. config.local.php 에서 채운다.
|
||||
define('RESPONSE_CONTACTS', cfg('RESPONSE_CONTACTS', []));
|
||||
|
||||
// ─── SMS 잔액(잔여 건수) 사전경보 ─────────────────────────────────────────────
|
||||
// Cafe24 응답의 잔여 건수가 이 값 이하로 내려가면 1회 '[SMS 잔액부족]' 경고를 보낸다
|
||||
// (경보 채널이 잔액 소진으로 조용히 죽는 것을 사전 방지). 쿨다운으로 반복을 억제.
|
||||
|
|
@ -304,14 +182,8 @@ define('ESCALATE_AFTER_SEC', (int)cfg('ESCALATE_AFTER_SEC', 1800));
|
|||
// 센서 고착(live-but-stuck) 검출: 최근 STUCK_DETECT_SAMPLES 측정의 표준편차가
|
||||
// STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장(같은 값 반복)을 의심해 알린다.
|
||||
// (하트비트는 '수신 끊김'만 본다 — I2C 는 살아있는데 값이 고착된 무성실패를 놓침.)
|
||||
//
|
||||
// 기본 비활성: 대상이 항온항습 문서고라 온습도가 원래 거의 평탄하다. 고착 검출을 켜면
|
||||
// 정상 평탄값을 '고착'으로 오탐해 [센서고착] 문자가 반복된다. 변동이 있는 환경에서만
|
||||
// config.local.php 로 STUCK_DETECT_ENABLED=true 로 켠다.
|
||||
define('STUCK_DETECT_ENABLED', (bool)cfg('STUCK_DETECT_ENABLED', false));
|
||||
// 온습도 보고 주기가 1시간이므로 표본 수가 크면 고착 감지가 너무 늦다. 기본 3(= 3회 연속
|
||||
// 완전 평탄 ≈ 3시간). 주기를 바꾸면 config.local.php 로 조정.
|
||||
define('STUCK_DETECT_SAMPLES', (int)cfg('STUCK_DETECT_SAMPLES', 3)); // 3 × 1시간 ≈ 3시간
|
||||
define('STUCK_DETECT_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시간
|
||||
|
||||
|
|
@ -319,149 +191,13 @@ define('STUCK_ALERT_COOLDOWN_SEC', (int)cfg('STUCK_ALERT_COOLDOWN_SEC', 21600));
|
|||
// 운영에서는 반드시 false. tests/chaos_drill.php 가 일시적으로 켜서 검증한다.
|
||||
define('SMS_FORCE_FAIL', (bool)cfg('SMS_FORCE_FAIL', false));
|
||||
|
||||
// ─── Heartbeat (장비 오프라인 감지) — 센서별 타임아웃 ──────────────────────────
|
||||
// 온습도(SHT30) 보고 주기는 1시간(raspberry_pi4_sht30/config.py REPORT_INTERVAL=3600),
|
||||
// 누수 모듈 정상 보고 주기는 10분(raspberry_pi4/config.py REPORT_INTERVAL=600). 보고 주기가
|
||||
// 센서마다 크게 다르므로 오프라인 판정 타임아웃을 센서별로 해소한다(전역 단일값이면
|
||||
// 온습도 1시간 주기가 20분 타임아웃에 매시간 오프라인 오탐, 또는 전역을 1시간+로 올리면
|
||||
// 누수 노드 다운을 늦게 감지).
|
||||
// heartbeat_timeout_for(sid): 명시 오버라이드 > 온습도(METRIC) 기본 > 전역 기본.
|
||||
// ⚠️ 각 값은 해당 센서의 보고 주기보다 넉넉히 길어야 오탐이 없다.
|
||||
define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200)); // 전역 기본(누수 등 20분)
|
||||
// 온습도 센서(METRIC_SENSOR_IDS) 오프라인 판정 기본. 1시간 주기 2회 누락 + 여유 ≈ 125분.
|
||||
define('METRIC_HEARTBEAT_TIMEOUT_SEC', (int)cfg('METRIC_HEARTBEAT_TIMEOUT_SEC', 7500));
|
||||
// 센서별 명시 오버라이드 맵: [sensor_id => 초]. 예: 'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [3 => 900]
|
||||
$sensor_hb_raw = cfg('SENSOR_HEARTBEAT_TIMEOUT_SEC', []);
|
||||
$sensor_hb = [];
|
||||
if (is_array($sensor_hb_raw)) {
|
||||
foreach ($sensor_hb_raw as $sid => $sec) {
|
||||
$sid = (int)$sid;
|
||||
$sec = (int)$sec;
|
||||
if ($sid >= 1 && $sid <= 255 && $sec > 0) {
|
||||
$sensor_hb[$sid] = $sec;
|
||||
}
|
||||
}
|
||||
}
|
||||
define('SENSOR_HEARTBEAT_TIMEOUT_SEC', $sensor_hb);
|
||||
// ─── Heartbeat (장비 오프라인 감지) ────────────────────────
|
||||
// SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300). 기본
|
||||
// 오프라인 판정은 약 4회 누락(20분)으로 둔다. config.local.php 로 조정 가능.
|
||||
define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200));
|
||||
define('HEARTBEAT_CHECK_INTERVAL_SEC', (int)cfg('HEARTBEAT_CHECK_INTERVAL_SEC', 300)); // 오프라인 체크 실행 주기
|
||||
define('CRON_SECRET', hash_hmac('sha256', 'cron', API_KEY)); // cron HTTP 접근용 시크릿(API_KEY 파생, HMAC)
|
||||
|
||||
/**
|
||||
* 센서별 오프라인 판정 타임아웃 해소(순수 로직). 명시 오버라이드 > 온습도 기본 > 전역 기본.
|
||||
* 테스트 용이성을 위해 상수 의존 없는 순수 함수로 분리한다.
|
||||
*/
|
||||
function resolve_heartbeat_timeout(int $sid, array $overrides, array $metricIds, int $metricDefault, int $globalDefault): int {
|
||||
if (array_key_exists($sid, $overrides)) {
|
||||
return (int)$overrides[$sid];
|
||||
}
|
||||
if (in_array($sid, $metricIds, true)) {
|
||||
return $metricDefault;
|
||||
}
|
||||
return $globalDefault;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 센서의 오프라인 판정 타임아웃(초). cron_heartbeat / dashboard 신선도 판정 공용.
|
||||
*/
|
||||
function heartbeat_timeout_for(int $sid): int {
|
||||
return resolve_heartbeat_timeout(
|
||||
$sid, SENSOR_HEARTBEAT_TIMEOUT_SEC, METRIC_SENSOR_IDS,
|
||||
METRIC_HEARTBEAT_TIMEOUT_SEC, HEARTBEAT_TIMEOUT_SEC
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 누수 모듈 sensor_id 목록 ────────────────────────────
|
||||
// 대시보드 누수 상태 카드 표시 대상. 신규 누수 지점 추가 시 config.local.php 로
|
||||
// 오버라이드한다. 예: 'LEAK_SENSOR_IDS' => [1, 3] (문자열 "1,3" 도 허용)
|
||||
// 유효 범위는 1..255 정수(api/sensor_data.php 의 sensor_id 검증과 동일). 범위 밖·
|
||||
// 비숫자 항목은 버리고, 결과가 비면 기본 [1] 로 되돌린다(오설정으로 누수 카드
|
||||
// 전체가 사라지는 것 방지).
|
||||
$leak_sensor_ids = cfg('LEAK_SENSOR_IDS', [1]);
|
||||
if (is_string($leak_sensor_ids)) {
|
||||
$leak_sensor_ids = explode(',', $leak_sensor_ids);
|
||||
}
|
||||
$leak_sensor_ids = array_values(array_filter(
|
||||
array_map('intval', (array)$leak_sensor_ids),
|
||||
static fn($id) => $id >= 1 && $id <= 255
|
||||
));
|
||||
define('LEAK_SENSOR_IDS', $leak_sensor_ids !== [] ? $leak_sensor_ids : [1]);
|
||||
|
||||
// ─── 온습도(SHT30) 센서 sensor_id 목록 ───────────────────
|
||||
// 상단 "센서 현재 상태" 카드에 항상 표시할 온습도 센서. 누수 카드와 동일 원칙 —
|
||||
// 아직 데이터가 없어도(미수신) placeholder 카드로 노출해, 기대 센서가 화면에서
|
||||
// 조용히 사라지지 않게 한다(예: SHT30 최초 기동 전). 기본 [2](2번=SHT30 온습도).
|
||||
// 신규 온습도 지점은 config.local.php 로 오버라이드한다. 예: [2, 4] (문자열 "2,4" 허용)
|
||||
$metric_sensor_ids = cfg('METRIC_SENSOR_IDS', [2]);
|
||||
if (is_string($metric_sensor_ids)) {
|
||||
$metric_sensor_ids = explode(',', $metric_sensor_ids);
|
||||
}
|
||||
$metric_sensor_ids = array_values(array_filter(
|
||||
array_map('intval', (array)$metric_sensor_ids),
|
||||
static fn($id) => $id >= 1 && $id <= 255
|
||||
));
|
||||
define('METRIC_SENSOR_IDS', $metric_sensor_ids !== [] ? $metric_sensor_ids : [2]);
|
||||
|
||||
// ─── 미세먼지(PMS7003) 센서 sensor_id 목록 ───────────────
|
||||
// 누수 노드(1·3)에 PMS7003 을 얹어 "같은 sensor_id" 로 metric_type='pms7003' 미세먼지를
|
||||
// 보고하는 구성의 표시 대상. 누수/온습도 목록과 동일 정제(intval, 1..255). 기본 [1, 3].
|
||||
// 신규 미세먼지 지점은 config.local.php 로 오버라이드한다. 예: [1, 3] (문자열 "1,3" 허용).
|
||||
// !defined 가드 — 다른 스트림이 먼저 정의했을 수 있으므로 재정의 fatal 을 피한다(좌표화).
|
||||
if (!defined('PM_SENSOR_IDS')) {
|
||||
$pm_sensor_ids = cfg('PM_SENSOR_IDS', [1, 3]);
|
||||
if (is_string($pm_sensor_ids)) {
|
||||
$pm_sensor_ids = explode(',', $pm_sensor_ids);
|
||||
}
|
||||
$pm_sensor_ids = array_values(array_filter(
|
||||
array_map('intval', (array)$pm_sensor_ids),
|
||||
static fn($id) => $id >= 1 && $id <= 255
|
||||
));
|
||||
define('PM_SENSOR_IDS', $pm_sensor_ids !== [] ? $pm_sensor_ids : [1, 3]);
|
||||
}
|
||||
|
||||
// ─── 센서별 표시 라벨 오버라이드 (서버측) ────────────────────
|
||||
// SMS·대시보드에 표시할 센서별 "위치"와 "관리주체(표시명)"를 서버에서 지정한다
|
||||
// (Pi 재배포 없이 config.local.php 만 바꾸면 됨). 지정이 없으면 Pi 가 보고한
|
||||
// device_location / sensor_name 을 그대로 쓴다. 예:
|
||||
// 'SENSOR_LABELS' => [
|
||||
// 1 => ['location' => '서버실(안)', 'name' => '정보기반'],
|
||||
// 2 => ['location' => '문서고', 'name' => '조직혁신'],
|
||||
// 3 => ['location' => '서버실(밖)', 'name' => '고객센터'],
|
||||
// ]
|
||||
$sensor_labels_raw = cfg('SENSOR_LABELS', []);
|
||||
$sensor_labels = [];
|
||||
if (is_array($sensor_labels_raw)) {
|
||||
foreach ($sensor_labels_raw as $sid => $lbl) {
|
||||
$sid = (int)$sid;
|
||||
if ($sid < 1 || $sid > 255 || !is_array($lbl)) {
|
||||
continue;
|
||||
}
|
||||
$entry = [];
|
||||
if (isset($lbl['location']) && is_string($lbl['location']) && $lbl['location'] !== '') {
|
||||
$entry['location'] = $lbl['location'];
|
||||
}
|
||||
if (isset($lbl['name']) && is_string($lbl['name']) && $lbl['name'] !== '') {
|
||||
$entry['name'] = $lbl['name'];
|
||||
}
|
||||
if ($entry !== []) {
|
||||
$sensor_labels[$sid] = $entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
define('SENSOR_LABELS', $sensor_labels);
|
||||
|
||||
/**
|
||||
* 센서 표시 라벨 결정: SENSOR_LABELS(서버 오버라이드) 우선, 없으면 보고값(Pi) 폴백.
|
||||
* SMS·대시보드 등 사람이 보는 모든 곳에서 센서의 위치/관리주체를 이 헬퍼로 얻는다.
|
||||
*
|
||||
* @return array{0:string,1:string} [위치, 관리주체(표시명)]
|
||||
*/
|
||||
function sensor_label(int $sensor_id, ?string $reported_location, ?string $reported_name): array {
|
||||
$ov = SENSOR_LABELS[$sensor_id] ?? [];
|
||||
$loc = $ov['location'] ?? ((($reported_location ?? '') !== '') ? $reported_location : '알 수 없음');
|
||||
$name = $ov['name'] ?? ((($reported_name ?? '') !== '') ? $reported_name : "센서{$sensor_id}");
|
||||
return [$loc, $name];
|
||||
}
|
||||
|
||||
// ─── 대시보드 세션 인증 ──────────────────────────────────
|
||||
define('ADMIN_USER', cfg('ADMIN_USER', 'admin'));
|
||||
define('ADMIN_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시
|
||||
|
|
@ -508,7 +244,7 @@ function json_response(array $data, int $http_code = 200): void {
|
|||
|
||||
// ─── 개인정보(수신번호) 마스킹 ─────────────────────────────
|
||||
// 화면/보고서에 수신번호를 노출할 때 가운데를 가린다(010-****-1234). 발송 기록
|
||||
// 자체(leak_sms_log)는 보존하되 표시 단계에서만 최소화한다(개인정보 최소표시 원칙).
|
||||
// 자체(sms_log)는 보존하되 표시 단계에서만 최소화한다(개인정보 최소표시 원칙).
|
||||
// 쉼표로 여러 번호가 들어오면 각각 마스킹한다.
|
||||
function mask_phone(string $value): string {
|
||||
if (strpos($value, ',') !== false) {
|
||||
|
|
@ -530,7 +266,7 @@ function mask_phone(string $value): string {
|
|||
function latest_sms_balance(PDO $db): ?int {
|
||||
try {
|
||||
$rm = $db->query("
|
||||
SELECT result_message FROM leak_sms_log
|
||||
SELECT result_message FROM sms_log
|
||||
WHERE status = 'success' AND result_message LIKE '%잔여%'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
")->fetchColumn();
|
||||
|
|
@ -551,11 +287,7 @@ function ingest_rejection_path(): string {
|
|||
return __DIR__ . '/var/ingest_rejections.json';
|
||||
}
|
||||
|
||||
function record_ingest_rejection(string $reason, array $ctx = []): void {
|
||||
// 출처 IP 포함 최근 거부 이벤트를 별도 링 파일에 남긴다(카운터와 독립 — 하나가
|
||||
// 실패해도 다른 하나는 기록). '무단 접근' 출처(우리 노드 vs 외부 스캐너) 식별용.
|
||||
record_ingest_reject_event($reason, $ctx);
|
||||
|
||||
function record_ingest_rejection(string $reason): void {
|
||||
$fh = @fopen(ingest_rejection_path(), 'c+');
|
||||
if ($fh === false) {
|
||||
return; // 카운터 실패가 수집 처리를 막아선 안 됨(best-effort)
|
||||
|
|
@ -604,80 +336,6 @@ function read_ingest_rejections(int $days = 1): array {
|
|||
return $totals;
|
||||
}
|
||||
|
||||
// ─── 수집 거부 이벤트 링 (출처 IP 포함 — 무단 접근 식별) ──────────────────────
|
||||
// 카운터(사유별 숫자)만으론 "우리 노드 오설정 vs 외부 스캐너"를 구분 못 한다. 최근 거부
|
||||
// 몇 건을 출처 IP·사유·서명모드와 함께 별도 파일 링버퍼에 남겨, 대시보드/ops 가 출처를
|
||||
// 즉시 분별한다. 인증 전 경로라 DB 대신 파일 flock(위조 폭주에도 부담 작음), 개수 상한
|
||||
// (INGEST_REJECT_EVENTS_MAX)으로 무한증가를 막는다. 본문 유래 값(device_id 등)은 신뢰
|
||||
// 불가 → 로그 인젝션 방지를 위해 제어문자 제거·절단한다. var/ 는 .htaccess 로 웹 차단됨.
|
||||
function ingest_reject_events_path(): string {
|
||||
return __DIR__ . '/var/ingest_reject_events.json';
|
||||
}
|
||||
|
||||
// 신뢰 가능한 출처 IP(REMOTE_ADDR). 형식 검증 실패/부재는 '?'. XFF 는 위조 가능하므로
|
||||
// 신뢰 출처로 쓰지 않는다(참고용은 record 에서 별도 'fwd' 필드로만 남긴다).
|
||||
function ingest_client_ip(): string {
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
return (is_string($ip) && filter_var($ip, FILTER_VALIDATE_IP)) ? $ip : '?';
|
||||
}
|
||||
|
||||
// 로그 인젝션/제어문자 방지: 인쇄가능 문자만 남기고(제어문자→공백) 최대 길이로 절단.
|
||||
function ingest_sanitize_token(string $s, int $max = 64): string {
|
||||
$s = preg_replace('/[[:cntrl:]]+/u', ' ', $s);
|
||||
$s = trim((string)$s);
|
||||
if ($max > 0) {
|
||||
$s = function_exists('mb_substr') ? mb_substr($s, 0, $max) : substr($s, 0, $max);
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
|
||||
// 최근 거부 이벤트 1건을 링버퍼(별도 파일)에 추가한다(best-effort, DoS 안전).
|
||||
// $ctx: ['mode'=>서명모드, 'device_id'=>본문 device_id(신뢰불가)] — 선택.
|
||||
function record_ingest_reject_event(string $reason, array $ctx = []): void {
|
||||
$fh = @fopen(ingest_reject_events_path(), 'c+');
|
||||
if ($fh === false) {
|
||||
return;
|
||||
}
|
||||
if (flock($fh, LOCK_EX)) {
|
||||
$raw = stream_get_contents($fh);
|
||||
$events = ($raw !== false && $raw !== '') ? json_decode($raw, true) : [];
|
||||
if (!is_array($events)) { $events = []; }
|
||||
$fwd = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
|
||||
$events[] = [
|
||||
't' => date('Y-m-d H:i:s'),
|
||||
'ip' => ingest_client_ip(),
|
||||
'reason' => ingest_sanitize_token($reason, 24),
|
||||
'mode' => isset($ctx['mode']) ? ingest_sanitize_token((string)$ctx['mode'], 12) : '',
|
||||
'dev' => isset($ctx['device_id']) ? ingest_sanitize_token((string)$ctx['device_id'], 32) : '',
|
||||
'fwd' => (is_string($fwd) && $fwd !== '') ? ingest_sanitize_token($fwd, 48) : '',
|
||||
];
|
||||
$max = (int)INGEST_REJECT_EVENTS_MAX;
|
||||
if ($max > 0 && count($events) > $max) {
|
||||
$events = array_slice($events, -$max);
|
||||
}
|
||||
ftruncate($fh, 0);
|
||||
rewind($fh);
|
||||
fwrite($fh, (string)json_encode($events, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
fflush($fh);
|
||||
flock($fh, LOCK_UN);
|
||||
}
|
||||
fclose($fh);
|
||||
}
|
||||
|
||||
// 최근 거부 이벤트 최신순 $limit 건. 대시보드 '최근 거부 출처' 표에 사용.
|
||||
function read_ingest_reject_events(int $limit = 20): array {
|
||||
$file = ingest_reject_events_path();
|
||||
if (!is_file($file)) {
|
||||
return [];
|
||||
}
|
||||
$events = json_decode((string)@file_get_contents($file), true);
|
||||
if (!is_array($events)) {
|
||||
return [];
|
||||
}
|
||||
$events = array_reverse($events); // 최신 먼저
|
||||
return $limit > 0 ? array_slice($events, 0, $limit) : $events;
|
||||
}
|
||||
|
||||
// ─── 서명 검증 (레거시: 본문 필드 기반, DEPRECATED) ───────
|
||||
// 디코드된 데이터를 ksort 후 재직렬화하여 해시한다. 과거 RPi Python 클라이언트
|
||||
// 전용의 하위 호환 경로이며, 현재 운영 클라이언트(STM32 펌웨어)는 사용하지 않는다.
|
||||
|
|
|
|||
|
|
@ -22,24 +22,21 @@ require_once __DIR__ . '/sms_send.php';
|
|||
function check_offline_sensors(): int {
|
||||
$db = get_db();
|
||||
|
||||
// 전 센서를 읽어 센서별 타임아웃(heartbeat_timeout_for)으로 오프라인을 판정한다. 보고
|
||||
// 주기가 센서마다 달라(온습도 1시간 / 누수 10분) 전역 단일 타임아웃으로는 온습도 오탐
|
||||
// 또는 누수 지연이 생긴다. silent_sec 로 무수신 경과를 함께 읽어 PHP 에서 비교한다.
|
||||
$rows = $db->query("
|
||||
SELECT sensor_id, sensor_name, device_id, device_location,
|
||||
last_seen, offline_alerted,
|
||||
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS silent_sec
|
||||
FROM leak_sensor_status
|
||||
")->fetchAll();
|
||||
$stmt = $db->prepare("
|
||||
SELECT * FROM sensor_status
|
||||
WHERE last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND)
|
||||
AND offline_alerted = 0
|
||||
");
|
||||
$stmt->execute([':timeout' => HEARTBEAT_TIMEOUT_SEC]);
|
||||
$offline_sensors = $stmt->fetchAll();
|
||||
|
||||
// 각 센서를 SMS 발송 전에 "원자적으로 선점"한다(0→1 전이가 한 프로세스에서만
|
||||
// 성공). 여러 진입점(sensor_data 쓰로틀, 로그인 시 즉시 체크, 대시보드)이
|
||||
// 동시에 실행돼도 오프라인 SMS 가 중복 발송되지 않도록 한다.
|
||||
// last_seen 조건을 함께 둬, SELECT 와 UPDATE 사이에 장비가 복귀 보고를 하면
|
||||
// (last_seen 갱신) 선점이 실패하여 오탐 오프라인 SMS 가 나가지 않도록 한다.
|
||||
// 타임아웃은 센서별로 바인딩한다.
|
||||
$claim = $db->prepare("
|
||||
UPDATE leak_sensor_status SET offline_alerted = 1
|
||||
UPDATE sensor_status SET offline_alerted = 1
|
||||
WHERE sensor_id = :sensor_id
|
||||
AND offline_alerted = 0
|
||||
AND last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND)
|
||||
|
|
@ -47,18 +44,10 @@ function check_offline_sensors(): int {
|
|||
|
||||
$count = 0;
|
||||
|
||||
foreach ($rows as $sensor) {
|
||||
if ((int)$sensor['offline_alerted'] !== 0) {
|
||||
continue; // 이미 오프라인 알림 발송됨
|
||||
}
|
||||
$sid = (int)$sensor['sensor_id'];
|
||||
$timeout = heartbeat_timeout_for($sid);
|
||||
if ($sensor['silent_sec'] === null || (int)$sensor['silent_sec'] < $timeout) {
|
||||
continue; // 이 센서의 타임아웃 이내 → 온라인
|
||||
}
|
||||
foreach ($offline_sensors as $sensor) {
|
||||
$claim->execute([
|
||||
':sensor_id' => $sid,
|
||||
':timeout' => $timeout,
|
||||
':sensor_id' => $sensor['sensor_id'],
|
||||
':timeout' => HEARTBEAT_TIMEOUT_SEC,
|
||||
]);
|
||||
if ($claim->rowCount() === 0) {
|
||||
// 다른 동시 실행이 이미 선점했거나, 그 사이 장비가 복귀 보고를 했다.
|
||||
|
|
@ -77,7 +66,7 @@ function check_offline_sensors(): int {
|
|||
if (!$sent && SMS_RETRY_OFFLINE) {
|
||||
// 일회성 오프라인 SMS 가 실패하면 선점(offline_alerted=1)을 되돌려, 다음 주기에
|
||||
// 재시도되게 한다(critical: 게이트웨이 일시오류로 장비 다운을 영영 못 알리던 갭).
|
||||
$db->prepare("UPDATE leak_sensor_status SET offline_alerted = 0 WHERE sensor_id = :sid")
|
||||
$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']} 재시도 예약");
|
||||
}
|
||||
|
|
@ -151,12 +140,6 @@ function check_offline_sensors_throttled(): void {
|
|||
} catch (\Throwable $e) {
|
||||
error_log("[Heartbeat] 에스컬레이션/고착 검출 실패: " . $e->getMessage());
|
||||
}
|
||||
// 누수 디바운스: 보고가 끊겨도 미확정 전이(복구/감지)를 시간 경과로 확정 해소.
|
||||
try {
|
||||
sweep_pending_leak_transitions(get_db());
|
||||
} catch (\Throwable $e) {
|
||||
error_log("[Heartbeat] 누수 디바운스 sweep 실패: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,9 +174,8 @@ if ($_is_direct) {
|
|||
maybe_send_daily_health_sms(get_db());
|
||||
maybe_escalate_alerts(get_db());
|
||||
maybe_detect_stuck_sensors(get_db());
|
||||
sweep_pending_leak_transitions(get_db());
|
||||
} catch (\Throwable $e) {
|
||||
error_log("[Heartbeat] 헬스/에스컬레이션/고착/디바운스 실패: " . $e->getMessage());
|
||||
error_log("[Heartbeat] 헬스/에스컬레이션/고착 실패: " . $e->getMessage());
|
||||
}
|
||||
|
||||
if (!$is_cli) {
|
||||
|
|
|
|||
1321
php/dashboard.php
1321
php/dashboard.php
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
272
php/forecast.php
272
php/forecast.php
|
|
@ -1,272 +0,0 @@
|
|||
<?php
|
||||
// =============================================================================
|
||||
// forecast.php — 예방·예측 신호 계산 (순수 함수: DB·출력·전역상태 없음)
|
||||
//
|
||||
// 대시보드가 기존 측정 데이터만으로 "경보가 터지기 전"을 보여주기 위한 계산부.
|
||||
// - 임계 여유(headroom): 현재값과 임계 사이 거리 + 3단계(safe/near/over)
|
||||
// - 추세 ETA: 최근점 선형회귀로 임계 도달 예상 시간(분)
|
||||
// - 이슬점 결로 마진: Magnus 식 Td → margin = T - Td
|
||||
// - 변화율 급변: 단기 기울기로 급상승/급락 플래그
|
||||
// - 신선도: last_seen 경과 → 오프라인 임박
|
||||
// - 위험 항목(risk items): 대응 섹션에 뿌릴 정규화된 목록
|
||||
//
|
||||
// 모든 함수는 인자로 데이터·임계·튜닝을 받는다(상수 참조 없음 → 단위 테스트 용이).
|
||||
// 임계 비활성(<=0 또는 명시 무효값)은 호출부가 걸러 넘긴다(config 규칙 존중).
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 이슬점(℃) — Magnus-Tetens. rh 는 0<rh<=100.
|
||||
* 유효하지 않으면 null.
|
||||
*/
|
||||
function dew_point_c(?float $t, ?float $rh): ?float
|
||||
{
|
||||
if ($t === null || $rh === null || $rh <= 0.0 || $rh > 100.0) {
|
||||
return null;
|
||||
}
|
||||
$a = 17.62;
|
||||
$b = 243.12;
|
||||
$gamma = log($rh / 100.0) + ($a * $t) / ($b + $t);
|
||||
if ($a - $gamma == 0.0) {
|
||||
return null;
|
||||
}
|
||||
return ($b * $gamma) / ($a - $gamma);
|
||||
}
|
||||
|
||||
/**
|
||||
* 최소자승 선형회귀. points = [[x, y], ...] (x·y float).
|
||||
* 반환 ['slope'=>Δy/Δx, 'intercept'=>, 'r2'=>0..1, 'n'=>] 또는 null(점<2, x 분산 0).
|
||||
*/
|
||||
function linear_trend(array $points): ?array
|
||||
{
|
||||
$n = count($points);
|
||||
if ($n < 2) {
|
||||
return null;
|
||||
}
|
||||
$sx = $sy = $sxx = $sxy = $syy = 0.0;
|
||||
foreach ($points as $p) {
|
||||
$x = (float)$p[0];
|
||||
$y = (float)$p[1];
|
||||
$sx += $x; $sy += $y; $sxx += $x * $x; $sxy += $x * $y; $syy += $y * $y;
|
||||
}
|
||||
$denom = $n * $sxx - $sx * $sx;
|
||||
if (abs($denom) < 1e-9) {
|
||||
return null; // x 분산 0 (모두 같은 시각) → 기울기 미정
|
||||
}
|
||||
$slope = ($n * $sxy - $sx * $sy) / $denom;
|
||||
$intercept = ($sy - $slope * $sx) / $n;
|
||||
|
||||
$ss_tot = $syy - ($sy * $sy) / $n;
|
||||
$ss_res = 0.0;
|
||||
foreach ($points as $p) {
|
||||
$e = (float)$p[1] - ($intercept + $slope * (float)$p[0]);
|
||||
$ss_res += $e * $e;
|
||||
}
|
||||
$r2 = $ss_tot > 1e-12 ? max(0.0, 1.0 - $ss_res / $ss_tot) : 1.0;
|
||||
|
||||
return ['slope' => $slope, 'intercept' => $intercept, 'r2' => $r2, 'n' => $n];
|
||||
}
|
||||
|
||||
/**
|
||||
* 임계 도달 ETA(분). cur 현재값, thr 임계, slope_per_min 분당 변화, horizon_min 지평.
|
||||
* 임계 방향으로 수렴 중이고 지평 내일 때만 양수 분, 아니면 null.
|
||||
*/
|
||||
function threshold_eta_min(float $cur, float $thr, float $slope_per_min, float $horizon_min): ?float
|
||||
{
|
||||
if (abs($slope_per_min) < 1e-6) {
|
||||
return null; // 정체 → 도달 안 함
|
||||
}
|
||||
$eta = ($thr - $cur) / $slope_per_min;
|
||||
if ($eta <= 0.0 || $eta > $horizon_min) {
|
||||
return null; // 이미 넘음/멀어짐 or 지평 밖
|
||||
}
|
||||
return $eta;
|
||||
}
|
||||
|
||||
/**
|
||||
* 여유 상태 3단계. margin = 현재값과 임계 거리(임계 안 넘었으면 양수).
|
||||
* over: 이미 임계 초과, near: warn_margin 이내 근접, safe: 여유.
|
||||
*/
|
||||
function headroom_state(float $margin, float $warn_margin): string
|
||||
{
|
||||
if ($margin <= 0.0) {
|
||||
return 'over';
|
||||
}
|
||||
if ($margin <= $warn_margin) {
|
||||
return 'near';
|
||||
}
|
||||
return 'safe';
|
||||
}
|
||||
|
||||
/**
|
||||
* points 를 [분(minutes-from-first), value] 로 변환(회귀 x축). null 값은 제외.
|
||||
* recent = [['t'=>epoch_sec, vkey=>?float], ...] 오래된→최신.
|
||||
*/
|
||||
function _series_minutes(array $recent, string $vkey): array
|
||||
{
|
||||
$pts = [];
|
||||
$t0 = null;
|
||||
foreach ($recent as $r) {
|
||||
$v = $r[$vkey] ?? null;
|
||||
if ($v === null) {
|
||||
continue;
|
||||
}
|
||||
$t = (int)($r['t'] ?? 0);
|
||||
if ($t0 === null) {
|
||||
$t0 = $t;
|
||||
}
|
||||
$pts[] = [($t - $t0) / 60.0, (float)$v];
|
||||
}
|
||||
return $pts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 온습도 센서의 예보 계산.
|
||||
* $temp/$hum: 현재값(없으면 null). $recent: [['t'=>epoch,'temp'=>,'hum'=>],...] 오래된→최신.
|
||||
* $c: 튜닝/임계 배열 — temp_high, temp_low, rh_high, rh_low(각 null=비활성),
|
||||
* dew_margin(>0 시 결로 판정), warn_temp, warn_rh, horizon_min, min_points, min_r2,
|
||||
* rate_temp_10, rate_rh_10.
|
||||
* 반환: ['headroom'=>[...], 'trend'=>[...], 'dew'=>..|null, 'rate'=>..].
|
||||
*/
|
||||
function forecast_metric(?float $temp, ?float $hum, array $recent, array $c): array
|
||||
{
|
||||
$out = ['headroom' => [], 'trend' => ['temp' => null, 'hum' => null], 'dew' => null, 'rate' => []];
|
||||
|
||||
// ── 임계 여유 ──────────────────────────────────────────────
|
||||
$band = [
|
||||
['key' => 'high_temp', 'label' => '고온', 'cur' => $temp, 'thr' => $c['temp_high'] ?? null, 'unit' => '℃', 'above' => true, 'warn' => $c['warn_temp'] ?? 2.0],
|
||||
['key' => 'low_temp', 'label' => '저온', 'cur' => $temp, 'thr' => $c['temp_low'] ?? null, 'unit' => '℃', 'above' => false, 'warn' => $c['warn_temp'] ?? 2.0],
|
||||
['key' => 'high_humidity', 'label' => '고습', 'cur' => $hum, 'thr' => $c['rh_high'] ?? null, 'unit' => '%', 'above' => true, 'warn' => $c['warn_rh'] ?? 8.0],
|
||||
['key' => 'low_humidity', 'label' => '저습', 'cur' => $hum, 'thr' => $c['rh_low'] ?? null, 'unit' => '%', 'above' => false, 'warn' => $c['warn_rh'] ?? 8.0],
|
||||
];
|
||||
foreach ($band as $b) {
|
||||
if ($b['cur'] === null || $b['thr'] === null) {
|
||||
continue; // 값 없거나 임계 비활성
|
||||
}
|
||||
// above=고온/고습: margin = thr - cur (양수면 아직 여유). below=저온/저습: margin = cur - thr.
|
||||
$margin = $b['above'] ? ($b['thr'] - $b['cur']) : ($b['cur'] - $b['thr']);
|
||||
$out['headroom'][] = [
|
||||
'key' => $b['key'], 'label' => $b['label'], 'thr' => $b['thr'], 'unit' => $b['unit'],
|
||||
'margin' => round($margin, 1), 'state' => headroom_state($margin, (float)$b['warn']),
|
||||
];
|
||||
}
|
||||
|
||||
// ── 추세 ETA + 변화율 ──────────────────────────────────────
|
||||
$minPts = (int)($c['min_points'] ?? 6);
|
||||
$minR2 = (float)($c['min_r2'] ?? 0.5);
|
||||
$horizon = (float)($c['horizon_min'] ?? 180.0);
|
||||
foreach (['temp' => [$temp, $c['temp_high'] ?? null, $c['temp_low'] ?? null],
|
||||
'hum' => [$hum, $c['rh_high'] ?? null, $c['rh_low'] ?? null]] as $vk => $vv) {
|
||||
[$cur, $thrHigh, $thrLow] = $vv;
|
||||
if ($cur === null) {
|
||||
continue;
|
||||
}
|
||||
$series = _series_minutes($recent, $vk);
|
||||
if (count($series) < $minPts) {
|
||||
continue;
|
||||
}
|
||||
$tr = linear_trend($series);
|
||||
if ($tr === null) {
|
||||
continue;
|
||||
}
|
||||
$slope = $tr['slope']; // 분당
|
||||
$dir = abs($slope) < 1e-4 ? 'flat' : ($slope > 0 ? 'up' : 'down');
|
||||
$eta = null;
|
||||
if ($tr['r2'] >= $minR2) {
|
||||
if ($slope > 0 && $thrHigh !== null) {
|
||||
$m = threshold_eta_min($cur, (float)$thrHigh, $slope, $horizon);
|
||||
if ($m !== null) { $eta = ['key' => $vk === 'temp' ? 'high_temp' : 'high_humidity', 'min' => (int)round($m)]; }
|
||||
} elseif ($slope < 0 && $thrLow !== null) {
|
||||
$m = threshold_eta_min($cur, (float)$thrLow, $slope, $horizon);
|
||||
if ($m !== null) { $eta = ['key' => $vk === 'temp' ? 'low_temp' : 'low_humidity', 'min' => (int)round($m)]; }
|
||||
}
|
||||
}
|
||||
$out['trend'][$vk] = ['slope_min' => round($slope, 3), 'dir' => $dir, 'r2' => round($tr['r2'], 2), 'eta' => $eta];
|
||||
|
||||
// ── 변화율 급변(최근 ~10분 환산) ──
|
||||
$rateKey = $vk === 'temp' ? 'rate_temp_10' : 'rate_rh_10';
|
||||
$rateThr = (float)($c[$rateKey] ?? ($vk === 'temp' ? 3.0 : 10.0));
|
||||
$per10 = $slope * 10.0;
|
||||
if (abs($per10) >= $rateThr) {
|
||||
$out['rate'][$vk] = ['per10' => round($per10, 1), 'spike' => $slope > 0 ? 'rise' : 'drop'];
|
||||
}
|
||||
}
|
||||
|
||||
// ── 이슬점 결로 마진 ───────────────────────────────────────
|
||||
$td = dew_point_c($temp, $hum);
|
||||
if ($td !== null && $temp !== null) {
|
||||
$margin = $temp - $td;
|
||||
$dewCfg = (float)($c['dew_margin'] ?? 0.0);
|
||||
$out['dew'] = [
|
||||
'td' => round($td, 1),
|
||||
'margin' => round($margin, 1),
|
||||
'risk' => ($dewCfg > 0.0 && $margin <= $dewCfg),
|
||||
'enabled' => ($dewCfg > 0.0),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 신선도/오프라인 임박. last_seen_ts=epoch(없으면 null), now=epoch, timeout=하트비트 초.
|
||||
* warn_ratio: timeout 대비 이 비율 넘으면 임박(기본 0.5).
|
||||
* 반환 ['elapsed_sec','elapsed_min','offline','imminent'].
|
||||
*/
|
||||
function forecast_freshness(?int $last_seen_ts, int $now, int $timeout, float $warn_ratio = 0.5): array
|
||||
{
|
||||
if ($last_seen_ts === null || $timeout <= 0) {
|
||||
return ['elapsed_sec' => null, 'elapsed_min' => null, 'offline' => false, 'imminent' => false];
|
||||
}
|
||||
$elapsed = max(0, $now - $last_seen_ts);
|
||||
return [
|
||||
'elapsed_sec' => $elapsed,
|
||||
'elapsed_min' => (int)round($elapsed / 60),
|
||||
'offline' => $elapsed >= $timeout,
|
||||
'imminent' => ($elapsed < $timeout) && ($elapsed >= $timeout * $warn_ratio),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 대응 섹션용 위험 항목 목록. sensor(sensor_id·name·location·is_leak) + forecast(metric) + freshness 를 받아
|
||||
* [['condition'=>, 'severity'=>'critical|warn', 'sensor_id'=>, 'name'=>, 'location'=>, 'detail'=>, 'eta_min'=>?], ...] 정규화.
|
||||
* 이미 발생한 것(critical) + 임박한 것(warn) 모두 포함. 대시보드가 이 목록으로 플레이북/담당자를 붙인다.
|
||||
*/
|
||||
function risk_items(array $sensor, array $forecast, array $freshness): array
|
||||
{
|
||||
$risks = [];
|
||||
$meta = ['sensor_id' => $sensor['sensor_id'] ?? 0, 'name' => $sensor['name'] ?? '', 'location' => $sensor['location'] ?? ''];
|
||||
|
||||
// 오프라인(critical) / 임박(warn)
|
||||
if (!empty($freshness['offline'])) {
|
||||
$risks[] = $meta + ['condition' => 'offline', 'severity' => 'critical', 'detail' => "마지막 수신 {$freshness['elapsed_min']}분 전", 'eta_min' => null];
|
||||
} elseif (!empty($freshness['imminent'])) {
|
||||
$risks[] = $meta + ['condition' => 'offline', 'severity' => 'warn', 'detail' => "수신 지연 {$freshness['elapsed_min']}분 — 오프라인 임박", 'eta_min' => null];
|
||||
}
|
||||
|
||||
// 누수(critical)
|
||||
if (!empty($sensor['is_leak'])) {
|
||||
$risks[] = $meta + ['condition' => 'leak', 'severity' => 'critical', 'detail' => '누수 감지', 'eta_min' => null];
|
||||
}
|
||||
|
||||
// 임계 초과(critical) / 근접(warn) — headroom 기반
|
||||
foreach ($forecast['headroom'] ?? [] as $h) {
|
||||
if ($h['state'] === 'over') {
|
||||
$risks[] = $meta + ['condition' => $h['key'], 'severity' => 'critical', 'detail' => "{$h['label']} 임계 초과 (현재 여유 {$h['margin']}{$h['unit']})", 'eta_min' => null];
|
||||
} elseif ($h['state'] === 'near') {
|
||||
$eta = null;
|
||||
foreach (['temp', 'hum'] as $vk) {
|
||||
$e = $forecast['trend'][$vk]['eta'] ?? null;
|
||||
if ($e && $e['key'] === $h['key']) { $eta = $e['min']; break; }
|
||||
}
|
||||
$detail = "{$h['label']} 임계 근접 (여유 {$h['margin']}{$h['unit']})" . ($eta !== null ? " · 현 추세 ~{$eta}분 뒤 도달" : '');
|
||||
$risks[] = $meta + ['condition' => $h['key'], 'severity' => 'warn', 'detail' => $detail, 'eta_min' => $eta];
|
||||
}
|
||||
}
|
||||
|
||||
// 결로(warn) — config 활성 시
|
||||
if (!empty($forecast['dew']['risk'])) {
|
||||
$risks[] = $meta + ['condition' => 'condensation', 'severity' => 'warn', 'detail' => "결로 위험 (이슬점 마진 {$forecast['dew']['margin']}℃)", 'eta_min' => null];
|
||||
}
|
||||
|
||||
return $risks;
|
||||
}
|
||||
|
|
@ -162,27 +162,62 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$is_locked && !$configuration_erro
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>로그인 - 누수감지 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title>
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<style>
|
||||
/* 로그인 페이지 전용: 카드 화면 중앙 정렬 (그 외 크롬/폼 스타일은 app.css) */
|
||||
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: var(--sp-4); }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #333;
|
||||
display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
.login-box { background: #fff; border-radius: 12px; padding: 40px 36px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,.1); width: 100%; max-width: 400px; }
|
||||
.login-box h1 { font-size: 1.4rem; color: #1a56db; margin-bottom: 8px; text-align: center; }
|
||||
.login-box p.sub { font-size: .85rem; color: #64748b; text-align: center; margin-bottom: 28px; }
|
||||
.form-group { margin-bottom: 18px; }
|
||||
.form-group label { display: block; font-size: .85rem; color: #475569; margin-bottom: 6px; font-weight: 600; }
|
||||
.form-group input { width: 100%; padding: 10px 14px; border: 1px solid #d1d5db; border-radius: 8px;
|
||||
font-size: .95rem; outline: none; transition: border-color .2s; }
|
||||
.form-group input:focus { border-color: #1a56db; box-shadow: 0 0 0 3px rgba(26,86,219,.1); }
|
||||
.btn { width: 100%; padding: 12px; background: #1a56db; color: #fff; border: none;
|
||||
border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer;
|
||||
transition: background .2s; }
|
||||
.btn:hover { background: #1347b8; }
|
||||
.btn:disabled { background: #94a3b8; cursor: not-allowed; }
|
||||
.error { background: #fef2f2; color: #991b1b; padding: 10px 14px; border-radius: 8px;
|
||||
font-size: .85rem; margin-bottom: 18px; border: 1px solid #fecaca; }
|
||||
.expired { background: #fefce8; color: #854d0e; padding: 10px 14px; border-radius: 8px;
|
||||
font-size: .85rem; margin-bottom: 18px; border: 1px solid #fde68a; }
|
||||
|
||||
/* --- 폰 (≤480px) --- */
|
||||
@media (max-width: 480px) {
|
||||
.login-box { padding: 28px 20px; margin: 0 12px; }
|
||||
.login-box h1 { font-size: 1.2rem; }
|
||||
.login-box p.sub { font-size: .8rem; margin-bottom: 20px; }
|
||||
.form-group input { font-size: .9rem; padding: 9px 12px; }
|
||||
.btn { font-size: .95rem; padding: 11px; }
|
||||
}
|
||||
|
||||
/* --- 초소형 (≤360px) --- */
|
||||
@media (max-width: 360px) {
|
||||
.login-box { padding: 24px 16px; margin: 0 8px; }
|
||||
.login-box h1 { font-size: 1.1rem; }
|
||||
.form-group input { font-size: .85rem; padding: 8px 10px; }
|
||||
.btn { font-size: .9rem; padding: 10px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="login-box">
|
||||
<div class="login-box">
|
||||
<h1>누수감지 모니터링 <?= htmlspecialchars(APP_VERSION) ?></h1>
|
||||
<p class="sub">대시보드에 접근하려면 로그인하세요.</p>
|
||||
|
||||
<?php if ($expired): ?>
|
||||
<div class="expired" role="status">세션이 만료되었습니다. 다시 로그인해주세요.</div>
|
||||
<div class="expired">세션이 만료되었습니다. 다시 로그인해주세요.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="error" role="alert"><?= htmlspecialchars($error) ?></div>
|
||||
<div class="error"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($configuration_error): ?>
|
||||
<div class="error" role="alert">관리자 비밀번호 해시 또는 MFA 인증앱 비밀키가 설정되지 않았습니다. config.local.php의 ADMIN_PASSWORD_HASH와 ADMIN_TOTP_SECRET을 확인하세요.</div>
|
||||
<div class="error">관리자 비밀번호 해시 또는 MFA 인증앱 비밀키가 설정되지 않았습니다. config.local.php의 ADMIN_PASSWORD_HASH와 ADMIN_TOTP_SECRET을 확인하세요.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="POST" action="">
|
||||
|
|
@ -205,6 +240,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$is_locked && !$configuration_erro
|
|||
</div>
|
||||
<button type="submit" class="btn" <?= ($is_locked || $configuration_error) ? 'disabled' : '' ?>>로그인</button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
<?php
|
||||
// =============================================================================
|
||||
// monthly_report.php - 월간 운영 보고서 (온습도 + 누수 통합)
|
||||
// monthly_report.php - 월간 운영 보고서 (SHT30 온습도 전용)
|
||||
//
|
||||
// 집계 범위: 온습도 임계 경보(고온/저온/고습/저습), 누수 감지/복구, 정상복귀,
|
||||
// 장비 오프라인/복구, SMS 실패, 측정 건수, 평균/최저/최고 온도·습도.
|
||||
// 데이터 출처: leak_sms_log(message 태그 prefix 기준 — config.php sms_summary_select_sql),
|
||||
// leak_sensor_metric(온습도 측정 이력).
|
||||
// 센서 표시명/위치는 대시보드·SMS 와 동일하게 sensor_label()(SENSOR_LABELS 오버라이드,
|
||||
// config.local.php)로 통일한다. 센서별 집계는 sensor_id 로만 묶어(과거 보고명이 달라도
|
||||
// 한 센서가 여러 행으로 쪼개지지 않게) 현재 관리주체 라벨로 표시한다.
|
||||
// 집계 범위: 온습도 임계 경보(고온/저온/고습/저습), 정상복귀, 장비 오프라인/복구,
|
||||
// SMS 실패, 측정 건수, 평균/최저/최고 온도·습도.
|
||||
// 데이터 출처: sms_log(message 태그 prefix 기준), sensor_metric(온습도 측정 이력).
|
||||
// =============================================================================
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
|
@ -26,11 +22,180 @@ if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
|
|||
}
|
||||
admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(대시보드 외 페이지에서도 강제)
|
||||
|
||||
// ── 집계 데이터 계층(단일 출처) ──────────────────────────────────────────────
|
||||
// 헬퍼(month_range/round_or_null/fmt_num/csv_cell)와 $summary/$sensor_rows 및 인쇄
|
||||
// 뷰 전용 파생(일별 추이·누수 사고·verdict)을 이 include 가 산출한다. HTML 조회 뷰,
|
||||
// CSV 다운로드, PDF 인쇄 뷰가 동일 소스를 공유한다(집계 중복 0 — 회귀 방지).
|
||||
require_once __DIR__ . '/monthly_report_data.php';
|
||||
function month_range(string $month): array {
|
||||
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
|
||||
$month = date('Y-m');
|
||||
}
|
||||
$start = DateTime::createFromFormat('Y-m-d H:i:s', $month . '-01 00:00:00');
|
||||
if (!$start) {
|
||||
$start = new DateTime('first day of this month 00:00:00');
|
||||
}
|
||||
$end = clone $start;
|
||||
$end->modify('first day of next month');
|
||||
return [$month, $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s')];
|
||||
}
|
||||
|
||||
// SMS 메시지 태그(prefix) → 집계 키 매핑. 태그 문자열은 config.php 의 SMS_TAG_*
|
||||
// 단일 출처 상수에서 가져온다(sms_send.php 가 발송 시 붙이는 태그와 자동 일치).
|
||||
function report_sms_tags(): array {
|
||||
return [
|
||||
'high_temp' => SMS_TAG_HIGH_TEMP,
|
||||
'low_temp' => SMS_TAG_LOW_TEMP,
|
||||
'high_humidity' => SMS_TAG_HIGH_HUMIDITY,
|
||||
'low_humidity' => SMS_TAG_LOW_HUMIDITY,
|
||||
'recovery' => SMS_TAG_RECOVERY,
|
||||
'device_offline' => SMS_TAG_DEVICE_OFFLINE,
|
||||
'device_online' => SMS_TAG_DEVICE_ONLINE,
|
||||
];
|
||||
}
|
||||
|
||||
function round_or_null($value, int $precision = 1): ?float {
|
||||
return $value !== null ? round((float)$value, $precision) : null;
|
||||
}
|
||||
|
||||
function fmt_num(?float $value, string $unit = ''): string {
|
||||
if ($value === null) {
|
||||
return '-';
|
||||
}
|
||||
return htmlspecialchars(rtrim(rtrim(number_format($value, 1), '0'), '.')) . $unit;
|
||||
}
|
||||
|
||||
// CSV 수식 주입(formula injection) 방지: 자유 텍스트 셀이 =,+,-,@,탭,CR 로 시작하면
|
||||
// 선행 작은따옴표로 무력화한다(Excel/Sheets 가 수식으로 실행하지 않도록).
|
||||
// 숫자 셀(음수 온도 등)은 호출하지 않는다 — 값이 깨지지 않도록.
|
||||
function csv_cell($value): string {
|
||||
$s = (string)$value;
|
||||
if ($s !== '' && in_array($s[0], ['=', '+', '-', '@', "\t", "\r"], true)) {
|
||||
return "'" . $s;
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
|
||||
$db = get_db();
|
||||
[$month, $start_at, $end_at] = month_range($_GET['month'] ?? date('Y-m'));
|
||||
$report_generated_at = date('Y-m-d H:i:s');
|
||||
$report_scope = '온습도 임계 경보, 장비 오프라인, SMS';
|
||||
|
||||
// ── 월간 요약: SMS 태그별 건수 + 실패 건수 ───────────────────────────────────
|
||||
$stmt = $db->prepare("
|
||||
SELECT
|
||||
" . sms_summary_select_sql() . "
|
||||
FROM sms_log
|
||||
WHERE sent_at >= :start_at AND sent_at < :end_at
|
||||
");
|
||||
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
|
||||
$row = $stmt->fetch() ?: [];
|
||||
|
||||
$summary = [
|
||||
'high_temp' => (int)($row['high_temp'] ?? 0),
|
||||
'low_temp' => (int)($row['low_temp'] ?? 0),
|
||||
'high_humidity' => (int)($row['high_humidity'] ?? 0),
|
||||
'low_humidity' => (int)($row['low_humidity'] ?? 0),
|
||||
'recovery' => (int)($row['recovery'] ?? 0),
|
||||
'device_offline' => (int)($row['device_offline'] ?? 0),
|
||||
'device_online' => (int)($row['device_online'] ?? 0),
|
||||
'sms_fail' => (int)($row['sms_fail'] ?? 0),
|
||||
'metrics' => 0,
|
||||
'avg_temp' => null,
|
||||
'min_temp' => null,
|
||||
'max_temp' => null,
|
||||
'avg_humidity' => null,
|
||||
'min_humidity' => null,
|
||||
'max_humidity' => null,
|
||||
];
|
||||
$summary['threshold_alerts'] = $summary['high_temp'] + $summary['low_temp']
|
||||
+ $summary['high_humidity'] + $summary['low_humidity'];
|
||||
|
||||
// ── 월간 요약: 측정 건수 + 평균/최저/최고 온도·습도 ───────────────────────────
|
||||
$stmt = $db->prepare("
|
||||
SELECT
|
||||
COUNT(*) AS metrics,
|
||||
AVG(temperature_c) AS avg_temp,
|
||||
MIN(temperature_c) AS min_temp,
|
||||
MAX(temperature_c) AS max_temp,
|
||||
AVG(humidity_percent) AS avg_humidity,
|
||||
MIN(humidity_percent) AS min_humidity,
|
||||
MAX(humidity_percent) AS max_humidity
|
||||
FROM sensor_metric
|
||||
WHERE measured_at >= :start_at AND measured_at < :end_at
|
||||
");
|
||||
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
|
||||
$row = $stmt->fetch() ?: [];
|
||||
$summary['metrics'] = (int)($row['metrics'] ?? 0);
|
||||
$summary['avg_temp'] = round_or_null($row['avg_temp'] ?? null);
|
||||
$summary['min_temp'] = round_or_null($row['min_temp'] ?? null);
|
||||
$summary['max_temp'] = round_or_null($row['max_temp'] ?? null);
|
||||
$summary['avg_humidity'] = round_or_null($row['avg_humidity'] ?? null);
|
||||
$summary['min_humidity'] = round_or_null($row['min_humidity'] ?? null);
|
||||
$summary['max_humidity'] = round_or_null($row['max_humidity'] ?? null);
|
||||
|
||||
// ── 센서별 SMS 태그별 건수 + 실패 건수 ───────────────────────────────────────
|
||||
$stmt = $db->prepare("
|
||||
SELECT sensor_id, sensor_name,
|
||||
" . sms_summary_select_sql() . "
|
||||
FROM sms_log
|
||||
WHERE sent_at >= :start_at AND sent_at < :end_at
|
||||
GROUP BY sensor_id, sensor_name
|
||||
");
|
||||
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
|
||||
$sms_by_sensor = [];
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$sms_by_sensor[(int)$r['sensor_id']] = $r;
|
||||
}
|
||||
|
||||
// ── 센서별 측정 집계(온습도) ─────────────────────────────────────────────────
|
||||
$stmt = $db->prepare("
|
||||
SELECT sensor_id, sensor_name, device_location,
|
||||
COUNT(*) AS metrics,
|
||||
AVG(temperature_c) AS avg_temp,
|
||||
MIN(temperature_c) AS min_temp,
|
||||
MAX(temperature_c) AS max_temp,
|
||||
AVG(humidity_percent) AS avg_humidity,
|
||||
MIN(humidity_percent) AS min_humidity,
|
||||
MAX(humidity_percent) AS max_humidity,
|
||||
MAX(measured_at) AS last_measured_at
|
||||
FROM sensor_metric
|
||||
WHERE measured_at >= :start_at AND measured_at < :end_at
|
||||
GROUP BY sensor_id, sensor_name, device_location
|
||||
");
|
||||
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
|
||||
$metric_by_sensor = [];
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$metric_by_sensor[(int)$r['sensor_id']] = $r;
|
||||
}
|
||||
|
||||
// ── 센서별 행 병합(측정 + SMS) ───────────────────────────────────────────────
|
||||
$sensor_rows = [];
|
||||
foreach (array_unique(array_merge(array_keys($metric_by_sensor), array_keys($sms_by_sensor))) as $sid) {
|
||||
$m = $metric_by_sensor[$sid] ?? [];
|
||||
$s = $sms_by_sensor[$sid] ?? [];
|
||||
$sensor_rows[] = [
|
||||
'sensor_id' => $sid,
|
||||
'sensor_name' => $m['sensor_name'] ?? $s['sensor_name'] ?? ('센서' . $sid),
|
||||
'device_location' => $m['device_location'] ?? '',
|
||||
'high_temp' => (int)($s['high_temp'] ?? 0),
|
||||
'low_temp' => (int)($s['low_temp'] ?? 0),
|
||||
'high_humidity' => (int)($s['high_humidity'] ?? 0),
|
||||
'low_humidity' => (int)($s['low_humidity'] ?? 0),
|
||||
'threshold_alerts' => (int)($s['high_temp'] ?? 0) + (int)($s['low_temp'] ?? 0)
|
||||
+ (int)($s['high_humidity'] ?? 0) + (int)($s['low_humidity'] ?? 0),
|
||||
'recovery' => (int)($s['recovery'] ?? 0),
|
||||
'device_offline' => (int)($s['device_offline'] ?? 0),
|
||||
'device_online' => (int)($s['device_online'] ?? 0),
|
||||
'sms_fail' => (int)($s['sms_fail'] ?? 0),
|
||||
'metrics' => (int)($m['metrics'] ?? 0),
|
||||
'avg_temp' => round_or_null($m['avg_temp'] ?? null),
|
||||
'min_temp' => round_or_null($m['min_temp'] ?? null),
|
||||
'max_temp' => round_or_null($m['max_temp'] ?? null),
|
||||
'avg_humidity' => round_or_null($m['avg_humidity'] ?? null),
|
||||
'min_humidity' => round_or_null($m['min_humidity'] ?? null),
|
||||
'max_humidity' => round_or_null($m['max_humidity'] ?? null),
|
||||
'last_measured_at' => $m['last_measured_at'] ?? '',
|
||||
];
|
||||
}
|
||||
usort($sensor_rows, function ($a, $b) {
|
||||
return [$b['threshold_alerts'], $a['sensor_id']] <=> [$a['threshold_alerts'], $b['sensor_id']];
|
||||
});
|
||||
|
||||
// ── CSV 다운로드 ─────────────────────────────────────────────────────────────
|
||||
if (($_GET['format'] ?? '') === 'csv') {
|
||||
|
|
@ -45,8 +210,6 @@ if (($_GET['format'] ?? '') === 'csv') {
|
|||
fputcsv($out, ['low_temp_alerts', $summary['low_temp']]);
|
||||
fputcsv($out, ['high_humidity_alerts', $summary['high_humidity']]);
|
||||
fputcsv($out, ['low_humidity_alerts', $summary['low_humidity']]);
|
||||
fputcsv($out, ['leak_alert_sms', $summary['leak_alert']]);
|
||||
fputcsv($out, ['leak_recovery_sms', $summary['leak_recovery']]);
|
||||
fputcsv($out, ['recovery_sms', $summary['recovery']]);
|
||||
fputcsv($out, ['device_offline_sms', $summary['device_offline']]);
|
||||
fputcsv($out, ['device_online_sms', $summary['device_online']]);
|
||||
|
|
@ -62,7 +225,6 @@ if (($_GET['format'] ?? '') === 'csv') {
|
|||
fputcsv($out, [
|
||||
'sensor_id', 'sensor_name', 'location',
|
||||
'high_temp', 'low_temp', 'high_humidity', 'low_humidity', 'threshold_alerts',
|
||||
'leak_alert', 'leak_recovery',
|
||||
'recovery', 'device_offline', 'device_online', 'sms_fail',
|
||||
'metrics', 'avg_temp_c', 'min_temp_c', 'max_temp_c',
|
||||
'avg_humidity_percent', 'min_humidity_percent', 'max_humidity_percent',
|
||||
|
|
@ -72,7 +234,6 @@ if (($_GET['format'] ?? '') === 'csv') {
|
|||
fputcsv($out, [
|
||||
$s['sensor_id'], csv_cell($s['sensor_name']), csv_cell($s['device_location']),
|
||||
$s['high_temp'], $s['low_temp'], $s['high_humidity'], $s['low_humidity'], $s['threshold_alerts'],
|
||||
$s['leak_alert'], $s['leak_recovery'],
|
||||
$s['recovery'], $s['device_offline'], $s['device_online'], $s['sms_fail'],
|
||||
$s['metrics'],
|
||||
$s['avg_temp'] ?? '', $s['min_temp'] ?? '', $s['max_temp'] ?? '',
|
||||
|
|
@ -83,15 +244,6 @@ if (($_GET['format'] ?? '') === 'csv') {
|
|||
fclose($out);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── PDF 저장 전용 A4 문서 뷰 ─────────────────────────────────────────────────
|
||||
// 다크 대시보드(app.css)를 그대로 인쇄하던 단순 프린트지를 대체한다. 화이트 A4 전문
|
||||
// 보고서(표지·경영요약·지표·추이·지점별·누수사고·서명)를 렌더한다. 집계는 위 include 가
|
||||
// 이미 산출했으므로 여기서 뷰만 그린다(CSV·HTML 조회 뷰는 불변 — 회귀 없음).
|
||||
if (($_GET['format'] ?? '') === 'print') {
|
||||
require __DIR__ . '/monthly_report_print.php';
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
|
@ -99,37 +251,52 @@ if (($_GET['format'] ?? '') === 'print') {
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>월간 운영 보고서 - <?= htmlspecialchars($month) ?></title>
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<style>
|
||||
/* 월간 리포트 전용: 조회 툴바(공유 app.css 미포함). 값은 전부 토큰 — 생 hex 없음 */
|
||||
.toolbar {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3);
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r);
|
||||
box-shadow: var(--shadow); padding: var(--sp-3) var(--sp-4); margin-bottom: var(--sp-5);
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; }
|
||||
header { background: #1a56db; color: #fff; padding: 16px 24px; display:flex; justify-content:space-between; align-items:center; gap:12px; }
|
||||
header h1 { margin:0; font-size:1.15rem; }
|
||||
header a { color:#fff; text-decoration:none; background:rgba(255,255,255,.15); padding:7px 12px; border-radius:6px; font-size:.85rem; }
|
||||
.container { max-width: 1060px; margin: 24px auto; padding: 0 16px; }
|
||||
.toolbar { background:#fff; border-radius:8px; padding:14px 16px; box-shadow:0 1px 4px rgba(0,0,0,.1); display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:18px; }
|
||||
input, button, .btn { border:1px solid #cbd5e1; border-radius:6px; padding:8px 10px; font-size:.9rem; }
|
||||
button, .btn { background:#1a56db; color:#fff; border-color:#1a56db; text-decoration:none; cursor:pointer; }
|
||||
.toolbar label { display:flex; align-items:center; gap:6px; }
|
||||
.grid { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:12px; margin-bottom:18px; }
|
||||
.card { background:#fff; border-radius:8px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.1); }
|
||||
.card span { color:#64748b; font-size:.78rem; display:block; margin-bottom:5px; }
|
||||
.card strong { font-size:1.35rem; }
|
||||
table { width:100%; border-collapse:collapse; background:#fff; border-radius:8px; overflow:hidden; box-shadow:0 1px 4px rgba(0,0,0,.1); margin-bottom:22px; }
|
||||
th, td { padding:9px 12px; border-bottom:1px solid #e2e8f0; text-align:left; font-size:.84rem; }
|
||||
th { background:#f8fafc; color:#64748b; }
|
||||
h2 { font-size:1rem; color:#475569; margin:22px 0 10px; }
|
||||
.table-wrap { overflow-x:auto; -webkit-overflow-scrolling:touch; margin-bottom:22px; border-radius:8px; }
|
||||
.table-wrap table { margin-bottom:0; min-width:860px; }
|
||||
@media print {
|
||||
body { background:#fff; }
|
||||
header, .toolbar { display:none; }
|
||||
.container { margin:0; max-width:none; }
|
||||
.card, table { box-shadow:none; }
|
||||
}
|
||||
.toolbar label { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.toolbar input[type="month"] {
|
||||
padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--r-sm);
|
||||
font-family: inherit; font-size: .875rem; color: var(--ink); background: var(--surface);
|
||||
@media (max-width:760px) { .grid { grid-template-columns: repeat(2, minmax(0,1fr)); } header { flex-direction:column; align-items:flex-start; } }
|
||||
@media (max-width:520px) {
|
||||
.container { margin:16px auto; padding:0 10px; }
|
||||
header { padding:14px 16px; }
|
||||
header h1 { font-size:1rem; }
|
||||
.toolbar { display:grid; grid-template-columns:1fr; }
|
||||
.toolbar label, .toolbar input, .toolbar button, .toolbar .btn { width:100%; }
|
||||
.toolbar label { display:grid; gap:6px; }
|
||||
.grid { grid-template-columns: repeat(2, minmax(0,1fr)); gap:8px; }
|
||||
.card { padding:12px; }
|
||||
.card strong { font-size:1.05rem; }
|
||||
}
|
||||
.toolbar button, .toolbar .tool-btn {
|
||||
padding: 8px 14px; border: 1px solid var(--primary); border-radius: var(--r-sm);
|
||||
font-size: .875rem; font-weight: 700; cursor: pointer; text-decoration: none;
|
||||
background: var(--primary); color: var(--on-primary);
|
||||
}
|
||||
.toolbar button:hover, .toolbar .tool-btn:hover {
|
||||
background: var(--primary-600); border-color: var(--primary-600);
|
||||
}
|
||||
/* 표 빈 상태 셀(가운데 정렬·뮤트) — app.css 는 블록형 .empty-note 만 제공 */
|
||||
.no-data { text-align: center; color: var(--ink-3); }
|
||||
/* 툴바는 페이지 전용 요소 → app.css print 규칙에 없어 중복이 아님 */
|
||||
@media print { .toolbar { display: none; } }
|
||||
@media (max-width:360px) { .grid { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><?= htmlspecialchars($month) ?> 월간 운영 보고서</h1>
|
||||
<div class="top-actions">
|
||||
<div>
|
||||
<a href="dashboard.php">대시보드</a>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -137,29 +304,27 @@ if (($_GET['format'] ?? '') === 'print') {
|
|||
<form class="toolbar" method="GET">
|
||||
<label>월 <input type="month" name="month" value="<?= htmlspecialchars($month) ?>"></label>
|
||||
<button type="submit">조회</button>
|
||||
<a class="tool-btn" href="?month=<?= htmlspecialchars($month) ?>&format=print" target="_blank" rel="noopener">PDF 보고서</a>
|
||||
<a class="tool-btn" href="?month=<?= htmlspecialchars($month) ?>&format=csv">CSV 다운로드</a>
|
||||
<a class="btn" href="?month=<?= htmlspecialchars($month) ?>&format=csv">CSV 다운로드</a>
|
||||
<button type="button" onclick="window.print()">인쇄/PDF</button>
|
||||
</form>
|
||||
|
||||
<section class="panel">
|
||||
<p class="caption">보고서 생성</p>
|
||||
<strong><?= htmlspecialchars($report_generated_at) ?></strong>
|
||||
<p class="caption">범위: <?= htmlspecialchars($report_scope) ?></p>
|
||||
<section class="card" style="margin-bottom:18px">
|
||||
<span>보고서 생성</span>
|
||||
<strong style="font-size:1rem"><?= htmlspecialchars($report_generated_at) ?></strong>
|
||||
<p style="color:#64748b;font-size:.84rem;margin:8px 0 0">범위: <?= htmlspecialchars($report_scope) ?></p>
|
||||
</section>
|
||||
|
||||
<h2>월간 요약</h2>
|
||||
<section class="report-grid">
|
||||
<div class="report-stat"><span>임계 경보(합계)</span><strong><?= $summary['threshold_alerts'] ?>건</strong></div>
|
||||
<div class="report-stat"><span>고온/저온 경보</span><strong><?= $summary['high_temp'] ?>/<?= $summary['low_temp'] ?>건</strong></div>
|
||||
<div class="report-stat"><span>고습/저습 경보</span><strong><?= $summary['high_humidity'] ?>/<?= $summary['low_humidity'] ?>건</strong></div>
|
||||
<div class="report-stat"><span>누수 감지/복구</span><strong class="<?= $summary['leak_alert'] > 0 ? 'err-color' : '' ?>"><?= $summary['leak_alert'] ?>/<?= $summary['leak_recovery'] ?>건</strong></div>
|
||||
<div class="report-stat"><span>정상복귀</span><strong><?= $summary['recovery'] ?>건</strong></div>
|
||||
<div class="report-stat"><span>장비 오프라인/복구</span><strong><?= $summary['device_offline'] ?>/<?= $summary['device_online'] ?>건</strong></div>
|
||||
<div class="report-stat"><span>SMS 실패</span><strong><?= $summary['sms_fail'] ?>건</strong></div>
|
||||
<div class="report-stat"><span>측정 건수</span><strong><?= $summary['metrics'] ?>건</strong></div>
|
||||
<div class="report-stat"><span>평균 온도/습도</span><strong><?= fmt_num($summary['avg_temp'], '℃') ?> / <?= fmt_num($summary['avg_humidity'], '%') ?></strong></div>
|
||||
<div class="report-stat"><span>온도 최저/최고</span><strong><?= fmt_num($summary['min_temp'], '℃') ?> / <?= fmt_num($summary['max_temp'], '℃') ?></strong></div>
|
||||
<div class="report-stat"><span>습도 최저/최고</span><strong><?= fmt_num($summary['min_humidity'], '%') ?> / <?= fmt_num($summary['max_humidity'], '%') ?></strong></div>
|
||||
<section class="grid">
|
||||
<div class="card"><span>임계 경보(합계)</span><strong><?= $summary['threshold_alerts'] ?>건</strong></div>
|
||||
<div class="card"><span>고온/저온 경보</span><strong><?= $summary['high_temp'] ?>/<?= $summary['low_temp'] ?>건</strong></div>
|
||||
<div class="card"><span>고습/저습 경보</span><strong><?= $summary['high_humidity'] ?>/<?= $summary['low_humidity'] ?>건</strong></div>
|
||||
<div class="card"><span>정상복귀</span><strong><?= $summary['recovery'] ?>건</strong></div>
|
||||
<div class="card"><span>장비 오프라인/복구</span><strong><?= $summary['device_offline'] ?>/<?= $summary['device_online'] ?>건</strong></div>
|
||||
<div class="card"><span>SMS 실패</span><strong><?= $summary['sms_fail'] ?>건</strong></div>
|
||||
<div class="card"><span>측정 건수</span><strong><?= $summary['metrics'] ?>건</strong></div>
|
||||
<div class="card"><span>평균 온도/습도</span><strong><?= fmt_num($summary['avg_temp'], '℃') ?> / <?= fmt_num($summary['avg_humidity'], '%') ?></strong></div>
|
||||
<div class="card"><span>온도 최저/최고</span><strong><?= fmt_num($summary['min_temp'], '℃') ?> / <?= fmt_num($summary['max_temp'], '℃') ?></strong></div>
|
||||
<div class="card"><span>습도 최저/최고</span><strong><?= fmt_num($summary['min_humidity'], '%') ?> / <?= fmt_num($summary['max_humidity'], '%') ?></strong></div>
|
||||
</section>
|
||||
|
||||
<h2>센서별 요약</h2>
|
||||
|
|
@ -167,7 +332,7 @@ if (($_GET['format'] ?? '') === 'print') {
|
|||
<table>
|
||||
<thead><tr>
|
||||
<th>센서</th><th>위치</th>
|
||||
<th>임계 경보</th><th>고온/저온</th><th>고습/저습</th><th>누수 감지/복구</th>
|
||||
<th>임계 경보</th><th>고온/저온</th><th>고습/저습</th>
|
||||
<th>정상복귀</th><th>오프라인/복구</th><th>SMS 실패</th>
|
||||
<th>측정</th><th>평균 온도/습도</th><th>온도 최저/최고</th><th>습도 최저/최고</th>
|
||||
<th>최근 측정</th>
|
||||
|
|
@ -180,7 +345,6 @@ if (($_GET['format'] ?? '') === 'print') {
|
|||
<td><?= $s['threshold_alerts'] ?></td>
|
||||
<td><?= $s['high_temp'] ?>/<?= $s['low_temp'] ?></td>
|
||||
<td><?= $s['high_humidity'] ?>/<?= $s['low_humidity'] ?></td>
|
||||
<td class="<?= $s['leak_alert'] > 0 ? 'err-color' : '' ?>"><?= $s['leak_alert'] ?>/<?= $s['leak_recovery'] ?></td>
|
||||
<td><?= $s['recovery'] ?></td>
|
||||
<td><?= $s['device_offline'] ?>/<?= $s['device_online'] ?></td>
|
||||
<td><?= $s['sms_fail'] ?></td>
|
||||
|
|
@ -192,7 +356,7 @@ if (($_GET['format'] ?? '') === 'print') {
|
|||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($sensor_rows)): ?>
|
||||
<tr><td colspan="14" class="no-data">해당 월 측정·경보 내역이 없습니다.</td></tr>
|
||||
<tr><td colspan="13" style="text-align:center;color:#94a3b8">해당 월 측정·경보 내역이 없습니다.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -1,396 +0,0 @@
|
|||
<?php
|
||||
// =============================================================================
|
||||
// monthly_report_data.php - 월간 보고서 공유 데이터 계층 (단일 출처)
|
||||
//
|
||||
// monthly_report.php(HTML 조회 + CSV 다운로드)와 monthly_report_print.php(PDF
|
||||
// 저장용 A4 문서 뷰)가 함께 require 한다. 집계 로직을 이 한 곳에만 두어 두 뷰가
|
||||
// 동일한 값을 쓰게 하고(중복 = 회귀 위험), 인쇄 뷰 전용의 일별 추이·누수 사고·
|
||||
// 판정(verdict) 파생을 여기서 함께 산출한다.
|
||||
//
|
||||
// 산출 변수(require 후 호출부에서 사용):
|
||||
// $db, $month, $start_at, $end_at, $report_generated_at, $report_scope,
|
||||
// $summary, $sensor_rows,
|
||||
// $cal_days, $daily_temp, $daily_hum, $daily_metric_alerts, $daily_leak_alerts,
|
||||
// $incidents, $has_incident_table, $unrecovered_leak,
|
||||
// $verdict, $verdict_label, $verdict_reason
|
||||
//
|
||||
// 제공 헬퍼: month_range(), round_or_null(), fmt_num(), csv_cell(),
|
||||
// report_table_exists()
|
||||
// =============================================================================
|
||||
|
||||
// include 전용 — 웹 직접 접근 차단(.htaccess 와 이중 방어). 호출부(monthly_report.php)
|
||||
// 가 세션 인증 후 require 한다. 익명 요청 컨텍스트에서 단독 실행되지 않게 막는다.
|
||||
if (PHP_SAPI !== 'cli'
|
||||
&& basename((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === basename(__FILE__)) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
// ─── 공용 헬퍼 (기존 monthly_report.php 에서 이관 — 로직 무변경) ────────────────
|
||||
if (!function_exists('month_range')) {
|
||||
function month_range(string $month): array {
|
||||
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
|
||||
$month = date('Y-m');
|
||||
}
|
||||
$start = DateTime::createFromFormat('Y-m-d H:i:s', $month . '-01 00:00:00');
|
||||
if (!$start) {
|
||||
$start = new DateTime('first day of this month 00:00:00');
|
||||
}
|
||||
$end = clone $start;
|
||||
$end->modify('first day of next month');
|
||||
return [$month, $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s')];
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('round_or_null')) {
|
||||
function round_or_null($value, int $precision = 1): ?float {
|
||||
return $value !== null ? round((float)$value, $precision) : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('fmt_num')) {
|
||||
function fmt_num(?float $value, string $unit = ''): string {
|
||||
if ($value === null) {
|
||||
return '-';
|
||||
}
|
||||
return htmlspecialchars(rtrim(rtrim(number_format($value, 1), '0'), '.')) . $unit;
|
||||
}
|
||||
}
|
||||
|
||||
// CSV 수식 주입(formula injection) 방지: 자유 텍스트 셀이 =,+,-,@,탭,CR 로 시작하면
|
||||
// 선행 작은따옴표로 무력화한다(Excel/Sheets 가 수식으로 실행하지 않도록).
|
||||
// 숫자 셀(음수 온도 등)은 호출하지 않는다 — 값이 깨지지 않도록.
|
||||
if (!function_exists('csv_cell')) {
|
||||
function csv_cell($value): string {
|
||||
$s = (string)$value;
|
||||
if ($s !== '' && in_array($s[0], ['=', '+', '-', '@', "\t", "\r"], true)) {
|
||||
return "'" . $s;
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 존재 확인(정보스키마) — dashboard_data.php 의 table_exists() 와 동일 취지지만
|
||||
// 그 파일은 include 시 부작용(전역 쿼리) 위험이 있어 결합을 피한다. 부재 시 graceful
|
||||
// degrade(누수 사고 섹션·판정 폴백). 요청 내 static 캐시로 재조회 방지.
|
||||
if (!function_exists('report_table_exists')) {
|
||||
function report_table_exists(PDO $db, string $table): bool {
|
||||
static $cache = [];
|
||||
if (array_key_exists($table, $cache)) {
|
||||
return $cache[$table];
|
||||
}
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table
|
||||
");
|
||||
$stmt->execute([':table' => $table]);
|
||||
return $cache[$table] = (int)$stmt->fetchColumn() > 0;
|
||||
} catch (Throwable $e) {
|
||||
return $cache[$table] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$db = get_db();
|
||||
[$month, $start_at, $end_at] = month_range($_GET['month'] ?? date('Y-m'));
|
||||
$report_generated_at = date('Y-m-d H:i:s');
|
||||
$report_scope = '온습도 임계 경보, 누수 감지, 장비 오프라인, SMS';
|
||||
|
||||
// ── 월간 요약: SMS 태그별 건수 + 실패 건수 + 총 발송 건수 ─────────────────────
|
||||
$stmt = $db->prepare("
|
||||
SELECT
|
||||
" . sms_summary_select_sql() . ",
|
||||
COUNT(*) AS total_sms
|
||||
FROM leak_sms_log
|
||||
WHERE sent_at >= :start_at AND sent_at < :end_at
|
||||
");
|
||||
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
|
||||
$row = $stmt->fetch() ?: [];
|
||||
|
||||
$summary = [
|
||||
'high_temp' => (int)($row['high_temp'] ?? 0),
|
||||
'low_temp' => (int)($row['low_temp'] ?? 0),
|
||||
'high_humidity' => (int)($row['high_humidity'] ?? 0),
|
||||
'low_humidity' => (int)($row['low_humidity'] ?? 0),
|
||||
'recovery' => (int)($row['recovery'] ?? 0),
|
||||
'device_offline' => (int)($row['device_offline'] ?? 0),
|
||||
'device_online' => (int)($row['device_online'] ?? 0),
|
||||
'leak_alert' => (int)($row['leak_alert'] ?? 0),
|
||||
'leak_recovery' => (int)($row['leak_recovery'] ?? 0),
|
||||
'sms_fail' => (int)($row['sms_fail'] ?? 0),
|
||||
'total_sms' => (int)($row['total_sms'] ?? 0),
|
||||
'metrics' => 0,
|
||||
'avg_temp' => null,
|
||||
'min_temp' => null,
|
||||
'max_temp' => null,
|
||||
'avg_humidity' => null,
|
||||
'min_humidity' => null,
|
||||
'max_humidity' => null,
|
||||
];
|
||||
$summary['threshold_alerts'] = $summary['high_temp'] + $summary['low_temp']
|
||||
+ $summary['high_humidity'] + $summary['low_humidity'];
|
||||
|
||||
// ── 월간 요약: 측정 건수 + 평균/최저/최고 온도·습도 ───────────────────────────
|
||||
$stmt = $db->prepare("
|
||||
SELECT
|
||||
COUNT(*) AS metrics,
|
||||
AVG(temperature_c) AS avg_temp,
|
||||
MIN(temperature_c) AS min_temp,
|
||||
MAX(temperature_c) AS max_temp,
|
||||
AVG(humidity_percent) AS avg_humidity,
|
||||
MIN(humidity_percent) AS min_humidity,
|
||||
MAX(humidity_percent) AS max_humidity
|
||||
FROM leak_sensor_metric
|
||||
WHERE measured_at >= :start_at AND measured_at < :end_at
|
||||
");
|
||||
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
|
||||
$row = $stmt->fetch() ?: [];
|
||||
$summary['metrics'] = (int)($row['metrics'] ?? 0);
|
||||
$summary['avg_temp'] = round_or_null($row['avg_temp'] ?? null);
|
||||
$summary['min_temp'] = round_or_null($row['min_temp'] ?? null);
|
||||
$summary['max_temp'] = round_or_null($row['max_temp'] ?? null);
|
||||
$summary['avg_humidity'] = round_or_null($row['avg_humidity'] ?? null);
|
||||
$summary['min_humidity'] = round_or_null($row['min_humidity'] ?? null);
|
||||
$summary['max_humidity'] = round_or_null($row['max_humidity'] ?? null);
|
||||
|
||||
// SMS 신뢰성(성공률, %) — 총 발송 대비 성공 비율. 발송이 없으면 100%로 본다.
|
||||
$summary['sms_reliability_pct'] = $summary['total_sms'] > 0
|
||||
? (int)round(($summary['total_sms'] - $summary['sms_fail']) / $summary['total_sms'] * 100)
|
||||
: 100;
|
||||
|
||||
// ── 센서별 SMS 태그별 건수 + 실패 건수 ───────────────────────────────────────
|
||||
// sensor_id 로만 묶는다(보고명 sensor_name 은 시기별로 다를 수 있어 GROUP 키에서 제외 —
|
||||
// 한 센서가 여러 행으로 쪼개지는 것 방지). 표시명은 아래에서 sensor_label() 로 결정.
|
||||
$stmt = $db->prepare("
|
||||
SELECT sensor_id, MAX(sensor_name) AS sensor_name,
|
||||
" . sms_summary_select_sql() . "
|
||||
FROM leak_sms_log
|
||||
WHERE sent_at >= :start_at AND sent_at < :end_at
|
||||
GROUP BY sensor_id
|
||||
");
|
||||
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
|
||||
$sms_by_sensor = [];
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$sms_by_sensor[(int)$r['sensor_id']] = $r;
|
||||
}
|
||||
|
||||
// ── 센서별 측정 집계(온습도) ─────────────────────────────────────────────────
|
||||
// 마찬가지로 sensor_id 로만 묶고, 보고명/위치는 최신 대표값(MAX)만 폴백용으로 가져온다.
|
||||
$stmt = $db->prepare("
|
||||
SELECT sensor_id,
|
||||
MAX(sensor_name) AS sensor_name,
|
||||
MAX(device_location) AS device_location,
|
||||
COUNT(*) AS metrics,
|
||||
AVG(temperature_c) AS avg_temp,
|
||||
MIN(temperature_c) AS min_temp,
|
||||
MAX(temperature_c) AS max_temp,
|
||||
AVG(humidity_percent) AS avg_humidity,
|
||||
MIN(humidity_percent) AS min_humidity,
|
||||
MAX(humidity_percent) AS max_humidity,
|
||||
MAX(measured_at) AS last_measured_at
|
||||
FROM leak_sensor_metric
|
||||
WHERE measured_at >= :start_at AND measured_at < :end_at
|
||||
GROUP BY sensor_id
|
||||
");
|
||||
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
|
||||
$metric_by_sensor = [];
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$metric_by_sensor[(int)$r['sensor_id']] = $r;
|
||||
}
|
||||
|
||||
// ── 센서별 행 병합(측정 + SMS) ───────────────────────────────────────────────
|
||||
$sensor_rows = [];
|
||||
foreach (array_unique(array_merge(array_keys($metric_by_sensor), array_keys($sms_by_sensor))) as $sid) {
|
||||
$m = $metric_by_sensor[$sid] ?? [];
|
||||
$s = $sms_by_sensor[$sid] ?? [];
|
||||
// 표시명/위치: SENSOR_LABELS(현재 관리주체) 우선, 없으면 보고 대표값 폴백 — 대시보드·SMS 와 동일
|
||||
[$loc, $name] = sensor_label((int)$sid, $m['device_location'] ?? null, $m['sensor_name'] ?? $s['sensor_name'] ?? null);
|
||||
$threshold_alerts = (int)($s['high_temp'] ?? 0) + (int)($s['low_temp'] ?? 0)
|
||||
+ (int)($s['high_humidity'] ?? 0) + (int)($s['low_humidity'] ?? 0);
|
||||
$leak_alert = (int)($s['leak_alert'] ?? 0);
|
||||
$sensor_rows[] = [
|
||||
'sensor_id' => $sid,
|
||||
'sensor_name' => $name,
|
||||
'device_location' => $loc,
|
||||
'high_temp' => (int)($s['high_temp'] ?? 0),
|
||||
'low_temp' => (int)($s['low_temp'] ?? 0),
|
||||
'high_humidity' => (int)($s['high_humidity'] ?? 0),
|
||||
'low_humidity' => (int)($s['low_humidity'] ?? 0),
|
||||
'threshold_alerts' => $threshold_alerts,
|
||||
'leak_alert' => $leak_alert,
|
||||
'leak_recovery' => (int)($s['leak_recovery'] ?? 0),
|
||||
'recovery' => (int)($s['recovery'] ?? 0),
|
||||
'device_offline' => (int)($s['device_offline'] ?? 0),
|
||||
'device_online' => (int)($s['device_online'] ?? 0),
|
||||
'sms_fail' => (int)($s['sms_fail'] ?? 0),
|
||||
'metrics' => (int)($m['metrics'] ?? 0),
|
||||
'avg_temp' => round_or_null($m['avg_temp'] ?? null),
|
||||
'min_temp' => round_or_null($m['min_temp'] ?? null),
|
||||
'max_temp' => round_or_null($m['max_temp'] ?? null),
|
||||
'avg_humidity' => round_or_null($m['avg_humidity'] ?? null),
|
||||
'min_humidity' => round_or_null($m['min_humidity'] ?? null),
|
||||
'max_humidity' => round_or_null($m['max_humidity'] ?? null),
|
||||
'last_measured_at' => $m['last_measured_at'] ?? '',
|
||||
'alert_sort' => $threshold_alerts + $leak_alert, // 정렬용(온습도 경보 + 누수 감지)
|
||||
];
|
||||
}
|
||||
usort($sensor_rows, function ($a, $b) {
|
||||
return [$b['alert_sort'], $a['sensor_id']] <=> [$a['alert_sort'], $b['sensor_id']];
|
||||
});
|
||||
|
||||
// ─── 인쇄 뷰 전용 파생: 당월 일자 축(cal_days) ────────────────────────────────
|
||||
// 일별 차트가 28~31 가변 말일을 정확히 다루도록 1일~말일 전체 축을 만든다. 결측일은
|
||||
// null 로 남겨(추이 라인 끊김) 왜곡을 막는다.
|
||||
$cal_days = [];
|
||||
try {
|
||||
$cursor = new DateTime($start_at);
|
||||
$stop = new DateTime($end_at);
|
||||
while ($cursor < $stop) {
|
||||
$cal_days[] = $cursor->format('Y-m-d');
|
||||
$cursor->modify('+1 day');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$cal_days = [];
|
||||
}
|
||||
|
||||
// ─── 쿼리A: 일별 평균 온·습도 (strip-chart 용) ────────────────────────────────
|
||||
$daily_temp = array_fill_keys($cal_days, null);
|
||||
$daily_hum = array_fill_keys($cal_days, null);
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT DATE(measured_at) AS d,
|
||||
AVG(temperature_c) AS avg_t,
|
||||
AVG(humidity_percent) AS avg_h,
|
||||
COUNT(*) AS c
|
||||
FROM leak_sensor_metric
|
||||
WHERE measured_at >= :start_at AND measured_at < :end_at
|
||||
GROUP BY DATE(measured_at)
|
||||
ORDER BY d
|
||||
");
|
||||
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$d = (string)$r['d'];
|
||||
if (!array_key_exists($d, $daily_temp)) { continue; }
|
||||
$daily_temp[$d] = $r['avg_t'] !== null ? round((float)$r['avg_t'], 1) : null;
|
||||
$daily_hum[$d] = $r['avg_h'] !== null ? round((float)$r['avg_h'], 1) : null;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// 스키마 편차(컬럼 부재 등) → 빈 추이로 graceful degrade
|
||||
$daily_temp = array_fill_keys($cal_days, null);
|
||||
$daily_hum = array_fill_keys($cal_days, null);
|
||||
}
|
||||
|
||||
// ─── 쿼리B: 일별 경보 발생(온습도 임계 + 누수 감지) — 스택 막대용 ─────────────
|
||||
$daily_metric_alerts = array_fill_keys($cal_days, 0);
|
||||
$daily_leak_alerts = array_fill_keys($cal_days, 0);
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT DATE(measured_at) AS d, COUNT(*) AS c
|
||||
FROM leak_sensor_metric
|
||||
WHERE measured_at >= :start_at AND measured_at < :end_at
|
||||
AND metric_status <> 'normal'
|
||||
GROUP BY DATE(measured_at)
|
||||
");
|
||||
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$d = (string)$r['d'];
|
||||
if (array_key_exists($d, $daily_metric_alerts)) {
|
||||
$daily_metric_alerts[$d] = (int)$r['c'];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$daily_metric_alerts = array_fill_keys($cal_days, 0);
|
||||
}
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT DATE(sensor_time) AS d, COUNT(*) AS c
|
||||
FROM leak_sensor_log
|
||||
WHERE sensor_time >= :start_at AND sensor_time < :end_at
|
||||
AND event_type = 'leak_alert'
|
||||
GROUP BY DATE(sensor_time)
|
||||
");
|
||||
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$d = (string)$r['d'];
|
||||
if (array_key_exists($d, $daily_leak_alerts)) {
|
||||
$daily_leak_alerts[$d] = (int)$r['c'];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$daily_leak_alerts = array_fill_keys($cal_days, 0);
|
||||
}
|
||||
|
||||
// ─── 쿼리C: 월중 누수 사고 로그(leak_incident, 존재 시) ───────────────────────
|
||||
$has_incident_table = report_table_exists($db, 'leak_incident');
|
||||
$incidents = [];
|
||||
if ($has_incident_table) {
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT id, sensor_id, sensor_name, device_location, started_at, recovered_at
|
||||
FROM leak_incident
|
||||
WHERE started_at >= :start_at AND started_at < :end_at
|
||||
ORDER BY started_at ASC
|
||||
");
|
||||
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
[$loc, $name] = sensor_label((int)$r['sensor_id'], $r['device_location'] ?? null, $r['sensor_name'] ?? null);
|
||||
$started = $r['started_at'] ?? null;
|
||||
$recovered = $r['recovered_at'] ?? null;
|
||||
// 지속시간(분) — 미복구(null)는 진행중
|
||||
$duration_min = null;
|
||||
if ($started !== null && $recovered !== null) {
|
||||
$ds = strtotime($started); $de = strtotime($recovered);
|
||||
if ($ds !== false && $de !== false && $de >= $ds) {
|
||||
$duration_min = (int)round(($de - $ds) / 60);
|
||||
}
|
||||
}
|
||||
$incidents[] = [
|
||||
'id' => (int)$r['id'],
|
||||
'sensor_id' => (int)$r['sensor_id'],
|
||||
'sensor_name' => $name,
|
||||
'device_location' => $loc,
|
||||
'started_at' => $started,
|
||||
'recovered_at' => $recovered,
|
||||
'duration_min' => $duration_min,
|
||||
'ongoing' => $recovered === null,
|
||||
];
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$incidents = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 판정(verdict) 파생 — 씰·경영 요약이 공유하는 결정론적 규칙 ───────────────
|
||||
// 미복구 누수: leak_incident 가 있으면 당월 사고 중 recovered_at IS NULL 개수,
|
||||
// 없으면 [누수감지] SMS − [누수복구] SMS 차이로 폴백(일관성).
|
||||
if ($has_incident_table) {
|
||||
$unrecovered_leak = 0;
|
||||
foreach ($incidents as $inc) {
|
||||
if ($inc['ongoing']) { $unrecovered_leak++; }
|
||||
}
|
||||
} else {
|
||||
$unrecovered_leak = max(0, $summary['leak_alert'] - $summary['leak_recovery']);
|
||||
}
|
||||
|
||||
if ($unrecovered_leak > 0 || $summary['sms_fail'] > 0) {
|
||||
$verdict = 'alert';
|
||||
$verdict_label = '경보';
|
||||
$bits = [];
|
||||
if ($unrecovered_leak > 0) { $bits[] = "미복구 누수 {$unrecovered_leak}건"; }
|
||||
if ($summary['sms_fail'] > 0) { $bits[] = "SMS 실패 {$summary['sms_fail']}건"; }
|
||||
$verdict_reason = implode(' · ', $bits);
|
||||
} elseif ($summary['threshold_alerts'] > 0 || $summary['device_offline'] > 0) {
|
||||
$verdict = 'warn';
|
||||
$verdict_label = '주의';
|
||||
$bits = [];
|
||||
if ($summary['threshold_alerts'] > 0) { $bits[] = "임계 경보 {$summary['threshold_alerts']}건"; }
|
||||
if ($summary['device_offline'] > 0) { $bits[] = "오프라인 {$summary['device_offline']}건"; }
|
||||
$verdict_reason = implode(' · ', $bits);
|
||||
} else {
|
||||
$verdict = 'ok';
|
||||
$verdict_label = '안정';
|
||||
$verdict_reason = '미복구 누수·SMS 실패·임계 경보·오프라인 없음';
|
||||
}
|
||||
|
|
@ -1,340 +0,0 @@
|
|||
<?php
|
||||
// =============================================================================
|
||||
// monthly_report_print.php - 월간 운영 보고서 · PDF 저장 전용 A4 문서 뷰
|
||||
//
|
||||
// monthly_report.php 의 ?format=print 분기에서 require 된다(단독 진입점 아님).
|
||||
// 집계 변수는 monthly_report_data.php 가 이미 산출해 이 스코프에 있다:
|
||||
// $month, $start_at, $end_at, $report_generated_at, $report_scope, $summary,
|
||||
// $sensor_rows, $cal_days, $daily_temp, $daily_hum, $daily_metric_alerts,
|
||||
// $daily_leak_alerts, $incidents, $has_incident_table, $unrecovered_leak,
|
||||
// $verdict, $verdict_label, $verdict_reason
|
||||
//
|
||||
// app.css(다크 셸)는 링크하지 않는다 — assets/report-print.css 만 링크해 다크 토큰과
|
||||
// 격리한다. 차트는 report_charts.php 의 인라인 SVG 서버렌더(폐쇄망: 외부 참조 0).
|
||||
// 모든 동적 텍스트는 htmlspecialchars, 숫자는 fmt_num() 으로 출력한다.
|
||||
// =============================================================================
|
||||
|
||||
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단(monthly_report.php 경유 전용)
|
||||
|
||||
require_once __DIR__ . '/report_charts.php';
|
||||
|
||||
// ── 표시용 파생값 ─────────────────────────────────────────────────────────────
|
||||
$h = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$month_dot = str_replace('-', '.', $month); // 2026-06 → 2026.06
|
||||
$year_n = (int)substr($month, 0, 4);
|
||||
$month_n = (int)substr($month, 5, 2);
|
||||
$doc_no = 'ENV-' . $month; // 문서번호
|
||||
|
||||
try {
|
||||
$dt_last = new DateTime($end_at);
|
||||
$dt_last->modify('-1 day');
|
||||
$period_end = $dt_last->format('Y-m-d');
|
||||
} catch (Throwable $e) {
|
||||
$period_end = substr($end_at, 0, 10);
|
||||
}
|
||||
$period_start = substr($start_at, 0, 10);
|
||||
|
||||
// 관제 대상(설정 기준)
|
||||
$metric_ids = defined('METRIC_SENSOR_IDS') ? METRIC_SENSOR_IDS : [];
|
||||
$leak_ids = defined('LEAK_SENSOR_IDS') ? LEAK_SENSOR_IDS : [];
|
||||
$scope_total = count(array_unique(array_merge($metric_ids, $leak_ids)));
|
||||
|
||||
$total_alerts = $summary['threshold_alerts'] + $summary['leak_alert'];
|
||||
$incident_count = $has_incident_table ? count($incidents) : $summary['leak_alert'];
|
||||
|
||||
// verdict 색 클래스(요약 카드/문장)
|
||||
$verdict_cls = 'is-' . ($verdict === 'alert' ? 'alert' : ($verdict === 'warn' ? 'warn' : 'ok'));
|
||||
$verdict_color = rc_verdict_color($verdict);
|
||||
|
||||
// 자동 총평 한 줄
|
||||
$auto_summary = sprintf(
|
||||
'당월 온습도 측정 %s건 · 임계 경보 %d건 · 누수 감지 %d건이 집계되었으며, SMS 발송 신뢰성은 %d%%(%d건 중 실패 %d건)입니다.',
|
||||
number_format($summary['metrics']),
|
||||
$summary['threshold_alerts'],
|
||||
$summary['leak_alert'],
|
||||
$summary['sms_reliability_pct'],
|
||||
$summary['total_sms'],
|
||||
$summary['sms_fail']
|
||||
);
|
||||
|
||||
// 센서(지점)별 판정 — 데이터 층 verdict 규칙과 동일(미복구 누수는 월 단위라 여기선 누수감지로 근사)
|
||||
$sensor_verdict = function (array $s): array {
|
||||
if ($s['leak_alert'] > 0 || $s['sms_fail'] > 0) { return ['alert', '경보']; }
|
||||
if ($s['threshold_alerts'] > 0 || $s['device_offline'] > 0) { return ['warn', '주의']; }
|
||||
return ['ok', '안정'];
|
||||
};
|
||||
|
||||
// 임계 기준 문자열
|
||||
$rh_low_txt = (METRIC_RH_LOW >= 0) ? (rc_fmt((float)METRIC_RH_LOW) . '%') : '비활성';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>정보시설 관제 월간 운영 보고서 - <?= $h($month) ?></title>
|
||||
<link rel="stylesheet" href="assets/report-print.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="report-canvas">
|
||||
|
||||
<!-- 화면 전용 안내 + 툴바(인쇄 시 자동 숨김) -->
|
||||
<div class="screen-toolbar screen-only">
|
||||
<a class="ghost" href="monthly_report.php?month=<?= $h($month) ?>">← 조회 화면</a>
|
||||
<button type="button" onclick="window.print()">PDF로 저장 / 인쇄</button>
|
||||
<a class="ghost" href="dashboard.php">대시보드</a>
|
||||
</div>
|
||||
<div class="print-note screen-only">
|
||||
이 화면은 <b>PDF 저장 전용 A4 보고서</b> 미리보기입니다. <b>Ctrl+P</b>(⌘P) → 대상 <b>“PDF로 저장”</b>,
|
||||
용지 <b>A4</b>, 여백 <b>기본</b>, <b>“배경 그래픽”</b> 옵션을 켜 주세요. 페이지 번호가 필요하면 인쇄
|
||||
대화상자의 <b>머리글/바닥글</b> 옵션을 사용하세요(폐쇄망 정책상 문서 내 자동 페이지번호는 미탑재).
|
||||
</div>
|
||||
|
||||
<!-- 러닝 헤더(매 장 반복) -->
|
||||
<div class="run-header">
|
||||
<span><b>정보시설 관제 월간 운영 보고서</b> · <?= $h($month_dot) ?></span>
|
||||
<span class="cls">대외비 (CONFIDENTIAL)</span>
|
||||
</div>
|
||||
<!-- 러닝 푸터(매 장 반복 — 문서번호·생성일시로 추적성 확보) -->
|
||||
<div class="run-footer">
|
||||
<span>문서번호 <?= $h($doc_no) ?> · 생성 <?= $h($report_generated_at) ?></span>
|
||||
<span>서버실·문서고 온습도/누수 관제 시스템 <?= $h(APP_VERSION) ?></span>
|
||||
</div>
|
||||
|
||||
<div class="report-paper">
|
||||
<div class="report-body">
|
||||
|
||||
<!-- ═══════════ 1. 표지 마스트헤드 ═══════════ -->
|
||||
<section class="cover">
|
||||
<div class="cover-seal">
|
||||
<?= render_verdict_seal($verdict, $verdict_label) ?>
|
||||
<div class="seal-cap">종합 판정</div>
|
||||
</div>
|
||||
<div class="cover-eyebrow">정보시설 관제 · 온습도 / 누수</div>
|
||||
<h1 class="cover-title">정보시설 관제<br>월간 운영 보고서</h1>
|
||||
<p class="cover-sub">서버실 · 문서고 온습도 및 누수 모니터링 / SMS 경보 운영 결과</p>
|
||||
|
||||
<div class="cover-month"><?= $h($month_dot) ?></div>
|
||||
<div class="cover-period">대상 기간 <?= $h($period_start) ?> ~ <?= $h($period_end) ?> (<?= $year_n ?>년 <?= $month_n ?>월)</div>
|
||||
|
||||
<div class="cover-scope">
|
||||
<div class="scope-item">
|
||||
<div class="k">관제 지점</div>
|
||||
<div class="v"><?= (int)$scope_total ?>개소</div>
|
||||
<div class="t">온습도 <?= count($metric_ids) ?> · 누수 <?= count($leak_ids) ?></div>
|
||||
</div>
|
||||
<div class="scope-item">
|
||||
<div class="k">정보기반</div>
|
||||
<div class="v">서버실(안)</div>
|
||||
<div class="t">온습도 계측</div>
|
||||
</div>
|
||||
<div class="scope-item">
|
||||
<div class="k">조직혁신 · 고객센터</div>
|
||||
<div class="v">문서고 · 서버실(밖)</div>
|
||||
<div class="t">누수 감시</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-control">
|
||||
<div><div class="dk">문서번호</div><div class="dv"><?= $h($doc_no) ?></div></div>
|
||||
<div><div class="dk">생성 일시</div><div class="dv"><?= $h($report_generated_at) ?></div></div>
|
||||
<div><div class="dk">문서 분류</div><div class="dv cls">대외비</div></div>
|
||||
<div><div class="dk">보고 범위</div><div class="dv"><?= $h($report_scope) ?></div></div>
|
||||
<div><div class="dk">데이터 출처</div><div class="dv">leak_sms_log · leak_sensor_metric</div></div>
|
||||
<div><div class="dk">시스템</div><div class="dv">관제 <?= $h(APP_VERSION) ?></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ 2. 경영 요약 ═══════════ -->
|
||||
<section class="section page-break">
|
||||
<div class="section-title"><span class="no">01</span> 경영 요약 <span class="en">EXECUTIVE SUMMARY</span></div>
|
||||
|
||||
<div class="kpi-row">
|
||||
<?php
|
||||
// SMS 신뢰성 게이지·카드는 종합 판정과 독립적으로 지표 자체 기준 색을 쓴다
|
||||
// (누수 등으로 판정이 경보여도 SMS 전달이 100%면 네이비로 정확히 표현).
|
||||
$rel_pct = (int)$summary['sms_reliability_pct'];
|
||||
$rel_fail = (int)$summary['sms_fail'];
|
||||
$rel_color = rc_reliability_color($rel_pct, $rel_fail);
|
||||
$rel_class = $rel_fail <= 0 ? 'is-ok' : 'is-warn';
|
||||
?>
|
||||
<div class="kpi-card <?= $rel_class ?>">
|
||||
<div class="kpi-gauge-wrap">
|
||||
<?= render_uptime_gauge($rel_pct, $rel_color, $rel_pct . '%', 'SMS 신뢰성') ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card <?= $total_alerts > 0 ? 'is-warn' : 'is-ok' ?>">
|
||||
<div class="kpi-k">총 경보</div>
|
||||
<div class="kpi-v"><?= (int)$total_alerts ?></div>
|
||||
<div class="kpi-s">임계 <?= (int)$summary['threshold_alerts'] ?> · 누수 <?= (int)$summary['leak_alert'] ?></div>
|
||||
</div>
|
||||
<div class="kpi-card <?= $summary['leak_alert'] > 0 ? 'is-alert' : 'is-ok' ?>">
|
||||
<div class="kpi-k">누수 감지</div>
|
||||
<div class="kpi-v"><?= (int)$summary['leak_alert'] ?></div>
|
||||
<div class="kpi-s"><?= $has_incident_table ? ('사고 ' . (int)$incident_count . '건 · 미복구 ' . (int)$unrecovered_leak) : ('복구 ' . (int)$summary['leak_recovery'] . '건') ?></div>
|
||||
</div>
|
||||
<div class="kpi-card is-ok">
|
||||
<div class="kpi-k">측정 건수</div>
|
||||
<div class="kpi-v"><?= number_format($summary['metrics']) ?></div>
|
||||
<div class="kpi-s">평균 <?= fmt_num($summary['avg_temp'], '℃') ?> / <?= fmt_num($summary['avg_humidity'], '%') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exec-summary">
|
||||
<div class="verdict-line <?= $verdict_cls ?>">종합 판정: <?= $h($verdict_label) ?><?= $verdict_reason !== '' ? ' — ' . $h($verdict_reason) : '' ?></div>
|
||||
<p><?= $h($auto_summary) ?></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ 3. 월간 지표 상세 ═══════════ -->
|
||||
<section class="section">
|
||||
<div class="section-title"><span class="no">02</span> 월간 지표 상세 <span class="en">MONTHLY METRICS</span></div>
|
||||
<div class="metric-grid">
|
||||
<div class="metric-block">
|
||||
<h3>경보 · 이벤트</h3>
|
||||
<table class="def">
|
||||
<tr><th>고온 경보</th><td><?= (int)$summary['high_temp'] ?>건</td></tr>
|
||||
<tr><th>저온 경보</th><td><?= (int)$summary['low_temp'] ?>건</td></tr>
|
||||
<tr><th>고습 경보</th><td><?= (int)$summary['high_humidity'] ?>건</td></tr>
|
||||
<tr><th>저습 경보</th><td><?= (int)$summary['low_humidity'] ?>건</td></tr>
|
||||
<tr><th>임계 경보 합계</th><td><?= (int)$summary['threshold_alerts'] ?>건</td></tr>
|
||||
<tr><th>누수 감지 / 복구</th><td class="<?= $summary['leak_alert'] > 0 ? 'is-alert' : '' ?>"><?= (int)$summary['leak_alert'] ?> / <?= (int)$summary['leak_recovery'] ?>건</td></tr>
|
||||
<tr><th>정상 복귀</th><td><?= (int)$summary['recovery'] ?>건</td></tr>
|
||||
<tr><th>장비 오프라인 / 복구</th><td><?= (int)$summary['device_offline'] ?> / <?= (int)$summary['device_online'] ?>건</td></tr>
|
||||
<tr><th>SMS 실패 / 총 발송</th><td class="<?= $summary['sms_fail'] > 0 ? 'is-alert' : '' ?>"><?= (int)$summary['sms_fail'] ?> / <?= (int)$summary['total_sms'] ?>건</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="metric-block">
|
||||
<h3>온습도 측정</h3>
|
||||
<table class="def">
|
||||
<tr><th>측정 건수</th><td><?= number_format($summary['metrics']) ?>건</td></tr>
|
||||
<tr><th>평균 온도</th><td><?= fmt_num($summary['avg_temp'], '℃') ?></td></tr>
|
||||
<tr><th>온도 최저 / 최고</th><td><?= fmt_num($summary['min_temp'], '℃') ?> / <?= fmt_num($summary['max_temp'], '℃') ?></td></tr>
|
||||
<tr><th>평균 습도</th><td><?= fmt_num($summary['avg_humidity'], '%') ?></td></tr>
|
||||
<tr><th>습도 최저 / 최고</th><td><?= fmt_num($summary['min_humidity'], '%') ?> / <?= fmt_num($summary['max_humidity'], '%') ?></td></tr>
|
||||
<tr><th>SMS 신뢰성</th><td><?= (int)$summary['sms_reliability_pct'] ?>%</td></tr>
|
||||
<tr><th>고온 / 저온 임계</th><td><?= $h(rc_fmt((float)METRIC_TEMP_HIGH_C)) ?>℃ / <?= $h(rc_fmt((float)METRIC_TEMP_LOW_C)) ?>℃</td></tr>
|
||||
<tr><th>고습 / 저습 임계</th><td><?= $h(rc_fmt((float)METRIC_RH_HIGH)) ?>% / <?= $h($rh_low_txt) ?></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ 4. 월중 추이(차트) ═══════════ -->
|
||||
<section class="section">
|
||||
<div class="section-title"><span class="no">03</span> 월중 추이 <span class="en">DAILY TRENDS</span></div>
|
||||
|
||||
<div class="chart-panel">
|
||||
<h3>일별 평균 온·습도</h3>
|
||||
<p class="chart-cap">임계 밴드(고온 적 · 안전 녹 · 저온 청)를 배경에 인쇄. 온도 실선(좌축) · 습도 파선(우축). 결측일은 선이 끊깁니다.</p>
|
||||
<?= render_daily_temp_hum_line($cal_days, $daily_temp, $daily_hum) ?>
|
||||
</div>
|
||||
|
||||
<div class="chart-panel">
|
||||
<h3>일별 경보 발생</h3>
|
||||
<p class="chart-cap">온습도 임계 경보(네이비)와 누수 감지(적 · 사선)를 당월 1일~말일 축에 스택으로 표시합니다.</p>
|
||||
<?= render_daily_alert_bars($cal_days, $daily_metric_alerts, $daily_leak_alerts) ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ 5. 관제 지점별 상세 ═══════════ -->
|
||||
<section class="section">
|
||||
<div class="section-title"><span class="no">04</span> 관제 지점별 상세 <span class="en">BY LOCATION</span></div>
|
||||
<?php if (empty($sensor_rows)): ?>
|
||||
<div class="chart-empty">해당 월 측정·경보 내역이 없습니다.</div>
|
||||
<?php else: ?>
|
||||
<div class="loc-cards">
|
||||
<?php foreach ($sensor_rows as $s): [$sv_cls, $sv_lab] = $sensor_verdict($s); ?>
|
||||
<div class="loc-card">
|
||||
<div class="lc-head">
|
||||
<div>
|
||||
<div class="lc-name"><?= $h($s['sensor_name']) ?></div>
|
||||
<div class="lc-loc"><?= $h($s['device_location']) ?></div>
|
||||
</div>
|
||||
<span class="pill is-<?= $sv_cls ?>"><?= $h($sv_lab) ?></span>
|
||||
</div>
|
||||
<dl>
|
||||
<dt>임계 경보</dt><dd><?= (int)$s['threshold_alerts'] ?>건</dd>
|
||||
<dt>고온/저온</dt><dd><?= (int)$s['high_temp'] ?>/<?= (int)$s['low_temp'] ?></dd>
|
||||
<dt>고습/저습</dt><dd><?= (int)$s['high_humidity'] ?>/<?= (int)$s['low_humidity'] ?></dd>
|
||||
<dt>누수 감지/복구</dt><dd class="<?= $s['leak_alert'] > 0 ? 'is-alert' : '' ?>"><?= (int)$s['leak_alert'] ?>/<?= (int)$s['leak_recovery'] ?></dd>
|
||||
<dt>오프라인/복구</dt><dd><?= (int)$s['device_offline'] ?>/<?= (int)$s['device_online'] ?></dd>
|
||||
<dt>SMS 실패</dt><dd class="<?= $s['sms_fail'] > 0 ? 'is-alert' : '' ?>"><?= (int)$s['sms_fail'] ?></dd>
|
||||
<dt>측정</dt><dd><?= number_format($s['metrics']) ?>건</dd>
|
||||
<dt>평균 온/습</dt><dd><?= fmt_num($s['avg_temp'], '℃') ?> / <?= fmt_num($s['avg_humidity'], '%') ?></dd>
|
||||
<dt>온도 최저/최고</dt><dd><?= fmt_num($s['min_temp'], '℃') ?> / <?= fmt_num($s['max_temp'], '℃') ?></dd>
|
||||
<dt>습도 최저/최고</dt><dd><?= fmt_num($s['min_humidity'], '%') ?> / <?= fmt_num($s['max_humidity'], '%') ?></dd>
|
||||
<dt>최근 측정</dt><dd><?= $h($s['last_measured_at'] ?: '-') ?></dd>
|
||||
</dl>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ 6. 누수 사고 로그 ═══════════ -->
|
||||
<section class="section">
|
||||
<div class="section-title"><span class="no">05</span> 누수 사고 로그 <span class="en">LEAK INCIDENTS</span></div>
|
||||
<?php if (!$has_incident_table): ?>
|
||||
<div class="chart-empty">누수 사고 이력(leak_incident) 테이블이 도입되지 않은 배포입니다. SMS 태그 집계로 대체합니다 — 당월 누수 감지 <?= (int)$summary['leak_alert'] ?>건 / 복구 <?= (int)$summary['leak_recovery'] ?>건.</div>
|
||||
<?php elseif (empty($incidents)): ?>
|
||||
<div class="incident-none">✓ 당월 누수 사고 없음 (0건) — 감시 구간 내 누수 미발생.</div>
|
||||
<?php else: ?>
|
||||
<table class="grid">
|
||||
<thead><tr><th>발생 일시</th><th>지점</th><th>위치</th><th>지속</th><th>상태</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($incidents as $inc): ?>
|
||||
<tr>
|
||||
<td class="l"><?= $h($inc['started_at']) ?></td>
|
||||
<td class="l"><?= $h($inc['sensor_name']) ?></td>
|
||||
<td class="l"><?= $h($inc['device_location']) ?></td>
|
||||
<td><?php
|
||||
if ($inc['ongoing']) { echo '진행중'; }
|
||||
elseif ($inc['duration_min'] !== null) {
|
||||
$mm = (int)$inc['duration_min'];
|
||||
echo $mm >= 60 ? intdiv($mm, 60) . '시간 ' . ($mm % 60) . '분' : $mm . '분';
|
||||
} else { echo '-'; }
|
||||
?></td>
|
||||
<td class="<?= $inc['ongoing'] ? 'is-alert' : '' ?>"><?= $inc['ongoing'] ? '미복구' : '복구' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ 7. 기준 · 주석 및 서명 ═══════════ -->
|
||||
<section class="section">
|
||||
<div class="section-title"><span class="no">06</span> 기준 · 주석 및 서명 <span class="en">BASIS & SIGN-OFF</span></div>
|
||||
<div class="basis-grid">
|
||||
<div class="basis-block">
|
||||
<h3>판정 · 임계 기준</h3>
|
||||
<ul>
|
||||
<li><b>임계</b> 고온 <?= $h(rc_fmt((float)METRIC_TEMP_HIGH_C)) ?>℃ · 저온 <?= $h(rc_fmt((float)METRIC_TEMP_LOW_C)) ?>℃ · 고습 <?= $h(rc_fmt((float)METRIC_RH_HIGH)) ?>% · 저습 <?= $h($rh_low_txt) ?></li>
|
||||
<li><b>판정 규칙</b> 미복구 누수 또는 SMS 실패 > 0 → 경보 · 임계 경보 또는 오프라인 발생 → 주의 · 그 외 → 안정</li>
|
||||
<li><b>오프라인 판정</b> <?= (int)(HEARTBEAT_TIMEOUT_SEC / 60) ?>분 무수신 · <b>경보 쿨다운</b> 온습도 <?= (int)(METRIC_ALERT_COOLDOWN_SEC / 60) ?>분 / 누수 <?= (int)(LEAK_ALERT_COOLDOWN_SEC / 60) ?>분</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="basis-block">
|
||||
<h3>데이터 · 주석</h3>
|
||||
<ul>
|
||||
<li><b>출처</b> leak_sms_log(태그 접두 집계) · leak_sensor_metric(측정) · leak_sensor_log/leak_incident(누수)</li>
|
||||
<li><b>집계 범위</b> <?= $h($period_start) ?> 00:00 ~ <?= $h($period_end) ?> 24:00 (센서는 sensor_id 기준, 표시명은 관리주체 라벨)</li>
|
||||
<li><b>개인정보</b> 수신번호는 표시 단계에서 마스킹(mask_phone) · 본 문서 분류: <span style="color:#b11f1f;font-weight:700">대외비</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sign-row">
|
||||
<div class="sign-box"><div class="sk">작성</div><div class="sv"></div><div class="sn">직/성명 · 일자</div></div>
|
||||
<div class="sign-box"><div class="sk">검토</div><div class="sv"></div><div class="sn">직/성명 · 일자</div></div>
|
||||
<div class="sign-box"><div class="sk">승인</div><div class="sv"></div><div class="sn">직/성명 · 일자</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div><!-- /report-body -->
|
||||
</div><!-- /report-paper -->
|
||||
</div><!-- /report-canvas -->
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -48,7 +48,7 @@ function collect_ops_checks(): array {
|
|||
$db = get_db();
|
||||
$checks[] = ops_check('서버', 'DB 연결', true, DB_NAME . ' 연결 성공', '', 'critical', 'setup_wizard.php 또는 security_evidence.php 화면');
|
||||
|
||||
$required_tables = ['leak_sensor_log', 'leak_sensor_status', 'leak_sensor_metric', 'leak_sms_log'];
|
||||
$required_tables = ['sensor_log', 'sensor_status', 'sensor_metric', 'sms_log'];
|
||||
foreach ($required_tables as $table) {
|
||||
$exists = ops_table_exists($db, $table);
|
||||
$checks[] = ops_check(
|
||||
|
|
@ -63,30 +63,30 @@ function collect_ops_checks(): array {
|
|||
}
|
||||
|
||||
// 최근 온습도 측정 수신 여부(센서 동작/수집 확인)
|
||||
if (ops_table_exists($db, 'leak_sensor_metric')) {
|
||||
if (ops_table_exists($db, 'sensor_metric')) {
|
||||
$recent_metric_stmt = $db->query("
|
||||
SELECT COUNT(*)
|
||||
FROM leak_sensor_metric
|
||||
FROM sensor_metric
|
||||
WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
|
||||
");
|
||||
$recent_metric = (int)$recent_metric_stmt->fetchColumn();
|
||||
$checks[] = ops_check('센서', '최근 1시간 온습도 수신', $recent_metric > 0, "{$recent_metric}건", 'SHT30 기기 전원/네트워크/배선 및 sensor_data.php 수신 확인', 'warn', 'leak_sensor_metric 최근 수신');
|
||||
$checks[] = ops_check('센서', '최근 1시간 온습도 수신', $recent_metric > 0, "{$recent_metric}건", 'SHT30 기기 전원/네트워크/배선 및 sensor_data.php 수신 확인', 'warn', 'sensor_metric 최근 수신');
|
||||
}
|
||||
|
||||
if (ops_table_exists($db, 'leak_sms_log')) {
|
||||
if (ops_table_exists($db, 'sms_log')) {
|
||||
$recent_sms_fail_stmt = $db->query("
|
||||
SELECT COUNT(*)
|
||||
FROM leak_sms_log
|
||||
FROM sms_log
|
||||
WHERE status = 'fail'
|
||||
AND sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
");
|
||||
$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', 'leak_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', 'leak_sms_log 최근 발송 잔여');
|
||||
$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) {
|
||||
|
|
@ -94,7 +94,7 @@ function collect_ops_checks(): array {
|
|||
}
|
||||
|
||||
$default_recipients = count(SMS_RECIPIENTS) === 1 && preg_replace('/\D+/', '', SMS_RECIPIENTS[0]) === '01000000000';
|
||||
$checks[] = ops_check('설정', 'API 키', API_KEY !== 'change-this-api-key', 'API_KEY 운영값 설정 여부', 'config.local.php와 라즈베리파이 config_local.py(또는 STM32 펌웨어 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('관리자 인증', '관리자 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 미설정 확인');
|
||||
|
|
|
|||
|
|
@ -1,348 +0,0 @@
|
|||
<?php
|
||||
// =============================================================================
|
||||
// report_charts.php - 월간 보고서 인라인 SVG 차트 렌더 함수 (폐쇄망: 외부 0)
|
||||
//
|
||||
// monthly_report_print.php 가 include 해 호출한다. 전부 서버렌더 인라인 SVG —
|
||||
// 차트 라이브러리·외부 폰트·CDN 없음. 색은 인쇄 안전 팔레트(app.css @media print
|
||||
// 토큰과 값 정합)를 hex 로 직접 박아 print-color-adjust:exact 로 유실을 막는다.
|
||||
// trend.php 의 좌표/스케일(x_at·y_temp·y_hum·build_segments·threshold) 로직을 이식하고
|
||||
// 임계 밴드 배경을 더했다.
|
||||
//
|
||||
// 흑백 프린터 대비: 색 외 채널을 병행한다 — 온도 실선 vs 습도 파선, 누수 막대 사선
|
||||
// 해치, 판정 씰 텍스트 라벨. (색·도형·텍스트 3중 인코딩)
|
||||
// =============================================================================
|
||||
|
||||
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단(include 전용)
|
||||
|
||||
// ─── 인쇄 안전 팔레트(단일 출처) ──────────────────────────────────────────────
|
||||
const RC_NAVY = '#14315e'; // 기준/온습도 경보 막대
|
||||
const RC_INK = '#1a1f2b';
|
||||
const RC_SUB = '#5b6472';
|
||||
const RC_HAIR = '#c9d0da'; // 그리드/괘선
|
||||
const RC_OK = '#0a6b39'; // 안정(녹)
|
||||
const RC_WARN = '#8a5a00'; // 주의(황갈)
|
||||
const RC_ALERT = '#b11f1f'; // 경보/누수(적)
|
||||
const RC_TEMP = '#b34a1f'; // 온도 실선(cat-hot)
|
||||
const RC_HUM = '#0f5e74'; // 습도 파선(cat-cold)
|
||||
const RC_BAND_SAFE = '#e7f0ea'; // 안전 밴드(녹)
|
||||
const RC_BAND_HOT = '#f6e3dc'; // 고온 밴드(적)
|
||||
const RC_BAND_COLD = '#dfeef2'; // 저온 밴드(청)
|
||||
|
||||
// verdict → 색 매핑(씰·게이지 공유)
|
||||
function rc_verdict_color(string $verdict): string {
|
||||
return match ($verdict) {
|
||||
'alert' => RC_ALERT,
|
||||
'warn' => RC_WARN,
|
||||
default => RC_NAVY, // ok(안정)은 신뢰감 있는 네이비
|
||||
};
|
||||
}
|
||||
|
||||
// '높을수록 좋은' 지표(SMS 신뢰성 등) 자체 기준 색 — 종합 판정과 독립.
|
||||
// 실패 0 = 완전(네이비) / 경미한 실패 = 주의(황) / 저하 = 경보(적).
|
||||
// 종합 판정이 경보여도 SMS 전달이 100%면 이 게이지는 네이비로 정확히 표현한다.
|
||||
function rc_reliability_color(int $pct, int $fail_count): string {
|
||||
if ($fail_count <= 0) { return RC_NAVY; } // 실패 0 → 완전 신뢰
|
||||
if ($pct >= 95) { return RC_WARN; } // 경미
|
||||
return RC_ALERT; // 저하
|
||||
}
|
||||
|
||||
// 짧은 수치 포맷(소수 1자리, 불필요한 0 제거)
|
||||
function rc_fmt(float $v): string {
|
||||
return rtrim(rtrim(number_format($v, 1, '.', ''), '0'), '.');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 1) 판정 씰 — 결재 도장형 스탬프. verdict 색·라벨 자동. 표지/경영요약 공유.
|
||||
// =============================================================================
|
||||
function render_verdict_seal(string $verdict, string $label): string {
|
||||
$c = rc_verdict_color($verdict);
|
||||
$lab = htmlspecialchars($label);
|
||||
ob_start(); ?>
|
||||
<svg viewBox="0 0 120 120" width="112" height="112" role="img"
|
||||
aria-label="관제 판정: <?= $lab ?>" class="verdict-seal"
|
||||
style="print-color-adjust:exact;-webkit-print-color-adjust:exact">
|
||||
<g transform="rotate(-8 60 60)" fill="none" stroke="<?= $c ?>">
|
||||
<circle cx="60" cy="60" r="56" stroke-width="2.4"></circle>
|
||||
<circle cx="60" cy="60" r="48" stroke-width="1"></circle>
|
||||
</g>
|
||||
<g transform="rotate(-8 60 60)" fill="<?= $c ?>" stroke="none" text-anchor="middle">
|
||||
<text x="60" y="34" font-size="10.5" font-weight="700" letter-spacing="1.5">관제 판정</text>
|
||||
<text x="60" y="74" font-size="30" font-weight="800" letter-spacing="2"><?= $lab ?></text>
|
||||
<text x="60" y="94" font-size="7.5" font-weight="700" letter-spacing="3">MONTHLY REVIEW</text>
|
||||
</g>
|
||||
</svg>
|
||||
<?php
|
||||
return (string)ob_get_clean();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 2) 원형 게이지 — 값 %(0~100). 경영 요약 KPI(예: SMS 신뢰성). trend.php 게이지 이식.
|
||||
// =============================================================================
|
||||
function render_uptime_gauge(int $pct, string $color, string $center_big, string $center_small): string {
|
||||
$pct = max(0, min(100, $pct));
|
||||
ob_start(); ?>
|
||||
<svg viewBox="0 0 120 120" width="100%" height="120" role="img"
|
||||
aria-label="<?= htmlspecialchars($center_small) ?> <?= $pct ?>%" class="kpi-gauge"
|
||||
style="print-color-adjust:exact;-webkit-print-color-adjust:exact">
|
||||
<g transform="rotate(-90 60 60)" fill="none" stroke-linecap="round">
|
||||
<circle cx="60" cy="60" r="50" pathLength="100" stroke="<?= RC_HAIR ?>" stroke-width="10"></circle>
|
||||
<circle cx="60" cy="60" r="50" pathLength="100" stroke="<?= htmlspecialchars($color) ?>"
|
||||
stroke-width="10" stroke-dasharray="<?= $pct ?> 100"></circle>
|
||||
</g>
|
||||
<text x="60" y="58" text-anchor="middle" font-size="26" font-weight="800"
|
||||
fill="<?= RC_INK ?>"><?= htmlspecialchars($center_big) ?></text>
|
||||
<text x="60" y="76" text-anchor="middle" font-size="9" font-weight="600"
|
||||
fill="<?= RC_SUB ?>"><?= htmlspecialchars($center_small) ?></text>
|
||||
</svg>
|
||||
<?php
|
||||
return (string)ob_get_clean();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 3) 일별 평균 온·습도 strip-chart — 임계 밴드 배경 + 온도 실선 + 습도 파선(이중축).
|
||||
// $cal_days: ['Y-m-d',...] $daily_temp/$daily_hum: 동일 순서, 결측 null.
|
||||
// =============================================================================
|
||||
function render_daily_temp_hum_line(array $cal_days, array $daily_temp, array $daily_hum): string {
|
||||
$n = count($cal_days);
|
||||
$temps = array_values($daily_temp); // cal_days 순서 유지(값 배열)
|
||||
$hums = array_values($daily_hum);
|
||||
|
||||
$has_temp = count(array_filter($temps, fn($v) => $v !== null)) > 0;
|
||||
$has_hum = count(array_filter($hums, fn($v) => $v !== null)) > 0;
|
||||
if ($n === 0 || (!$has_temp && !$has_hum)) {
|
||||
return '<div class="chart-empty">당월 측정 데이터가 없습니다.</div>';
|
||||
}
|
||||
|
||||
$W = 920; $H = 300;
|
||||
$pad_l = 46; $pad_r = 48; $pad_t = 22; $pad_b = 40;
|
||||
$plot_w = $W - $pad_l - $pad_r;
|
||||
$plot_h = $H - $pad_t - $pad_b;
|
||||
|
||||
// 임계 상수(밴드/스케일에 포함해 항상 보이게)
|
||||
$t_high = (float)METRIC_TEMP_HIGH_C;
|
||||
$t_low = (float)METRIC_TEMP_LOW_C;
|
||||
|
||||
// 온도 축: 데이터 + 임계 포함해 범위 산정
|
||||
$t_vals = array_filter($temps, fn($v) => $v !== null);
|
||||
$t_pool = array_merge($t_vals, [$t_high, $t_low]);
|
||||
$t_min = min($t_pool); $t_max = max($t_pool);
|
||||
if ($t_min === $t_max) { $t_min -= 1; $t_max += 1; }
|
||||
$t_span = ($t_max - $t_min) * 0.12;
|
||||
$t_min -= $t_span; $t_max += $t_span;
|
||||
|
||||
// 습도 축: 데이터 기준
|
||||
$h_vals = array_filter($hums, fn($v) => $v !== null);
|
||||
if ($h_vals) {
|
||||
$h_min = min($h_vals); $h_max = max($h_vals);
|
||||
if ($h_min === $h_max) { $h_min -= 1; $h_max += 1; }
|
||||
$h_span = ($h_max - $h_min) * 0.12;
|
||||
$h_min -= $h_span; $h_max += $h_span;
|
||||
} else {
|
||||
$h_min = 0; $h_max = 100;
|
||||
}
|
||||
|
||||
$x_at = function (int $i) use ($pad_l, $plot_w, $n): float {
|
||||
if ($n <= 1) { return $pad_l + $plot_w / 2; }
|
||||
return $pad_l + ($plot_w * $i / ($n - 1));
|
||||
};
|
||||
$y_temp = function (?float $v) use ($pad_t, $plot_h, $t_min, $t_max): ?float {
|
||||
if ($v === null) { return null; }
|
||||
$r = ($t_max - $t_min) ?: 1;
|
||||
return $pad_t + $plot_h * (1 - (($v - $t_min) / $r));
|
||||
};
|
||||
$y_hum = function (?float $v) use ($pad_t, $plot_h, $h_min, $h_max): ?float {
|
||||
if ($v === null) { return null; }
|
||||
$r = ($h_max - $h_min) ?: 1;
|
||||
return $pad_t + $plot_h * (1 - (($v - $h_min) / $r));
|
||||
};
|
||||
$clampy = fn(float $y) => max($pad_t, min($pad_t + $plot_h, $y));
|
||||
|
||||
// 결측(null)은 구간을 끊어 polyline 을 만든다
|
||||
$build_segments = function (array $vals, callable $y_fn) use ($x_at): array {
|
||||
$segments = []; $current = [];
|
||||
foreach ($vals as $i => $v) {
|
||||
if ($v === null) {
|
||||
if (count($current) >= 2) { $segments[] = $current; }
|
||||
$current = [];
|
||||
continue;
|
||||
}
|
||||
$y = $y_fn((float)$v);
|
||||
$current[] = sprintf('%.1f,%.1f', $x_at($i), $y);
|
||||
}
|
||||
if (count($current) >= 2) { $segments[] = $current; }
|
||||
return $segments;
|
||||
};
|
||||
$temp_segments = $build_segments($temps, $y_temp);
|
||||
$hum_segments = $build_segments($hums, $y_hum);
|
||||
|
||||
// 임계 밴드 경계(온도축)
|
||||
$y_high = $clampy($y_temp($t_high));
|
||||
$y_low = $clampy($y_temp($t_low));
|
||||
$band_top = $pad_t; $band_bottom = $pad_t + $plot_h;
|
||||
|
||||
ob_start(); ?>
|
||||
<svg viewBox="0 0 <?= $W ?> <?= $H ?>" width="100%" height="<?= $H ?>"
|
||||
preserveAspectRatio="xMidYMid meet" role="img"
|
||||
aria-label="당월 일별 평균 온도·습도 추이(임계 밴드 배경)" class="strip-chart"
|
||||
style="print-color-adjust:exact;-webkit-print-color-adjust:exact">
|
||||
<!-- 임계 밴드: 고온(적)·안전(녹)·저온(청) -->
|
||||
<rect x="<?= $pad_l ?>" y="<?= sprintf('%.1f', $band_top) ?>" width="<?= $plot_w ?>"
|
||||
height="<?= sprintf('%.1f', max(0, $y_high - $band_top)) ?>" fill="<?= RC_BAND_HOT ?>"></rect>
|
||||
<rect x="<?= $pad_l ?>" y="<?= sprintf('%.1f', $y_high) ?>" width="<?= $plot_w ?>"
|
||||
height="<?= sprintf('%.1f', max(0, $y_low - $y_high)) ?>" fill="<?= RC_BAND_SAFE ?>"></rect>
|
||||
<rect x="<?= $pad_l ?>" y="<?= sprintf('%.1f', $y_low) ?>" width="<?= $plot_w ?>"
|
||||
height="<?= sprintf('%.1f', max(0, $band_bottom - $y_low)) ?>" fill="<?= RC_BAND_COLD ?>"></rect>
|
||||
<rect x="<?= $pad_l ?>" y="<?= $pad_t ?>" width="<?= $plot_w ?>" height="<?= $plot_h ?>"
|
||||
fill="none" stroke="<?= RC_HAIR ?>" stroke-width="1"></rect>
|
||||
|
||||
<!-- 가로 그리드 + 좌(온도)/우(습도) 눈금 -->
|
||||
<?php for ($g = 0; $g <= 4; $g++):
|
||||
$gy = $pad_t + $plot_h * $g / 4;
|
||||
$tv = $t_max - ($t_max - $t_min) * $g / 4;
|
||||
$hv = $h_max - ($h_max - $h_min) * $g / 4; ?>
|
||||
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $gy) ?>" x2="<?= $pad_l + $plot_w ?>"
|
||||
y2="<?= sprintf('%.1f', $gy) ?>" stroke="<?= RC_HAIR ?>" stroke-width="0.6" stroke-dasharray="3,3"></line>
|
||||
<text x="<?= $pad_l - 7 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="end"
|
||||
font-size="10" fill="<?= RC_TEMP ?>"><?= htmlspecialchars(rc_fmt($tv)) ?></text>
|
||||
<text x="<?= $pad_l + $plot_w + 7 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="start"
|
||||
font-size="10" fill="<?= RC_HUM ?>"><?= htmlspecialchars(rc_fmt($hv)) ?></text>
|
||||
<?php endfor; ?>
|
||||
|
||||
<!-- 임계선(온도 고온/저온) 라벨 -->
|
||||
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $y_high) ?>" x2="<?= $pad_l + $plot_w ?>"
|
||||
y2="<?= sprintf('%.1f', $y_high) ?>" stroke="<?= RC_ALERT ?>" stroke-width="1" stroke-dasharray="2,3" opacity="0.7"></line>
|
||||
<text x="<?= $pad_l + 4 ?>" y="<?= sprintf('%.1f', $y_high - 3) ?>" font-size="9"
|
||||
fill="<?= RC_ALERT ?>">고온 <?= htmlspecialchars(rc_fmt($t_high)) ?>℃</text>
|
||||
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $y_low) ?>" x2="<?= $pad_l + $plot_w ?>"
|
||||
y2="<?= sprintf('%.1f', $y_low) ?>" stroke="<?= RC_HUM ?>" stroke-width="1" stroke-dasharray="2,3" opacity="0.7"></line>
|
||||
<text x="<?= $pad_l + 4 ?>" y="<?= sprintf('%.1f', $y_low + 11) ?>" font-size="9"
|
||||
fill="<?= RC_HUM ?>">저온 <?= htmlspecialchars(rc_fmt($t_low)) ?>℃</text>
|
||||
|
||||
<!-- X축 날짜(일자) 라벨: 1/5/10.../말일 -->
|
||||
<?php
|
||||
for ($i = 0; $i < $n; $i++):
|
||||
$day = (int)substr($cal_days[$i], 8, 2);
|
||||
$is_tick = ($day === 1 || $day % 5 === 0 || $i === $n - 1);
|
||||
if (!$is_tick) { continue; }
|
||||
$lx = $x_at($i);
|
||||
$anchor = $i === 0 ? 'start' : ($i === $n - 1 ? 'end' : 'middle'); ?>
|
||||
<line x1="<?= sprintf('%.1f', $lx) ?>" y1="<?= $pad_t + $plot_h ?>" x2="<?= sprintf('%.1f', $lx) ?>"
|
||||
y2="<?= $pad_t + $plot_h + 4 ?>" stroke="<?= RC_HAIR ?>" stroke-width="0.8"></line>
|
||||
<text x="<?= sprintf('%.1f', $lx) ?>" y="<?= $H - 22 ?>" text-anchor="<?= $anchor ?>"
|
||||
font-size="9.5" fill="<?= RC_SUB ?>"><?= $day ?></text>
|
||||
<?php endfor; ?>
|
||||
<text x="<?= $pad_l + $plot_w / 2 ?>" y="<?= $H - 6 ?>" text-anchor="middle"
|
||||
font-size="9" fill="<?= RC_SUB ?>">일(당월)</text>
|
||||
|
||||
<!-- 습도 파선 -->
|
||||
<?php foreach ($hum_segments as $seg): ?>
|
||||
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>" fill="none" stroke="<?= RC_HUM ?>"
|
||||
stroke-width="1.8" stroke-dasharray="6,3" stroke-linejoin="round" stroke-linecap="round"></polyline>
|
||||
<?php endforeach; ?>
|
||||
<!-- 온도 실선 -->
|
||||
<?php foreach ($temp_segments as $seg): ?>
|
||||
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>" fill="none" stroke="<?= RC_TEMP ?>"
|
||||
stroke-width="2" stroke-linejoin="round" stroke-linecap="round"></polyline>
|
||||
<?php endforeach; ?>
|
||||
</svg>
|
||||
<div class="chart-legend-row">
|
||||
<span><span class="lg-line" style="background:<?= RC_TEMP ?>"></span> 평균 온도(℃, 좌축·실선)</span>
|
||||
<span><span class="lg-line dash" style="background:<?= RC_HUM ?>"></span> 평균 습도(%, 우축·파선)</span>
|
||||
<span><span class="lg-band" style="background:<?= RC_BAND_SAFE ?>"></span> 안전대</span>
|
||||
<span><span class="lg-band" style="background:<?= RC_BAND_HOT ?>"></span> 고온대</span>
|
||||
<span><span class="lg-band" style="background:<?= RC_BAND_COLD ?>"></span> 저온대</span>
|
||||
</div>
|
||||
<?php
|
||||
return (string)ob_get_clean();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 4) 일별 경보 발생 스택 막대 — 온습도 임계(네이비) + 누수 감지(적·사선 해치).
|
||||
// =============================================================================
|
||||
function render_daily_alert_bars(array $cal_days, array $daily_metric, array $daily_leak): string {
|
||||
$n = count($cal_days);
|
||||
$metric = array_values($daily_metric);
|
||||
$leak = array_values($daily_leak);
|
||||
if ($n === 0) {
|
||||
return '<div class="chart-empty">당월 일자 축이 없습니다.</div>';
|
||||
}
|
||||
|
||||
$totals = [];
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$totals[$i] = (int)($metric[$i] ?? 0) + (int)($leak[$i] ?? 0);
|
||||
}
|
||||
$peak = max(1, max($totals));
|
||||
// Y 눈금 상한을 보기 좋은 정수로 올림(최대 4단계)
|
||||
$step = (int)max(1, ceil($peak / 4));
|
||||
$y_top = $step * 4;
|
||||
|
||||
$W = 920; $H = 240;
|
||||
$pad_l = 40; $pad_r = 16; $pad_t = 20; $pad_b = 40;
|
||||
$plot_w = $W - $pad_l - $pad_r;
|
||||
$plot_h = $H - $pad_t - $pad_b;
|
||||
|
||||
$slot = $plot_w / $n;
|
||||
$bar_w = min(22, $slot * 0.6);
|
||||
|
||||
ob_start(); ?>
|
||||
<svg viewBox="0 0 <?= $W ?> <?= $H ?>" width="100%" height="<?= $H ?>"
|
||||
preserveAspectRatio="xMidYMid meet" role="img"
|
||||
aria-label="당월 일별 경보 발생(온습도 임계 + 누수 감지) 스택 막대"
|
||||
style="print-color-adjust:exact;-webkit-print-color-adjust:exact">
|
||||
<defs>
|
||||
<pattern id="leakHatch" patternUnits="userSpaceOnUse" width="5" height="5" patternTransform="rotate(45)">
|
||||
<rect width="5" height="5" fill="<?= RC_ALERT ?>"></rect>
|
||||
<line x1="0" y1="0" x2="0" y2="5" stroke="#fff" stroke-width="1.4"></line>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x="<?= $pad_l ?>" y="<?= $pad_t ?>" width="<?= $plot_w ?>" height="<?= $plot_h ?>"
|
||||
fill="none" stroke="<?= RC_HAIR ?>" stroke-width="1"></rect>
|
||||
|
||||
<!-- Y 그리드 + 눈금 -->
|
||||
<?php for ($g = 0; $g <= 4; $g++):
|
||||
$gv = $y_top - $step * $g;
|
||||
$gy = $pad_t + $plot_h * $g / 4; ?>
|
||||
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $gy) ?>" x2="<?= $pad_l + $plot_w ?>"
|
||||
y2="<?= sprintf('%.1f', $gy) ?>" stroke="<?= RC_HAIR ?>" stroke-width="0.6" stroke-dasharray="3,3"></line>
|
||||
<text x="<?= $pad_l - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="end"
|
||||
font-size="10" fill="<?= RC_SUB ?>"><?= $gv ?></text>
|
||||
<?php endfor; ?>
|
||||
|
||||
<!-- 스택 막대 -->
|
||||
<?php for ($i = 0; $i < $n; $i++):
|
||||
$mv = (int)($metric[$i] ?? 0);
|
||||
$lv = (int)($leak[$i] ?? 0);
|
||||
$cx = $pad_l + $slot * ($i + 0.5);
|
||||
$bx = $cx - $bar_w / 2;
|
||||
$base = $pad_t + $plot_h;
|
||||
// 온습도(네이비) 아래, 누수(해치) 위
|
||||
$m_h = $mv > 0 ? ($plot_h * $mv / $y_top) : 0;
|
||||
$l_h = $lv > 0 ? ($plot_h * $lv / $y_top) : 0;
|
||||
$m_y = $base - $m_h;
|
||||
$l_y = $m_y - $l_h;
|
||||
?>
|
||||
<?php if ($mv > 0): ?>
|
||||
<rect x="<?= sprintf('%.1f', $bx) ?>" y="<?= sprintf('%.1f', $m_y) ?>" width="<?= sprintf('%.1f', $bar_w) ?>"
|
||||
height="<?= sprintf('%.1f', $m_h) ?>" fill="<?= RC_NAVY ?>"></rect>
|
||||
<?php endif; ?>
|
||||
<?php if ($lv > 0): ?>
|
||||
<rect x="<?= sprintf('%.1f', $bx) ?>" y="<?= sprintf('%.1f', $l_y) ?>" width="<?= sprintf('%.1f', $bar_w) ?>"
|
||||
height="<?= sprintf('%.1f', $l_h) ?>" fill="url(#leakHatch)" stroke="<?= RC_ALERT ?>" stroke-width="0.5"></rect>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$day = (int)substr($cal_days[$i], 8, 2);
|
||||
if ($day === 1 || $day % 5 === 0 || $i === $n - 1): ?>
|
||||
<text x="<?= sprintf('%.1f', $cx) ?>" y="<?= $H - 22 ?>" text-anchor="middle"
|
||||
font-size="9.5" fill="<?= RC_SUB ?>"><?= $day ?></text>
|
||||
<?php endif; ?>
|
||||
<?php endfor; ?>
|
||||
<text x="<?= $pad_l + $plot_w / 2 ?>" y="<?= $H - 6 ?>" text-anchor="middle"
|
||||
font-size="9" fill="<?= RC_SUB ?>">일(당월) · 발생 건수</text>
|
||||
</svg>
|
||||
<div class="chart-legend-row">
|
||||
<span><span class="lg-sw" style="background:<?= RC_NAVY ?>"></span> 온습도 임계 경보</span>
|
||||
<span><span class="lg-sw hatch"></span> 누수 감지</span>
|
||||
</div>
|
||||
<?php
|
||||
return (string)ob_get_clean();
|
||||
}
|
||||
|
|
@ -46,9 +46,9 @@ $summary = [
|
|||
'sensor_log_retention_days' => SENSOR_LOG_RETENTION_DAYS,
|
||||
'sensor_metric_retention_days' => SENSOR_METRIC_RETENTION_DAYS,
|
||||
'admin_audit_retention_days' => ADMIN_AUDIT_RETENTION_DAYS,
|
||||
'leak_sms_log' => ['matched' => 0, 'rows_deleted' => 0],
|
||||
'leak_sensor_log' => ['matched' => 0, 'rows_deleted' => 0],
|
||||
'leak_sensor_metric' => ['matched' => 0, 'rows_deleted' => 0],
|
||||
'sms_log' => ['matched' => 0, 'rows_deleted' => 0],
|
||||
'sensor_log' => ['matched' => 0, 'rows_deleted' => 0],
|
||||
'sensor_metric' => ['matched' => 0, 'rows_deleted' => 0],
|
||||
'admin_audit' => ['kept_lines' => null, 'removed_lines' => null],
|
||||
];
|
||||
|
||||
|
|
@ -62,16 +62,16 @@ try {
|
|||
}
|
||||
|
||||
$smsCutoff = retention_cutoff(SMS_LOG_RETENTION_DAYS);
|
||||
$summary['leak_sms_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM leak_sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff]);
|
||||
$summary['leak_sms_log']['rows_deleted'] = retention_delete($db, "DELETE FROM leak_sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff], $dryRun);
|
||||
$summary['sms_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff]);
|
||||
$summary['sms_log']['rows_deleted'] = retention_delete($db, "DELETE FROM sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff], $dryRun);
|
||||
|
||||
$sensorCutoff = retention_cutoff(SENSOR_LOG_RETENTION_DAYS);
|
||||
$summary['leak_sensor_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM leak_sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff]);
|
||||
$summary['leak_sensor_log']['rows_deleted'] = retention_delete($db, "DELETE FROM leak_sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff], $dryRun);
|
||||
$summary['sensor_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff]);
|
||||
$summary['sensor_log']['rows_deleted'] = retention_delete($db, "DELETE FROM sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff], $dryRun);
|
||||
|
||||
$metricCutoff = retention_cutoff(SENSOR_METRIC_RETENTION_DAYS);
|
||||
$summary['leak_sensor_metric']['matched'] = retention_count($db, "SELECT COUNT(*) FROM leak_sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff]);
|
||||
$summary['leak_sensor_metric']['rows_deleted'] = retention_delete($db, "DELETE FROM leak_sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff], $dryRun);
|
||||
$summary['sensor_metric']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff]);
|
||||
$summary['sensor_metric']['rows_deleted'] = retention_delete($db, "DELETE FROM sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff], $dryRun);
|
||||
|
||||
$auditPath = admin_audit_log_path();
|
||||
if (is_file($auditPath)) {
|
||||
|
|
@ -113,9 +113,9 @@ if (is_file($auditPath)) {
|
|||
|
||||
admin_audit_log('retention_cleanup', 'system', true, [
|
||||
'mode' => $summary['mode'],
|
||||
'sms_matches' => $summary['leak_sms_log']['matched'],
|
||||
'sensor_matches' => $summary['leak_sensor_log']['matched'],
|
||||
'metric_matches' => $summary['leak_sensor_metric']['matched'],
|
||||
'sms_matches' => $summary['sms_log']['matched'],
|
||||
'sensor_matches' => $summary['sensor_log']['matched'],
|
||||
'metric_matches' => $summary['sensor_metric']['matched'],
|
||||
]);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
|
|
|||
|
|
@ -27,11 +27,11 @@ $control_rows = [
|
|||
['사업 범위', '서버실 온습도(SHT30) 모니터링 및 임계치 초과 SMS 경보, 장비 오프라인 알림', '보안대책서 사업개요', 'SECURITY_PLAN_PASS_READINESS.md'],
|
||||
['전송구간', 'HTTPS/TLS 사용, API 서명 검증', 'api/sensor_data.php, config.php', 'SOURCE_SECURITY_EVIDENCE.md'],
|
||||
['API 인증', '공유 API 키 기반 요청 서명 검증', 'config.php', '403 테스트 결과'],
|
||||
['비밀값', 'config.local.php(서버)와 config_local.py(라즈베리파이)로 분리', 'config.php', '설정 파일 권한 화면'],
|
||||
['비밀값', 'config.local.php와 /etc/sht30-sensor.env로 분리', 'config.php', '설정 파일 권한 화면'],
|
||||
['관리자 인증', '비밀번호 해시, CSRF, 세션 보호, 실패 제한, 관리자 MFA', 'login.php, admin_security.php', '로그인 화면, MFA 등록 확인표'],
|
||||
['감사로그', '로그인 성공/실패, 로그아웃, 관리자 활동 접속기록 저장', 'admin_security.php, php/var/admin_audit.log', '감사로그 파일'],
|
||||
['로그관리', '센서 측정 이력(leak_sensor_metric)·SMS 발송 이력(leak_sms_log)·관리자 감사로그 저장 및 보관기간 정리', 'MySQL, retention_cleanup.php', '보관기간 정리 dry-run 결과'],
|
||||
['장애대응', '온습도 임계 경보 SMS + 장비 오프라인/복구 알림', 'cron_heartbeat.php', 'SMS 로그, leak_sensor_metric 이력'],
|
||||
['로그관리', '센서 측정 이력(sensor_metric)·SMS 발송 이력(sms_log)·관리자 감사로그 저장 및 보관기간 정리', 'MySQL, retention_cleanup.php', '보관기간 정리 dry-run 결과'],
|
||||
['장애대응', '온습도 임계 경보 SMS + 장비 오프라인/복구 알림', 'cron_heartbeat.php', 'SMS 로그, sensor_metric 이력'],
|
||||
['백업', 'DB/설정 백업 목록과 복구 테스트 증적 관리', 'scripts/backup_evidence.php', 'backup-evidence 보고서'],
|
||||
];
|
||||
|
||||
|
|
@ -68,43 +68,56 @@ if (($_GET['format'] ?? '') === 'md') {
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>보안 증적 보고서 - 온습도 모니터링 <?= htmlspecialchars(APP_VERSION) ?></title>
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<style>
|
||||
/* 보안 증적 전용 잔여 스타일 — 값은 app.css 토큰(var)만 사용 */
|
||||
.evi-summary { margin-bottom: var(--sp-5); }
|
||||
.evi-summary strong { font-size: 1.25rem; }
|
||||
.evi-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; margin-top: var(--sp-4); }
|
||||
.evi-actions a, .evi-actions button {
|
||||
display: inline-block; background: var(--primary); color: var(--on-primary);
|
||||
border: 0; border-radius: var(--r-sm); padding: 8px 14px;
|
||||
font-size: .8125rem; font-weight: 700; font-family: var(--font);
|
||||
text-decoration: none; cursor: pointer;
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; }
|
||||
header { background: #1a56db; color: #fff; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
header h1 { font-size: 1.15rem; margin: 0; }
|
||||
header a, .btn { color: #fff; text-decoration: none; background: rgba(255,255,255,.15); padding: 7px 12px; border-radius: 6px; font-size: .85rem; border: 0; cursor: pointer; }
|
||||
.container { max-width: 1120px; margin: 24px auto; padding: 0 16px; }
|
||||
.summary { background: #fff; border-radius: 8px; padding: 18px; box-shadow: 0 1px 4px rgba(0,0,0,.1); margin-bottom: 18px; }
|
||||
.summary strong { font-size: 1.2rem; color: <?= $failed_count ? '#b91c1c' : '#166534' ?>; }
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||
.actions a, .actions button { background: #1a56db; color: #fff; border: 0; border-radius: 6px; padding: 8px 12px; text-decoration: none; font-weight: 700; cursor: pointer; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.08); margin-bottom: 24px; }
|
||||
th, td { border-bottom: 1px solid #e2e8f0; padding: 9px 10px; text-align: left; font-size: .84rem; vertical-align: top; }
|
||||
th { background: #f8fafc; color: #475569; }
|
||||
h2 { font-size: 1rem; color: #334155; margin: 24px 0 10px; }
|
||||
.ok { color: #166534; font-weight: 700; }
|
||||
.fail { color: #b91c1c; font-weight: 700; }
|
||||
.muted { color: #64748b; font-size: .84rem; }
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
header, .actions { display: none; }
|
||||
.container { margin: 0; max-width: none; }
|
||||
table, .summary { box-shadow: none; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
header { align-items: flex-start; flex-direction: column; }
|
||||
table { display: block; overflow-x: auto; }
|
||||
}
|
||||
.evi-actions a:hover, .evi-actions button:hover { background: var(--primary-600); }
|
||||
@media print { .evi-actions { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>보안 증적 보고서 <?= htmlspecialchars(APP_VERSION) ?></h1>
|
||||
<div class="top-actions">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<a href="dashboard.php">대시보드</a>
|
||||
<a href="setup_wizard.php">설치 점검</a>
|
||||
</div>
|
||||
</header>
|
||||
<main class="container">
|
||||
<section class="panel evi-summary">
|
||||
<strong class="<?= $failed_count === 0 ? 'check-ok' : 'check-fail' ?>"><?= $failed_count === 0 ? '전체 점검 통과' : "확인 필요 {$failed_count}건" ?></strong>
|
||||
<p class="caption">생성시각: <?= htmlspecialchars($generated_at) ?> · 버전: <?= htmlspecialchars(APP_VERSION) ?></p>
|
||||
<p class="caption">이 보고서는 보안대책서 첨부용 운영 점검 결과와 보안통제 매트릭스를 제공합니다. 비밀번호, API 키, SMS 인증키 원문은 표시하지 않습니다.</p>
|
||||
<div class="evi-actions">
|
||||
<section class="summary">
|
||||
<strong><?= $failed_count === 0 ? '전체 점검 통과' : "확인 필요 {$failed_count}건" ?></strong>
|
||||
<p class="muted">생성시각: <?= htmlspecialchars($generated_at) ?> · 버전: <?= htmlspecialchars(APP_VERSION) ?></p>
|
||||
<p class="muted">이 보고서는 보안대책서 첨부용 운영 점검 결과와 보안통제 매트릭스를 제공합니다. 비밀번호, API 키, SMS 인증키 원문은 표시하지 않습니다.</p>
|
||||
<div class="actions">
|
||||
<a href="?format=md">Markdown 다운로드</a>
|
||||
<button type="button" onclick="window.print()">인쇄/PDF</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2>운영 점검 결과</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>분류</th><th>항목</th><th>결과</th><th>상세</th><th>조치</th><th>증적</th></tr>
|
||||
|
|
@ -114,7 +127,7 @@ if (($_GET['format'] ?? '') === 'md') {
|
|||
<tr>
|
||||
<td><?= htmlspecialchars($check['category']) ?></td>
|
||||
<td><?= htmlspecialchars($check['label']) ?></td>
|
||||
<td class="<?= $check['ok'] ? 'check-ok' : 'check-fail' ?>"><?= $check['ok'] ? '정상' : '확인 필요' ?></td>
|
||||
<td class="<?= $check['ok'] ? 'ok' : 'fail' ?>"><?= $check['ok'] ? '정상' : '확인 필요' ?></td>
|
||||
<td><?= htmlspecialchars($check['detail']) ?></td>
|
||||
<td><?= htmlspecialchars($check['fix'] ?: '-') ?></td>
|
||||
<td><?= htmlspecialchars($check['evidence'] ?: '-') ?></td>
|
||||
|
|
@ -122,10 +135,8 @@ if (($_GET['format'] ?? '') === 'md') {
|
|||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>보안통제 매트릭스</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>영역</th><th>보안대책</th><th>구현 위치</th><th>확인 증적</th></tr>
|
||||
|
|
@ -140,7 +151,6 @@ if (($_GET['format'] ?? '') === 'md') {
|
|||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -70,25 +70,36 @@ $token = $_GET['token'] ?? $_POST['token'] ?? '';
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>관리자 MFA 등록</title>
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<style>
|
||||
/* 페이지 전용: 문서형 좁은 단 (app.css .container는 1320px로 넓음). 값은 토큰만. */
|
||||
.mfa-doc { max-width: 760px; margin: var(--sp-6) auto; padding: 0 var(--sp-4); }
|
||||
.mfa-doc .panel { margin-bottom: var(--sp-4); }
|
||||
.mfa-doc textarea { min-height: 82px; resize: vertical; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; }
|
||||
main { max-width: 760px; margin: 32px auto; padding: 0 16px; }
|
||||
.panel { background: #fff; border-radius: 8px; padding: 22px; box-shadow: 0 1px 5px rgba(15,23,42,.12); margin-bottom: 16px; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 8px; color: #1a56db; }
|
||||
h2 { font-size: 1rem; margin: 20px 0 8px; }
|
||||
p, li { line-height: 1.55; font-size: .92rem; }
|
||||
code, textarea { font-family: Consolas, Monaco, monospace; }
|
||||
textarea, input[type=text] { width: 100%; border: 1px solid #cbd5e1; border-radius: 6px; padding: 10px; font-size: .9rem; }
|
||||
textarea { min-height: 82px; resize: vertical; }
|
||||
label { display: block; font-weight: 700; margin: 12px 0 6px; }
|
||||
.secret { font-size: 1.05rem; letter-spacing: .08em; word-break: break-all; background: #f8fafc; padding: 12px; border-radius: 6px; border: 1px solid #e2e8f0; }
|
||||
.btn { background: #1a56db; color: #fff; border: 0; border-radius: 6px; padding: 10px 14px; font-weight: 700; cursor: pointer; }
|
||||
.error { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; border-radius: 6px; padding: 10px; }
|
||||
.ok { background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534; border-radius: 6px; padding: 10px; }
|
||||
.muted { color: #64748b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="mfa-doc">
|
||||
<main>
|
||||
<section class="panel">
|
||||
<h1>Google Authenticator MFA 등록</h1>
|
||||
<p class="caption">이 화면은 Google Authenticator, Microsoft Authenticator, Authy 등 TOTP 인증 앱에 관리자 계정을 등록하기 위한 도구입니다. 외부 Google API로 비밀키를 보내지 않습니다.</p>
|
||||
<p class="muted">이 화면은 Google Authenticator, Microsoft Authenticator, Authy 등 TOTP 인증 앱에 관리자 계정을 등록하기 위한 도구입니다. 외부 Google API로 비밀키를 보내지 않습니다.</p>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="error" role="alert"><?= htmlspecialchars($error) ?></div>
|
||||
<div class="error"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($verified): ?>
|
||||
<div class="banner-ok" role="status">인증 앱 코드가 확인되었습니다. 아래 설정 줄을 `config.local.php`에 반영하세요.</div>
|
||||
<div class="ok">인증 앱 코드가 확인되었습니다. 아래 설정 줄을 `config.local.php`에 반영하세요.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2>1. 인증 앱에 수동 등록</h2>
|
||||
|
|
@ -100,7 +111,7 @@ $token = $_GET['token'] ?? $_POST['token'] ?? '';
|
|||
<div class="secret"><?= htmlspecialchars($secret) ?></div>
|
||||
|
||||
<h2>2. 앱 링크 등록</h2>
|
||||
<p class="caption">모바일 브라우저에서 열면 인증 앱이 이 URI를 인식할 수 있습니다. 데스크톱에서는 수동 등록을 사용하세요.</p>
|
||||
<p class="muted">모바일 브라우저에서 열면 인증 앱이 이 URI를 인식할 수 있습니다. 데스크톱에서는 수동 등록을 사용하세요.</p>
|
||||
<textarea readonly><?= htmlspecialchars($uri) ?></textarea>
|
||||
<p><a class="btn" href="<?= htmlspecialchars($uri) ?>">인증 앱으로 열기</a></p>
|
||||
|
||||
|
|
@ -119,7 +130,7 @@ $token = $_GET['token'] ?? $_POST['token'] ?? '';
|
|||
<?php if ($verified): ?>
|
||||
<h2>4. config.local.php 반영</h2>
|
||||
<textarea readonly>'ADMIN_TOTP_SECRET' => '<?= htmlspecialchars($secret) ?>',</textarea>
|
||||
<p class="caption">반영 후 `MFA_SETUP_TOKEN`은 빈 값으로 바꾸거나 삭제하세요.</p>
|
||||
<p class="muted">반영 후 `MFA_SETUP_TOKEN`은 빈 값으로 바꾸거나 삭제하세요.</p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'send_
|
|||
exit;
|
||||
}
|
||||
|
||||
$message = "[모니터링 테스트]\n"
|
||||
. "설치/운영 점검 문자\n"
|
||||
. date('m-d H:i');
|
||||
$message = "[온습도 모니터링 테스트]\n"
|
||||
. "설치/운영 점검 문자입니다.\n"
|
||||
. "시각: " . date('Y-m-d H:i:s');
|
||||
$results = [];
|
||||
foreach (SMS_RECIPIENTS as $receiver) {
|
||||
$digits = preg_replace('/\D+/', '', $receiver);
|
||||
|
|
@ -52,64 +52,66 @@ $failed_count = ops_failed_count($checks);
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>설치 점검 - 온습도 모니터링 <?= htmlspecialchars(APP_VERSION) ?></title>
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<style>
|
||||
/* 설치 점검 전용 잔여 스타일 — 값은 app.css 토큰(var)만 사용 */
|
||||
.wiz-summary { margin-bottom: var(--sp-5); }
|
||||
.wiz-summary strong { font-size: 1.25rem; }
|
||||
.check-list { display: grid; gap: var(--sp-3); margin-bottom: var(--sp-5); }
|
||||
/* 점검 카드 = .panel + 좌측레일(도형). 상태 텍스트는 .check-ok/.check-fail(글리프+색) */
|
||||
.check-list .panel { border-left: 6px solid var(--normal); }
|
||||
.check-list .panel.is-fail { border-left-color: var(--leak); background: var(--leak-bg); }
|
||||
.wiz-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; margin: var(--sp-5) 0; }
|
||||
.wiz-actions button {
|
||||
display: inline-block; background: var(--primary); color: var(--on-primary);
|
||||
border: 0; border-radius: var(--r-sm); padding: 10px 16px;
|
||||
font-size: .8125rem; font-weight: 700; font-family: var(--font); cursor: pointer;
|
||||
}
|
||||
.wiz-actions button:hover { background: var(--primary-600); }
|
||||
@media print { .wiz-actions { display: none; } }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; }
|
||||
header { background: #1a56db; color: #fff; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; }
|
||||
header h1 { font-size: 1.15rem; margin: 0; }
|
||||
header a { color: #fff; text-decoration: none; background: rgba(255,255,255,.15); padding: 7px 12px; border-radius: 6px; font-size: .85rem; }
|
||||
.container { max-width: 980px; margin: 24px auto; padding: 0 16px; }
|
||||
.summary { background: #fff; border-radius: 8px; padding: 18px; box-shadow: 0 1px 4px rgba(0,0,0,.1); margin-bottom: 18px; }
|
||||
.summary strong { font-size: 1.2rem; color: <?= $failed_count ? '#b91c1c' : '#166534' ?>; }
|
||||
.check-list { display: grid; gap: 10px; }
|
||||
.check { background: #fff; border-radius: 8px; padding: 14px 16px; box-shadow: 0 1px 4px rgba(0,0,0,.08); border-left: 6px solid #22c55e; }
|
||||
.check.fail { border-left-color: #ef4444; }
|
||||
.check h2 { margin: 0 0 6px; font-size: .98rem; }
|
||||
.check p { margin: 0; color: #64748b; font-size: .84rem; }
|
||||
.fix { margin-top: 7px; color: #92400e; font-size: .8rem; }
|
||||
.actions { margin: 18px 0; display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
button { border: 0; background: #1a56db; color: #fff; padding: 10px 14px; border-radius: 7px; font-weight: 700; cursor: pointer; }
|
||||
.result { background: #fff; border-radius: 8px; padding: 14px 16px; box-shadow: 0 1px 4px rgba(0,0,0,.08); margin-top: 18px; }
|
||||
@media (max-width: 520px) { header { align-items: flex-start; flex-direction: column; gap: 10px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>설치/운영 점검 <?= htmlspecialchars(APP_VERSION) ?></h1>
|
||||
<div class="top-actions">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<a href="dashboard.php">대시보드</a>
|
||||
<a href="security_evidence.php">보안 증적</a>
|
||||
</div>
|
||||
</header>
|
||||
<main class="container">
|
||||
<section class="panel wiz-summary">
|
||||
<strong class="<?= $failed_count === 0 ? 'check-ok' : 'check-fail' ?>"><?= $failed_count === 0 ? '전체 점검 통과' : "확인 필요 {$failed_count}건" ?></strong>
|
||||
<p class="caption">운영 반영 전 이 화면에서 설정, DB, 권한, SMS를 확인합니다.</p>
|
||||
<section class="summary">
|
||||
<strong><?= $failed_count === 0 ? '전체 점검 통과' : "확인 필요 {$failed_count}건" ?></strong>
|
||||
<div style="color:#64748b;font-size:.86rem;margin-top:6px">운영 반영 전 이 화면에서 설정, DB, 권한, SMS를 확인합니다.</div>
|
||||
</section>
|
||||
|
||||
<div class="check-list">
|
||||
<?php foreach ($checks as $check): ?>
|
||||
<section class="panel<?= $check['ok'] ? '' : ' is-fail' ?>">
|
||||
<h2><?= htmlspecialchars($check['category']) ?> · <?= htmlspecialchars($check['label']) ?> · <span class="<?= $check['ok'] ? 'check-ok' : 'check-fail' ?>"><?= $check['ok'] ? '정상' : '확인 필요' ?></span></h2>
|
||||
<p class="caption"><?= htmlspecialchars($check['detail']) ?></p>
|
||||
<section class="check <?= $check['ok'] ? '' : 'fail' ?>">
|
||||
<h2><?= htmlspecialchars($check['category']) ?> · <?= htmlspecialchars($check['label']) ?> · <?= $check['ok'] ? '정상' : '확인 필요' ?></h2>
|
||||
<p><?= htmlspecialchars($check['detail']) ?></p>
|
||||
<?php if (!$check['ok'] && $check['fix'] !== ''): ?>
|
||||
<p class="caption">조치: <?= htmlspecialchars($check['fix']) ?></p>
|
||||
<div class="fix">조치: <?= htmlspecialchars($check['fix']) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (($check['evidence'] ?? '') !== ''): ?>
|
||||
<p class="caption">증적: <?= htmlspecialchars($check['evidence']) ?></p>
|
||||
<div class="fix">증적: <?= htmlspecialchars($check['evidence']) ?></div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="wiz-actions">
|
||||
<form method="POST" class="actions">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||
<button type="submit" name="action" value="send_test_sms">테스트 SMS 발송</button>
|
||||
</form>
|
||||
|
||||
<?php if ($sms_test_result !== null): ?>
|
||||
<section class="panel">
|
||||
<h2>테스트 SMS 결과</h2>
|
||||
<section class="result">
|
||||
<strong>테스트 SMS 결과</strong>
|
||||
<?php foreach ($sms_test_result as $row): ?>
|
||||
<p class="caption"><?= htmlspecialchars(mask_phone($row['receiver'])) ?> · <span class="<?= $row['result']['success'] ? 'check-ok' : 'check-fail' ?>"><?= $row['result']['success'] ? '성공' : '실패' ?></span> · <?= htmlspecialchars($row['result']['message']) ?></p>
|
||||
<p><?= htmlspecialchars(mask_phone($row['receiver'])) ?> · <?= $row['result']['success'] ? '성공' : '실패' ?> · <?= htmlspecialchars($row['result']['message']) ?></p>
|
||||
<?php endforeach; ?>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
|
|
|||
544
php/sms_send.php
544
php/sms_send.php
|
|
@ -12,10 +12,6 @@
|
|||
// =============================================================================
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
// dew_point_c() 는 forecast.php 의 정규(nullable-safe) 정의를 단일 출처로 사용한다.
|
||||
// (과거 이 파일에도 중복 정의가 있어 forecast.php 와 함께 로드되는 dashboard 에서
|
||||
// Cannot redeclare 치명오류가 났다 — 단일 정의로 통합.)
|
||||
require_once __DIR__ . '/forecast.php';
|
||||
|
||||
/**
|
||||
* 발신번호를 3분할 (sphone1, sphone2, sphone3)
|
||||
|
|
@ -173,7 +169,7 @@ function send_sms_cafe24(string $receiver, string $message): array {
|
|||
}
|
||||
|
||||
/**
|
||||
* 완성된 메시지를 모든 수신자(SMS_RECIPIENTS)에게 발송하고 leak_sms_log 에 기록한다.
|
||||
* 완성된 메시지를 모든 수신자(SMS_RECIPIENTS)에게 발송하고 sms_log 에 기록한다.
|
||||
*
|
||||
* @param PDO $db
|
||||
* @param array $sensor_data ['sensor_id','sensor_name','device_id', ...]
|
||||
|
|
@ -182,12 +178,10 @@ function send_sms_cafe24(string $receiver, string $message): array {
|
|||
* @return bool 전원 발송 성공 여부
|
||||
*/
|
||||
function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message, string $logLabel, ?array $recipients = null): bool {
|
||||
[, $name] = sensor_label((int)($sensor_data['sensor_id'] ?? 0), null, $sensor_data['sensor_name'] ?? null);
|
||||
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
|
||||
$allSuccess = true;
|
||||
$lastRemaining = null; // 게이트웨이가 알려준 최신 잔여 건수(잔액 사전경보용)
|
||||
// 명시 전달($recipients: 에스컬레이션 TIER2)이 우선. 없으면 센서별 수신자 그룹
|
||||
// (SENSOR_RECIPIENTS[sid]) → 지정 없으면 전역 SMS_RECIPIENTS 로 폴백.
|
||||
$targets = $recipients ?? recipients_for_sensor((int)($sensor_data['sensor_id'] ?? 0));
|
||||
$targets = $recipients ?? SMS_RECIPIENTS; // 기본 1차, 에스컬레이션은 TIER2 전달
|
||||
|
||||
foreach ($targets as $receiver) {
|
||||
$receiverDigits = preg_replace('/\D+/', '', $receiver);
|
||||
|
|
@ -197,7 +191,7 @@ function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message
|
|||
}
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO leak_sms_log
|
||||
INSERT INTO sms_log
|
||||
(sensor_id, sensor_name, device_id, message, receivers,
|
||||
status, result_code, result_message, sent_at)
|
||||
VALUES
|
||||
|
|
@ -240,7 +234,7 @@ function maybe_warn_sms_balance(PDO $db, ?int $remaining): void {
|
|||
}
|
||||
// 쿨다운: 최근 '[SMS 잔액부족]' 성공/실패 불문 발송이 있으면 생략(반복 폭주 방지).
|
||||
$recent = $db->prepare("
|
||||
SELECT sent_at FROM leak_sms_log
|
||||
SELECT sent_at FROM sms_log
|
||||
WHERE message LIKE :tag
|
||||
ORDER BY sent_at DESC
|
||||
LIMIT 1
|
||||
|
|
@ -252,14 +246,15 @@ function maybe_warn_sms_balance(PDO $db, ?int $remaining): void {
|
|||
}
|
||||
|
||||
$message = SMS_TAG_BALANCE_LOW . "\n"
|
||||
. "잔여 {$remaining}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건)\n"
|
||||
. "충전 필요 · " . date('m-d H:i');
|
||||
. "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 leak_sms_log
|
||||
INSERT INTO sms_log
|
||||
(sensor_id, sensor_name, device_id, message, receivers,
|
||||
status, result_code, result_message, sent_at)
|
||||
VALUES
|
||||
|
|
@ -288,7 +283,19 @@ function metric_value_str(float $value): string {
|
|||
return $s;
|
||||
}
|
||||
|
||||
// dew_point_c() 정의는 forecast.php 로 이동(단일 출처). 여기서는 require 로 로드해 사용.
|
||||
/**
|
||||
* 이슬점(℃) — Magnus 근사(물, 약 -45~60℃, 계수 a=17.62 b=243.12).
|
||||
* 상대습도 0% 이하면 이슬점 정의 불가로 null(결로 위험 없음으로 취급).
|
||||
*/
|
||||
function dew_point_c(float $temp_c, float $rh): ?float {
|
||||
if ($rh <= 0.0) {
|
||||
return null;
|
||||
}
|
||||
$a = 17.62;
|
||||
$b = 243.12;
|
||||
$gamma = ($a * $temp_c) / ($b + $temp_c) + log($rh / 100.0);
|
||||
return ($b * $gamma) / ($a - $gamma);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결로마진(℃) = 현재온도 − 이슬점. 좁을수록 결로 위험. 측정 결측/산출불가면 null.
|
||||
|
|
@ -320,21 +327,11 @@ function condensation_is_clear(?float $temp_c, ?float $rh): bool {
|
|||
}
|
||||
|
||||
/**
|
||||
* 온습도·미세먼지 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다.
|
||||
*
|
||||
* pm25/pm10 은 PMS7003(µg/m³ 대기환산 정수)용 선택 인자다(온습도 전용 호출은 종전대로
|
||||
* 두 인자만 넘기면 된다 — 후방호환). PMS 는 누수 노드(sensor_id 1·3)에 온습도와 같은
|
||||
* sensor_id 로 통합되며 metric_type='pms7003' 로 구분되므로, 한 보고에는 온습도 또는
|
||||
* 미세먼지 중 한쪽만 실린다.
|
||||
*
|
||||
* @param ?float $temp_c 온도(℃) — 없으면 null
|
||||
* @param ?float $rh 습도(%) — 없으면 null
|
||||
* @param ?float $pm25 PM2.5(µg/m³) — 없으면 null
|
||||
* @param ?float $pm10 PM10(µg/m³) — 없으면 null
|
||||
* 온습도 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다.
|
||||
* @return array{breaches: string[], status: string}
|
||||
* breaches: 발생한 경보 종류 목록, status: leak_sensor_metric 저장용 대표 상태
|
||||
* breaches: 발생한 경보 종류 목록, status: sensor_metric 저장용 대표 상태
|
||||
*/
|
||||
function evaluate_metric_thresholds(?float $temp_c, ?float $rh, ?float $pm25 = null, ?float $pm10 = null): array {
|
||||
function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array {
|
||||
$breaches = [];
|
||||
if ($temp_c !== null) {
|
||||
if ($temp_c >= METRIC_TEMP_HIGH_C) {
|
||||
|
|
@ -358,13 +355,6 @@ function evaluate_metric_thresholds(?float $temp_c, ?float $rh, ?float $pm25 = n
|
|||
$breaches[] = 'condensation_risk';
|
||||
}
|
||||
}
|
||||
// 미세먼지(PMS7003): PM2.5·PM10 각각 고농도 임계 초과 시 발화. 온습도와 독립.
|
||||
if ($pm25 !== null && $pm25 > METRIC_PM25_HIGH) {
|
||||
$breaches[] = 'high_pm25';
|
||||
}
|
||||
if ($pm10 !== null && $pm10 > METRIC_PM10_HIGH) {
|
||||
$breaches[] = 'high_pm10';
|
||||
}
|
||||
return [
|
||||
'breaches' => $breaches,
|
||||
'status' => empty($breaches) ? 'normal' : $breaches[0],
|
||||
|
|
@ -389,7 +379,7 @@ function metric_is_recovered(?float $temp_c, ?float $rh): bool {
|
|||
|
||||
/**
|
||||
* 센서가 현재 임계 경보 상태인지 판정한다(최근 metric SMS가 경보이고 그 뒤 복귀가
|
||||
* 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(leak_sms_log 기준).
|
||||
* 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(sms_log 기준).
|
||||
*/
|
||||
function metric_was_alerting(PDO $db, int $sensor_id): bool {
|
||||
// 태그는 config.php 상수(단일 출처)에서 가져와 바인딩한다. 문자열 하드코딩 금지.
|
||||
|
|
@ -407,7 +397,7 @@ function metric_was_alerting(PDO $db, int $sensor_id): bool {
|
|||
$likes[] = "message LIKE {$key}";
|
||||
$params[$key] = $tag . '%';
|
||||
}
|
||||
$sql = "SELECT message FROM leak_sms_log
|
||||
$sql = "SELECT message FROM sms_log
|
||||
WHERE sensor_id = :sid
|
||||
AND status = 'success'
|
||||
AND (" . implode(' OR ', $likes) . ")
|
||||
|
|
@ -422,82 +412,6 @@ function metric_was_alerting(PDO $db, int $sensor_id): bool {
|
|||
return !str_starts_with($msg, SMS_TAG_RECOVERY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 에피소드 인지 쿨다운 판정(순수 함수 — DB/네트워크 없음, 테스트 용이).
|
||||
*
|
||||
* $latestMessage/$latestSentAt 은 "이 경보 태그" 또는 "복구 태그" 로 시작하는 SMS 중
|
||||
* 가장 최근 1건(없으면 둘 다 null). 최신이 경보 태그로 시작하고(=아직 미복구) 발송
|
||||
* 시각이 쿨다운 이내이면 true(생략). 최신이 복구면 에피소드 종료 → false(발송).
|
||||
*
|
||||
* 이렇게 하면 "같은 경보 지속 중"의 중복 폭주는 계속 억제하되, 복구 후 재발한 경보는
|
||||
* 별개 에피소드로 보아 즉시 발송한다(쿨다운이 복구 직후의 새 사고를 삼키던 버그 수정).
|
||||
*
|
||||
* @param ?string $latestMessage 최신 경보/복구 SMS 본문(없으면 null)
|
||||
* @param ?string $latestSentAt 그 sent_at(파싱 가능한 일시 문자열, 없으면 null)
|
||||
* @param string $alertTag 이 경보의 태그(prefix). 최신이 이걸로 시작해야 억제 후보.
|
||||
* @param int $cooldownSec 쿨다운(초)
|
||||
* @param int $now 현재 Unix 시각(테스트 주입용)
|
||||
*/
|
||||
function alert_cooldown_suppresses(
|
||||
?string $latestMessage,
|
||||
?string $latestSentAt,
|
||||
string $alertTag,
|
||||
int $cooldownSec,
|
||||
int $now
|
||||
): bool {
|
||||
if ($latestMessage === null || $latestSentAt === null) {
|
||||
return false; // 이력 없음 → 발송
|
||||
}
|
||||
if (!str_starts_with($latestMessage, $alertTag)) {
|
||||
return false; // 최신이 복구(또는 다른 상태) → 새 에피소드 → 발송
|
||||
}
|
||||
return ($now - strtotime($latestSentAt)) < $cooldownSec;
|
||||
}
|
||||
|
||||
/**
|
||||
* 누수 감지/복구 디바운스 판정(순수 함수 — DB/네트워크 없음, 테스트 용이).
|
||||
*
|
||||
* raw(현재 실제) 상태가 확정 상태와 어긋나면 확인창 동안 지속돼야 확정한다. 단일 타이머로
|
||||
* 감지·복구를 대칭 처리한다. raw 가 확정과 같아지면(짧은 블립이 원상 복귀) pending 을
|
||||
* 즉시 해제해, 감지↔복구 채터링(플래핑)이 SMS 로 번지는 것을 원천 차단한다.
|
||||
*
|
||||
* @param int $raw 0/1 현재 raw 누수 상태
|
||||
* @param int $confirmed 0/1 현재 확정 누수 상태(SMS/사고추적 기준)
|
||||
* @param ?int $pendingSinceTs 미확정 전이 시작 Unix (안정이면 null)
|
||||
* @param int $alertConfirmSec raw=1 확정에 필요한 지속(초)
|
||||
* @param int $recoveryConfirmSec raw=0 확정에 필요한 지속(초)
|
||||
* @param int $now 현재 Unix (테스트 주입)
|
||||
* @return array{confirmed:int, pending_since:?int, sms:?string}
|
||||
* sms: 확정 시 'leak'|'recovery', 아니면 null.
|
||||
*/
|
||||
function leak_debounce_decide(
|
||||
int $raw,
|
||||
int $confirmed,
|
||||
?int $pendingSinceTs,
|
||||
int $alertConfirmSec,
|
||||
int $recoveryConfirmSec,
|
||||
int $now
|
||||
): array {
|
||||
// raw 가 확정과 같음 → 안정. 진행 중이던 반대 전이(pending)를 해제한다(블립 필터).
|
||||
if ($raw === $confirmed) {
|
||||
return ['confirmed' => $confirmed, 'pending_since' => null, 'sms' => null];
|
||||
}
|
||||
// raw != confirmed → 전이 대기.
|
||||
if ($pendingSinceTs === null) {
|
||||
return ['confirmed' => $confirmed, 'pending_since' => $now, 'sms' => null]; // 타이머 시작
|
||||
}
|
||||
$threshold = ($raw === 1) ? $alertConfirmSec : $recoveryConfirmSec;
|
||||
if (($now - $pendingSinceTs) >= $threshold) {
|
||||
// 확정: 확정 상태를 raw 로 갱신하고 SMS 1회.
|
||||
return [
|
||||
'confirmed' => $raw,
|
||||
'pending_since' => null,
|
||||
'sms' => ($raw === 1) ? 'leak' : 'recovery',
|
||||
];
|
||||
}
|
||||
return ['confirmed' => $confirmed, 'pending_since' => $pendingSinceTs, 'sms' => null]; // 대기 유지
|
||||
}
|
||||
|
||||
/**
|
||||
* 온습도 임계 경보 SMS 발송 (종류별 쿨다운 포함).
|
||||
*
|
||||
|
|
@ -505,8 +419,8 @@ function leak_debounce_decide(
|
|||
* 이내면 발송을 생략한다(중복 폭주 방지).
|
||||
*
|
||||
* @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location']
|
||||
* @param string $kind 'high_temp'|'low_temp'|'high_humidity'|'low_humidity'|'high_pm25'|'high_pm10'
|
||||
* @param float $value 측정값 (온도℃ / 습도% / 미세먼지 µg/m³)
|
||||
* @param string $kind 'high_temp'|'low_temp'|'high_humidity'|'low_humidity'
|
||||
* @param float $value 측정값 (온도℃ 또는 습도%)
|
||||
* @return bool 발송 시도 여부 (쿨다운/오류로 생략 시 false)
|
||||
*/
|
||||
function send_metric_alert_sms(array $sensor_data, string $kind, float $value): bool {
|
||||
|
|
@ -514,16 +428,12 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
|
|||
$sensor_id = (int)$sensor_data['sensor_id'];
|
||||
|
||||
// [태그, 지표명, 단위, 임계값, 방향문구] (태그는 config.php 단일 출처 상수)
|
||||
// 화재의심(high_pm25/high_pm10)은 PM2.5·PM10 이 같은 태그([화재의심])를 쓰되 지표명·
|
||||
// 임계값으로 구분한다. 쿨다운은 태그 기준이므로 두 종류가 같은 쿨다운 창을 공유한다.
|
||||
$labels = [
|
||||
'high_temp' => [SMS_TAG_HIGH_TEMP, '온도', '℃', METRIC_TEMP_HIGH_C, '이상'],
|
||||
'low_temp' => [SMS_TAG_LOW_TEMP, '온도', '℃', METRIC_TEMP_LOW_C, '이하'],
|
||||
'high_humidity' => [SMS_TAG_HIGH_HUMIDITY, '습도', '%', METRIC_RH_HIGH, '이상'],
|
||||
'low_humidity' => [SMS_TAG_LOW_HUMIDITY, '습도', '%', METRIC_RH_LOW, '이하'],
|
||||
'condensation_risk' => [SMS_TAG_CONDENSATION, '결로마진', '℃', METRIC_DEWPOINT_MARGIN_C, '이하'],
|
||||
'high_pm25' => [SMS_TAG_HIGH_PM, 'PM2.5', '㎍/㎥', METRIC_PM25_HIGH, '초과'],
|
||||
'high_pm10' => [SMS_TAG_HIGH_PM, 'PM10', '㎍/㎥', METRIC_PM10_HIGH, '초과'],
|
||||
];
|
||||
if (!isset($labels[$kind])) {
|
||||
error_log("[SMS] 알 수 없는 임계 경보 종류: {$kind}");
|
||||
|
|
@ -532,7 +442,7 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
|
|||
[$tag, $metricName, $unit, $threshold, $dir] = $labels[$kind];
|
||||
|
||||
// ── 동시 발송 직렬화 (중복 SMS 방지) ────────────────────────
|
||||
// 쿨다운 검사→발송→leak_sms_log 기록 사이에 다른 요청(서로 다른 측정의 동시 수신 등)이
|
||||
// 쿨다운 검사→발송→sms_log 기록 사이에 다른 요청(서로 다른 측정의 동시 수신 등)이
|
||||
// 끼어들면 둘 다 쿨다운을 통과해 같은 경보를 중복 발송할 수 있다. (센서,종류) 단위
|
||||
// MySQL 네임드 락으로 직렬화한다. 락이 잡혀 있으면(busy) 다른 프로세스가 같은 경보를
|
||||
// 처리 중이므로 생략한다. GET_LOCK 미지원/오류(NULL)면 종전 동작(락 없이 진행)으로
|
||||
|
|
@ -556,44 +466,35 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
|
|||
}
|
||||
|
||||
try {
|
||||
// ── 종류별 쿨다운 (에피소드 인지) ────────────────────────────────
|
||||
// 이 종류 경보([고온경보] 등)와 정상복귀([정상복귀]) 중 최신 1건을 본다. 최신이
|
||||
// 이 종류 경보이고(=아직 미복귀) 쿨다운 이내일 때만 생략한다. 최신이 정상복귀면
|
||||
// 에피소드가 끝난 것이라 재발한 경보는 별개 에피소드 → 즉시 발송한다.
|
||||
// (과거 버그: 종류 태그만 봐서, 복귀 직후 재발한 경보가 이전 경보의 쿨다운에
|
||||
// 걸려 통째로 누락됐다.)
|
||||
// ── 종류별 쿨다운 (sms_log 메시지 prefix 기준) ──────────────
|
||||
$recent = $db->prepare("
|
||||
SELECT message, sent_at FROM leak_sms_log
|
||||
SELECT sent_at FROM sms_log
|
||||
WHERE sensor_id = :sensor_id
|
||||
AND status = 'success'
|
||||
AND (message LIKE :alert OR message LIKE :recovery)
|
||||
ORDER BY sent_at DESC, id DESC
|
||||
AND message LIKE :tag
|
||||
ORDER BY sent_at DESC
|
||||
LIMIT 1
|
||||
");
|
||||
$recent->execute([
|
||||
':sensor_id' => $sensor_id,
|
||||
':alert' => $tag . '%',
|
||||
':recovery' => SMS_TAG_RECOVERY . '%',
|
||||
]);
|
||||
$row = $recent->fetch();
|
||||
$lastMsg = ($row === false) ? null : (string)$row['message'];
|
||||
$lastAt = ($row === false) ? null : (string)$row['sent_at'];
|
||||
if (alert_cooldown_suppresses($lastMsg, $lastAt, $tag, METRIC_ALERT_COOLDOWN_SEC, time())) {
|
||||
$elapsed = time() - strtotime((string)$lastAt);
|
||||
error_log("[SMS] {$tag} 쿨다운 생략(지속중) - 센서{$sensor_id} ({$elapsed}s/" . METRIC_ALERT_COOLDOWN_SEC . "s)");
|
||||
$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;
|
||||
}
|
||||
|
||||
// ── 메시지 구성 (단문 90B EUC-KR 이내) ─────────────────────
|
||||
$dt = date('m-d H:i');
|
||||
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
|
||||
// ── 메시지 구성 ──────────────────────────────────────────
|
||||
$dt = date('Y-m-d H:i:s');
|
||||
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}";
|
||||
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
||||
$valStr = metric_value_str($value);
|
||||
$thrStr = metric_value_str((float)$threshold);
|
||||
$op = ($dir === '이하') ? '>' : '<'; // 정상 범위 방향(임계 미만/초과)
|
||||
|
||||
$message = "{$tag} {$loc}\n"
|
||||
. "{$metricName} {$valStr}{$unit} 정상{$op}{$thrStr}\n"
|
||||
. "{$dt} 즉시확인";
|
||||
$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 {
|
||||
|
|
@ -618,12 +519,16 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
|
|||
function send_metric_recovery_sms(array $sensor_data, float $temp_c, float $rh): bool {
|
||||
$db = get_db();
|
||||
|
||||
$dt = date('m-d H:i');
|
||||
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
|
||||
$dt = date('Y-m-d H:i:s');
|
||||
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
|
||||
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
||||
|
||||
$message = SMS_TAG_RECOVERY . " {$loc}\n"
|
||||
. "온도 " . metric_value_str($temp_c) . "℃ 습도 " . metric_value_str($rh) . "%\n"
|
||||
. "{$dt} 정상복귀";
|
||||
$message = SMS_TAG_RECOVERY . "\n"
|
||||
. "위치: {$loc}\n"
|
||||
. "센서: {$name}\n"
|
||||
. "온도 " . metric_value_str($temp_c) . "℃ / 습도 " . metric_value_str($rh) . "%\n"
|
||||
. "시각: {$dt}\n"
|
||||
. "온습도가 정상 범위로 복귀했습니다.";
|
||||
|
||||
return sms_dispatch_to_recipients($db, $sensor_data, $message, "정상복귀");
|
||||
}
|
||||
|
|
@ -662,41 +567,33 @@ function send_leak_alert_sms(array $sensor_data): bool {
|
|||
}
|
||||
|
||||
try {
|
||||
// ── 쿨다운 (에피소드 인지) ──
|
||||
// [누수감지]와 [누수복구] 중 최신 1건을 본다. 최신이 [누수감지](=아직 미복구)이고
|
||||
// 쿨다운 이내일 때만 생략(지속 누수 중 주기보고 폭주 방지). 최신이 [누수복구]면
|
||||
// 사고가 끝난 것이므로 재발한 누수는 별개 에피소드 → 즉시 발송한다.
|
||||
// (과거 버그: [누수감지] 태그만 봐서, 복구 직후 재발한 누수가 이전 감지의 쿨다운에
|
||||
// 걸려 통째로 누락됐다.)
|
||||
// ── 쿨다운 (sms_log 메시지 prefix 기준) ──
|
||||
$recent = $db->prepare("
|
||||
SELECT message, sent_at FROM leak_sms_log
|
||||
SELECT sent_at FROM sms_log
|
||||
WHERE sensor_id = :sensor_id
|
||||
AND status = 'success'
|
||||
AND (message LIKE :alert OR message LIKE :recovery)
|
||||
ORDER BY sent_at DESC, id DESC
|
||||
AND message LIKE :tag
|
||||
ORDER BY sent_at DESC
|
||||
LIMIT 1
|
||||
");
|
||||
$recent->execute([
|
||||
':sensor_id' => $sensor_id,
|
||||
':alert' => SMS_TAG_LEAK_ALERT . '%',
|
||||
':recovery' => SMS_TAG_LEAK_RECOVERY . '%',
|
||||
]);
|
||||
$row = $recent->fetch();
|
||||
$lastMsg = ($row === false) ? null : (string)$row['message'];
|
||||
$lastAt = ($row === false) ? null : (string)$row['sent_at'];
|
||||
if (alert_cooldown_suppresses($lastMsg, $lastAt, SMS_TAG_LEAK_ALERT, LEAK_ALERT_COOLDOWN_SEC, time())) {
|
||||
$elapsed = time() - strtotime((string)$lastAt);
|
||||
error_log("[SMS] " . SMS_TAG_LEAK_ALERT . " 쿨다운 생략(지속중) - 센서{$sensor_id} ({$elapsed}s/" . LEAK_ALERT_COOLDOWN_SEC . "s)");
|
||||
$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('m-d H:i');
|
||||
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
|
||||
$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 . " {$loc}\n"
|
||||
. "누수 감지! 즉시 확인\n"
|
||||
. "{$dt}";
|
||||
$message = SMS_TAG_LEAK_ALERT . "\n"
|
||||
. "위치: {$loc}\n"
|
||||
. "센서: {$name}\n"
|
||||
. "누수가 감지되었습니다. 즉시 확인하세요!\n"
|
||||
. "시각: {$dt}";
|
||||
|
||||
return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수감지");
|
||||
} finally {
|
||||
|
|
@ -724,7 +621,7 @@ function send_leak_recovery_sms(array $sensor_data): bool {
|
|||
// 직전 누수 상태 SMS([누수감지]/[누수복구]) 중 최신 1건을 본다. 최신이 [누수감지]일
|
||||
// 때만(=아직 복구 통지 전) 복구 SMS 를 보낸다.
|
||||
$stmt = $db->prepare("
|
||||
SELECT message FROM leak_sms_log
|
||||
SELECT message FROM sms_log
|
||||
WHERE sensor_id = :sid
|
||||
AND status = 'success'
|
||||
AND (message LIKE :alert OR message LIKE :recovery)
|
||||
|
|
@ -741,12 +638,15 @@ function send_leak_recovery_sms(array $sensor_data): bool {
|
|||
return false; // 직전 누수 경보 없음(또는 이미 복구 통지함) → 발송 안 함
|
||||
}
|
||||
|
||||
$dt = date('m-d H:i');
|
||||
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
|
||||
$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 . " {$loc}\n"
|
||||
. "누수 해소(복구)\n"
|
||||
. "{$dt}";
|
||||
$message = SMS_TAG_LEAK_RECOVERY . "\n"
|
||||
. "위치: {$loc}\n"
|
||||
. "센서: {$name}\n"
|
||||
. "누수가 해소(복구)되었습니다.\n"
|
||||
. "시각: {$dt}";
|
||||
|
||||
return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수복구");
|
||||
}
|
||||
|
|
@ -760,11 +660,17 @@ function send_leak_recovery_sms(array $sensor_data): bool {
|
|||
function send_device_offline_sms(array $sensor_data): bool {
|
||||
$db = get_db();
|
||||
|
||||
$dt = date('m-d H:i');
|
||||
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
|
||||
$message = SMS_TAG_DEVICE_OFFLINE . " {$loc}\n"
|
||||
. "통신 두절 · 장비 확인\n"
|
||||
. "{$dt}";
|
||||
$dt = date('Y-m-d H:i:s');
|
||||
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
|
||||
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
||||
$last = $sensor_data['last_seen'] ?? '알 수 없음';
|
||||
|
||||
$message = SMS_TAG_DEVICE_OFFLINE . "\n"
|
||||
. "위치: {$loc}\n"
|
||||
. "센서: {$name}\n"
|
||||
. "마지막 통신: {$last}\n"
|
||||
. "감지 시각: {$dt}\n"
|
||||
. "장비 상태를 확인하세요!";
|
||||
|
||||
return sms_dispatch_to_recipients($db, $sensor_data, $message, "오프라인");
|
||||
}
|
||||
|
|
@ -778,12 +684,15 @@ function send_device_offline_sms(array $sensor_data): bool {
|
|||
function send_device_online_sms(array $sensor_data): bool {
|
||||
$db = get_db();
|
||||
|
||||
$dt = date('m-d H:i');
|
||||
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
|
||||
$dt = date('Y-m-d H:i:s');
|
||||
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
|
||||
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
||||
|
||||
$message = SMS_TAG_DEVICE_ONLINE . " {$loc}\n"
|
||||
. "통신 정상 복구\n"
|
||||
. "{$dt}";
|
||||
$message = SMS_TAG_DEVICE_ONLINE . "\n"
|
||||
. "위치: {$loc}\n"
|
||||
. "센서: {$name}\n"
|
||||
. "복구 시각: {$dt}\n"
|
||||
. "통신이 정상 복구되었습니다.";
|
||||
|
||||
return sms_dispatch_to_recipients($db, $sensor_data, $message, "장비복구");
|
||||
}
|
||||
|
|
@ -793,14 +702,14 @@ function send_device_online_sms(array $sensor_data): bool {
|
|||
* 예: "센서 2/2 정상, 최근수신 3분전, 24h경보 0건, 잔여 95건"
|
||||
*/
|
||||
function build_health_summary(PDO $db): string {
|
||||
$online = (int)$db->query("SELECT COUNT(*) FROM leak_sensor_status WHERE offline_alerted = 0")->fetchColumn();
|
||||
$total = (int)$db->query("SELECT COUNT(*) FROM leak_sensor_status")->fetchColumn();
|
||||
$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 leak_sensor_status")->fetchColumn();
|
||||
$recvStr = ($lastSeen === null) ? '수신없음' : ((int)$lastSeen . '분');
|
||||
$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 leak_sms_log
|
||||
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 . "%'
|
||||
|
|
@ -808,10 +717,10 @@ function build_health_summary(PDO $db): string {
|
|||
OR message LIKE '" . SMS_TAG_CONDENSATION . "%' OR message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%')
|
||||
")->fetchColumn();
|
||||
|
||||
$summary = "센서 {$online}/{$total} 최근{$recvStr} 24h경보{$alerts}건";
|
||||
$summary = "센서 {$online}/{$total} 정상, 최근수신 {$recvStr}, 24h경보 {$alerts}건";
|
||||
$bal = latest_sms_balance($db);
|
||||
if ($bal !== null) {
|
||||
$summary .= " 잔여{$bal}";
|
||||
$summary .= ", 잔여 {$bal}건";
|
||||
}
|
||||
return $summary;
|
||||
}
|
||||
|
|
@ -826,7 +735,7 @@ function maybe_send_daily_health_sms(PDO $db): void {
|
|||
return;
|
||||
}
|
||||
$today = $db->prepare("
|
||||
SELECT 1 FROM leak_sms_log
|
||||
SELECT 1 FROM sms_log
|
||||
WHERE message LIKE :tag AND DATE(sent_at) = CURDATE()
|
||||
LIMIT 1
|
||||
");
|
||||
|
|
@ -876,7 +785,7 @@ function maybe_send_daily_health_sms(PDO $db): void {
|
|||
*/
|
||||
function recent_tag_within(PDO $db, int $sensor_id, string $tag, int $seconds): bool {
|
||||
$stmt = $db->prepare("
|
||||
SELECT sent_at FROM leak_sms_log
|
||||
SELECT sent_at FROM sms_log
|
||||
WHERE sensor_id = :sid AND message LIKE :tag
|
||||
ORDER BY sent_at DESC LIMIT 1
|
||||
");
|
||||
|
|
@ -919,7 +828,7 @@ function latest_alert_state_row(PDO $db, int $sensor_id): ?array {
|
|||
}
|
||||
// id(AUTO_INCREMENT) 를 2차 정렬키로 둬, 같은 초에 경보·복귀가 기록돼도 최신 행이
|
||||
// 결정적으로 선택되게 한다(sent_at 은 1초 해상도).
|
||||
$sql = "SELECT message, sent_at FROM leak_sms_log
|
||||
$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";
|
||||
|
|
@ -939,7 +848,7 @@ function maybe_escalate_alerts(PDO $db): void {
|
|||
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 leak_sensor_status")->fetchAll();
|
||||
$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);
|
||||
|
|
@ -965,11 +874,13 @@ function maybe_escalate_alerts(PDO $db): void {
|
|||
continue;
|
||||
}
|
||||
$firstLine = strtok($msg, "\n");
|
||||
[$loc, $name] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null);
|
||||
$name = $s['sensor_name'] ?: "센서{$sid}";
|
||||
$loc = $s['device_location'] ?: '알 수 없음';
|
||||
$mins = (int)floor((time() - strtotime((string)$row['sent_at'])) / 60);
|
||||
$message = SMS_TAG_ESCALATION . "\n"
|
||||
. "{$firstLine} 미복귀 {$mins}분\n"
|
||||
. date('m-d H:i') . " 즉시확인";
|
||||
. "위치: {$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}분 미복귀)");
|
||||
|
|
@ -986,12 +897,12 @@ function maybe_detect_stuck_sensors(PDO $db): void {
|
|||
return;
|
||||
}
|
||||
$n = (int)STUCK_DETECT_SAMPLES; // 정수 상수 — LIMIT 에 안전하게 인라인
|
||||
$sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM leak_sensor_status")->fetchAll();
|
||||
$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 leak_sensor_metric
|
||||
FROM sensor_metric
|
||||
WHERE sensor_id = :sid
|
||||
ORDER BY measured_at DESC
|
||||
LIMIT {$n}
|
||||
|
|
@ -1012,10 +923,11 @@ function maybe_detect_stuck_sensors(PDO $db): void {
|
|||
if (recent_tag_within($db, $sid, SMS_TAG_SENSOR_STUCK, STUCK_ALERT_COOLDOWN_SEC)) {
|
||||
continue; // 쿨다운
|
||||
}
|
||||
[$loc, $name] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null);
|
||||
$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"
|
||||
$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];
|
||||
|
|
@ -1023,213 +935,3 @@ function maybe_detect_stuck_sensors(PDO $db): void {
|
|||
error_log("[SMS] 센서고착 의심 - 센서{$sid} (최근 {$n}회 평탄)");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 누수 디바운스 (감지/복구 확정 상태머신) ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* leak_sensor_status 에 디바운스 컬럼(leak_confirmed/leak_pending_since)이 있는지.
|
||||
* 없으면(구배포) 디바운스를 비활성하고 레거시 즉시 발송 경로로 graceful degrade 한다.
|
||||
* 1회 조회 후 캐시.
|
||||
*/
|
||||
function leak_status_has_debounce_cols(PDO $db): bool {
|
||||
static $cached = null;
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'leak_sensor_status'
|
||||
AND COLUMN_NAME IN ('leak_confirmed', 'leak_pending_since')
|
||||
");
|
||||
$stmt->execute();
|
||||
$cached = ((int)$stmt->fetchColumn() === 2);
|
||||
} catch (Throwable $e) {
|
||||
error_log("[Leak] 디바운스 컬럼 확인 실패: " . $e->getMessage());
|
||||
$cached = false;
|
||||
}
|
||||
return $cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확정 누수 사고(leak_incident)를 연다(미복구 사고 없을 때만). started_at=확정 시각(NOW),
|
||||
* log_id_alert=해당 센서의 최근 leak_alert 로그. 사고 테이블 없으면 조용히 스킵.
|
||||
*/
|
||||
function leak_incident_open(PDO $db, int $sensor_id, array $info): void {
|
||||
try {
|
||||
$open = $db->prepare("
|
||||
SELECT id FROM leak_incident
|
||||
WHERE sensor_id = :sid AND recovered_at IS NULL
|
||||
ORDER BY started_at DESC, id DESC LIMIT 1
|
||||
");
|
||||
$open->execute([':sid' => $sensor_id]);
|
||||
if ($open->fetchColumn() !== false) {
|
||||
return; // 이미 미복구 사고 있음
|
||||
}
|
||||
$lg = $db->prepare("
|
||||
SELECT id FROM leak_sensor_log
|
||||
WHERE sensor_id = :sid AND event_type = 'leak_alert'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
");
|
||||
$lg->execute([':sid' => $sensor_id]);
|
||||
$logId = $lg->fetchColumn();
|
||||
[$loc, $name] = sensor_label($sensor_id, $info['device_location'] ?? null, $info['sensor_name'] ?? null);
|
||||
$ins = $db->prepare("
|
||||
INSERT INTO leak_incident
|
||||
(sensor_id, device_id, sensor_name, device_location, started_at, log_id_alert, created_at)
|
||||
VALUES
|
||||
(:sid, :dev, :name, :loc, NOW(), :log, NOW())
|
||||
");
|
||||
$ins->execute([
|
||||
':sid' => $sensor_id,
|
||||
':dev' => $info['device_id'] ?? '',
|
||||
':name' => $name,
|
||||
':loc' => $loc,
|
||||
':log' => $logId !== false ? (int)$logId : null,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
if ($e->getCode() !== '42S02') { // 테이블 없음이면 조용히
|
||||
error_log("[Leak] incident open 오류: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 센서의 가장 최근 미복구 사고를 닫는다(recovered_at=NOW, log_id_recovery 링크).
|
||||
*/
|
||||
function leak_incident_close(PDO $db, int $sensor_id): void {
|
||||
try {
|
||||
$lg = $db->prepare("
|
||||
SELECT id FROM leak_sensor_log
|
||||
WHERE sensor_id = :sid AND event_type = 'leak_recovery'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
");
|
||||
$lg->execute([':sid' => $sensor_id]);
|
||||
$logId = $lg->fetchColumn();
|
||||
$close = $db->prepare("
|
||||
UPDATE leak_incident
|
||||
SET recovered_at = NOW(), log_id_recovery = :log
|
||||
WHERE sensor_id = :sid AND recovered_at IS NULL
|
||||
ORDER BY started_at DESC, id DESC LIMIT 1
|
||||
");
|
||||
$close->execute([
|
||||
':log' => $logId !== false ? (int)$logId : null,
|
||||
':sid' => $sensor_id,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
if ($e->getCode() !== '42S02') {
|
||||
error_log("[Leak] incident close 오류: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 누수 디바운스 상태머신 1스텝. raw(현재 실제 누수) 상태를 확정 상태와 비교해 확정 엣지에서만
|
||||
* SMS 를 1회 보낸다(감지→[누수감지], 복구→[누수복구]). 확정 시 leak_incident 도 함께 갱신.
|
||||
* (센서) 네임드 락으로 read-decide-update 를 직렬화해 동시 보고 경쟁을 막는다.
|
||||
*
|
||||
* 디바운스 비활성/컬럼 부재/상태행 부재면 false 를 반환하고, 호출자가 레거시 경로로 처리한다.
|
||||
*
|
||||
* @return bool 이번 호출에서 확정 SMS 를 발송했는지
|
||||
*/
|
||||
function apply_leak_debounce(PDO $db, int $sensor_id, int $raw, array $sensor_info): bool {
|
||||
if (!LEAK_DEBOUNCE_ENABLED || !leak_status_has_debounce_cols($db)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── 센서 단위 직렬화 ──
|
||||
$lockName = substr("leak_debounce_{$sensor_id}", 0, 64);
|
||||
$lockState = 'unsupported';
|
||||
try {
|
||||
$lk = $db->prepare('SELECT GET_LOCK(:n, 0)');
|
||||
$lk->execute([':n' => $lockName]);
|
||||
$res = $lk->fetchColumn();
|
||||
$lockState = ($res === null || $res === false)
|
||||
? 'unsupported'
|
||||
: (((int)$res === 1) ? 'acquired' : 'busy');
|
||||
} catch (Throwable $e) {
|
||||
$lockState = 'unsupported';
|
||||
}
|
||||
if ($lockState === 'busy') {
|
||||
return false; // 다른 프로세스가 이 센서를 처리 중
|
||||
}
|
||||
|
||||
try {
|
||||
$sel = $db->prepare("
|
||||
SELECT leak_confirmed, UNIX_TIMESTAMP(leak_pending_since) AS pend
|
||||
FROM leak_sensor_status WHERE sensor_id = :sid
|
||||
");
|
||||
$sel->execute([':sid' => $sensor_id]);
|
||||
$row = $sel->fetch();
|
||||
if ($row === false) {
|
||||
return false; // 상태행 없음(업서트 전) — 다음 보고에서 처리
|
||||
}
|
||||
$confirmed = (int)$row['leak_confirmed'];
|
||||
$pend = ($row['pend'] !== null) ? (int)$row['pend'] : null;
|
||||
|
||||
$d = leak_debounce_decide(
|
||||
$raw, $confirmed, $pend,
|
||||
LEAK_ALERT_CONFIRM_SEC, LEAK_RECOVERY_CONFIRM_SEC, time()
|
||||
);
|
||||
|
||||
// 상태 저장(변경 없어도 pending 해제/유지 반영 위해 항상 갱신).
|
||||
$upd = $db->prepare("
|
||||
UPDATE leak_sensor_status
|
||||
SET leak_confirmed = :c, leak_pending_since = :p
|
||||
WHERE sensor_id = :sid
|
||||
");
|
||||
$upd->execute([
|
||||
':c' => $d['confirmed'],
|
||||
':p' => $d['pending_since'] !== null ? date('Y-m-d H:i:s', $d['pending_since']) : null,
|
||||
':sid' => $sensor_id,
|
||||
]);
|
||||
|
||||
if ($d['sms'] === 'leak') {
|
||||
leak_incident_open($db, $sensor_id, $sensor_info);
|
||||
return function_exists('send_leak_alert_sms') ? send_leak_alert_sms($sensor_info) : false;
|
||||
}
|
||||
if ($d['sms'] === 'recovery') {
|
||||
leak_incident_close($db, $sensor_id);
|
||||
return function_exists('send_leak_recovery_sms') ? send_leak_recovery_sms($sensor_info) : false;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if ($lockState === 'acquired') {
|
||||
try {
|
||||
$db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => $lockName]);
|
||||
} catch (Throwable $e) {
|
||||
/* 해제 실패 무시 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 보고가 끊겨도(예: 복구 대기 중 장기 주기, 장비 무응답) 미확정 전이를 확정으로 해소한다.
|
||||
* leak_pending_since 가 세팅된 센서를 raw=is_leak 로 재평가한다. cron_heartbeat 가 주기 호출.
|
||||
*/
|
||||
function sweep_pending_leak_transitions(PDO $db): void {
|
||||
if (!LEAK_DEBOUNCE_ENABLED || !leak_status_has_debounce_cols($db)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$rows = $db->query("
|
||||
SELECT sensor_id, sensor_name, device_id, device_location, is_leak
|
||||
FROM leak_sensor_status WHERE leak_pending_since IS NOT NULL
|
||||
")->fetchAll();
|
||||
} catch (Throwable $e) {
|
||||
error_log("[Leak] pending sweep 조회 실패: " . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
foreach ($rows as $r) {
|
||||
$sid = (int)$r['sensor_id'];
|
||||
$info = [
|
||||
'sensor_id' => $sid,
|
||||
'sensor_name' => $r['sensor_name'],
|
||||
'device_id' => $r['device_id'],
|
||||
'device_location' => $r['device_location'],
|
||||
];
|
||||
apply_leak_debounce($db, $sid, (int)$r['is_leak'], $info);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
# tabs/ 는 dashboard.php 가 include 하는 뷰 partial 전용 — 웹 직접 접근 전면 차단.
|
||||
# (각 partial 의 APP_VERSION 가드 + 루트 .htaccess FilesMatch 와 삼중 방어)
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
<?php
|
||||
// tabs/history.php — [이력 조회] 탭. dashboard.php 셸에서 include 된다.
|
||||
// 장애 원인 추적용: 누수 사고·측정·이벤트·SMS 이력을 한 탭에서 대조한다.
|
||||
// 사용 변수: load_tab_history() 결과(extract) — $leak_incidents, $leak_photos_by_log,
|
||||
// $recent_metrics, $logs, $sms_logs, $sms_stats.
|
||||
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
|
||||
?>
|
||||
<div class="zone" id="tab-history">
|
||||
<div class="zone-label"><b>운영 이력</b> <span class="en">History</span></div>
|
||||
|
||||
<!-- 누수 사고 이력 + 사진 -->
|
||||
<h2 class="table-title">누수 사고 이력 <span class="h2-sub">최근 20건</span></h2>
|
||||
<details class="mobile-detail" id="d-incidents" data-mobile-collapsed open>
|
||||
<summary>누수 사고 이력 <span>최근 20건</span></summary>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-hide-xs">#</th><th>센서</th><th class="col-hide-xs">위치</th>
|
||||
<th>감지 시각</th><th>복구 시각</th><th>사진</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($leak_incidents as $inc):
|
||||
$is_open = ($inc['recovered_at'] === null);
|
||||
$lid = $inc['log_id_alert'] !== null ? (int)$inc['log_id_alert'] : 0;
|
||||
$photos = $leak_photos_by_log[$lid] ?? [];
|
||||
?>
|
||||
<tr class="<?= $is_open ? 'alert-row' : '' ?>">
|
||||
<td class="col-hide-xs"><?= (int)$inc['id'] ?></td>
|
||||
<td><?= htmlspecialchars($inc['sensor_name']) ?></td>
|
||||
<td class="col-hide-xs"><?= htmlspecialchars($inc['device_location']) ?></td>
|
||||
<td><?= htmlspecialchars($inc['started_at']) ?></td>
|
||||
<td>
|
||||
<?php if ($is_open): ?>
|
||||
<span class="pill leak">미복구</span>
|
||||
<?php else: ?>
|
||||
<?= htmlspecialchars($inc['recovered_at']) ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if (!empty($photos)): ?>
|
||||
<div class="leak-thumbs">
|
||||
<?php foreach ($photos as $ph): ?>
|
||||
<a href="photo.php?id=<?= (int)$ph['id'] ?>" target="_blank" rel="noopener">
|
||||
<img src="photo.php?id=<?= (int)$ph['id'] ?>"
|
||||
alt="누수 사진 <?= htmlspecialchars($ph['captured_at']) ?>"
|
||||
loading="lazy">
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="caption">-</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($leak_incidents)): ?>
|
||||
<tr><td colspan="6" class="empty-cell">아직 누수 사고 이력이 없습니다.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 최근 온습도 측정 이력 -->
|
||||
<h2 id="metrics-section" class="table-title">최근 온습도 측정 <span class="h2-sub">30건 기준</span></h2>
|
||||
<details class="mobile-detail" id="d-metrics" data-mobile-collapsed open>
|
||||
<summary>최근 온습도 측정 <span>30건 기준</span></summary>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>측정시각</th><th>센서</th><th class="col-hide-xs">위치</th>
|
||||
<th>온도</th><th>습도</th><th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($recent_metrics as $row):
|
||||
$status_code = $row['metric_status'] ?? 'normal';
|
||||
$is_alert = $status_code !== 'normal';
|
||||
?>
|
||||
<tr class="<?= $is_alert ? 'alert-row' : '' ?>">
|
||||
<td><?= htmlspecialchars($row['measured_at']) ?></td>
|
||||
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
|
||||
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></td>
|
||||
<td><?= htmlspecialchars(metric_display($row['temperature_c'], '°C')) ?></td>
|
||||
<td><?= htmlspecialchars(metric_display($row['humidity_percent'], '%')) ?></td>
|
||||
<td><span class="pill <?= htmlspecialchars($status_code) ?>"><?= htmlspecialchars(metric_status_label($status_code)) ?></span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($recent_metrics)): ?>
|
||||
<tr><td colspan="6" class="empty-cell">아직 온습도 측정 데이터가 없습니다.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 최근 미세먼지 측정 이력 (PMS7003 — 온습도와 별도 계통) -->
|
||||
<h2 id="pm-section" class="table-title">최근 화재감시(PM) 측정 <span class="h2-sub">30건 기준</span></h2>
|
||||
<details class="mobile-detail" id="d-pm" data-mobile-collapsed open>
|
||||
<summary>최근 화재감시(PM) 측정 <span>30건 기준</span></summary>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>측정시각</th><th>센서</th><th class="col-hide-xs">위치</th>
|
||||
<th class="col-hide-xs">PM1.0</th><th>PM2.5</th><th>PM10</th><th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach (($recent_pm ?? []) as $row):
|
||||
$status_code = $row['metric_status'] ?? 'normal';
|
||||
$is_alert = $status_code !== 'normal';
|
||||
?>
|
||||
<tr class="<?= $is_alert ? 'alert-row' : '' ?>">
|
||||
<td><?= htmlspecialchars($row['measured_at']) ?></td>
|
||||
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
|
||||
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></td>
|
||||
<td class="col-hide-xs"><?= $row['pm1_0'] !== null ? (int)$row['pm1_0'] . 'µg/m³' : '-' ?></td>
|
||||
<td><?= $row['pm2_5'] !== null ? (int)$row['pm2_5'] . 'µg/m³' : '-' ?></td>
|
||||
<td><?= $row['pm10'] !== null ? (int)$row['pm10'] . 'µg/m³' : '-' ?></td>
|
||||
<td><span class="pill <?= htmlspecialchars($status_code) ?>"><?= htmlspecialchars(metric_status_label($status_code)) ?></span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($recent_pm)): ?>
|
||||
<tr><td colspan="7" class="empty-cell">아직 화재감시(PM) 측정 데이터가 없습니다.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 센서 이벤트 로그 (startup/periodic/leak_alert/leak_recovery) -->
|
||||
<h2 id="logs-section" class="table-title">센서 이벤트 로그 <span class="h2-sub">30건 기준</span></h2>
|
||||
<details class="mobile-detail" id="d-logs" data-mobile-collapsed open>
|
||||
<summary>센서 이벤트 로그 <span>30건 기준</span></summary>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-hide-xs">#</th><th>시각</th><th>센서</th>
|
||||
<th class="col-hide-xs">위치</th><th>이벤트</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($logs as $row):
|
||||
$event_labels = [
|
||||
'startup' => '장비시작',
|
||||
'periodic' => '정기보고',
|
||||
'leak_alert' => '누수감지',
|
||||
'leak_recovery' => '누수복구',
|
||||
];
|
||||
$evt = $row['event_type'];
|
||||
?>
|
||||
<tr class="<?= $evt === 'leak_alert' ? 'alert-row' : '' ?>">
|
||||
<td class="col-hide-xs"><?= (int)$row['id'] ?></td>
|
||||
<td><?= htmlspecialchars($row['sensor_time']) ?></td>
|
||||
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
|
||||
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></td>
|
||||
<td>
|
||||
<?php if ($evt === 'leak_alert' || $evt === 'leak_recovery'): ?>
|
||||
<span class="pill <?= htmlspecialchars($evt) ?>"><?= htmlspecialchars($event_labels[$evt]) ?></span>
|
||||
<?php else: ?>
|
||||
<?= htmlspecialchars($event_labels[$evt] ?? $evt) ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($logs)): ?>
|
||||
<tr><td colspan="5" class="empty-cell">아직 센서 이벤트가 없습니다.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- SMS 발송 로그 -->
|
||||
<h2 id="sms-section" class="table-title">SMS 발송 이력 <span class="h2-sub">최근 20건</span></h2>
|
||||
<details class="mobile-detail" id="d-sms" data-mobile-collapsed open>
|
||||
<summary>SMS 발송 이력 <span>최근 20건</span></summary>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th class="col-hide-xs">#</th><th>발송시각</th><th>센서</th><th>수신자</th><th>상태</th><th class="col-hide-xs">결과</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($sms_logs as $row): ?>
|
||||
<tr>
|
||||
<td class="col-hide-xs"><?= (int)$row['id'] ?></td>
|
||||
<td><?= htmlspecialchars($row['sent_at']) ?></td>
|
||||
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
|
||||
<td><?= htmlspecialchars(mask_phone($row['receivers'] ?? '')) ?></td>
|
||||
<td>
|
||||
<?php if ($row['status'] === 'success'): ?>
|
||||
<span class="ok-color">성공</span>
|
||||
<?php else: ?>
|
||||
<span class="err-color">실패</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="col-hide-xs caption"><?= htmlspecialchars($row['result_message']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($sms_logs)): ?>
|
||||
<tr><td colspan="6" class="empty-cell">아직 SMS 발송 이력이 없습니다.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 30일 평균 온습도 (센서별 — 전 센서 혼합 평균은 위치별 환경 차이를 뭉개므로 분리) -->
|
||||
<?php if (!empty($metric_avgs)): ?>
|
||||
<section class="panel">
|
||||
<h2>30일 평균 온습도 <span class="h2-sub">· 센서별</span></h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>센서</th><th class="col-hide-xs">위치</th><th>평균 온도</th><th>평균 습도</th><th class="col-hide-xs">측정 수</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($metric_avgs as $ma): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($ma['name']) ?></td>
|
||||
<td class="col-hide-xs"><?= htmlspecialchars($ma['location']) ?></td>
|
||||
<td><?= $ma['avg_temp'] !== null ? htmlspecialchars(number_format((float)$ma['avg_temp'], 1)) . '°C' : '-' ?></td>
|
||||
<td><?= $ma['avg_hum'] !== null ? htmlspecialchars(number_format((float)$ma['avg_hum'], 1)) . '%' : '-' ?></td>
|
||||
<td class="col-hide-xs"><?= (int)$ma['n'] ?>건</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 30일 SMS 발송 통계 (발송 건수 기준 — 측정 기준 경보 통계는 추이·통계 탭) -->
|
||||
<section class="panel" id="summary-section">
|
||||
<h2>30일 SMS 발송 통계</h2>
|
||||
<div class="report-grid">
|
||||
<div class="report-stat">
|
||||
<span>총 발송</span>
|
||||
<strong><?= (int)$sms_stats['total'] ?>건</strong>
|
||||
</div>
|
||||
<div class="report-stat">
|
||||
<span>온습도 경보 SMS</span>
|
||||
<strong class="<?= $sms_stats['alert_total'] > 0 ? 'warn-color' : '' ?>"><?= (int)$sms_stats['alert_total'] ?>건</strong>
|
||||
</div>
|
||||
<div class="report-stat">
|
||||
<span>화재의심 SMS</span>
|
||||
<strong class="<?= ($sms_stats['high_pm'] ?? 0) > 0 ? 'warn-color' : '' ?>"><?= (int)($sms_stats['high_pm'] ?? 0) ?>건</strong>
|
||||
</div>
|
||||
<div class="report-stat">
|
||||
<span>누수감지 SMS</span>
|
||||
<strong class="<?= $sms_stats['leak_alert'] > 0 ? 'err-color' : '' ?>"><?= (int)$sms_stats['leak_alert'] ?>건</strong>
|
||||
</div>
|
||||
<div class="report-stat">
|
||||
<span>복구 SMS</span>
|
||||
<strong><?= (int)$sms_stats['recovery'] ?>건</strong>
|
||||
</div>
|
||||
<div class="report-stat">
|
||||
<span>오프라인 SMS</span>
|
||||
<strong><?= (int)$sms_stats['offline'] ?>건</strong>
|
||||
</div>
|
||||
<div class="report-stat">
|
||||
<span>발송 실패</span>
|
||||
<strong class="<?= $sms_stats['fail'] > 0 ? 'err-color' : '' ?>"><?= (int)$sms_stats['fail'] ?>건</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-note">종류별 상세 집계와 월별 분석은 <a href="monthly_report.php">월간 보고</a>에서 확인하세요. 측정 기준 경보 통계(도넛·추이)는 <a href="<?= htmlspecialchars(dash_url(['tab' => 'trend'])) ?>">추이·통계 탭</a>에 있습니다.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
|
@ -1,308 +0,0 @@
|
|||
<?php
|
||||
// tabs/live.php — [실시간 현황] 탭. dashboard.php 셸에서 include 된다.
|
||||
// 사용 변수: load_global_data() 결과 — $metric_statuses, $leak_statuses,
|
||||
// $unrecovered_incidents, $forecasts, $sms_*, $today_* 등
|
||||
// + load_tab_live() 결과 — $live_sparks, $live_leak_meta.
|
||||
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
|
||||
|
||||
// ── 센서 id 별 결합 카드 구성 ────────────────────────────────────────────────
|
||||
// 1·3번(누수+온습도 결합 노드)은 한 장의 카드에 두 계통을 함께 표시한다.
|
||||
// (구버전은 metric 카드가 leak 카드를 가려 누수 상태가 실시간 화면에서 빠졌음)
|
||||
$sensor_cards = [];
|
||||
foreach ($metric_statuses as $sid => $s) {
|
||||
$sensor_cards[(int)$sid] = ['metric' => true, 'leak' => false, 's' => $s];
|
||||
}
|
||||
foreach ($leak_statuses as $sid => $s) {
|
||||
$sid = (int)$sid;
|
||||
if (isset($sensor_cards[$sid])) {
|
||||
$sensor_cards[$sid]['leak'] = true;
|
||||
if ($sensor_cards[$sid]['s'] === null) { $sensor_cards[$sid]['s'] = $s; }
|
||||
} else {
|
||||
$sensor_cards[$sid] = ['metric' => false, 'leak' => true, 's' => $s];
|
||||
}
|
||||
}
|
||||
ksort($sensor_cards);
|
||||
?>
|
||||
<div class="zone" id="tab-live">
|
||||
<div class="zone-label"><b>실시간 상태</b> <span class="en">Live</span></div>
|
||||
|
||||
<h2 id="sensors-section">센서 현황 <span class="h2-sub">· <?= count($sensor_cards) ?>개소</span></h2>
|
||||
<div class="sensor-grid" id="leak-section">
|
||||
<?php foreach ($sensor_cards as $sid => $card):
|
||||
$is_metric = $card['metric'];
|
||||
$is_leak_node = $card['leak'];
|
||||
$s = $card['s'];
|
||||
// 보고 주기 표기 — 누수 노드(결합)는 10분, 온습도 단독은 1시간
|
||||
// (config.php heartbeat 주석 기준: raspberry_pi4 REPORT_INTERVAL=600 / sht30=3600)
|
||||
$cycle_label = $is_leak_node ? '10분 주기' : '1시간 주기';
|
||||
// 미세먼지 노드 여부(PM_SENSOR_IDS) — 온습도·누수와 독립적으로 카드에 PM 계통 표시.
|
||||
$is_pm_node = in_array((int)$sid, array_map('intval', PM_SENSOR_IDS), true);
|
||||
$type_labels = [];
|
||||
if ($is_leak_node) { $type_labels[] = ['leak', '누수']; }
|
||||
if ($is_pm_node) { $type_labels[] = ['pm', '화재감시']; }
|
||||
if ($is_metric) { $type_labels[] = ['metric', '온습도']; }
|
||||
|
||||
// ── 미수신 placeholder ──
|
||||
if ($s === null): ?>
|
||||
<div class="sensor-card offline">
|
||||
<div class="sc-head">
|
||||
<strong><?= (int)$sid ?>번 센서</strong>
|
||||
<span class="sc-types">
|
||||
<?php foreach ($type_labels as [$t_cls, $t_txt]): ?>
|
||||
<span class="sc-type <?= $t_cls ?>"><?= $t_txt ?></span>
|
||||
<?php endforeach; ?>
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-line off"><span class="sdot"></span>데이터 없음</div>
|
||||
<div class="metric-time">아직 수신된 데이터가 없습니다.</div>
|
||||
<div class="sc-foot"><span>수신 대기</span><span><?= $cycle_label ?></span></div>
|
||||
</div>
|
||||
<?php continue; endif;
|
||||
|
||||
// ── 상태 판정: 오프라인 > 누수 확정 > 확인중(디바운스) > 미세먼지 경보 > 온습도 경보 > 정상 ──
|
||||
$metric_code = $s['metric_status'] ?? 'normal';
|
||||
$pm_code = $s['pm_status'] ?? 'normal';
|
||||
$has_m_alert = $is_metric && !empty($s['is_alerting']);
|
||||
$has_pm_alert = $is_pm_node && !empty($s['is_pm_alerting']);
|
||||
$is_leaking = $is_leak_node && (int)($s['leak_confirmed'] ?? $s['is_leak'] ?? 0) === 1;
|
||||
$is_pending = $is_leak_node && !empty($s['leak_pending']);
|
||||
|
||||
if ($s['is_offline']) {
|
||||
$card_class = 'offline'; $line_class = 'off';
|
||||
$line_text = '오프라인';
|
||||
if ($is_leaking) { $line_text .= ' (누수 중 끊김)'; }
|
||||
elseif ($has_pm_alert) { $line_text .= ' (' . metric_status_label($pm_code) . ' 중 끊김)'; }
|
||||
elseif ($has_m_alert) { $line_text .= ' (' . metric_status_label($metric_code) . ' 중 끊김)'; }
|
||||
} elseif ($is_leaking) {
|
||||
$card_class = 'leak'; $line_class = 'leakst';
|
||||
$line_text = '누수 감지';
|
||||
} elseif ($is_pending) {
|
||||
$card_class = 'pending'; $line_class = 'pend';
|
||||
$line_text = '확인중(디바운스)';
|
||||
} elseif ($has_pm_alert) {
|
||||
$card_class = 'pm'; $line_class = 'pmst';
|
||||
$line_text = metric_status_label($pm_code) . ' 경보';
|
||||
} elseif ($has_m_alert) {
|
||||
$card_class = 'alert'; $line_class = 'err';
|
||||
$line_text = metric_status_label($metric_code) . ' 경보';
|
||||
} else {
|
||||
$card_class = ''; $line_class = 'ok';
|
||||
$line_text = $is_leak_node ? '정상 (건조)' : '정상';
|
||||
}
|
||||
?>
|
||||
<div class="sensor-card <?= $card_class ?>">
|
||||
<div class="sc-head">
|
||||
<strong><?= htmlspecialchars($s['sensor_name']) ?></strong>
|
||||
<span class="sc-types">
|
||||
<?php foreach ($type_labels as [$t_cls, $t_txt]): ?>
|
||||
<span class="sc-type <?= $t_cls ?>"><?= $t_txt ?></span>
|
||||
<?php endforeach; ?>
|
||||
</span>
|
||||
</div>
|
||||
<small><?= htmlspecialchars($s['device_location']) ?> / <?= htmlspecialchars($s['device_id']) ?></small>
|
||||
<div class="status-line <?= $line_class ?>"><span class="sdot"></span><?= htmlspecialchars($line_text) ?></div>
|
||||
|
||||
<?php
|
||||
// ── 누수 행: 확정 누수 시 경과·SMS·사진 (결합/단독 공통) ──
|
||||
if ($is_leaking):
|
||||
$lm = $live_leak_meta[$sid] ?? null; ?>
|
||||
<div class="leak-row">
|
||||
<?php if ($lm): ?>
|
||||
<span class="leak-fact">경과 <b><?= (int)$lm['elapsed_min'] ?>분</b></span>
|
||||
<span class="leak-fact">SMS <b><?= (int)$lm['sms_count'] ?>건</b></span>
|
||||
<?php if (!empty($lm['photos'])): ?>
|
||||
<div class="leak-thumbs">
|
||||
<?php foreach ($lm['photos'] as $lp): ?>
|
||||
<a href="photo.php?id=<?= (int)$lp['id'] ?>" target="_blank" rel="noopener">
|
||||
<img src="photo.php?id=<?= (int)$lp['id'] ?>"
|
||||
alt="누수 사진 <?= htmlspecialchars($lp['captured_at']) ?>" loading="lazy">
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<span class="leak-fact">감지 확정 — 현장 확인 필요</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// ── 온습도 블록: 큰 수치 2열 + 24h 스파크라인 ──
|
||||
if ($is_metric):
|
||||
$temp_cls = ''; $hum_cls = '';
|
||||
if ($metric_code === 'high_temp') { $temp_cls = 'hot'; }
|
||||
elseif ($metric_code === 'low_temp') { $temp_cls = 'cold'; }
|
||||
elseif ($metric_code === 'high_humidity') { $hum_cls = 'humid'; }
|
||||
elseif ($metric_code === 'low_humidity') { $hum_cls = 'dry'; }
|
||||
|
||||
if (!empty($s['latest_metric'])):
|
||||
$metric = $s['latest_metric']; ?>
|
||||
<div class="vals">
|
||||
<div class="v temp">
|
||||
<div class="n <?= $temp_cls ?>"><?= htmlspecialchars(metric_display($metric['temperature_c'], '')) ?><small>°C</small></div>
|
||||
<div class="l">온도</div>
|
||||
</div>
|
||||
<div class="v hum">
|
||||
<div class="n <?= $hum_cls ?>"><?= htmlspecialchars(metric_display($metric['humidity_percent'], '')) ?><small>%</small></div>
|
||||
<div class="l">습도</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
// 24h 스파크라인 (2pt 이상일 때만 — 온도 실선/습도 파선, viewBox 300×34)
|
||||
$sp = $live_sparks[$sid] ?? [];
|
||||
$sp_n = count($sp);
|
||||
if ($sp_n >= 2):
|
||||
$sp_series = [];
|
||||
foreach (['temp' => 'sp-t', 'hum' => 'sp-h'] as $sp_key => $sp_cls) {
|
||||
$sp_vals = array_map(fn($p) => $p[$sp_key], $sp);
|
||||
$sp_nums = array_filter($sp_vals, fn($v) => $v !== null);
|
||||
if (count($sp_nums) < 2) { continue; }
|
||||
$sp_min = min($sp_nums); $sp_max = max($sp_nums);
|
||||
$sp_rng = ($sp_max - $sp_min) ?: 1;
|
||||
$sp_pts = [];
|
||||
foreach ($sp_vals as $sp_i => $sp_v) {
|
||||
if ($sp_v === null) { continue; }
|
||||
$sp_x = $sp_n > 1 ? $sp_i * (300 / ($sp_n - 1)) : 150;
|
||||
$sp_y = 3 + (1 - ($sp_v - $sp_min) / $sp_rng) * 28;
|
||||
$sp_pts[] = sprintf('%.1f,%.1f', $sp_x, $sp_y);
|
||||
}
|
||||
if (count($sp_pts) >= 2) { $sp_series[$sp_cls] = implode(' ', $sp_pts); }
|
||||
}
|
||||
if (!empty($sp_series)): ?>
|
||||
<svg class="spark24" viewBox="0 0 300 34" preserveAspectRatio="none" role="img" aria-label="최근 24시간 온습도 추이">
|
||||
<?php foreach ($sp_series as $sp_cls => $sp_points): ?>
|
||||
<polyline class="<?= $sp_cls ?>" points="<?= htmlspecialchars($sp_points) ?>"></polyline>
|
||||
<?php endforeach; ?>
|
||||
</svg>
|
||||
<?php endif; endif; ?>
|
||||
<?php else: ?>
|
||||
<div class="metric-time">측정값 없음</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// ── 미세먼지 블록: PM2.5/PM10 (µg/m³) — 온습도와 별도 계통, 위반 시 hot 강조 ──
|
||||
if ($is_pm_node):
|
||||
$pm25_cls = ($pm_code === 'high_pm25') ? 'hot' : '';
|
||||
$pm10_cls = ($pm_code === 'high_pm10') ? 'hot' : '';
|
||||
if (!empty($s['latest_pm'])):
|
||||
$pmr = $s['latest_pm']; ?>
|
||||
<div class="vals pm-vals">
|
||||
<div class="v pm25">
|
||||
<div class="n <?= $pm25_cls ?>"><?= $pmr['pm2_5'] !== null ? (int)$pmr['pm2_5'] : '-' ?><small>µg/m³</small></div>
|
||||
<div class="l">PM2.5</div>
|
||||
</div>
|
||||
<div class="v pm10">
|
||||
<div class="n <?= $pm10_cls ?>"><?= $pmr['pm10'] !== null ? (int)$pmr['pm10'] : '-' ?><small>µg/m³</small></div>
|
||||
<div class="l">PM10</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="metric-time">화재감시(PM) 측정값 없음</div>
|
||||
<?php endif; endif; ?>
|
||||
|
||||
<?php
|
||||
// ── 예보 칩: 온라인 + 예보 있으면 임계 여유 상시 표시(over>near>safe) ──
|
||||
if ($is_metric):
|
||||
$fc = (!$s['is_offline'] && isset($forecasts[$sid])) ? $forecasts[$sid] : null;
|
||||
if ($fc):
|
||||
$chips = [];
|
||||
foreach ($fc['headroom'] as $h) {
|
||||
$chips[] = ['state' => $h['state'], 'text' => $h['label'] . ' 여유 ' . $h['margin'] . $h['unit']];
|
||||
}
|
||||
foreach (['temp', 'hum'] as $vk) {
|
||||
$e = $fc['trend'][$vk]['eta'] ?? null;
|
||||
if ($e) { $chips[] = ['state' => 'near', 'text' => '↗ ~' . (int)$e['min'] . '분 뒤 ' . metric_status_label($e['key'])]; }
|
||||
}
|
||||
foreach ($fc['rate'] as $r) {
|
||||
$chips[] = ['state' => 'near', 'text' => ($r['spike'] === 'rise' ? '급상승 ' : '급락 ') . ($r['per10'] > 0 ? '+' : '') . $r['per10'] . '/10분'];
|
||||
}
|
||||
if (!empty($fc['dew']['risk'])) {
|
||||
$chips[] = ['state' => 'near', 'text' => '결로주의 (마진 ' . $fc['dew']['margin'] . '℃)'];
|
||||
}
|
||||
$chip_rank = ['over' => 0, 'near' => 1, 'safe' => 2];
|
||||
usort($chips, fn($a, $b) => ($chip_rank[$a['state']] ?? 3) <=> ($chip_rank[$b['state']] ?? 3));
|
||||
?>
|
||||
<div class="forecast">
|
||||
<?php if ($chips): foreach ($chips as $c): ?>
|
||||
<span class="forecast-chip <?= htmlspecialchars($c['state']) ?>"><?= htmlspecialchars($c['text']) ?></span>
|
||||
<?php endforeach; else: ?>
|
||||
<span class="forecast-note">예보: 임계 여유 충분</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; endif; ?>
|
||||
|
||||
<div class="sc-foot">
|
||||
<span class="<?= $s['is_offline'] ? 'stale' : '' ?>">
|
||||
최종확인 <?= htmlspecialchars($s['last_seen']) ?><?php if ($s['is_offline']): ?> (<?= $s['offline_elapsed_min'] ?>분 전)<?php endif; ?>
|
||||
</span>
|
||||
<span><?= $cycle_label ?></span>
|
||||
</div>
|
||||
|
||||
<?php if ($is_leaking): ?>
|
||||
<svg class="leak-wave" viewBox="0 0 300 42" preserveAspectRatio="none" aria-hidden="true">
|
||||
<path d="M0 26 Q 25 16 50 26 T 100 26 T 150 26 T 200 26 T 250 26 T 300 26 V42 H0 Z"/>
|
||||
</svg>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($sensor_cards)): ?>
|
||||
<p class="empty-note">아직 수신된 센서 데이터가 없습니다.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($unrecovered_incidents)): ?>
|
||||
<!-- 미복구 누수 사고 (전체 이력·사진은 '이력 조회' 탭) -->
|
||||
<h2>미복구 누수 사고 <span class="h2-sub">· <?= count($unrecovered_incidents) ?>건</span></h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th class="col-hide-xs">#</th><th>센서</th><th>위치</th><th>감지 시각</th><th>상태</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($unrecovered_incidents as $inc): ?>
|
||||
<tr class="alert-row">
|
||||
<td class="col-hide-xs"><?= (int)$inc['id'] ?></td>
|
||||
<td><?= htmlspecialchars($inc['sensor_name']) ?></td>
|
||||
<td><?= htmlspecialchars($inc['device_location']) ?></td>
|
||||
<td><?= htmlspecialchars($inc['started_at']) ?></td>
|
||||
<td><span class="pill leak">미복구</span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="panel-note">사진·복구 시각 등 전체 사고 이력은 <a href="<?= htmlspecialchars(dash_url(['tab' => 'history'])) ?>">이력 조회 탭</a>에서 확인하세요.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 채널·오늘 요약 -->
|
||||
<h2>채널·오늘 요약</h2>
|
||||
<div class="mini-panels">
|
||||
<section class="panel mini-panel" aria-label="SMS 채널 상태">
|
||||
<h3>SMS 채널 상태</h3>
|
||||
<dl class="mini-rows">
|
||||
<div><dt>잔여 건수</dt>
|
||||
<dd class="<?= ($sms_balance !== null && $sms_balance <= SMS_BALANCE_WARN_THRESHOLD) ? 'err-color' : '' ?>">
|
||||
<?= $sms_balance !== null ? (int)$sms_balance . '건' : '확인 전' ?>
|
||||
</dd></div>
|
||||
<div><dt>마지막 성공 발송</dt>
|
||||
<dd><?= $sms_last_success_at !== null ? htmlspecialchars($sms_last_success_at) : '기록 없음' ?></dd></div>
|
||||
<div><dt>오늘 발송 / 실패</dt>
|
||||
<dd><?= (int)$sms_today_total ?>건 / <span class="<?= $sms_today_fail > 0 ? 'err-color' : '' ?>"><?= (int)$sms_today_fail ?>건</span></dd></div>
|
||||
</dl>
|
||||
<p class="panel-note">SMS는 유일한 경보 채널입니다. 잔여 건수가 <?= (int)SMS_BALANCE_WARN_THRESHOLD ?>건 이하면 충전하세요.</p>
|
||||
</section>
|
||||
<section class="panel mini-panel" aria-label="오늘 경보 요약">
|
||||
<h3>오늘 경보 요약</h3>
|
||||
<dl class="mini-rows">
|
||||
<div><dt>오늘 경보 횟수</dt>
|
||||
<dd class="<?= $today_alert_count > 0 ? 'warn-color' : 'ok-color' ?>"><?= (int)$today_alert_count ?>건</dd></div>
|
||||
<div><dt>오늘 최근 경보</dt>
|
||||
<dd><?= $today_last_alert_at !== null ? htmlspecialchars($today_last_alert_at) : '오늘 없음' ?></dd></div>
|
||||
<div><dt>가동률</dt>
|
||||
<dd><?= (int)$uptime_pct ?>% (<?= (int)$online_count ?>/<?= (int)$sensor_total ?> 온라인)</dd></div>
|
||||
</dl>
|
||||
<p class="panel-note">7일·30일 추이는 <a href="<?= htmlspecialchars(dash_url(['tab' => 'trend'])) ?>">추이·통계 탭</a>에서 확인하세요.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
<?php
|
||||
// tabs/system.php — [시스템 점검] 탭. dashboard.php 셸에서 include 된다.
|
||||
// 관제(무슨 일이 나는가)와 분리된 관리 정보(시스템이 올바로 설정·동작하는가):
|
||||
// 자가진단 · 수집 거부 관측성 · SMS 채널 점검 · 대응 플레이북 사본 · 임계 설정 요약.
|
||||
// 사용 변수: load_tab_system() 결과(extract) — $ops_issues, $ops_ok, $reject_events,
|
||||
// $sms_fail_30d, $config_rows + 전역 $ops_*, $ingest_rejections, $sms_* 등.
|
||||
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
|
||||
|
||||
$ops_ok_count = count($ops_ok);
|
||||
$ops_pill = $ops_critical_count > 0 ? 'crit' : ($ops_warn_count > 0 ? 'warn' : 'ok');
|
||||
$ops_pill_text = $ops_failed > 0 ? '확인 필요' : '정상';
|
||||
|
||||
// 수집 거부 사유 한국어 라벨(관측성 — api/sensor_data.php 의 거부 사유 키와 대응)
|
||||
$reason_ko = [
|
||||
'signature' => '서명불일치(API키/펌웨어 점검)',
|
||||
'timestamp' => '시각오차(SNTP/RTC 점검)',
|
||||
'key_default' => '기본 API키(미설정)',
|
||||
'body_too_large' => '본문과대',
|
||||
'invalid_json' => 'JSON오류',
|
||||
'missing_field' => '필드누락',
|
||||
'sensor_id_range' => 'sensor_id 범위',
|
||||
];
|
||||
?>
|
||||
<div class="zone" id="tab-system">
|
||||
<div class="zone-label"><b>시스템 점검</b> <span class="en">System</span></div>
|
||||
|
||||
<div class="system-grid">
|
||||
|
||||
<!-- 운영 자가진단 (전체) -->
|
||||
<section class="panel" id="diagnostics-section">
|
||||
<div class="diag-head">
|
||||
<h2>운영 자가진단</h2>
|
||||
<span class="diag-pill <?= $ops_pill ?>"><?= htmlspecialchars($ops_pill_text) ?></span>
|
||||
</div>
|
||||
<div class="diag-counts">
|
||||
<span class="dc ok">정상<b><?= $ops_ok_count ?></b></span>
|
||||
<span class="dc warn">주의<b><?= $ops_warn_count ?></b></span>
|
||||
<span class="dc crit">중요<b><?= $ops_critical_count ?></b></span>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($ops_issues)): ?>
|
||||
<ul class="diag-issues">
|
||||
<?php foreach ($ops_issues as $check):
|
||||
// 색 외 채널: 심각도를 텍스트로도 구분(critical=중요, warn=주의)
|
||||
$sev_text = $check['severity'] === 'critical' ? '확인 필요 · 중요' : '확인 필요 · 주의';
|
||||
$issue_detail = $check['detail']
|
||||
. ($check['fix'] !== '' ? ' — ' . $check['fix'] : '');
|
||||
?>
|
||||
<li class="diag-issue <?= htmlspecialchars($check['severity']) ?>">
|
||||
<i class="diag-dot"></i>
|
||||
<div>
|
||||
<strong><?= htmlspecialchars($check['label']) ?> · <?= htmlspecialchars($sev_text) ?></strong>
|
||||
<span><?= htmlspecialchars($issue_detail) ?></span>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($ops_ok)): ?>
|
||||
<details class="diag-ok" id="d-diag-ok"<?= !empty($render_all) ? ' open' : '' ?>>
|
||||
<summary>정상 항목 <?= $ops_ok_count ?>건 보기</summary>
|
||||
<ul class="diag-ok-grid">
|
||||
<?php foreach ($ops_ok as $check): ?>
|
||||
<li><i></i><span><?= htmlspecialchars($check['label']) ?></span></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
|
||||
<p class="panel-note">진단 결과는 최대 5분 캐시됩니다. 보안대책서 첨부용 점검표는 <a href="security_evidence.php">보안 증적</a>에서 내려받을 수 있습니다.</p>
|
||||
</section>
|
||||
|
||||
<!-- 수집 거부 관측성 (왜 데이터가 안 들어오는가 / 무단 접근 출처) -->
|
||||
<section class="panel" aria-label="수집 거부 관측성">
|
||||
<h2>수집 거부 <span class="h2-sub">· 최근 24시간 <?= (int)$rejection_total ?>건</span></h2>
|
||||
<?php if ($rejection_total > 0): ?>
|
||||
<p class="diag-summary"><?php
|
||||
$parts = [];
|
||||
foreach ($ingest_rejections as $r => $c) {
|
||||
$parts[] = htmlspecialchars($reason_ko[$r] ?? $r) . ' ' . (int)$c;
|
||||
}
|
||||
echo implode(' · ', $parts);
|
||||
?></p>
|
||||
<?php else: ?>
|
||||
<p class="diag-summary">최근 24시간 수집 거부가 없습니다.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($reject_events)): ?>
|
||||
<h3 class="sub-h3">최근 거부 출처 <?= count($reject_events) ?>건 <span class="h2-sub">(우리 노드 오설정 vs 외부 스캐너 판별)</span></h3>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>시각</th><th>출처 IP</th><th>사유</th><th class="col-hide-xs">모드</th><th class="col-hide-xs">device_id</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($reject_events as $ev): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars((string)($ev['t'] ?? '')) ?></td>
|
||||
<td><?= htmlspecialchars((string)($ev['ip'] ?? '?')) ?><?php if (!empty($ev['fwd'])): ?> <small>(xff <?= htmlspecialchars((string)$ev['fwd']) ?>)</small><?php endif; ?></td>
|
||||
<td><?= htmlspecialchars($reason_ko[$ev['reason'] ?? ''] ?? (string)($ev['reason'] ?? '')) ?></td>
|
||||
<td class="col-hide-xs"><?= htmlspecialchars((string)($ev['mode'] ?? '')) ?></td>
|
||||
<td class="col-hide-xs"><?= htmlspecialchars((string)($ev['dev'] ?? '')) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<!-- SMS 채널 점검 -->
|
||||
<section class="panel" aria-label="SMS 채널 점검">
|
||||
<h2>SMS 채널 점검</h2>
|
||||
<dl class="mini-rows">
|
||||
<div><dt>잔여 건수</dt>
|
||||
<dd class="<?= ($sms_balance !== null && $sms_balance <= SMS_BALANCE_WARN_THRESHOLD) ? 'err-color' : '' ?>">
|
||||
<?= $sms_balance !== null ? (int)$sms_balance . '건 (경고 임계 ' . (int)SMS_BALANCE_WARN_THRESHOLD . '건)' : '확인 전' ?>
|
||||
</dd></div>
|
||||
<div><dt>마지막 성공 발송</dt>
|
||||
<dd><?= $sms_last_success_at !== null ? htmlspecialchars($sms_last_success_at) : '기록 없음' ?></dd></div>
|
||||
<div><dt>최근 1시간 경보 SMS 실패</dt>
|
||||
<dd class="<?= $recent_alert_fail > 0 ? 'err-color' : 'ok-color' ?>"><?= (int)$recent_alert_fail ?>건</dd></div>
|
||||
<div><dt>최근 30일 발송 실패</dt>
|
||||
<dd class="<?= $sms_fail_30d > 0 ? 'warn-color' : 'ok-color' ?>"><?= (int)$sms_fail_30d ?>건</dd></div>
|
||||
</dl>
|
||||
<p class="panel-note">실패 지속 시 Cafe24 SMS 잔액·계정·발신번호를 확인하세요. 발송 이력은 <a href="<?= htmlspecialchars(dash_url(['tab' => 'history'], 'sms-section')) ?>">이력 조회 탭</a>에 있습니다.</p>
|
||||
</section>
|
||||
|
||||
<!-- 대응 플레이북·담당자 (평시 열람 사본 — 경보 시에는 상단 '대응 필요'에 자동 표시) -->
|
||||
<section class="panel" aria-label="대응 플레이북">
|
||||
<h2>대응 플레이북 <span class="h2-sub">· 평시 열람용</span></h2>
|
||||
<?php foreach (RESPONSE_PLAYBOOKS as $pb_key => $pb): if (empty($pb['steps'])) { continue; } ?>
|
||||
<details class="playbook-item" id="pb-<?= htmlspecialchars((string)$pb_key) ?>"<?= !empty($render_all) ? ' open' : '' ?>>
|
||||
<summary><?= htmlspecialchars($pb['title'] ?? '대응 절차') ?></summary>
|
||||
<ol class="playbook-steps">
|
||||
<?php foreach ($pb['steps'] as $step): ?><li><?= htmlspecialchars($step) ?></li><?php endforeach; ?>
|
||||
</ol>
|
||||
</details>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!empty(RESPONSE_CONTACTS)): ?>
|
||||
<div class="response-contacts">
|
||||
<span class="contacts-label">담당자</span>
|
||||
<?php foreach (RESPONSE_CONTACTS as $ct):
|
||||
$ph = preg_replace('/[^0-9+]/', '', (string)($ct['phone'] ?? '')); ?>
|
||||
<span class="contact">
|
||||
<?= htmlspecialchars(trim(($ct['role'] ?? '') . ' ' . ($ct['name'] ?? ''))) ?><?php if ($ph !== ''): ?> <a href="tel:<?= htmlspecialchars($ph) ?>"><?= htmlspecialchars((string)$ct['phone']) ?></a><?php endif; ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p class="panel-note">담당자 연락처는 config.local.php 의 RESPONSE_CONTACTS 로 등록합니다.</p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<!-- 임계·설정 요약 (읽기 전용) -->
|
||||
<section class="panel" aria-label="임계 및 설정 요약">
|
||||
<h2>임계·설정 요약 <span class="h2-sub">· 읽기 전용</span></h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>항목</th><th>현재값</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($config_rows as [$label, $value]): ?>
|
||||
<tr><td><?= htmlspecialchars($label) ?></td><td><?= htmlspecialchars((string)$value) ?></td></tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="panel-note">값 변경은 config.local.php 오버라이드로 합니다(폐쇄망에서 펌웨어 재배포 없이 조정 가능).</p>
|
||||
</section>
|
||||
|
||||
<!-- 관리 화면 바로가기 -->
|
||||
<section class="panel" aria-label="관리 화면 바로가기">
|
||||
<h2>관리 화면 바로가기</h2>
|
||||
<ul class="link-list">
|
||||
<li><a href="setup_wizard.php">설치 점검</a> — DB·테이블·수신 상태 점검</li>
|
||||
<li><a href="monthly_report.php">월간 보고</a> — 월별 경보·SMS 통계 보고서</li>
|
||||
<li><a href="security_evidence.php">보안 증적</a> — 보안대책서 첨부용 점검표</li>
|
||||
<li><a href="<?= htmlspecialchars(dash_url(['tab' => 'all'])) ?>">인쇄용 전체 보기</a> — 전 탭 한 페이지 렌더(자동 새로고침 없음)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,471 +0,0 @@
|
|||
<?php
|
||||
// tabs/trend.php — [추이·통계] 탭. dashboard.php 셸에서 include 된다.
|
||||
// 사용 변수: load_tab_trend() 결과(extract) — $chart_*, $trend_*, $donut_*, $board_*,
|
||||
// $spark_alerts, $ov_badge* + 전역 $uptime_pct, $online_count, $sensor_total.
|
||||
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
|
||||
?>
|
||||
<div class="zone" id="tab-trend">
|
||||
<div class="zone-label"><b>추세 분석</b> <span class="en">Trend</span></div>
|
||||
|
||||
<!-- 온습도 추이 차트 (인라인 SVG, 외부 라이브러리 없음) -->
|
||||
<h2 id="chart-section">온습도 추이
|
||||
<?php if ($chart_sensor_id > 0): ?>
|
||||
<span class="h2-sub">
|
||||
· <?= htmlspecialchars($chart_sensor_name !== '' ? $chart_sensor_name : ('센서 ' . $chart_sensor_id)) ?> 최근 <?= count($chart_points) ?>개
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</h2>
|
||||
<div class="chart-panel">
|
||||
<div class="chart-head">
|
||||
<div class="chart-legend" style="margin:0">
|
||||
<span><i class="legend-swatch temp"></i> 온도(°C)</span>
|
||||
<span><i class="legend-swatch hum"></i> 습도(%)</span>
|
||||
</div>
|
||||
<?php if (count($chart_sensor_options ?? []) > 1): ?>
|
||||
<div class="range-toggle" role="group" aria-label="추이 센서 선택">
|
||||
<?php foreach ($chart_sensor_options as $opt): ?>
|
||||
<a href="<?= htmlspecialchars(dash_url(['sensor' => $opt['id']], 'chart-section')) ?>"<?= $chart_sensor_id === $opt['id'] ? ' aria-current="true"' : '' ?>><?= htmlspecialchars($opt['name'] !== '' ? $opt['name'] : ('센서 ' . $opt['id'])) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="range-toggle" role="group" aria-label="추이 기간 선택">
|
||||
<?php foreach (['today' => '오늘', '3d' => '3일', '7d' => '7일'] as $rk => $rlabel): ?>
|
||||
<a href="<?= htmlspecialchars(dash_url(['range' => $rk], 'chart-section')) ?>"<?= $chart_range === $rk ? ' aria-current="true"' : '' ?>><?= htmlspecialchars($rlabel) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (count($chart_points) >= 2): ?>
|
||||
<div class="chart-svg-wrap">
|
||||
<?php
|
||||
// ── 인라인 SVG 라인 차트 (온도/습도 2계열) ──────────────
|
||||
$chart_w = 920; $chart_h = 280;
|
||||
$pad_l = 44; $pad_r = 44; $pad_t = 16; $pad_b = 34;
|
||||
$plot_w = $chart_w - $pad_l - $pad_r;
|
||||
$plot_h = $chart_h - $pad_t - $pad_b;
|
||||
$n = count($chart_points);
|
||||
|
||||
$temps = array_values(array_filter(array_map(fn($p) => $p['temp'], $chart_points), fn($v) => $v !== null));
|
||||
$hums = array_values(array_filter(array_map(fn($p) => $p['hum'], $chart_points), fn($v) => $v !== null));
|
||||
|
||||
// 온도 축 범위 (여유 패딩)
|
||||
$t_min = !empty($temps) ? min($temps) : 0;
|
||||
$t_max = !empty($temps) ? max($temps) : 1;
|
||||
if ($t_min === $t_max) { $t_min -= 1; $t_max += 1; }
|
||||
$t_pad = ($t_max - $t_min) * 0.1;
|
||||
$t_min -= $t_pad; $t_max += $t_pad;
|
||||
|
||||
// 습도 축 범위
|
||||
$h_min = !empty($hums) ? min($hums) : 0;
|
||||
$h_max = !empty($hums) ? max($hums) : 1;
|
||||
if ($h_min === $h_max) { $h_min -= 1; $h_max += 1; }
|
||||
$h_pad = ($h_max - $h_min) * 0.1;
|
||||
$h_min -= $h_pad; $h_max += $h_pad;
|
||||
|
||||
$x_at = function (int $i) use ($pad_l, $plot_w, $n): float {
|
||||
if ($n <= 1) { return $pad_l; }
|
||||
return $pad_l + ($plot_w * $i / ($n - 1));
|
||||
};
|
||||
$y_temp = function (?float $v) use ($pad_t, $plot_h, $t_min, $t_max): ?float {
|
||||
if ($v === null) { return null; }
|
||||
$r = ($t_max - $t_min) ?: 1;
|
||||
return $pad_t + $plot_h * (1 - (($v - $t_min) / $r));
|
||||
};
|
||||
$y_hum = function (?float $v) use ($pad_t, $plot_h, $h_min, $h_max): ?float {
|
||||
if ($v === null) { return null; }
|
||||
$r = ($h_max - $h_min) ?: 1;
|
||||
return $pad_t + $plot_h * (1 - (($v - $h_min) / $r));
|
||||
};
|
||||
|
||||
// 결측(null)은 구간을 끊어 polyline 들을 만든다
|
||||
$build_segments = function (string $key, callable $y_fn) use ($chart_points, $x_at): array {
|
||||
$segments = [];
|
||||
$current = [];
|
||||
foreach ($chart_points as $i => $p) {
|
||||
$val = $p[$key];
|
||||
if ($val === null) {
|
||||
if (count($current) >= 2) { $segments[] = $current; }
|
||||
$current = [];
|
||||
continue;
|
||||
}
|
||||
$current[] = sprintf('%.1f,%.1f', $x_at($i), $y_fn((float)$val));
|
||||
}
|
||||
if (count($current) >= 2) { $segments[] = $current; }
|
||||
return $segments;
|
||||
};
|
||||
|
||||
$temp_segments = $build_segments('temp', $y_temp);
|
||||
$hum_segments = $build_segments('hum', $y_hum);
|
||||
|
||||
// 각 계열의 마지막 유효 지점(끝점 마커 + 최신값 라벨용)
|
||||
$last_temp_i = null; $last_temp_v = null;
|
||||
$last_hum_i = null; $last_hum_v = null;
|
||||
foreach ($chart_points as $i => $p) {
|
||||
if ($p['temp'] !== null) { $last_temp_i = $i; $last_temp_v = (float)$p['temp']; }
|
||||
if ($p['hum'] !== null) { $last_hum_i = $i; $last_hum_v = (float)$p['hum']; }
|
||||
}
|
||||
|
||||
$fmt = fn($v) => rtrim(rtrim(number_format($v, 1, '.', ''), '0'), '.');
|
||||
?>
|
||||
<svg viewBox="0 0 <?= $chart_w ?> <?= $chart_h ?>" width="100%" height="<?= $chart_h ?>"
|
||||
preserveAspectRatio="xMidYMid meet" role="img"
|
||||
aria-label="온습도 추이 라인 차트" class="chart-svg">
|
||||
<!-- 플롯 배경/축 -->
|
||||
<rect x="<?= $pad_l ?>" y="<?= $pad_t ?>" width="<?= $plot_w ?>" height="<?= $plot_h ?>"
|
||||
class="plot-bg"></rect>
|
||||
<?php
|
||||
// 가로 그리드 + 좌(온도)/우(습도) 눈금 라벨 (5단계)
|
||||
for ($g = 0; $g <= 4; $g++):
|
||||
$gy = $pad_t + $plot_h * $g / 4;
|
||||
$tv = $t_max - ($t_max - $t_min) * $g / 4;
|
||||
$hv = $h_max - ($h_max - $h_min) * $g / 4;
|
||||
?>
|
||||
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $gy) ?>"
|
||||
x2="<?= $pad_l + $plot_w ?>" y2="<?= sprintf('%.1f', $gy) ?>"
|
||||
class="grid-line" stroke-dasharray="3,3"></line>
|
||||
<text x="<?= $pad_l - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>"
|
||||
text-anchor="end" font-size="10" class="axis-temp"><?= htmlspecialchars($fmt($tv)) ?></text>
|
||||
<text x="<?= $pad_l + $plot_w + 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>"
|
||||
text-anchor="start" font-size="10" class="axis-hum"><?= htmlspecialchars($fmt($hv)) ?></text>
|
||||
<?php endfor; ?>
|
||||
|
||||
<!-- X축 시간 라벨 (시작/중간/끝) -->
|
||||
<?php
|
||||
$label_idx = $n > 1 ? [0, intdiv($n - 1, 2), $n - 1] : [0];
|
||||
foreach ($label_idx as $li):
|
||||
$lx = $x_at($li);
|
||||
$anchor = $li === 0 ? 'start' : ($li === $n - 1 ? 'end' : 'middle');
|
||||
$tlabel = substr($chart_points[$li]['t'], 5, 11); // MM-DD HH:MM
|
||||
?>
|
||||
<text x="<?= sprintf('%.1f', $lx) ?>" y="<?= $chart_h - 12 ?>"
|
||||
text-anchor="<?= $anchor ?>" font-size="10" class="axis-x"><?= htmlspecialchars($tlabel) ?></text>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- 임계선 오버레이 (폴리라인과 동일한 y 스케일; -1/미정의는 생략, 스케일 밖도 생략) -->
|
||||
<?php
|
||||
// [임계값, y 계산 클로저, 축(temp|hum), 라벨, 단위]
|
||||
$threshold_defs = [
|
||||
[METRIC_TEMP_HIGH_C, $y_temp, 'temp', '고온', '℃'],
|
||||
[METRIC_TEMP_LOW_C, $y_temp, 'temp', '저온', '℃'],
|
||||
[METRIC_RH_HIGH, $y_hum, 'hum', '고습', '%'],
|
||||
[METRIC_RH_LOW, $y_hum, 'hum', '저습', '%'],
|
||||
];
|
||||
foreach ($threshold_defs as [$cval, $yfn, $axis, $tlabel, $unit]):
|
||||
if ($cval === null || (float)$cval === -1.0) { continue; } // 비활성
|
||||
$ty = $yfn((float)$cval);
|
||||
if ($ty === null || $ty < $pad_t || $ty > $pad_t + $plot_h) { continue; } // 스케일 밖
|
||||
$thr_line_cls = $axis === 'temp' ? 'line-temp' : 'line-hum';
|
||||
$thr_label_cls = $axis === 'temp' ? 'axis-temp' : 'axis-hum';
|
||||
$thr_lx = $axis === 'temp' ? ($pad_l + 4) : ($pad_l + $plot_w - 4);
|
||||
$thr_anchor = $axis === 'temp' ? 'start' : 'end';
|
||||
?>
|
||||
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $ty) ?>"
|
||||
x2="<?= $pad_l + $plot_w ?>" y2="<?= sprintf('%.1f', $ty) ?>"
|
||||
class="<?= $thr_line_cls ?>" stroke-width="1" stroke-dasharray="2,4" opacity="0.85"></line>
|
||||
<text x="<?= sprintf('%.1f', $thr_lx) ?>" y="<?= sprintf('%.1f', $ty - 3) ?>"
|
||||
text-anchor="<?= $thr_anchor ?>" font-size="9"
|
||||
class="<?= $thr_label_cls ?>"><?= htmlspecialchars($tlabel . ' ' . $fmt((float)$cval) . $unit) ?></text>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- 습도 라인 (파선 — 흑백 인쇄 시 온도선과 형태로 구분) -->
|
||||
<?php foreach ($hum_segments as $seg): ?>
|
||||
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
|
||||
class="line-hum" fill="none" stroke-width="1.8" stroke-dasharray="6,3"
|
||||
stroke-linejoin="round" stroke-linecap="round"></polyline>
|
||||
<?php endforeach; ?>
|
||||
<!-- 온도 라인 -->
|
||||
<?php foreach ($temp_segments as $seg): ?>
|
||||
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
|
||||
class="line-temp" fill="none" stroke-width="1.8"
|
||||
stroke-linejoin="round" stroke-linecap="round"></polyline>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- 각 계열 마지막 점: 채운 원 + 최신값 라벨(폴리라인과 동일 스케일) -->
|
||||
<?php if ($last_hum_i !== null):
|
||||
$hx = $x_at($last_hum_i); $hy = $y_hum($last_hum_v);
|
||||
?>
|
||||
<circle cx="<?= sprintf('%.1f', $hx) ?>" cy="<?= sprintf('%.1f', $hy) ?>" r="3" class="axis-hum"></circle>
|
||||
<text x="<?= sprintf('%.1f', $hx - 5) ?>" y="<?= sprintf('%.1f', $hy - 5) ?>"
|
||||
text-anchor="end" font-size="10" class="axis-hum"><?= htmlspecialchars($fmt($last_hum_v) . '%') ?></text>
|
||||
<?php endif; ?>
|
||||
<?php if ($last_temp_i !== null):
|
||||
$tx = $x_at($last_temp_i); $tyv = $y_temp($last_temp_v);
|
||||
?>
|
||||
<circle cx="<?= sprintf('%.1f', $tx) ?>" cy="<?= sprintf('%.1f', $tyv) ?>" r="3" class="axis-temp"></circle>
|
||||
<text x="<?= sprintf('%.1f', $tx - 5) ?>" y="<?= sprintf('%.1f', $tyv - 5) ?>"
|
||||
text-anchor="end" font-size="10" class="axis-temp"><?= htmlspecialchars($fmt($last_temp_v) . '℃') ?></text>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 피크(기간 내 최고 온도) 마커 + 플래그 라벨 -->
|
||||
<?php if ($peak_i !== null):
|
||||
$pk_x = $x_at($peak_i); $pk_y = $y_temp($peak_v);
|
||||
$pk_fw = 66;
|
||||
$pk_fx = min(max($pk_x - $pk_fw / 2, $pad_l), $pad_l + $plot_w - $pk_fw);
|
||||
?>
|
||||
<line x1="<?= sprintf('%.1f', $pk_x) ?>" y1="<?= sprintf('%.1f', $pk_y) ?>"
|
||||
x2="<?= sprintf('%.1f', $pk_x) ?>" y2="<?= $pad_t ?>" class="grid-line"></line>
|
||||
<rect x="<?= sprintf('%.1f', $pk_fx) ?>" y="<?= $pad_t - 15 ?>" width="<?= $pk_fw ?>" height="14" rx="4" class="peak-flag"></rect>
|
||||
<text x="<?= sprintf('%.1f', $pk_fx + $pk_fw / 2) ?>" y="<?= $pad_t - 4 ?>"
|
||||
text-anchor="middle" font-size="10" class="peak-label"><?= htmlspecialchars('피크 ' . $fmt($peak_v) . '℃') ?></text>
|
||||
<circle cx="<?= sprintf('%.1f', $pk_x) ?>" cy="<?= sprintf('%.1f', $pk_y) ?>" r="4" class="peak-marker"></circle>
|
||||
<?php endif; ?>
|
||||
</svg>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="chart-empty">추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- 미세먼지 추이 차트 (PM2.5/PM10 µg/m³ — 온습도와 별도 스케일; PM 센서에서만 표시) -->
|
||||
<?php if (!empty($chart_pm_points)): ?>
|
||||
<h2 id="pm-chart-section">화재감시 추이 (PM)
|
||||
<span class="h2-sub">· <?= htmlspecialchars($chart_sensor_name !== '' ? $chart_sensor_name : ('센서 ' . $chart_sensor_id)) ?> 최근 <?= count($chart_pm_points) ?>개</span>
|
||||
</h2>
|
||||
<div class="chart-panel">
|
||||
<div class="chart-head">
|
||||
<div class="chart-legend" style="margin:0">
|
||||
<span><i class="legend-swatch pm25"></i> PM2.5(µg/m³)</span>
|
||||
<span><i class="legend-swatch pm10"></i> PM10(µg/m³)</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (count($chart_pm_points) >= 2): ?>
|
||||
<div class="chart-svg-wrap">
|
||||
<?php
|
||||
// ── 인라인 SVG (PM2.5/PM10 2계열, 공통 µg/m³ 스케일) ──────────
|
||||
$pmc_w = 920; $pmc_h = 240;
|
||||
$pmp_l = 44; $pmp_r = 20; $pmp_t = 16; $pmp_b = 34;
|
||||
$pmplot_w = $pmc_w - $pmp_l - $pmp_r;
|
||||
$pmplot_h = $pmc_h - $pmp_t - $pmp_b;
|
||||
$pmn = count($chart_pm_points);
|
||||
|
||||
// 스케일: 0..(측정 최댓값·임계값 중 큰 값 + 여유). 임계선이 항상 보이게 임계값 포함.
|
||||
$pm_all = [(float)METRIC_PM25_HIGH, (float)METRIC_PM10_HIGH];
|
||||
foreach ($chart_pm_points as $p) {
|
||||
if ($p['pm25'] !== null) { $pm_all[] = (float)$p['pm25']; }
|
||||
if ($p['pm10'] !== null) { $pm_all[] = (float)$p['pm10']; }
|
||||
}
|
||||
$pm_min = 0.0;
|
||||
$pm_max = max($pm_all) * 1.1;
|
||||
if ($pm_max <= 0) { $pm_max = 1.0; }
|
||||
|
||||
$pmx = function (int $i) use ($pmp_l, $pmplot_w, $pmn): float {
|
||||
if ($pmn <= 1) { return $pmp_l; }
|
||||
return $pmp_l + ($pmplot_w * $i / ($pmn - 1));
|
||||
};
|
||||
$pmy = function (?float $v) use ($pmp_t, $pmplot_h, $pm_min, $pm_max): ?float {
|
||||
if ($v === null) { return null; }
|
||||
$r = ($pm_max - $pm_min) ?: 1;
|
||||
return $pmp_t + $pmplot_h * (1 - (($v - $pm_min) / $r));
|
||||
};
|
||||
$pm_build = function (string $key) use ($chart_pm_points, $pmx, $pmy): array {
|
||||
$segments = []; $cur = [];
|
||||
foreach ($chart_pm_points as $i => $p) {
|
||||
$val = $p[$key];
|
||||
if ($val === null) { if (count($cur) >= 2) { $segments[] = $cur; } $cur = []; continue; }
|
||||
$cur[] = sprintf('%.1f,%.1f', $pmx($i), $pmy((float)$val));
|
||||
}
|
||||
if (count($cur) >= 2) { $segments[] = $cur; }
|
||||
return $segments;
|
||||
};
|
||||
$pm25_segs = $pm_build('pm25');
|
||||
$pm10_segs = $pm_build('pm10');
|
||||
$pm_fmt = fn($v) => rtrim(rtrim(number_format($v, 1, '.', ''), '0'), '.');
|
||||
?>
|
||||
<svg viewBox="0 0 <?= $pmc_w ?> <?= $pmc_h ?>" width="100%" height="<?= $pmc_h ?>"
|
||||
preserveAspectRatio="xMidYMid meet" role="img"
|
||||
aria-label="화재감시 추이 라인 차트" class="chart-svg">
|
||||
<rect x="<?= $pmp_l ?>" y="<?= $pmp_t ?>" width="<?= $pmplot_w ?>" height="<?= $pmplot_h ?>" class="plot-bg"></rect>
|
||||
<?php for ($g = 0; $g <= 4; $g++):
|
||||
$gy = $pmp_t + $pmplot_h * $g / 4;
|
||||
$gv = $pm_max - ($pm_max - $pm_min) * $g / 4; ?>
|
||||
<line x1="<?= $pmp_l ?>" y1="<?= sprintf('%.1f', $gy) ?>"
|
||||
x2="<?= $pmp_l + $pmplot_w ?>" y2="<?= sprintf('%.1f', $gy) ?>"
|
||||
class="grid-line" stroke-dasharray="3,3"></line>
|
||||
<text x="<?= $pmp_l - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>"
|
||||
text-anchor="end" font-size="10" class="axis-pm"><?= htmlspecialchars($pm_fmt($gv)) ?></text>
|
||||
<?php endfor; ?>
|
||||
|
||||
<!-- X축 시간 라벨 (시작/중간/끝) -->
|
||||
<?php
|
||||
$pm_lbl_idx = $pmn > 1 ? [0, intdiv($pmn - 1, 2), $pmn - 1] : [0];
|
||||
foreach ($pm_lbl_idx as $li):
|
||||
$anchor = $li === 0 ? 'start' : ($li === $pmn - 1 ? 'end' : 'middle');
|
||||
$tlabel = substr($chart_pm_points[$li]['t'], 5, 11); ?>
|
||||
<text x="<?= sprintf('%.1f', $pmx($li)) ?>" y="<?= $pmc_h - 12 ?>"
|
||||
text-anchor="<?= $anchor ?>" font-size="10" class="axis-x"><?= htmlspecialchars($tlabel) ?></text>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- 임계선 오버레이 (PM2.5/PM10) -->
|
||||
<?php
|
||||
$pm_thr = [
|
||||
[(float)METRIC_PM25_HIGH, 'PM2.5', 'line-pm25'],
|
||||
[(float)METRIC_PM10_HIGH, 'PM10', 'line-pm10'],
|
||||
];
|
||||
foreach ($pm_thr as [$cval, $lbl, $cls]):
|
||||
$ty = $pmy($cval);
|
||||
if ($ty === null || $ty < $pmp_t || $ty > $pmp_t + $pmplot_h) { continue; } ?>
|
||||
<line x1="<?= $pmp_l ?>" y1="<?= sprintf('%.1f', $ty) ?>"
|
||||
x2="<?= $pmp_l + $pmplot_w ?>" y2="<?= sprintf('%.1f', $ty) ?>"
|
||||
class="<?= $cls ?>" stroke-width="1" stroke-dasharray="2,4" opacity="0.85"></line>
|
||||
<text x="<?= $pmp_l + 4 ?>" y="<?= sprintf('%.1f', $ty - 3) ?>"
|
||||
text-anchor="start" font-size="9" class="axis-pm"><?= htmlspecialchars($lbl . ' ' . $pm_fmt($cval) . 'µg/m³') ?></text>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- PM10 라인 (파선) -->
|
||||
<?php foreach ($pm10_segs as $seg): ?>
|
||||
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
|
||||
class="line-pm10" fill="none" stroke-width="1.8" stroke-dasharray="6,3"
|
||||
stroke-linejoin="round" stroke-linecap="round"></polyline>
|
||||
<?php endforeach; ?>
|
||||
<!-- PM2.5 라인 -->
|
||||
<?php foreach ($pm25_segs as $seg): ?>
|
||||
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>"
|
||||
class="line-pm25" fill="none" stroke-width="1.8"
|
||||
stroke-linejoin="round" stroke-linecap="round"></polyline>
|
||||
<?php endforeach; ?>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="caption donut-note">임계 — PM2.5 <?= htmlspecialchars($pm_fmt((float)METRIC_PM25_HIGH)) ?> · PM10 <?= htmlspecialchars($pm_fmt((float)METRIC_PM10_HIGH)) ?> µg/m³ 초과 시 경보</p>
|
||||
<?php else: ?>
|
||||
<div class="chart-empty">화재감시(PM) 추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 통계 위젯 (7일/30일 — 시간축 라벨을 각 타일 부제에 명기) -->
|
||||
<h2>경보·가동 통계</h2>
|
||||
<div class="overview trend-widgets">
|
||||
|
||||
<!-- 경보 발생 추이 (7일 · 온습도 경보 측정 vs 누수 감지 이벤트) -->
|
||||
<div class="ov-tile ov-trend">
|
||||
<h3>경보 발생 추이 <span class="sub">최근 7일 · 유형별</span></h3>
|
||||
<div class="mini-legend">
|
||||
<span><i style="background:var(--warn)"></i>온습도 경보</span>
|
||||
<span><i style="background:var(--pm)"></i>화재의심</span>
|
||||
<span><i style="background:var(--leak)"></i>누수 감지</span>
|
||||
</div>
|
||||
<?php
|
||||
$tw = 640; $th = 200; $tpl = 40; $tpr = 16; $tpt = 16; $tpb = 34;
|
||||
$tplot_w = $tw - $tpl - $tpr; $tplot_h = $th - $tpt - $tpb; $tn = count($trend_days);
|
||||
$t_x = function ($i) use ($tpl, $tplot_w, $tn) { return $tn > 1 ? $tpl + $tplot_w * $i / ($tn - 1) : $tpl; };
|
||||
$t_y = function ($v) use ($tpt, $tplot_h, $trend_max) { return $tpt + $tplot_h * (1 - $v / $trend_max); };
|
||||
$t_line = function (array $vals) use ($t_x, $t_y) {
|
||||
$pts = [];
|
||||
foreach ($vals as $i => $v) { $pts[] = sprintf('%.1f,%.1f', $t_x($i), $t_y($v)); }
|
||||
return implode(' ', $pts);
|
||||
};
|
||||
$t_peak_i = 0;
|
||||
foreach ($trend_metric as $i => $v) { if ($v > $trend_metric[$t_peak_i]) { $t_peak_i = $i; } }
|
||||
?>
|
||||
<svg class="trend-svg" viewBox="0 0 <?= $tw ?> <?= $th ?>" preserveAspectRatio="xMidYMid meet" role="img" aria-label="온습도 경보 vs 누수 감지 7일 추이">
|
||||
<rect x="<?= $tpl ?>" y="<?= $tpt ?>" width="<?= $tplot_w ?>" height="<?= $tplot_h ?>" class="plot-bg"></rect>
|
||||
<?php for ($g = 0; $g <= 2; $g++):
|
||||
$gy = $tpt + $tplot_h * $g / 2; $gv = $trend_max - $trend_max * $g / 2; ?>
|
||||
<line x1="<?= $tpl ?>" y1="<?= sprintf('%.1f', $gy) ?>" x2="<?= $tpl + $tplot_w ?>" y2="<?= sprintf('%.1f', $gy) ?>" class="grid-line" stroke-dasharray="3,3"></line>
|
||||
<text x="<?= $tpl - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="end" font-size="10" class="ax"><?= htmlspecialchars(rtrim(rtrim(number_format($gv, 1, '.', ''), '0'), '.')) ?></text>
|
||||
<?php endfor; ?>
|
||||
<polyline points="<?= htmlspecialchars($t_line($trend_leak)) ?>" class="line-alert" fill="none" stroke-width="2" stroke-dasharray="6,3" stroke-linejoin="round" stroke-linecap="round"></polyline>
|
||||
<?php if (!empty($trend_pm) && max($trend_pm) > 0): ?>
|
||||
<polyline points="<?= htmlspecialchars($t_line($trend_pm)) ?>" class="line-pm" fill="none" stroke-width="2" stroke-dasharray="2,3" stroke-linejoin="round" stroke-linecap="round"></polyline>
|
||||
<?php endif; ?>
|
||||
<polyline points="<?= htmlspecialchars($t_line($trend_metric)) ?>" class="line-metric" fill="none" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"></polyline>
|
||||
<?php if ($trend_metric[$t_peak_i] > 0): ?>
|
||||
<circle cx="<?= sprintf('%.1f', $t_x($t_peak_i)) ?>" cy="<?= sprintf('%.1f', $t_y($trend_metric[$t_peak_i])) ?>" r="3.2" class="dot-metric"></circle>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$t_lbl_idx = $tn > 1 ? [0, intdiv($tn - 1, 2), $tn - 1] : [0];
|
||||
foreach ($t_lbl_idx as $li):
|
||||
$anchor = $li === 0 ? 'start' : ($li === $tn - 1 ? 'end' : 'middle'); ?>
|
||||
<text x="<?= sprintf('%.1f', $t_x($li)) ?>" y="<?= $th - 12 ?>" text-anchor="<?= $anchor ?>" font-size="9" class="ax"><?= htmlspecialchars(substr($trend_days[$li], 5)) ?></text>
|
||||
<?php endforeach; ?>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 경보 종류 도넛 (30일) — 단위가 다른 두 카운트를 명기해 오독 방지 -->
|
||||
<div class="ov-tile ov-donut">
|
||||
<h3>경보 종류 <span class="sub">최근 30일</span></h3>
|
||||
<?php if ($donut_total > 0): ?>
|
||||
<div class="donut-body">
|
||||
<div class="donut-ring">
|
||||
<svg viewBox="0 0 120 120" role="img" aria-label="경보 종류 구성 총 <?= (int)$donut_total ?>건">
|
||||
<circle class="d-track" cx="60" cy="60" r="47" pathLength="100"></circle>
|
||||
<g transform="rotate(-90 60 60)">
|
||||
<?php $d_acc = 0; foreach ($donut_segs as $seg):
|
||||
if ((int)$seg['val'] <= 0) { continue; }
|
||||
$d_pct = $seg['val'] / $donut_total * 100; ?>
|
||||
<circle class="seg cat-<?= htmlspecialchars($seg['cat']) ?>" cx="60" cy="60" r="47" pathLength="100"
|
||||
stroke-dasharray="<?= sprintf('%.2f', $d_pct) ?> 100"
|
||||
stroke-dashoffset="<?= sprintf('%.2f', -$d_acc) ?>"></circle>
|
||||
<?php $d_acc += $d_pct; endforeach; ?>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="donut-num"><b><?= (int)$donut_total ?></b><small>건</small></div>
|
||||
</div>
|
||||
<div class="donut-legend">
|
||||
<?php foreach ($donut_segs as $seg): if ((int)$seg['val'] <= 0) { continue; } ?>
|
||||
<span class="dl"><i class="cat-<?= htmlspecialchars($seg['cat']) ?>"></i><?= htmlspecialchars($seg['label']) ?><span class="dl-c"><?= (int)$seg['val'] ?></span></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<p class="caption donut-note">단위 — 온습도·화재의심: 비정상 측정 건수 · 누수: 감지 이벤트 건수</p>
|
||||
<?php else: ?>
|
||||
<div class="donut-empty">최근 30일 경보 없음</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- 가동률 게이지 + 7일 경보 스파크라인 -->
|
||||
<div class="ov-tile ov-gauge">
|
||||
<h3>가동률 <span class="sub">온라인 센서</span></h3>
|
||||
<div class="gauge-body">
|
||||
<div class="gauge-ring <?= htmlspecialchars($ov_badge) ?>">
|
||||
<svg viewBox="0 0 120 120" role="img" aria-label="가동률 <?= (int)$uptime_pct ?>%">
|
||||
<g transform="rotate(-90 60 60)">
|
||||
<circle class="g-track" cx="60" cy="60" r="52" pathLength="100"></circle>
|
||||
<circle class="g-val" cx="60" cy="60" r="52" pathLength="100" stroke-dasharray="<?= (int)$uptime_pct ?> 100"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="gauge-num"><b><?= (int)$uptime_pct ?>%</b><small><?= (int)$online_count ?> / <?= (int)$sensor_total ?> 온라인</small></div>
|
||||
</div>
|
||||
<div class="gauge-foot">
|
||||
<span class="lbl">7일 경보</span>
|
||||
<?php
|
||||
$sp_n = count($spark_alerts);
|
||||
$sp_max = max(1, max($spark_alerts));
|
||||
$sp_pts = [];
|
||||
foreach ($spark_alerts as $spi => $spv) {
|
||||
$spx = $sp_n > 1 ? $spi * (84 / ($sp_n - 1)) : 42;
|
||||
$spy = 4 + (1 - $spv / $sp_max) * 22;
|
||||
$sp_pts[] = sprintf('%.1f,%.1f', $spx, $spy);
|
||||
}
|
||||
$sp_last = explode(',', end($sp_pts));
|
||||
?>
|
||||
<svg class="spark" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true">
|
||||
<polyline points="<?= htmlspecialchars(implode(' ', $sp_pts)) ?>"></polyline>
|
||||
<circle class="sp-dot" cx="<?= htmlspecialchars($sp_last[0]) ?>" cy="<?= htmlspecialchars($sp_last[1]) ?>" r="2"></circle>
|
||||
</svg>
|
||||
<span class="g-badge <?= htmlspecialchars($ov_badge) ?>"><?= htmlspecialchars($ov_badge_txt) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($board_rows)): ?>
|
||||
<!-- 경보 최다 센서 리더보드 (30일 · 온습도 센서 4개 이상 규모에서만 렌더) -->
|
||||
<div class="ov-tile ov-board">
|
||||
<h3>경보 최다 센서 <span class="sub">30일 · TOP <?= count($board_rows) ?></span></h3>
|
||||
<div class="board">
|
||||
<?php foreach ($board_rows as $b_rank => $br):
|
||||
$b_w = $board_max > 0 ? (int)round((int)$br['c'] / $board_max * 100) : 0; ?>
|
||||
<div class="board-row">
|
||||
<span class="board-rank"><?= $b_rank + 1 ?></span>
|
||||
<div class="board-main">
|
||||
<div class="board-name"><?= htmlspecialchars($br['sensor_name']) ?></div>
|
||||
<div class="board-bar"><span style="width:<?= $b_w ?>%"></span></div>
|
||||
</div>
|
||||
<span class="board-cnt"><?= (int)$br['c'] ?><small>건</small></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,18 +1,6 @@
|
|||
<?php
|
||||
// 모바일 접근 점검용 단순 핑. User-Agent 를 그대로 반사하면 반사형 XSS 가 되므로
|
||||
// text/plain 으로 응답하고 출력값도 이스케이프한다(이중 방어).
|
||||
//
|
||||
// 인증 가드(심층 방어): 1차 차단은 .htaccess FilesMatch(403)지만, .htaccess 가
|
||||
// 무시되는 배포(AllowOverride None 등)에서도 비인증 노출되지 않도록 세션을 검사한다.
|
||||
ini_set('session.cookie_httponly', 1);
|
||||
ini_set('session.cookie_secure', 1);
|
||||
ini_set('session.cookie_samesite', 'Strict');
|
||||
ini_set('session.use_strict_mode', 1);
|
||||
session_start();
|
||||
if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
|
|
|
|||
8
raspberry_pi4/.gitignore
vendored
8
raspberry_pi4/.gitignore
vendored
|
|
@ -1,14 +1,14 @@
|
|||
# Operational secrets (never commit)
|
||||
# 운영·비밀값 (절대 커밋 금지)
|
||||
config_local.py
|
||||
|
||||
# Python venv / cache
|
||||
# Python venv / 캐시
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Offline install bundle (vendor.sh output - large; transferred separately)
|
||||
# 오프라인 설치 번들 (vendor.sh 산출물 — 용량이 큼, 전송은 별도)
|
||||
wheelhouse/
|
||||
debs/
|
||||
|
||||
# Runtime logs
|
||||
# 런타임 로그
|
||||
*.log
|
||||
|
|
|
|||
|
|
@ -1,135 +1,123 @@
|
|||
# Leak Detection Sensor Module — Raspberry Pi 4 Model B Only
|
||||
# 누수감지 센서 모듈 — Raspberry Pi 4 Model B 전용
|
||||
|
||||
A monitoring module that reads the e-con **5C-08 resistive leak detection sensor** via the
|
||||
GPIO of a Raspberry Pi 4 Model B. On a leak it immediately reports to the PHP API server
|
||||
(+ triggers an SMS), and **captures an on-site photo with the Pi Camera and uploads it**.
|
||||
e-con **5C-08 저항형 누수감지센서**를 Raspberry Pi 4 Model B 의 GPIO 로 읽어,
|
||||
누수 발생 시 즉시 PHP API 서버에 보고(+SMS 발송 트리거)하고 **Pi Camera 로 현장 사진을
|
||||
촬영해 업로드**하는 모니터링 모듈입니다.
|
||||
|
||||
> This folder is a port of a module originally written for the **Raspberry Pi Zero 2 W**,
|
||||
> adapted to be **Pi 4 Model B only**. The GPIO (BCM) numbering scheme and the
|
||||
> `rpicam`/`libcamera` camera stack are identical on both boards, so the sensor/capture
|
||||
> logic is unchanged; the differences are only in the **wiring/power/Ethernet/camera
|
||||
> connector** and the install environment.
|
||||
> 원래 **Raspberry Pi Zero 2 W** 용으로 작성된 모듈을 **Pi 4 Model B 전용**으로 이식한
|
||||
> 폴더입니다. GPIO(BCM) 번호 체계와 `rpicam`/`libcamera` 카메라 스택은 두 보드가 동일해
|
||||
> 센서·촬영 로직은 그대로이며, 차이는 **배선/전원/이더넷/카메라 커넥터**와 설치 환경뿐입니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Files
|
||||
## 1. 구성 파일
|
||||
|
||||
| File | Role |
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `leak_sensor.py` | Main — GPIO interrupt leak detection, periodic reporting, capture+upload on leak |
|
||||
| `config.py` | Non-secret default settings (sensor/threshold/interval/camera). Loads secrets from `config_local` |
|
||||
| `config_local.example.py` | Operational/secret value template → copy to `config_local.py` before use (not committed) |
|
||||
| `requirements.txt` | Python dependencies (pinned) |
|
||||
| `install.sh` | Pi 4 install script (air-gapped offline + online fallback) |
|
||||
| `vendor.sh` | Pre-download dependencies for air-gapped use (run on build machine) |
|
||||
| `test_connection.py` | Server connection/signature check |
|
||||
| `systemd/leak-sensor.service` | Auto-start-on-boot service unit |
|
||||
| `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. Hardware (based on Pi 4 Model B)
|
||||
## 2. 하드웨어 (Pi 4 Model B 기준)
|
||||
|
||||
- **Raspberry Pi 4 Model B** (BCM2711) + Raspberry Pi OS **Bookworm 64-bit**
|
||||
- e-con **5C-08** leak detection sensor (resistive, high-impedance ~1.5MΩ)
|
||||
- **External 10MΩ pull-down resistor** (required — internal pull resistor is off)
|
||||
- (Optional) **Pi Camera** — Pi 4 = **15-pin (1.0mm pitch) standard CSI**, Zero 2 W = **22-pin (0.5mm pitch) CSI**
|
||||
→ Ribbon/camera boards are not compatible (a Pi 4 15-pin ribbon or a 15↔22 adapter is required)
|
||||
- Power: **USB-C 5V/3A** (a power margin is recommended when using the camera)
|
||||
- Network: Pi 4 **Gigabit Ethernet** (wired) or WiFi
|
||||
- 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 Pinout (default single sensor)
|
||||
### 2.1 핀 배치 (기본 센서 1개)
|
||||
|
||||
| Signal | BCM | 40-pin header physical pin |
|
||||
| 신호 | BCM | 40핀 헤더 물리 핀 |
|
||||
|---|---|---|
|
||||
| Sensor input | GPIO17 | 11 |
|
||||
| 센서 입력 | GPIO17 | 11 |
|
||||
| 3.3V | — | 1 |
|
||||
| GND | — | 6 (or 9/14/20/25/30/34/39) |
|
||||
| GND | — | 6 (또는 9/14/20/25/30/34/39) |
|
||||
|
||||
> To add a sensor, add an entry by BCM number to `SENSORS` in `config.py`.
|
||||
> (e.g. GPIO27=physical pin 13, GPIO22=physical pin 15)
|
||||
> 센서를 추가하려면 `config.py` 의 `SENSORS` 에 BCM 번호로 항목을 추가합니다.
|
||||
> (예: GPIO27=물리 13번, GPIO22=물리 15번)
|
||||
|
||||
### 2.2 Electrical design (important)
|
||||
### 2.2 전기 설계 (중요)
|
||||
|
||||
- `ACTIVE_LOW = False`: when dry GPIO=LOW(0), on leak GPIO=HIGH(1)
|
||||
- `USE_INTERNAL_PULL = False` (**keep PUD_OFF**): if you turn on the internal 50kΩ pull-down,
|
||||
the 1.5MΩ sensor cannot pull GPIO up to HIGH, causing **detection failure**. Use only the
|
||||
external 10MΩ pull-down.
|
||||
- `ACTIVE_LOW = False`: 건조 시 GPIO=LOW(0), 누수 시 GPIO=HIGH(1)
|
||||
- `USE_INTERNAL_PULL = False`(**PUD_OFF 유지**): 내부 50kΩ 풀다운을 켜면 1.5MΩ 센서가
|
||||
GPIO 를 HIGH 로 끌어올리지 못해 **감지 실패**합니다. 외부 4.7MΩ 풀다운만 사용하세요.
|
||||
|
||||
---
|
||||
|
||||
## 3. Server compatibility (must verify)
|
||||
## 3. 서버 호환성 (반드시 확인)
|
||||
|
||||
This module reports to a **server that receives** the `leak_alert` / `leak_recovery` events
|
||||
and `photo_upload.php`.
|
||||
이 모듈은 `leak_alert` / `leak_recovery` 이벤트와 `photo_upload.php` 를 **수신하는 서버**에
|
||||
보고합니다.
|
||||
|
||||
> ✅ **The `php/` server in this repo is exactly that server (single-server integration complete).**
|
||||
> Both module 1 (leak + camera) and module 2 (SHT30 temperature/humidity) report to the
|
||||
> **same cafe24 `php/` server** with the raw-body signature
|
||||
> `X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))`,
|
||||
> and leak status, photos, and temperature/humidity are shown together on a single
|
||||
> dashboard (`dashboard.php`).
|
||||
> ⚠️ **현재 이 repo 의 `php/` 서버는 SHT30 온습도 전용으로 전환**되었습니다
|
||||
> (`sql/migration_drop_leak.sql` 가 `leak_photo`/`leak_incident` 테이블과 `is_leak` 컬럼을
|
||||
> 제거하고 `event_type` 을 `startup|periodic` 로 축소). 따라서 현재 repo 서버는 leak 이벤트와
|
||||
> 사진 업로드를 **거부**합니다.
|
||||
>
|
||||
> - **New install:** run only `sql/schema_sht30.sql` — the leak elements
|
||||
> (`leak_sensor_log.is_leak` / `leak_sensor_status.is_leak` columns, `leak_incident`, `leak_photo`,
|
||||
> `leak_ingest_nonce` tables) are already included.
|
||||
>
|
||||
> Set `API_URL` / `PHOTO_UPLOAD_URL` in `config_local.py` to this integrated server, and make
|
||||
> `API_KEY` **byte-for-byte identical** to the `API_KEY` in the server's `config.local.php`.
|
||||
> 이 Pi 4 모듈은 **leak 를 받는 기존 서버**(기존 `raspi_leck_detecter` 엔드포인트)로 보고하도록
|
||||
> `config_local.py` 의 `API_URL` / `PHOTO_UPLOAD_URL` 을 설정해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Installation
|
||||
## 4. 설치
|
||||
|
||||
### 4.1 Prepare configuration values
|
||||
### 4.1 설정값 준비
|
||||
|
||||
```bash
|
||||
cp config_local.example.py config_local.py
|
||||
# Open config_local.py and fill in API_URL / PHOTO_UPLOAD_URL / API_KEY / DEVICE_ID
|
||||
# config_local.py 를 열어 API_URL / PHOTO_UPLOAD_URL / API_KEY / DEVICE_ID 채움
|
||||
```
|
||||
|
||||
If `config_local.py` is missing, execution **stops immediately with a clear error** (fail closed).
|
||||
**Never commit** `config_local.py` or `API_KEY` (they are handled by `.gitignore`).
|
||||
`config_local.py` 가 없으면 실행 시 **명확한 오류로 즉시 중단**됩니다(fail closed).
|
||||
`config_local.py` 와 `API_KEY` 는 **절대 커밋하지 마세요**(`.gitignore` 처리됨).
|
||||
|
||||
### 4.2 Online install (environment with internet)
|
||||
### 4.2 온라인 설치 (인터넷 가능 환경)
|
||||
|
||||
```bash
|
||||
chmod +x install.sh
|
||||
./install.sh
|
||||
```
|
||||
|
||||
### 4.3 Air-gapped offline install
|
||||
### 4.3 폐쇄망(air-gapped) 오프라인 설치
|
||||
|
||||
On a machine with internet of the **same architecture (aarch64, ideally the same Pi 4 +
|
||||
Bookworm 64-bit)**, first fetch the dependencies to build a bundle, then move it to the
|
||||
target Pi 4 to install.
|
||||
인터넷이 되는 **동일 아키텍처(aarch64, 가급적 동일 Pi 4 + Bookworm 64-bit)** 머신에서
|
||||
의존성을 먼저 받아 번들을 만든 뒤, 타깃 Pi 4 로 옮겨 설치합니다.
|
||||
|
||||
```bash
|
||||
# (build machine, internet YES) create wheelhouse/ + debs/
|
||||
# (빌드 머신, 인터넷 O) wheelhouse/ + debs/ 생성
|
||||
./vendor.sh
|
||||
|
||||
# After copying wheelhouse/ and debs/ into this folder on the target Pi 4:
|
||||
# (target Pi 4, internet NO)
|
||||
./install.sh # if debs/ · wheelhouse/ exist, installs offline automatically
|
||||
# wheelhouse/ 와 debs/ 를 타깃 Pi 4 의 이 폴더로 복사한 뒤:
|
||||
# (타깃 Pi 4, 인터넷 X)
|
||||
./install.sh # debs/ · wheelhouse/ 가 있으면 자동으로 오프라인 설치
|
||||
```
|
||||
|
||||
> If `debs/` exists, `install.sh` installs offline with `dpkg`; if `wheelhouse/` exists, it
|
||||
> installs offline with `pip --no-index`. It falls back to internet apt/pip only when neither
|
||||
> is present. wheel/deb are architecture-dependent, so ones fetched on an x86_64 PC will not
|
||||
> work on the Pi 4.
|
||||
> `install.sh` 는 `debs/` 가 있으면 `dpkg` 오프라인, `wheelhouse/` 가 있으면
|
||||
> `pip --no-index` 오프라인으로 설치합니다. 둘 다 없을 때만 인터넷 apt/pip 로 폴백합니다.
|
||||
> wheel/deb 는 아키텍처 종속이므로 x86_64 PC 에서 받은 것은 Pi 4 에서 동작하지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Running
|
||||
## 5. 실행
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
python3 leak_sensor.py
|
||||
```
|
||||
|
||||
In an environment without `RPi.GPIO` (a development PC, etc.), it automatically runs in
|
||||
**simulation mode**.
|
||||
`RPi.GPIO` 가 없는 환경(개발 PC 등)에서는 자동으로 **시뮬레이션 모드**로 동작합니다.
|
||||
|
||||
### Connection check
|
||||
### 연결 점검
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
|
|
@ -138,10 +126,10 @@ python3 test_connection.py
|
|||
|
||||
---
|
||||
|
||||
## 6. Auto-start on boot (systemd)
|
||||
## 6. 부팅 자동 실행 (systemd)
|
||||
|
||||
```bash
|
||||
# Assumes the folder is placed at /home/pi/raspberry_pi4 (if different, edit the path in .service)
|
||||
# 폴더를 /home/pi/raspberry_pi4 에 배치했다고 가정 (다르면 .service 의 경로 수정)
|
||||
sudo cp systemd/leak-sensor.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable leak-sensor
|
||||
|
|
@ -150,19 +138,15 @@ sudo systemctl status leak-sensor
|
|||
journalctl -u leak-sensor -f
|
||||
```
|
||||
|
||||
`SupplementaryGroups=video` is required for camera access.
|
||||
`SupplementaryGroups=video` 가 카메라 접근에 필요합니다.
|
||||
|
||||
---
|
||||
|
||||
## 7. Security notes
|
||||
## 7. 보안 메모
|
||||
|
||||
- Secret values (`API_KEY`), the server URL, and device info live only in `config_local.py`
|
||||
(not committed). Committed files contain only placeholders.
|
||||
- The report body is protected against tampering with a **raw-body HMAC signature**:
|
||||
`X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))`
|
||||
(`leak_sensor.sign_raw` ↔ server `config.php::verify_signature_raw`, same scheme as module 2).
|
||||
- The canonical string for the photo upload signature is `device_id|sensor_id|log_id|timestamp`
|
||||
(no spaces) (see `config_local.example.py`).
|
||||
- The old `sha256(API_KEY + sorted JSON)` body-field signature is **deprecated**, and the
|
||||
server default rejects it (`ALLOW_LEGACY_BODY_SIGNATURE=false`, `php/config.php`).
|
||||
- If `config_local.py` is exposed, immediately rotate `API_KEY` (change it on the server at the same time).
|
||||
- 비밀값(`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` 를 교체(서버 동시 변경)하세요.
|
||||
|
|
|
|||
|
|
@ -1,621 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Leak Detection Node · Wiring & Pinmap (Raspberry Pi 4)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#eef1f5; --card:#ffffff; --ink:#212529; --muted:#6b7280; --line:#e5e8ec;
|
||||
--sig:#2f9e44; --sig-d:#2b8a3e; --pwr:#e03131; --pwr-d:#c92a2a;
|
||||
--gnd:#1971c2; --gnd-d:#1864ab; --exp:#e8590c; --exp-b:#ffa94d;
|
||||
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Malgun Gothic","Apple SD Gothic Neo","Noto Sans KR",sans-serif;
|
||||
--mono:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0}
|
||||
body{font-family:var(--sans);background:var(--bg);color:var(--ink);line-height:1.6;
|
||||
-webkit-font-smoothing:antialiased;padding:24px 16px 60px}
|
||||
.wrap{max-width:1000px;margin:0 auto}
|
||||
svg text{font-family:var(--sans)}
|
||||
|
||||
/* Header */
|
||||
.hero{background:linear-gradient(135deg,#0b2545 0%,#134074 55%,#1971c2 100%);
|
||||
color:#fff;border-radius:16px;padding:26px 28px;box-shadow:0 8px 24px rgba(11,37,69,.18)}
|
||||
.hero .eyebrow{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:#a5c4e6;font-weight:700;margin:0 0 6px}
|
||||
.hero h1{margin:0 0 10px;font-size:26px;line-height:1.25;font-weight:800}
|
||||
.hero p{margin:0 0 16px;font-size:15px;color:#dbe7f5;max-width:70ch}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.chip{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.25);color:#fff;
|
||||
border-radius:999px;padding:5px 12px;font-size:12.5px;font-weight:600;white-space:nowrap}
|
||||
.chip.mono{font-family:var(--mono);letter-spacing:.02em}
|
||||
|
||||
/* Section cards */
|
||||
section{background:var(--card);border:1px solid var(--line);border-radius:14px;
|
||||
padding:22px 24px;margin-top:20px;box-shadow:0 2px 8px rgba(16,24,40,.05)}
|
||||
h2{display:flex;align-items:center;gap:12px;margin:0 0 18px;font-size:19px;font-weight:800}
|
||||
h2 .num{flex:none;width:30px;height:30px;border-radius:9px;background:#134074;color:#fff;
|
||||
font-size:15px;display:flex;align-items:center;justify-content:center;font-weight:800}
|
||||
h3{margin:22px 0 10px;font-size:15px;font-weight:700;color:#134074}
|
||||
|
||||
/* Pinmap block */
|
||||
.pinwrap{display:grid;grid-template-columns:minmax(260px,1.15fr) 1fr;gap:24px;align-items:start}
|
||||
.diagram{background:#fbfcfd;border:1px solid var(--line);border-radius:12px;padding:14px}
|
||||
.legend{display:flex;flex-direction:column;gap:11px}
|
||||
.legend .lg{display:flex;align-items:flex-start;gap:11px;font-size:13.5px}
|
||||
.sw{flex:none;width:22px;height:22px;border-radius:6px;margin-top:1px}
|
||||
.sw.sig{background:var(--sig);border:2px solid var(--sig-d)}
|
||||
.sw.pwr{background:var(--pwr);border:2px solid var(--pwr-d)}
|
||||
.sw.gnd{background:var(--gnd);border:2px solid var(--gnd-d)}
|
||||
.sw.alt{background:#e7f5ff;border:2px dashed #4dabf7}
|
||||
.sw.exp{background:#fff4e6;border:2px dashed var(--exp-b)}
|
||||
.sw.off{background:#e9ecef;border:2px solid #ced4da}
|
||||
.lg b{display:block;font-weight:700}
|
||||
.lg span{color:var(--muted);font-size:12.5px}
|
||||
.hint{margin-top:6px;font-size:12.5px;color:var(--muted);background:#f8f9fb;border:1px dashed #d5dbe3;
|
||||
border-radius:8px;padding:9px 11px}
|
||||
|
||||
/* Table */
|
||||
table{width:100%;border-collapse:collapse;font-size:14px}
|
||||
caption{caption-side:bottom;text-align:left;color:var(--muted);font-size:12.5px;padding-top:10px}
|
||||
th,td{padding:11px 12px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
thead th{background:#f3f6fa;color:#134074;font-weight:700;font-size:13px;border-bottom:2px solid #d6e0ec}
|
||||
td.mono,th.mono{font-family:var(--mono);white-space:nowrap}
|
||||
.tag{display:inline-block;font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;line-height:1.7}
|
||||
.tag.sig{background:#e7f6ec;color:var(--sig-d)}
|
||||
.tag.pwr{background:#fdecec;color:var(--pwr-d)}
|
||||
.tag.gnd{background:#e7f1fb;color:var(--gnd-d)}
|
||||
.tag.exp{background:#fff1e3;color:var(--exp)}
|
||||
tr.exprow td{background:#fffaf3}
|
||||
|
||||
/* Electrical notes */
|
||||
.notes{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.note{border:1px solid var(--line);border-left:4px solid #1971c2;border-radius:10px;padding:13px 15px;background:#fbfcfd}
|
||||
.note h4{margin:0 0 4px;font-size:14px}
|
||||
.note p{margin:0;font-size:13.5px;color:#374151}
|
||||
.note code,.inline-code{font-family:var(--mono);background:#eef2f7;padding:1px 6px;border-radius:5px;font-size:12.5px}
|
||||
.crit{grid-column:1 / -1;border:2px solid var(--pwr);border-left-width:6px;background:#fff5f5;border-radius:10px;padding:15px 18px}
|
||||
.crit .badge{display:inline-block;background:var(--pwr);color:#fff;font-weight:800;font-size:12px;
|
||||
padding:3px 11px;border-radius:6px;letter-spacing:.04em;margin-bottom:8px}
|
||||
.crit h4{margin:0 0 6px;font-size:16px;color:var(--pwr-d)}
|
||||
.crit p{margin:0;font-size:14px;color:#5c1a1a}
|
||||
.cam{margin-top:16px;border:1px solid var(--line);border-left:4px solid #7048e8;border-radius:10px;
|
||||
padding:13px 16px;background:#fbfaff}
|
||||
.cam h4{margin:0 0 6px;font-size:14px;color:#5f3dc4}
|
||||
.cam p{margin:0;font-size:13.5px;color:#374151}
|
||||
|
||||
/* Checklist */
|
||||
ol.check{counter-reset:c;list-style:none;margin:0;padding:0}
|
||||
ol.check li{position:relative;padding:12px 14px 12px 80px;border-bottom:1px solid #f1f3f5;
|
||||
line-height:1.5;font-size:14px}
|
||||
ol.check li:last-child{border-bottom:none}
|
||||
ol.check li::before{counter-increment:c;content:counter(c);position:absolute;left:12px;top:11px;
|
||||
width:26px;height:26px;border-radius:50%;background:#e7f1fb;color:#1971c2;font-weight:800;
|
||||
font-size:13px;display:flex;align-items:center;justify-content:center}
|
||||
ol.check li::after{content:"";position:absolute;left:50px;top:13px;width:18px;height:18px;
|
||||
border:2px solid #adb5bd;border-radius:4px}
|
||||
ol.check li.key{background:#fff8f8}
|
||||
ol.check li b{color:var(--pwr-d)}
|
||||
|
||||
@media (max-width:720px){
|
||||
.pinwrap{grid-template-columns:1fr}
|
||||
.notes{grid-template-columns:1fr}
|
||||
.hero h1{font-size:22px}
|
||||
}
|
||||
|
||||
@media print{
|
||||
:root{--bg:#fff}
|
||||
body{background:#fff;color:#000;padding:0;font-size:12px}
|
||||
.wrap{max-width:100%}
|
||||
.hero{background:#fff!important;color:#000;border:2px solid #134074;box-shadow:none}
|
||||
.hero .eyebrow{color:#134074}.hero p{color:#333}
|
||||
.chip{background:#fff;border:1px solid #999;color:#000}
|
||||
section{box-shadow:none;border:1px solid #999;page-break-inside:avoid;break-inside:avoid}
|
||||
h2 .num{background:#134074!important;-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||||
svg{max-width:100%!important;height:auto}
|
||||
table,tr,td,th,.note,.crit,.cam,ol.check li{page-break-inside:avoid;break-inside:avoid}
|
||||
*{-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||||
.diagram,.note,.crit,.cam{background:#fff}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<!-- 1) HEADER -->
|
||||
<header class="hero">
|
||||
<p class="eyebrow">Field Wiring & Pinmap · Leak Detection Node</p>
|
||||
<h1>Leak Detection Node · Wiring & Pinmap</h1>
|
||||
<p>Standard wiring diagram for connecting the e-con 5C-08 resistive leak sensor to <b>GPIO17 (physical pin 11)</b>.
|
||||
Pi-A (sensor_id=1 · Site 1 · Server Room) and Pi-C (sensor_id=3 · Site 3 · New) are <b>common nodes</b> with identical wiring.</p>
|
||||
<div class="chips">
|
||||
<span class="chip">Raspberry Pi 4 Model B</span>
|
||||
<span class="chip mono">BCM2711</span>
|
||||
<span class="chip">Raspberry Pi OS Bookworm 64-bit</span>
|
||||
<span class="chip">e-con 5C-08 · Resistive ~1.5MΩ</span>
|
||||
<span class="chip mono">GPIO17 / Pin 11</span>
|
||||
<span class="chip">External 10MΩ pull-down required</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 2) 40-pin header pinmap diagram -->
|
||||
<section>
|
||||
<h2><span class="num">2</span>40-Pin Header Pinmap Diagram</h2>
|
||||
<div class="pinwrap">
|
||||
<div class="diagram">
|
||||
<svg viewBox="0 0 600 681" role="img" aria-label="Raspberry Pi 4 40-pin GPIO header pinmap" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;max-width:600px;">
|
||||
<rect x="70" y="69" width="460" height="31" fill="#f6f8fa"/>
|
||||
<rect x="70" y="131" width="460" height="31" fill="#f6f8fa"/>
|
||||
<rect x="70" y="193" width="460" height="31" fill="#f6f8fa"/>
|
||||
<rect x="70" y="255" width="460" height="31" fill="#f6f8fa"/>
|
||||
<rect x="70" y="317" width="460" height="31" fill="#f6f8fa"/>
|
||||
<rect x="70" y="379" width="460" height="31" fill="#f6f8fa"/>
|
||||
<rect x="70" y="441" width="460" height="31" fill="#f6f8fa"/>
|
||||
<rect x="70" y="503" width="460" height="31" fill="#f6f8fa"/>
|
||||
<rect x="70" y="565" width="460" height="31" fill="#f6f8fa"/>
|
||||
<rect x="70" y="627" width="460" height="31" fill="#f6f8fa"/>
|
||||
<rect x="234" y="30" width="112" height="633" rx="10" fill="#f1f3f5" stroke="#adb5bd" stroke-width="1.5"/>
|
||||
<rect x="236" y="36" width="32" height="32" rx="6" fill="none" stroke="#e03131" stroke-width="1.5" stroke-dasharray="3 2"/>
|
||||
<text x="252" y="22" text-anchor="middle" font-size="10" fill="#868e96">Odd pins</text>
|
||||
<text x="330" y="22" text-anchor="middle" font-size="10" fill="#868e96">Even pins</text>
|
||||
<text x="220" y="56" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="700" fill="#7d1616">3V3</text>
|
||||
<text x="360" y="56" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">5V</text>
|
||||
<circle cx="252" cy="52" r="12" fill="#e03131" stroke="#c92a2a" stroke-width="1.5"/><text x="252" y="55.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#ffffff">1</text>
|
||||
<circle cx="330" cy="52" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="55.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">2</text>
|
||||
<text x="220" y="87" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO2 (SDA1)</text>
|
||||
<text x="360" y="87" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">5V</text>
|
||||
<circle cx="252" cy="83" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="86.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">3</text>
|
||||
<circle cx="330" cy="83" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="86.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">4</text>
|
||||
<text x="220" y="118" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO3 (SCL1)</text>
|
||||
<text x="360" y="118" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
|
||||
<circle cx="252" cy="114" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="117.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">5</text>
|
||||
<circle cx="330" cy="114" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="330" y="117.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">6</text>
|
||||
<text x="220" y="149" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO4</text>
|
||||
<text x="360" y="149" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO14 (TXD)</text>
|
||||
<circle cx="252" cy="145" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="148.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">7</text>
|
||||
<circle cx="330" cy="145" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="148.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">8</text>
|
||||
<text x="220" y="180" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="700" fill="#123a63">GND</text>
|
||||
<text x="360" y="180" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO15 (RXD)</text>
|
||||
<circle cx="252" cy="176" r="12" fill="#1971c2" stroke="#1864ab" stroke-width="1.5"/><text x="252" y="179.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#ffffff">9</text>
|
||||
<circle cx="330" cy="176" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="179.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">10</text>
|
||||
<text x="220" y="211" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="700" fill="#1b4332">GPIO17</text>
|
||||
<text x="360" y="211" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO18</text>
|
||||
<circle cx="252" cy="207" r="12" fill="#2f9e44" stroke="#2b8a3e" stroke-width="1.5"/><text x="252" y="210.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#ffffff">11</text>
|
||||
<circle cx="330" cy="207" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="210.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">12</text>
|
||||
<text x="220" y="242" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#e8590c">GPIO27</text>
|
||||
<text x="360" y="242" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
|
||||
<circle cx="252" cy="238" r="12" fill="#fff4e6" stroke="#ffa94d" stroke-width="1.5" stroke-dasharray="3 2"/><text x="252" y="241.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#e8590c">13</text>
|
||||
<circle cx="330" cy="238" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="330" y="241.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">14</text>
|
||||
<text x="220" y="273" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#e8590c">GPIO22</text>
|
||||
<text x="360" y="273" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO23</text>
|
||||
<circle cx="252" cy="269" r="12" fill="#fff4e6" stroke="#ffa94d" stroke-width="1.5" stroke-dasharray="3 2"/><text x="252" y="272.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#e8590c">15</text>
|
||||
<circle cx="330" cy="269" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="272.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">16</text>
|
||||
<text x="220" y="304" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">3V3</text>
|
||||
<text x="360" y="304" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO24</text>
|
||||
<circle cx="252" cy="300" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="303.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">17</text>
|
||||
<circle cx="330" cy="300" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="303.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">18</text>
|
||||
<text x="220" y="335" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO10 (MOSI)</text>
|
||||
<text x="360" y="335" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
|
||||
<circle cx="252" cy="331" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="334.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">19</text>
|
||||
<circle cx="330" cy="331" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="330" y="334.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">20</text>
|
||||
<text x="220" y="366" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO9 (MISO)</text>
|
||||
<text x="360" y="366" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO25</text>
|
||||
<circle cx="252" cy="362" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="365.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">21</text>
|
||||
<circle cx="330" cy="362" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="365.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">22</text>
|
||||
<text x="220" y="397" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO11 (SCLK)</text>
|
||||
<text x="360" y="397" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO8 (CE0)</text>
|
||||
<circle cx="252" cy="393" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="396.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">23</text>
|
||||
<circle cx="330" cy="393" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="396.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">24</text>
|
||||
<text x="220" y="428" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
|
||||
<text x="360" y="428" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO7 (CE1)</text>
|
||||
<circle cx="252" cy="424" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="252" y="427.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">25</text>
|
||||
<circle cx="330" cy="424" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="427.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">26</text>
|
||||
<text x="220" y="459" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO0 (ID_SD)</text>
|
||||
<text x="360" y="459" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO1 (ID_SC)</text>
|
||||
<circle cx="252" cy="455" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="458.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">27</text>
|
||||
<circle cx="330" cy="455" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="458.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">28</text>
|
||||
<text x="220" y="490" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO5</text>
|
||||
<text x="360" y="490" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
|
||||
<circle cx="252" cy="486" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="489.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">29</text>
|
||||
<circle cx="330" cy="486" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="330" y="489.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">30</text>
|
||||
<text x="220" y="521" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO6</text>
|
||||
<text x="360" y="521" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO12</text>
|
||||
<circle cx="252" cy="517" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="520.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">31</text>
|
||||
<circle cx="330" cy="517" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="520.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">32</text>
|
||||
<text x="220" y="552" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO13</text>
|
||||
<text x="360" y="552" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
|
||||
<circle cx="252" cy="548" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="551.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">33</text>
|
||||
<circle cx="330" cy="548" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="330" y="551.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">34</text>
|
||||
<text x="220" y="583" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO19</text>
|
||||
<text x="360" y="583" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO16</text>
|
||||
<circle cx="252" cy="579" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="582.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">35</text>
|
||||
<circle cx="330" cy="579" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="582.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">36</text>
|
||||
<text x="220" y="614" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO26</text>
|
||||
<text x="360" y="614" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO20</text>
|
||||
<circle cx="252" cy="610" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="613.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">37</text>
|
||||
<circle cx="330" cy="610" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="613.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">38</text>
|
||||
<text x="220" y="645" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
|
||||
<text x="360" y="645" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO21</text>
|
||||
<circle cx="252" cy="641" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="252" y="644.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">39</text>
|
||||
<circle cx="330" cy="641" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="644.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">40</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<div class="lg"><span class="sw sig"></span><b>Signal (GPIO17 · Pin 11)<span>Sensor signal line input</span></b></div>
|
||||
<div class="lg"><span class="sw pwr"></span><b>Power 3V3 (Pin 1)<span>Sensor power supply</span></b></div>
|
||||
<div class="lg"><span class="sw gnd"></span><b>GND (Pin 9) · 풀다운용<span>10MΩ 풀다운 리턴 · 센서 선 아님</span></b></div>
|
||||
<div class="lg"><span class="sw alt"></span><b>Ground alternatives<span>Pin 9 / 14 / 20 / 25 / 30 / 34 / 39</span></b></div>
|
||||
<div class="lg"><span class="sw exp"></span><b>Expansion example (optional)<span>GPIO27·Pin 13, GPIO22·Pin 15</span></b></div>
|
||||
<div class="lg"><span class="sw off"></span><b>Unused pins<span>Not connected on this node</span></b></div>
|
||||
<div class="hint">The red dashed square marks the <b>Pin 1</b> position. Always verify the header orientation (relative to Pin 1) before wiring. Only three pins are actually used: <b>1 · 9 · 11</b>.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3) Pinmap table -->
|
||||
<section>
|
||||
<h2><span class="num">3</span>Pinmap Table</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Signal</th><th class="mono">BCM</th><th class="mono">Physical Pin</th><th>Notes</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="tag sig">Signal</span> Sensor signal line</td>
|
||||
<td class="mono">GPIO17</td>
|
||||
<td class="mono">Pin 11</td>
|
||||
<td>Sensor signal input. Connect an external 10MΩ pull-down between <b>Pin 11 ↔ GND</b>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag pwr">Power</span> Sensor power 3.3V</td>
|
||||
<td class="mono">3V3</td>
|
||||
<td class="mono">Pin 1</td>
|
||||
<td>Sensor VCC supply. (Spare 3V3 = Pin 17)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag gnd">Ground</span> 풀다운 GND <span style="color:#868e96">(센서 선 아님)</span></td>
|
||||
<td class="mono">GND</td>
|
||||
<td class="mono">Pin 9</td>
|
||||
<td>10MΩ 풀다운(GPIO17↔GND) 리턴용. Alternatives: any GND among Pin 6 / 14 / 20 / 25 / 30 / 34 / 39.</td>
|
||||
</tr>
|
||||
<tr class="exprow">
|
||||
<td><span class="tag exp">Expansion</span> Additional sensor example</td>
|
||||
<td class="mono">GPIO27</td>
|
||||
<td class="mono">Pin 13</td>
|
||||
<td>Optional. Add by BCM number to SENSORS in <span class="inline-code">config.py</span>.</td>
|
||||
</tr>
|
||||
<tr class="exprow">
|
||||
<td><span class="tag exp">Expansion</span> Additional sensor example</td>
|
||||
<td class="mono">GPIO22</td>
|
||||
<td class="mono">Pin 15</td>
|
||||
<td>Optional. Add the same way as above (each channel needs its own external pull-down).</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<caption>Three pins used (Pin 1 · 9 · 11). The expansion rows are reference examples for multi-sensor setups. Per-device sensor_id / location is set in <span class="inline-code">config_local.py</span>.</caption>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- 4) Wiring schematic -->
|
||||
<section>
|
||||
<h2><span class="num">4</span>Wiring Schematic</h2>
|
||||
<div class="diagram">
|
||||
<svg viewBox="0 0 640 400" role="img" aria-label="누수 저항식 2전극 프로브 배선 — 전극A→3V3, 전극B→GPIO17. 10MΩ 풀다운은 GPIO17↔GND 보드 부품(센서 선 아님)" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto">
|
||||
<!-- Pi header block -->
|
||||
<rect x="36" y="70" width="150" height="250" rx="10" fill="#f1f3f5" stroke="#adb5bd" stroke-width="1.5"/>
|
||||
<text x="111" y="60" text-anchor="middle" font-size="13" font-weight="700" fill="#212529">Raspberry Pi 4 · 40-pin header</text>
|
||||
<!-- header pin rows -->
|
||||
<circle cx="60" cy="120" r="5" fill="#e03131"/>
|
||||
<text x="74" y="124" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">Pin 1 · 3V3</text>
|
||||
<rect x="182" y="114" width="10" height="12" rx="2" fill="#e03131"/>
|
||||
<circle cx="60" cy="195" r="5" fill="#2f9e44"/>
|
||||
<text x="74" y="199" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">Pin 11 · GPIO17</text>
|
||||
<rect x="182" y="189" width="10" height="12" rx="2" fill="#2f9e44"/>
|
||||
<circle cx="60" cy="270" r="5" fill="#1971c2"/>
|
||||
<text x="74" y="274" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">Pin 9 · GND</text>
|
||||
<rect x="182" y="264" width="10" height="12" rx="2" fill="#1971c2"/>
|
||||
<!-- sensor block -->
|
||||
<rect x="470" y="92" width="164" height="150" rx="10" fill="#ffffff" stroke="#495057" stroke-width="1.5"/>
|
||||
<text x="552" y="82" text-anchor="middle" font-size="13" font-weight="700" fill="#212529">e-con 5C-08 리크 프로브</text>
|
||||
<text x="552" y="106" text-anchor="middle" font-size="11" font-weight="700" fill="#2b8a3e">2선(2전극)</text>
|
||||
<rect x="462" y="114" width="10" height="12" rx="2" fill="#e03131"/>
|
||||
<text x="484" y="124" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">전극 A</text>
|
||||
<rect x="462" y="189" width="10" height="12" rx="2" fill="#2f9e44"/>
|
||||
<text x="484" y="199" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">전극 B</text>
|
||||
<rect x="556" y="150" width="9" height="58" rx="2" fill="#e03131"/>
|
||||
<rect x="580" y="150" width="9" height="58" rx="2" fill="#2f9e44"/>
|
||||
<path d="M560,208 q13,9 25,0" fill="none" stroke="#4dabf7" stroke-width="2.5"/>
|
||||
<text x="572" y="228" text-anchor="middle" font-size="10.5" fill="#1971c2">물 닿으면 도통</text>
|
||||
<!-- wires -->
|
||||
<path d="M192,120 H462" stroke="#e03131" stroke-width="2.5" fill="none"/>
|
||||
<path d="M192,195 H462" stroke="#2f9e44" stroke-width="2.5" fill="none"/>
|
||||
<path d="M192,270 H340" stroke="#1971c2" stroke-width="2.5" fill="none"/>
|
||||
<text x="322" y="111" text-anchor="middle" font-size="11" fill="#c92a2a">① 전극 A → 3V3 · Pin 1</text>
|
||||
<text x="248" y="186" text-anchor="middle" font-size="11" fill="#2b8a3e">② 전극 B → GPIO17 · Pin 11</text>
|
||||
<text x="228" y="288" text-anchor="middle" font-size="11" fill="#1864ab">GND · Pin 9 (풀다운용)</text>
|
||||
<!-- external pulldown between GPIO17 wire and GND wire (board part, not a sensor lead) -->
|
||||
<circle cx="340" cy="195" r="4" fill="#2f9e44"/>
|
||||
<circle cx="340" cy="270" r="4" fill="#1971c2"/>
|
||||
<polyline points="340,199 340,206 330,210 350,218 330,226 350,234 330,242 340,246 340,266" fill="none" stroke="#343a40" stroke-width="2"/>
|
||||
<text x="356" y="222" text-anchor="start" font-size="12" font-weight="700" fill="#343a40">10MΩ 풀다운</text>
|
||||
<text x="356" y="238" text-anchor="start" font-size="10" fill="#c92a2a">(보드 부품·센서 선 아님)</text>
|
||||
<!-- internal pull off note -->
|
||||
<rect x="60" y="300" width="330" height="26" rx="6" fill="#fff5f5" stroke="#ffa8a8" stroke-width="1.5" stroke-dasharray="4 3"/>
|
||||
<text x="72" y="317" text-anchor="start" font-size="11" font-weight="700" fill="#c92a2a">내부 풀 OFF · USE_INTERNAL_PULL=False (PUD_OFF)</text>
|
||||
<!-- logic box -->
|
||||
<rect x="60" y="350" width="540" height="30" rx="6" fill="#f1f3f5" stroke="#ced4da"/>
|
||||
<text x="330" y="369" text-anchor="middle" font-size="11.5" fill="#343a40">건조 → GPIO17=LOW(0) · 누수(물 도통) → GPIO17=HIGH(1) · ACTIVE_LOW=False</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="hint"><b>센서는 2선(2전극).</b> 전극 A는 3V3(Pin 1), 전극 B는 GPIO17(Pin 11)에 연결한다. Pin 6 GND는 <b>센서 선이 아니라</b> GPIO17↔GND 사이 10MΩ 풀다운 저항용이다. 건조 시 프로브가 초고임피던스(거의 개방)라 풀다운이 GPIO17을 <b>LOW</b>로 잡고, 물이 두 전극을 이으면 3.3V가 ~1.5MΩ를 통해 전극 B 선을 끌어올려 GPIO17이 <b>HIGH</b>가 된다.</div>
|
||||
</section>
|
||||
|
||||
<!-- 5) Electrical design · cautions -->
|
||||
<section>
|
||||
<h2><span class="num">5</span>Electrical Design · Cautions</h2>
|
||||
|
||||
<div class="crit">
|
||||
<span class="badge">★ Critical Caution · CRITICAL</span>
|
||||
<h4>Do not enable the internal pull-down</h4>
|
||||
<p>If you enable the internal <b>50kΩ</b> pull-down, the ~1.5MΩ high-impedance sensor cannot pull the GPIO HIGH,
|
||||
so <b>leaks will not be detected (detection failure)</b>. Always turn the internal pull off with <span class="inline-code">PUD_OFF</span>
|
||||
and use <b>only the external 10MΩ pull-down</b>. (<span class="inline-code">USE_INTERNAL_PULL = False</span>)</p>
|
||||
</div>
|
||||
|
||||
<div class="notes" style="margin-top:14px">
|
||||
<div class="note">
|
||||
<h4>Polarity · Logic Level</h4>
|
||||
<p><span class="inline-code">ACTIVE_LOW = False</span> → when dry <b>GPIO=LOW(0)</b>, on leak <b>GPIO=HIGH(1)</b>.</p>
|
||||
</div>
|
||||
<div class="note">
|
||||
<h4>External Pull-down (required)</h4>
|
||||
<p>Connect a 10MΩ resistor between <b>GPIO17 (Pin 11) ↔ GND</b>. Essential for the high-impedance sensor.</p>
|
||||
</div>
|
||||
<div class="note">
|
||||
<h4>Internal Pull Setting</h4>
|
||||
<p><span class="inline-code">USE_INTERNAL_PULL = False</span> — keep the internal pull at <span class="inline-code">PUD_OFF</span>.</p>
|
||||
</div>
|
||||
<div class="note">
|
||||
<h4>Debounce</h4>
|
||||
<p><span class="inline-code">DEBOUNCE_MS = 500</span> — set generously given the high-impedance behavior.</p>
|
||||
</div>
|
||||
<div class="note" style="grid-column:1 / -1">
|
||||
<h4>Configuration Files</h4>
|
||||
<p>Electrical parameters live in <span class="inline-code">raspberry_pi4/config.py</span> (SENSORS · ACTIVE_LOW · USE_INTERNAL_PULL · DEBOUNCE_MS).
|
||||
Per-device <b>sensor_id / location</b> is in <span class="inline-code">config_local.py</span>
|
||||
(Pi-A = id 1 · Site 1 · Server Room / Pi-C = id 3 · Site 3 · New).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cam">
|
||||
<h4>Camera (optional · CAMERA_ENABLED=True)</h4>
|
||||
<p>The Raspberry Pi 4 uses a <b>15-pin (1.0mm pitch) standard CSI connector</b>. The Zero 2 W uses 22-pin (0.5mm),
|
||||
so its ribbon and camera board are <b>incompatible</b> → you need a <b>15-pin ribbon</b> for the Pi 4 or a <b>15↔22 conversion adapter</b>.
|
||||
Capture with <span class="inline-code">rpicam-still</span> (Bookworm) / <span class="inline-code">libcamera-still</span> (Bullseye), <b>640×480 JPEG</b>.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 6) Installation checklist -->
|
||||
<section>
|
||||
<h2><span class="num">6</span>Installation Checklist</h2>
|
||||
<ol class="check">
|
||||
<li>Start wiring with the Raspberry Pi power <b>fully disconnected</b>.</li>
|
||||
<li>Verify the header orientation (Pin 1 position). Red dashed marker = Pin 1.</li>
|
||||
<li>Connect the sensor signal line to <b>Pin 11 (GPIO17)</b>.</li>
|
||||
<li>Connect sensor power (3.3V) to <b>Pin 1 (3V3)</b> and sensor GND to <b>Pin 6 (GND)</b>.</li>
|
||||
<li class="key">Connect an external <b>10MΩ pull-down</b> between <b>Pin 11 ↔ GND</b>. <b>(Required · omitting it causes detection failure)</b></li>
|
||||
<li class="key">Confirm the internal pull is off — <span class="inline-code">USE_INTERNAL_PULL=False</span> (PUD_OFF). <b>Never use the internal pull-down.</b></li>
|
||||
<li>Check <span class="inline-code">config.py</span>: <span class="inline-code">ACTIVE_LOW=False</span>, <span class="inline-code">DEBOUNCE_MS=500</span>, GPIO17 registered in SENSORS.</li>
|
||||
<li>Set the per-device <b>sensor_id / location</b> in <span class="inline-code">config_local.py</span> (Pi-A=1/Server Room, Pi-C=3/New).</li>
|
||||
<li>After powering on, confirm via the log that GPIO17 = <b>LOW(0)</b> in the <b>dry state</b>.</li>
|
||||
<li>Wet the sensor with water (or a damp cloth) and confirm GPIO17 switches to <b>HIGH(1)</b> and a leak event is logged.</li>
|
||||
<li>(Optional) When using the camera, verify the ribbon orientation and connector spec (15-pin / adapter) and run a 640×480 capture test.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- 7) Camera lighting (always-on) -->
|
||||
<section>
|
||||
<h2><span class="num">7</span>Camera Lighting (Always-On)</h2>
|
||||
|
||||
<div class="cam">
|
||||
<h4>White LED module — always on, no switching, no code</h4>
|
||||
<p>Power a small <b>white LED module</b> directly from the Pi so the leak-check camera has light.
|
||||
<b>VCC → 5V (Pin 2)</b>, <b>GND → GND (Pin 6)</b>. It stays <b>lit continuously</b> — there is
|
||||
<b>no GPIO control, no switching, and no software</b>. The module is low-power, so continuous
|
||||
operation produces <b>negligible heat</b> (harmless to leave on).</p>
|
||||
</div>
|
||||
|
||||
<div class="notes" style="margin-top:14px">
|
||||
<div class="note">
|
||||
<h4>Wiring</h4>
|
||||
<p>LED module <span class="inline-code">VCC → 5V (Pin 2)</span>, <span class="inline-code">GND → GND (Pin 6)</span>.
|
||||
No signal pin — it is a bare power connection, always on.</p>
|
||||
</div>
|
||||
<div class="note">
|
||||
<h4>No conflict with sensors</h4>
|
||||
<p>Uses only 5V/GND power rails — it does not touch GPIO17 (leak), the I2C pins (SHT30),
|
||||
or the UART pins (PMS7003 on GPIO15). All can coexist.</p>
|
||||
</div>
|
||||
<div class="note" style="grid-column:1 / -1">
|
||||
<h4>NoIR camera + white light → pink tint</h4>
|
||||
<p>A <b>NoIR</b> camera (no IR-cut filter) under white LED light renders the image with a
|
||||
<b>pink/magenta tint</b>. This does <b>not</b> hinder leak identification (a wet patch is still
|
||||
clearly visible). If you need <b>natural color</b>, use a <b>standard camera with a built-in
|
||||
IR-cut filter</b> instead of the NoIR module.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 8) 통합 노드 배선 (combined node master reference) -->
|
||||
<section>
|
||||
<h2><span class="num">8</span>통합 노드 배선 (누수 + 온습도 + 미세먼지 + 카메라 + 조명)</h2>
|
||||
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#374151">
|
||||
Pi-A (id 1) / Pi-C (id 3) are <b>combined nodes</b> carrying <b>all five loads on one Pi</b>:
|
||||
leak sensor + SHT30 (온습도) + PMS7003 (미세먼지) + camera + always-on light. The three standalone
|
||||
sensor sheets each independently claim <b>Pin 1 / Pin 2 / Pin 6</b>, which <b>physically collide</b>
|
||||
on a shared board. <b>This single table is the de-conflicted master; wire the combined node from
|
||||
here, not from the per-sensor sections above.</b>
|
||||
</p>
|
||||
|
||||
<div class="crit">
|
||||
<span class="badge">★ De-conflict · KEY FIX</span>
|
||||
<h4>GND is common, but you cannot stack 4 wires on Pin 6</h4>
|
||||
<p>Every device shares the same ground, but the Pi 4 exposes <b>8 separate GND pins
|
||||
(6 · 9 · 14 · 20 · 25 · 30 · 34 · 39)</b>. Spread the four grounds across them, one per device.
|
||||
Likewise there are <b>two 3V3 pins (1, 17)</b> and <b>two 5V pins (2, 4)</b>: give the leak sensor
|
||||
Pin 1 and the SHT30 the <b>second</b> 3V3 (Pin 17); give the PMS7003 Pin 2 and the LED the
|
||||
<b>second</b> 5V (Pin 4). Never double up two wires on a single rail pin.</p>
|
||||
</div>
|
||||
|
||||
<table style="margin-top:16px">
|
||||
<thead>
|
||||
<tr><th>Device</th><th>Line</th><th class="mono">BCM / Rail</th><th class="mono">Physical Pin</th><th>Notes</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Leak -->
|
||||
<tr>
|
||||
<td rowspan="3"><b>누수 Leak</b><br><span style="color:var(--muted);font-size:12.5px">e-con 5C-08</span></td>
|
||||
<td><span class="tag sig">Signal</span> Sensor signal</td>
|
||||
<td class="mono">GPIO17</td>
|
||||
<td class="mono">Pin 11</td>
|
||||
<td>External <b>10MΩ pull-down</b> Pin 11 ↔ GND (required). Internal pull <b>OFF</b> (PUD_OFF).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag pwr">Power</span> VCC 3.3V</td>
|
||||
<td class="mono">3V3</td>
|
||||
<td class="mono">Pin 1</td>
|
||||
<td>First 3V3 rail.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag gnd">Ground</span> GND</td>
|
||||
<td class="mono">GND</td>
|
||||
<td class="mono">Pin 20</td>
|
||||
<td>10MΩ 풀다운 리턴 GND(<b>센서 선 아님</b>). 고임피던스 라인이라 짧고 견고하게. (alt: Pin 25)</td>
|
||||
</tr>
|
||||
<!-- SHT30 -->
|
||||
<tr>
|
||||
<td rowspan="4"><b>온습도 SHT30</b><br><span style="color:var(--muted);font-size:12.5px">I2C · 3.3V ONLY</span></td>
|
||||
<td><span class="tag sig">I2C</span> SDA</td>
|
||||
<td class="mono">GPIO2</td>
|
||||
<td class="mono">Pin 3</td>
|
||||
<td>I2C data (SDA1).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag sig">I2C</span> SCL</td>
|
||||
<td class="mono">GPIO3</td>
|
||||
<td class="mono">Pin 5</td>
|
||||
<td>I2C clock (SCL1).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag pwr">Power</span> VCC 3.3V</td>
|
||||
<td class="mono">3V3</td>
|
||||
<td class="mono">Pin 17</td>
|
||||
<td><b>SECOND 3V3, NOT Pin 1.</b> <b>3.3V only</b>, never 5V (5V damages the SHT30).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag gnd">Ground</span> GND</td>
|
||||
<td class="mono">GND</td>
|
||||
<td class="mono">Pin 9</td>
|
||||
<td>I2C ground (adjacent to Pin 3 / 5).</td>
|
||||
</tr>
|
||||
<!-- PMS7003 -->
|
||||
<tr>
|
||||
<td rowspan="4"><b>미세먼지 PMS7003</b><br><span style="color:var(--muted);font-size:12.5px">UART</span></td>
|
||||
<td><span class="tag sig">UART</span> PMS TXD → Pi RXD</td>
|
||||
<td class="mono">GPIO15</td>
|
||||
<td class="mono">Pin 10</td>
|
||||
<td>PMS transmit into the Pi; the data line that must be present.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag sig">UART</span> Pi TXD → PMS RXD <em>(opt)</em></td>
|
||||
<td class="mono">GPIO14</td>
|
||||
<td class="mono">Pin 8</td>
|
||||
<td>Optional: only for sleep/wake commands. Passive read does not need it.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag pwr">Power</span> VCC 5V</td>
|
||||
<td class="mono">5V</td>
|
||||
<td class="mono">Pin 2</td>
|
||||
<td>PMS7003 runs on <b>5V</b> (its UART logic is 3.3V-safe for the Pi).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag gnd">Ground</span> GND</td>
|
||||
<td class="mono">GND</td>
|
||||
<td class="mono">Pin 6</td>
|
||||
<td>UART ground.</td>
|
||||
</tr>
|
||||
<!-- Camera -->
|
||||
<tr>
|
||||
<td><b>카메라 Camera</b></td>
|
||||
<td><span class="tag exp">CSI</span> Ribbon</td>
|
||||
<td class="mono">n/a</td>
|
||||
<td class="mono">CSI (separate)</td>
|
||||
<td><b>Not on the 40-pin header.</b> 15-pin CSI ribbon into the camera connector (see §5).</td>
|
||||
</tr>
|
||||
<!-- LED -->
|
||||
<tr>
|
||||
<td rowspan="2"><b>조명 LED</b><br><span style="color:var(--muted);font-size:12.5px">always-on</span></td>
|
||||
<td><span class="tag pwr">Power</span> VCC 5V</td>
|
||||
<td class="mono">5V</td>
|
||||
<td class="mono">Pin 4</td>
|
||||
<td><b>SECOND 5V, NOT Pin 2.</b> Must be an LED <b>module with an integrated resistor</b>, or add
|
||||
a <b>~150–330Ω series resistor</b> (a bare LED on 5V burns out).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag gnd">Ground</span> GND</td>
|
||||
<td class="mono">GND</td>
|
||||
<td class="mono">Pin 14</td>
|
||||
<td>Always lit: no GPIO, no switching, no code.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<caption>Combined-node master pinout. Header pins used: 1 · 2 · 3 · 4 · 5 · 6 · 8 · 9 · 10 · 11 · 14 · 17 · 20 (+ CSI ribbon). Four grounds spread over Pin 6 / 9 / 14 / 20, never stacked on one pin.</caption>
|
||||
</table>
|
||||
|
||||
<h3>Boot configuration (Bookworm · idempotent via install.sh)</h3>
|
||||
<div class="notes">
|
||||
<div class="note">
|
||||
<h4>I2C · SHT30</h4>
|
||||
<p><span class="inline-code">dtparam=i2c_arm=on</span> in
|
||||
<span class="inline-code">/boot/firmware/config.txt</span>. Verify with
|
||||
<span class="inline-code">i2cdetect -y 1</span> (SHT30 at 0x44 / 0x45).</p>
|
||||
</div>
|
||||
<div class="note">
|
||||
<h4>UART · PMS7003</h4>
|
||||
<p><span class="inline-code">enable_uart=1</span> in
|
||||
<span class="inline-code">config.txt</span>, and <b>remove</b>
|
||||
<span class="inline-code">console=serial0,115200</span> from
|
||||
<span class="inline-code">/boot/firmware/cmdline.txt</span> so the login console does not fight the sensor.</p>
|
||||
</div>
|
||||
<div class="note">
|
||||
<h4>Camera</h4>
|
||||
<p><span class="inline-code">camera_auto_detect=1</span> in
|
||||
<span class="inline-code">config.txt</span>. Verify with
|
||||
<span class="inline-code">rpicam-hello --list-cameras</span>.</p>
|
||||
</div>
|
||||
<div class="note">
|
||||
<h4>Leak GPIO17 · unaffected</h4>
|
||||
<p>Enabling UART / disabling the serial console does <b>not</b> affect the GPIO17 leak read or
|
||||
the camera. GPIO17 is a plain input, independent of the serial peripheral.</p>
|
||||
</div>
|
||||
<div class="note" style="grid-column:1 / -1">
|
||||
<h4>One reboot applies everything</h4>
|
||||
<p>All three <span class="inline-code">install.sh</span> scripts set these <b>idempotently</b>
|
||||
(I2C · UART + cmdline · camera). Run them, then a <b>single reboot</b> applies I2C, UART, and the
|
||||
camera together (no per-sensor reboot needed).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Power supply</h3>
|
||||
<div class="note" style="border-left-color:var(--pwr)">
|
||||
<h4>Use a genuine 5.1V / 3A USB-C PSU</h4>
|
||||
<p>Combined draw of all five loads is <b>~1.4–1.8A typical, ~2.2A worst-case</b>, so the official
|
||||
<b>5.1V / 3A</b> supply has ample headroom. <b>Avoid phone chargers and unpowered USB hubs</b>:
|
||||
sag below ~4.8V triggers a brown-out → CPU throttle and <b>SD-card corruption</b>. Check health with
|
||||
<span class="inline-code">vcgencmd get_throttled</span> (expect <span class="inline-code">0x0</span>;
|
||||
any non-zero indicates under-voltage).</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,83 +1,73 @@
|
|||
# =============================================================================
|
||||
# config.py - Leak Detection System v2 (Pi 4 Model B) settings — non-secret defaults
|
||||
# config.py - 누수감지 시스템 v2 (Pi 4 Model B) 설정 — 비밀이 아닌 기본값
|
||||
#
|
||||
# Operational/secret values such as server URL / API_KEY / device identifier are loaded from config_local.py.
|
||||
# config_local.py is excluded from commits via .gitignore.
|
||||
# Setup: cp config_local.example.py config_local.py → fill in with real values
|
||||
# 서버 URL / API_KEY / 기기 식별자 등 운영·비밀값은 config_local.py 에서 로드한다.
|
||||
# config_local.py 는 .gitignore 로 커밋 제외 대상이다.
|
||||
# 설정 방법: cp config_local.example.py config_local.py → 실제 값으로 채움
|
||||
# =============================================================================
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Sensor GPIO settings (BCM numbering — same as Pi 4 40-pin header)
|
||||
# GPIO17 = physical pin 11 on the 40-pin header
|
||||
# 센서 GPIO 설정 (BCM 번호 기준 — Pi 4 40핀 헤더와 동일)
|
||||
# GPIO17 = 40핀 헤더 물리 11번 핀
|
||||
# ─────────────────────────────────────────────
|
||||
SENSORS = [
|
||||
{"id": 1, "gpio": 17, "name": "Sensor 1 (Server Room)"},
|
||||
# Add more sensors below if present (BCM numbering)
|
||||
# {"id": 2, "gpio": 27, "name": "Sensor 2 (Machine Room)"}, # physical pin 13
|
||||
# {"id": 3, "gpio": 22, "name": "Sensor 3 (Restroom)"}, # physical pin 15
|
||||
{"id": 1, "gpio": 17, "name": "1번 센서 (서버실)"},
|
||||
# 추가 센서가 있으면 아래에 추가 (BCM 번호 기준)
|
||||
# {"id": 2, "gpio": 27, "name": "2번 센서 (기계실)"}, # 물리 13번
|
||||
# {"id": 3, "gpio": 22, "name": "3번 센서 (화장실)"}, # 물리 15번
|
||||
]
|
||||
|
||||
# Sensor active-level setting
|
||||
# When using the e-con 5C-08 resistive sensor + 10MΩ pull-down:
|
||||
# Dry: GPIO = LOW(0) → ACTIVE_LOW = False
|
||||
# Leak: GPIO = HIGH(1)
|
||||
# 센서 활성 레벨 설정
|
||||
# e-con 5C-08 저항형 센서 + 4.7MΩ 풀다운 사용 시:
|
||||
# 건조: GPIO = LOW(0) → ACTIVE_LOW = False
|
||||
# 누수: GPIO = HIGH(1)
|
||||
ACTIVE_LOW = False
|
||||
|
||||
# GPIO internal pull-resistor setting
|
||||
# Must stay False (PUD_OFF)!
|
||||
# Enabling the internal pull-down (50kΩ) keeps the line from going HIGH even on a leak, causing detection failure.
|
||||
# → Use only the external 10MΩ pull-down resistor
|
||||
# GPIO 내부 풀저항 설정
|
||||
# 반드시 False (PUD_OFF) 유지!
|
||||
# 내부 풀다운(50kΩ)을 켜면 누수 시에도 HIGH가 되지 않아 감지 실패함.
|
||||
# → 외부 4.7MΩ 풀다운 저항만 사용
|
||||
USE_INTERNAL_PULL = False # True = PUD_DOWN, False = PUD_OFF
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Interval settings
|
||||
# 주기 설정
|
||||
# ─────────────────────────────────────────────
|
||||
REPORT_INTERVAL = 600 # Normal-state periodic report interval (sec) = 10 min
|
||||
# Must be shorter than the server's HEARTBEAT_TIMEOUT_SEC (default 1200 sec)
|
||||
# to avoid offline false positives (600 sec = offline after 2 missed reports).
|
||||
LEAK_REPORT_INTERVAL = 10 # Re-report interval while a leak is detected (sec)
|
||||
DEBOUNCE_MS = 500 # Debounce time (ms) — set generously due to high impedance
|
||||
|
||||
# Confirm-read debounce: an edge alone never changes state. After an edge the pin is
|
||||
# re-read CONFIRM_READS times, CONFIRM_INTERVAL_SEC apart; ALL reads must agree with the
|
||||
# new state or the event is discarded as noise. Real water persists; sub-second EMI
|
||||
# blips on the 10MΩ high-impedance line (server-room noise) do not.
|
||||
# Total confirmation latency ≈ CONFIRM_READS × CONFIRM_INTERVAL_SEC ≈ 0.9 sec.
|
||||
CONFIRM_READS = 3 # Consecutive identical reads required to accept a change
|
||||
CONFIRM_INTERVAL_SEC = 0.3 # Delay between confirmation reads (sec)
|
||||
REPORT_INTERVAL = 3600 # 정상 상태 주기 보고 간격 (초) = 1시간
|
||||
LEAK_REPORT_INTERVAL = 10 # 누수 감지 시 재보고 간격 (초)
|
||||
DEBOUNCE_MS = 500 # 바운싱 방지 시간 (ms) — 고임피던스라 넉넉히 설정
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Log settings
|
||||
# 로그 설정
|
||||
# ─────────────────────────────────────────────
|
||||
LOG_FILE = "/var/log/leak_sensor.log"
|
||||
LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB
|
||||
LOG_BACKUP_COUNT = 3
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Retry settings
|
||||
# 재시도 설정
|
||||
# ─────────────────────────────────────────────
|
||||
HTTP_TIMEOUT = 10 # HTTP request timeout (sec)
|
||||
HTTP_RETRY_COUNT = 3 # Retry count
|
||||
HTTP_RETRY_DELAY = 5 # Retry delay (sec)
|
||||
HTTP_TIMEOUT = 10 # HTTP 요청 타임아웃 (초)
|
||||
HTTP_RETRY_COUNT = 3 # 재시도 횟수
|
||||
HTTP_RETRY_DELAY = 5 # 재시도 대기 (초)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Camera settings
|
||||
# Pi 4 = 15-pin (1.0mm pitch) standard CSI, Zero 2 W = 22-pin (0.5mm pitch) CSI →
|
||||
# ribbon/camera board not compatible (needs a Pi 4 15-pin ribbon or a 15↔22 adapter).
|
||||
# Capture uses rpicam-still (Bookworm) / libcamera-still (Bullseye).
|
||||
# 카메라 설정
|
||||
# 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 # Set to False to skip capture/upload
|
||||
PHOTO_WIDTH = 640 # Capture resolution (width)
|
||||
PHOTO_HEIGHT = 480 # Capture resolution (height)
|
||||
PHOTO_QUALITY = 60 # JPEG quality (1-100, 60 ≈ 50-80KB)
|
||||
CAMERA_ENABLED = True # False로 설정하면 촬영/업로드 건너뜀
|
||||
PHOTO_WIDTH = 640 # 촬영 해상도 (가로)
|
||||
PHOTO_HEIGHT = 480 # 촬영 해상도 (세로)
|
||||
PHOTO_QUALITY = 60 # JPEG 품질 (1-100, 60 ≈ 50-80KB)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Load operational/secret values (fail closed)
|
||||
# If config_local.py is missing, stop immediately with a clear error
|
||||
# instead of wrongly starting with placeholder/hardcoded secrets.
|
||||
# 운영·비밀값 로드 (fail closed)
|
||||
# config_local.py 가 없으면 placeholder/하드코딩 비밀로 잘못 기동하는 대신
|
||||
# 명확한 오류로 즉시 중단한다.
|
||||
# ─────────────────────────────────────────────
|
||||
try:
|
||||
from config_local import ( # noqa: F401 (leak_sensor.py re-imports from config)
|
||||
from config_local import ( # noqa: F401 (leak_sensor.py 가 config 에서 재수입)
|
||||
API_URL,
|
||||
API_KEY,
|
||||
PHOTO_UPLOAD_URL,
|
||||
|
|
@ -86,36 +76,7 @@ try:
|
|||
)
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"config_local.py not found. "
|
||||
"Run `cp config_local.example.py config_local.py`, then fill in the real server URL / API_KEY / "
|
||||
"device info. (Never commit secret values.)"
|
||||
"config_local.py 를 찾을 수 없습니다. "
|
||||
"`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / "
|
||||
"기기 정보를 채우세요. (비밀값은 절대 커밋하지 마세요.)"
|
||||
) from e
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Optional override — per-device values can be overridden in config_local.py.
|
||||
# Editing the tracked file (config.py) per device causes deployment drift, so
|
||||
# define SENSORS in config_local.py for a new site node (e.g. sensor_id 3).
|
||||
# ─────────────────────────────────────────────
|
||||
try:
|
||||
from config_local import SENSORS # noqa: F811 — per-device sensor list override
|
||||
except ImportError:
|
||||
pass # use the default above if config_local has no SENSORS
|
||||
|
||||
# SENSORS format validation (fail closed) — prevents an override typo/format error from
|
||||
# silently starting with a wrong sensor_id (e.g. default 1) and overwriting another site's state.
|
||||
if not isinstance(SENSORS, list) or not SENSORS:
|
||||
raise RuntimeError("SENSORS must be a non-empty list (check config_local.py).")
|
||||
_ids, _gpios = set(), set()
|
||||
for _s in SENSORS:
|
||||
if not (isinstance(_s, dict)
|
||||
and isinstance(_s.get("id"), int) and 1 <= _s["id"] <= 255
|
||||
and isinstance(_s.get("gpio"), int)
|
||||
and isinstance(_s.get("name"), str) and _s["name"]):
|
||||
raise RuntimeError(
|
||||
f"SENSORS entry format error: {_s!r} — id(int 1..255)/gpio(int)/name(str) required."
|
||||
)
|
||||
if _s["id"] in _ids or _s["gpio"] in _gpios:
|
||||
raise RuntimeError(f"SENSORS duplicate id/gpio: {_s!r}")
|
||||
_ids.add(_s["id"])
|
||||
_gpios.add(_s["gpio"])
|
||||
del _ids, _gpios
|
||||
|
|
|
|||
|
|
@ -1,49 +1,35 @@
|
|||
# =============================================================================
|
||||
# config_local.example.py - Operational/secret value template (Pi 4 leak detection module)
|
||||
# config_local.example.py - 운영·비밀값 템플릿 (Pi 4 누수감지 모듈)
|
||||
#
|
||||
# Usage: cp config_local.example.py config_local.py
|
||||
# → Fill in with real values, then save. Never commit config_local.py
|
||||
# (excluded via .gitignore).
|
||||
# 사용법: cp config_local.example.py config_local.py
|
||||
# → 실제 값으로 채운 뒤 저장. config_local.py 는 절대 커밋하지 말 것
|
||||
# (.gitignore 로 제외되어 있음).
|
||||
#
|
||||
# ⚠️ Server compatibility
|
||||
# This module (Module 1) reports to the "same" cafe24 server php/ as Module 2 (SHT30 temp/humidity) — integrated.
|
||||
# For leak events (leak_alert / leak_recovery) and photo uploads, the server requires a raw-body signature:
|
||||
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))
|
||||
# photo upload canonical = device_id|sensor_id|log_id|timestamp (no spaces)
|
||||
# → API_KEY must be "exactly identical" to the API_KEY in the server's config.local.php.
|
||||
# The server DB is installed from the single sql/schema_sht30.sql (includes leak components),
|
||||
# and leak status, photos, and temp/humidity are shown together on one dashboard (dashboard.php).
|
||||
# (The legacy sha256(API_KEY + sorted JSON) body-field signature is deprecated — the server rejects it.)
|
||||
# ⚠️ 서버 호환성
|
||||
# 이 모듈(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 서명은 폐기됨 — 서버가 거부한다.)
|
||||
# =============================================================================
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Server API endpoints (integrated server = cafe24 php/, same server as Module 2)
|
||||
# Web root per INSTALL_PI_SERVER.md: public_html/raspi_leck_detecter/
|
||||
# 서버 API 엔드포인트 (통합 서버 = 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"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Shared secret key — must be exactly identical to the server-side API_KEY. Never commit.
|
||||
# 공유 비밀키 — 서버 측 API_KEY 와 정확히 동일해야 함. 절대 커밋 금지.
|
||||
# ─────────────────────────────────────────────
|
||||
API_KEY = "CHANGE_ME"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Device identification (distinguishes multiple units)
|
||||
# 기기 식별 (여러 대 운영 시 구분)
|
||||
# ─────────────────────────────────────────────
|
||||
DEVICE_ID = "rpi4-leak-01"
|
||||
DEVICE_LOCATION = "Server Room"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# (Optional) Sensor list override — for new site nodes
|
||||
# If undefined, uses the config.py default (SENSORS = [{"id": 1, ...}]).
|
||||
# When deploying a new site (e.g. Pi-C, sensor_id 3), define it only in this file;
|
||||
# the tracked file (config.py) does not need to be modified. For server dashboard card
|
||||
# display, also update 'LEAK_SENSOR_IDS' => [1, 3] in the server's config.local.php.
|
||||
# ⚠️ sensor_id must be unique per site — reusing 1/2 will silently overwrite the
|
||||
# existing site's status (leak_sensor_status is keyed on sensor_id alone).
|
||||
# ─────────────────────────────────────────────
|
||||
# SENSORS = [
|
||||
# {"id": 3, "gpio": 17, "name": "Sensor 3 (New Site)"},
|
||||
# ]
|
||||
# (When enabling the above, DEVICE_ID e.g. "rpi4-leak-02", and change DEVICE_LOCATION to the site name too)
|
||||
DEVICE_LOCATION = "서버실"
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Leak Detection Sensor v2 (Raspberry Pi 4 Model B) install script
|
||||
# Target OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
|
||||
# 누수감지 센서 v2 (Raspberry Pi 4 Model B) 설치 스크립트
|
||||
# 대상 OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
|
||||
#
|
||||
# Air-gapped support:
|
||||
# - If debs/ or wheelhouse/ is present, automatically switch to "offline mode".
|
||||
# - In offline mode, never access the network. If the bundle is only partial,
|
||||
# do not silently fall back to online; abort with a clear error (air-gap rule:
|
||||
# if the network is needed, always FLAG it). Pre-download is done on the build
|
||||
# machine via vendor.sh.
|
||||
# - Can be forced with the OFFLINE=1/0 environment variable.
|
||||
# - Log file owner: SERVICE_USER (default pi).
|
||||
# 폐쇄망(air-gapped) 지원:
|
||||
# - debs/ 또는 wheelhouse/ 가 있으면 자동으로 "오프라인 모드"로 전환.
|
||||
# - 오프라인 모드에서는 절대 네트워크에 접근하지 않는다. 번들이 일부만 있으면
|
||||
# 온라인으로 몰래 빠지지 않고 명확한 오류로 중단한다(폐쇄망 규칙: 네트워크가
|
||||
# 필요하면 반드시 FLAG). 사전 다운로드는 vendor.sh 로 빌드 머신에서 수행.
|
||||
# - 환경변수 OFFLINE=1/0 으로 강제 지정 가능.
|
||||
# - 로그 파일 소유자: SERVICE_USER (기본 pi).
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
|
@ -18,101 +17,91 @@ cd "$(dirname "$0")"
|
|||
SERVICE_USER="${SERVICE_USER:-pi}"
|
||||
|
||||
echo "=========================================="
|
||||
echo " Leak Detection Sensor v2 (Pi 4) install"
|
||||
echo " 누수감지 센서 v2 (Pi 4) 설치"
|
||||
echo "=========================================="
|
||||
|
||||
# 0) Board model notice (not enforced)
|
||||
# 0) 보드 모델 안내 (강제 아님)
|
||||
if [ -r /proc/device-tree/model ]; then
|
||||
MODEL="$(tr -d '\0' < /proc/device-tree/model)"
|
||||
echo "[INFO] Board: ${MODEL}"
|
||||
echo "[INFO] 보드: ${MODEL}"
|
||||
case "${MODEL}" in
|
||||
*"Raspberry Pi 4"*) : ;;
|
||||
*) echo "[WARN] This folder is intended for the Raspberry Pi 4 Model B only (continuing is possible)." ;;
|
||||
*) echo "[WARN] 이 폴더는 Raspberry Pi 4 Model B 전용입니다 (계속 진행은 가능)." ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Determine offline mode
|
||||
# 오프라인 모드 판정
|
||||
OFFLINE="${OFFLINE:-}"
|
||||
if [ -z "${OFFLINE}" ]; then
|
||||
if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then
|
||||
OFFLINE=1
|
||||
echo "[INFO] Offline bundle detected -> air-gapped offline install mode (no network)"
|
||||
echo "[INFO] 오프라인 번들 감지 → 폐쇄망 오프라인 설치 모드 (네트워크 미사용)"
|
||||
else
|
||||
OFFLINE=0
|
||||
echo "[INFO] No offline bundle -> online install mode (internet required)"
|
||||
echo "[INFO] 오프라인 번들 없음 → 온라인 설치 모드 (인터넷 필요)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1) System packages: RPi.GPIO replacement (lgpio) + requests + venv + camera app
|
||||
echo "[1/3] Installing system packages..."
|
||||
# 1) 시스템 패키지: RPi.GPIO 대체(lgpio) + requests + venv + 카메라 앱
|
||||
echo "[1/3] 시스템 패키지 설치..."
|
||||
if [ "${OFFLINE}" = "1" ]; then
|
||||
if ! compgen -G "debs/*.deb" >/dev/null; then
|
||||
echo "[ERROR] Offline mode but debs/ is empty. Place the vendor.sh output (debs/) in this folder and re-run." >&2
|
||||
echo "[ERROR] 오프라인 모드인데 debs/ 가 비어있습니다. vendor.sh 산출물(debs/)을 이 폴더에 두고 재실행하세요." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Installing offline .deb (apt-get --no-download - automatic dependency ordering/resolution, no network)"
|
||||
# --no-install-recommends: vendor.sh does not include Recommends (--no-recommends), so match that.
|
||||
sudo apt-get install -y --no-install-recommends --no-download ./debs/*.deb
|
||||
echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)"
|
||||
sudo apt-get install -y --no-download ./debs/*.deb
|
||||
else
|
||||
echo " Online apt install (internet required)"
|
||||
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] Setting up Python virtual environment..."
|
||||
echo "[2/3] Python 가상환경 설정..."
|
||||
if [ ! -d venv ]; then
|
||||
python3 -m venv --system-site-packages venv
|
||||
echo " venv created"
|
||||
echo " venv 생성 완료"
|
||||
else
|
||||
echo " venv already exists - skipping"
|
||||
echo " venv 이미 존재 — 건너뜀"
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
source venv/bin/activate
|
||||
if [ "${OFFLINE}" = "1" ]; then
|
||||
if [ ! -d wheelhouse ]; then
|
||||
echo "[ERROR] Offline mode but wheelhouse/ is missing. Place the vendor.sh output (wheelhouse/) and re-run." >&2
|
||||
echo "[ERROR] 오프라인 모드인데 wheelhouse/ 가 없습니다. vendor.sh 산출물(wheelhouse/)을 두고 재실행하세요." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Installing offline wheels (--no-index - no network)"
|
||||
echo " 오프라인 wheel 설치 (--no-index — 네트워크 미사용)"
|
||||
pip install --no-index --find-links=wheelhouse -r requirements.txt
|
||||
else
|
||||
echo " Online pip install (internet required)"
|
||||
echo " 온라인 pip 설치 (인터넷 필요)"
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
fi
|
||||
echo " Package installation complete"
|
||||
echo " 패키지 설치 완료"
|
||||
|
||||
# 3) Log file (least privilege: owner write only - leak_sensor is the sole writer)
|
||||
echo "[3/3] Creating log file..."
|
||||
# 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 failed - check whether SERVICE_USER=${SERVICE_USER} exists (must match the service user for file logging to work)"
|
||||
echo "[WARN] chown 실패 — SERVICE_USER=${SERVICE_USER} 존재 여부 확인 (서비스 사용자와 일치해야 파일 로깅 가능)"
|
||||
fi
|
||||
sudo chmod 640 /var/log/leak_sensor.log
|
||||
|
||||
# 3.5) Lock config_local.py immediately (contains API_KEY - prevent world-readable until harden)
|
||||
# Hardening (harden_pi.sh) tightens this again later, but lock it to 600 right at install
|
||||
# time so it is not exposed even in the window between. Protected regardless of harden.
|
||||
if [ -f config_local.py ]; then
|
||||
chmod 600 config_local.py
|
||||
sudo chown "${SERVICE_USER}:${SERVICE_USER}" config_local.py 2>/dev/null || true
|
||||
echo " config_local.py permissions set to 600 (owner ${SERVICE_USER})"
|
||||
fi
|
||||
|
||||
# 4) config_local notice (fail closed - if missing, execution aborts immediately)
|
||||
# 4) config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨)
|
||||
if [ ! -f config_local.py ]; then
|
||||
echo ""
|
||||
echo "[!] config_local.py is missing. Run the following, then fill in the real values:"
|
||||
echo "[!] config_local.py 가 없습니다. 다음을 실행 후 실제 값으로 채우세요:"
|
||||
echo " cp config_local.example.py config_local.py"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Installation complete! (OFFLINE=${OFFLINE})"
|
||||
echo " 설치 완료! (OFFLINE=${OFFLINE})"
|
||||
echo "=========================================="
|
||||
echo " Run: source venv/bin/activate && python3 leak_sensor.py"
|
||||
echo " Service: see the systemd section in README.md"
|
||||
echo " 실행: source venv/bin/activate && python3 leak_sensor.py"
|
||||
echo " 서비스: README.md 의 systemd 섹션 참고"
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -1,27 +1,26 @@
|
|||
#!/usr/bin/env python3
|
||||
# =============================================================================
|
||||
# leak_sensor.py - e-con 5C-08 leak detection sensor monitoring main script (v2, Pi 4 only)
|
||||
# leak_sensor.py - e-con 5C-08 누수감지센서 모니터링 메인 스크립트 (v2, Pi 4 전용)
|
||||
#
|
||||
# [Target hardware] Raspberry Pi 4 Model B (BCM2711, 40-pin GPIO header)
|
||||
# - Module originally for Raspberry Pi Zero 2 W, ported to Pi 4 Model B only.
|
||||
# - GPIO BCM numbering and the rpicam/libcamera camera stack are identical on
|
||||
# both boards, so the sensor/capture logic is unchanged. Only the wiring/
|
||||
# power/Ethernet/camera-connector docs differ.
|
||||
# (Pi 4 = 15-pin 1.0mm CSI / Zero 2 W = 22-pin 0.5mm CSI -> ribbon incompatible,
|
||||
# Gigabit Ethernet, USB-C 5V/3A) -> see README.md.
|
||||
# [대상 하드웨어] 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 참고.
|
||||
#
|
||||
# [Operation flow]
|
||||
# 1. GPIO init -> configure sensor pins as input
|
||||
# 2. POST current state to the PHP API periodically (REPORT_INTERVAL)
|
||||
# 3. On leak-detection interrupt, POST an alert immediately
|
||||
# 4. While the leak persists, re-report every LEAK_REPORT_INTERVAL
|
||||
# 5. On recovery (normal) detection, POST a recovery alert
|
||||
# 6. On leak detection, capture with the Pi Camera -> upload to photo_upload.php
|
||||
# (even if capture/upload fails, the alert+SMS has already been sent)
|
||||
# [동작 흐름]
|
||||
# 1. GPIO 초기화 → 센서 핀 입력 설정
|
||||
# 2. 주기적(REPORT_INTERVAL)으로 현재 상태를 PHP API에 POST
|
||||
# 3. 누수 감지 인터럽트 발생 시 즉시 알림 POST
|
||||
# 4. 누수 지속 중엔 LEAK_REPORT_INTERVAL 마다 재보고
|
||||
# 5. 복구(정상) 감지 시 복구 알림 POST
|
||||
# 6. 누수 감지 시 Pi Camera로 촬영 → photo_upload.php에 업로드
|
||||
# (촬영/업로드 실패해도 알림+SMS는 이미 전송된 상태)
|
||||
#
|
||||
# [Config/secrets] config.py holds only non-secret defaults; the server URL/API_KEY/
|
||||
# device identifiers are loaded from config_local.py (not committed). If config_local.py
|
||||
# is missing, startup aborts immediately with a clear error (fail closed).
|
||||
# [설정·비밀값] config.py 는 비밀이 아닌 기본값만 보유하고, 서버 URL/API_KEY/
|
||||
# 기기 식별자는 config_local.py(미커밋)에서 불러온다. config_local.py 가 없으면
|
||||
# 기동 시 명확한 오류로 즉시 중단(fail closed)된다.
|
||||
# =============================================================================
|
||||
|
||||
import time
|
||||
|
|
@ -41,15 +40,14 @@ try:
|
|||
import RPi.GPIO as GPIO
|
||||
SIMULATION = False
|
||||
except (ImportError, RuntimeError):
|
||||
# Simulation mode when RPi.GPIO is unavailable (dev/test environment)
|
||||
# 개발/테스트 환경에서 RPi.GPIO 없을 때 시뮬레이션 모드
|
||||
SIMULATION = True
|
||||
print("[WARNING] RPi.GPIO not found -> running in simulation mode.")
|
||||
print("[WARNING] RPi.GPIO 없음 → 시뮬레이션 모드로 실행됩니다.")
|
||||
|
||||
from config import (
|
||||
SENSORS, ACTIVE_LOW, USE_INTERNAL_PULL,
|
||||
API_URL, API_KEY,
|
||||
REPORT_INTERVAL, LEAK_REPORT_INTERVAL, DEBOUNCE_MS,
|
||||
CONFIRM_READS, CONFIRM_INTERVAL_SEC,
|
||||
DEVICE_ID, DEVICE_LOCATION,
|
||||
LOG_FILE, LOG_MAX_BYTES, LOG_BACKUP_COUNT,
|
||||
HTTP_TIMEOUT, HTTP_RETRY_COUNT, HTTP_RETRY_DELAY,
|
||||
|
|
@ -58,7 +56,7 @@ from config import (
|
|||
)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Logger setup
|
||||
# 로거 설정
|
||||
# ─────────────────────────────────────────────
|
||||
def setup_logger():
|
||||
logger = logging.getLogger("leak_sensor")
|
||||
|
|
@ -69,7 +67,7 @@ def setup_logger():
|
|||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
|
||||
# File handler (rotating)
|
||||
# 파일 핸들러 (로테이션)
|
||||
try:
|
||||
fh = logging.handlers.RotatingFileHandler(
|
||||
LOG_FILE, maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUP_COUNT,
|
||||
|
|
@ -78,9 +76,9 @@ def setup_logger():
|
|||
fh.setFormatter(fmt)
|
||||
logger.addHandler(fh)
|
||||
except PermissionError:
|
||||
pass # No permission -> console only
|
||||
pass # 권한 없으면 콘솔만 사용
|
||||
|
||||
# Console handler
|
||||
# 콘솔 핸들러
|
||||
ch = logging.StreamHandler(sys.stdout)
|
||||
ch.setFormatter(fmt)
|
||||
logger.addHandler(ch)
|
||||
|
|
@ -91,7 +89,7 @@ log = setup_logger()
|
|||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Simulation GPIO (for testing)
|
||||
# 시뮬레이션 GPIO (테스트용)
|
||||
# ─────────────────────────────────────────────
|
||||
class SimGPIO:
|
||||
BCM = "BCM"
|
||||
|
|
@ -120,12 +118,12 @@ if SIMULATION:
|
|||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Leak state management
|
||||
# 누수 상태 관리
|
||||
# ─────────────────────────────────────────────
|
||||
class SensorState:
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
# Per-sensor state dictionary {sensor_id: bool}
|
||||
# 센서별 상태 딕셔너리 {sensor_id: bool}
|
||||
self.leak_detected = {s["id"]: False for s in SENSORS}
|
||||
self.last_report_time = {s["id"]: 0 for s in SENSORS}
|
||||
|
||||
|
|
@ -148,19 +146,18 @@ state = SensorState()
|
|||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# HTTP POST utilities
|
||||
# HTTP POST 유틸
|
||||
# ─────────────────────────────────────────────
|
||||
# [Security note — raw-body HMAC signature (same scheme as sensor module 2)]
|
||||
# The server (php/api/sensor_data.php + config.php::verify_signature_raw) verifies
|
||||
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)) over the
|
||||
# received body bytes exactly as received. So the "signed bytes" and the "actually
|
||||
# sent bytes" must be 100% identical. Letting requests' json= serialization handle it
|
||||
# could change whitespace/ordering/float formatting and break the signature, so we
|
||||
# build the body as a string ourselves, sign those bytes, and POST those same bytes
|
||||
# as-is (data=body_bytes).
|
||||
# (The old sha256(API_KEY + sorted JSON) body-field signature is retired — the server rejects it.)
|
||||
# [보안 메모 — 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"""Escape a JSON string value (only '"' / '\\' / control chars <0x20). Multibyte left as-is."""
|
||||
r"""JSON 문자열 값 이스케이프 ('"' / '\\' / 제어문자<0x20 만). 멀티바이트는 그대로."""
|
||||
out = []
|
||||
for ch in s:
|
||||
o = ord(ch)
|
||||
|
|
@ -176,10 +173,9 @@ def _esc(s: str) -> str:
|
|||
|
||||
|
||||
def leak_event_body(event_type: str, sensor: dict, is_leak: bool, timestamp: int) -> str:
|
||||
"""Build the leak-event body JSON string with a fixed field order (signature-compatible).
|
||||
"""누수 이벤트 본문 JSON 문자열을 고정 필드 순서로 생성(서명 호환).
|
||||
|
||||
Field order (server parsing is order-agnostic, but the signature requires byte
|
||||
identity, so it is fixed):
|
||||
필드 순서(서버 파싱은 순서 무관이나, 서명은 바이트 동일성을 요구하므로 고정):
|
||||
device_id, device_location, sensor_id, sensor_name, event_type, is_leak, timestamp
|
||||
"""
|
||||
return (
|
||||
|
|
@ -196,16 +192,16 @@ def leak_event_body(event_type: str, sensor: dict, is_leak: bool, timestamp: int
|
|||
|
||||
|
||||
def sign_raw(body: str) -> str:
|
||||
"""raw-body signature: lowercase_hex(HMAC-SHA256(key=API_KEY, msg=body bytes))."""
|
||||
"""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):
|
||||
"""POST a leak event with a raw-body HMAC signature.
|
||||
"""누수 이벤트를 raw-body HMAC 서명으로 POST.
|
||||
|
||||
Builds the body string, signs those bytes, and sends them as-is via data=body_bytes.
|
||||
If want_response=False, returns success (bool); if True, returns the response JSON (dict) or None.
|
||||
body 문자열을 만들고 그 바이트에 서명한 뒤 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")
|
||||
|
|
@ -226,30 +222,30 @@ def _post_event(event_type: str, sensor: dict, is_leak: bool, want_response: boo
|
|||
headers=headers,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
log.info(f"[POST OK] sensor{sensor['id']} event={event_type} leak={is_leak} -> {resp.text[:80]}")
|
||||
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] failed to parse response JSON")
|
||||
log.warning("[POST] 응답 JSON 파싱 실패")
|
||||
return None
|
||||
else:
|
||||
log.warning(f"[POST {resp.status_code}] attempt {attempt}/{HTTP_RETRY_COUNT}: {resp.text[:120]}")
|
||||
log.warning(f"[POST {resp.status_code}] 시도 {attempt}/{HTTP_RETRY_COUNT}: {resp.text[:120]}")
|
||||
except requests.exceptions.Timeout:
|
||||
log.warning(f"[POST TIMEOUT] attempt {attempt}/{HTTP_RETRY_COUNT}")
|
||||
log.warning(f"[POST TIMEOUT] 시도 {attempt}/{HTTP_RETRY_COUNT}")
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
log.warning(f"[POST CONN ERR] attempt {attempt}/{HTTP_RETRY_COUNT}: {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] all {HTTP_RETRY_COUNT} attempts failed. sensor{sensor['id']}")
|
||||
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):
|
||||
"""POST sensor data to the PHP API server (returns success as bool).
|
||||
"""PHP API 서버에 센서 데이터 POST (성공 여부 bool 반환).
|
||||
|
||||
event_type: "periodic" | "startup" | "leak_alert" | "leak_recovery"
|
||||
"""
|
||||
|
|
@ -257,17 +253,17 @@ def post_to_server(event_type: str, sensor: dict, is_leak: bool):
|
|||
|
||||
|
||||
def post_to_server_with_response(event_type: str, sensor: dict, is_leak: bool):
|
||||
"""Same as post_to_server, but returns the response JSON (dict) or None."""
|
||||
"""post_to_server 와 동일하나 응답 JSON(dict) 또는 None 을 반환."""
|
||||
return _post_event(event_type, sensor, is_leak, want_response=True)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Camera capture / photo upload
|
||||
# 카메라 촬영 / 사진 업로드
|
||||
# ─────────────────────────────────────────────
|
||||
def capture_photo(output_path: str) -> bool:
|
||||
"""
|
||||
Capture a photo, preferring rpicam-still (Bookworm+), falling back to libcamera-still (Bullseye).
|
||||
Returns True on success, False on failure.
|
||||
rpicam-still (Bookworm+) 우선, 없으면 libcamera-still (Bullseye)로 사진 촬영.
|
||||
성공 시 True, 실패 시 False.
|
||||
"""
|
||||
for cmd in ["rpicam-still", "libcamera-still"]:
|
||||
try:
|
||||
|
|
@ -285,34 +281,34 @@ def capture_photo(output_path: str) -> bool:
|
|||
)
|
||||
if result.returncode == 0 and os.path.exists(output_path):
|
||||
file_size = os.path.getsize(output_path)
|
||||
log.info(f"[CAMERA] capture succeeded: {output_path} ({file_size} bytes, cmd={cmd})")
|
||||
log.info(f"[CAMERA] 촬영 성공: {output_path} ({file_size} bytes, cmd={cmd})")
|
||||
return True
|
||||
else:
|
||||
log.warning(f"[CAMERA] {cmd} failed (rc={result.returncode}): {result.stderr[:200]}")
|
||||
log.warning(f"[CAMERA] {cmd} 실패 (rc={result.returncode}): {result.stderr[:200]}")
|
||||
except FileNotFoundError:
|
||||
continue # This command is missing -> try the next one
|
||||
continue # 이 명령어가 없으면 다음 것 시도
|
||||
except subprocess.TimeoutExpired:
|
||||
log.warning(f"[CAMERA] {cmd} timeout")
|
||||
log.warning(f"[CAMERA] {cmd} 타임아웃")
|
||||
except Exception as e:
|
||||
log.warning(f"[CAMERA] {cmd} exception: {e}")
|
||||
log.warning(f"[CAMERA] {cmd} 예외: {e}")
|
||||
|
||||
log.error("[CAMERA] capture failed: neither rpicam-still nor libcamera-still available")
|
||||
log.error("[CAMERA] 촬영 실패: rpicam-still, libcamera-still 모두 사용 불가")
|
||||
return False
|
||||
|
||||
|
||||
def upload_photo(photo_path: str, log_id: int, sensor: dict) -> bool:
|
||||
"""
|
||||
multipart POST the captured photo to photo_upload.php.
|
||||
Returns True on success, False on failure.
|
||||
촬영된 사진을 photo_upload.php에 multipart POST.
|
||||
성공 시 True, 실패 시 False.
|
||||
|
||||
[Auth] X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=canonical))
|
||||
canonical = f"{DEVICE_ID}|{sensor_id}|{log_id}|{timestamp}" (no whitespace)
|
||||
Must be byte-identical to the canonical in the server's php/api/photo_upload.php.
|
||||
[인증] 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"])
|
||||
# Exact same string as the server: device_id|sensor_id|log_id|timestamp (no whitespace).
|
||||
# The form fields must be sent in the same string representation to match the server's reconstructed canonical.
|
||||
# 서버와 정확히 동일한 문자열: device_id|sensor_id|log_id|timestamp (공백 없음).
|
||||
# 폼 필드도 동일한 문자열 표현으로 보내야 서버 재구성 canonical 과 일치한다.
|
||||
sensor_id_str = str(sensor_id)
|
||||
log_id_str = str(log_id)
|
||||
timestamp_str = str(timestamp)
|
||||
|
|
@ -343,7 +339,7 @@ def upload_photo(photo_path: str, log_id: int, sensor: dict) -> bool:
|
|||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
log.info(f"[PHOTO UPLOAD OK] log_id={log_id} -> {resp.text[:80]}")
|
||||
log.info(f"[PHOTO UPLOAD OK] log_id={log_id} → {resp.text[:80]}")
|
||||
return True
|
||||
else:
|
||||
log.warning(f"[PHOTO UPLOAD {resp.status_code}] {resp.text[:120]}")
|
||||
|
|
@ -356,43 +352,43 @@ def upload_photo(photo_path: str, log_id: int, sensor: dict) -> bool:
|
|||
|
||||
def _handle_leak_with_photo(sensor: dict):
|
||||
"""
|
||||
Processing order on leak detection:
|
||||
1. POST to sensor_data.php -> receive log_id (+ send SMS)
|
||||
2. Capture with camera (when CAMERA_ENABLED)
|
||||
3. Upload to photo_upload.php
|
||||
4. Delete temp file
|
||||
Even if capture/upload fails, step 1 (alert+SMS) is already done.
|
||||
누수 감지 시 처리 순서:
|
||||
1. sensor_data.php에 POST → log_id 수신 (+ SMS 발송)
|
||||
2. 카메라 촬영 (CAMERA_ENABLED 시)
|
||||
3. photo_upload.php에 업로드
|
||||
4. 임시파일 삭제
|
||||
촬영/업로드 실패해도 1단계(알림+SMS)는 이미 완료.
|
||||
"""
|
||||
# 1) leak_alert POST -> receive log_id
|
||||
# 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{sensor['id']} leak_alert POST failed -> skipping capture")
|
||||
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{sensor['id']} response has no log_id -> skipping capture")
|
||||
log.warning(f"[PHOTO] 센서{sensor['id']} 응답에 log_id 없음 → 촬영 건너뜀")
|
||||
return
|
||||
|
||||
# 2) Camera capture
|
||||
# 2) 카메라 촬영
|
||||
if not CAMERA_ENABLED:
|
||||
log.info(f"[PHOTO] camera disabled -> skipping capture (log_id={log_id})")
|
||||
log.info(f"[PHOTO] 카메라 비활성화 → 촬영 건너뜀 (log_id={log_id})")
|
||||
return
|
||||
|
||||
# Create a temp JPEG file in /tmp
|
||||
# /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] capture failed -> skipping upload (log_id={log_id})")
|
||||
log.warning(f"[PHOTO] 촬영 실패 → 업로드 건너뜀 (log_id={log_id})")
|
||||
return
|
||||
|
||||
# 3) Upload
|
||||
# 3) 업로드
|
||||
upload_photo(photo_path, log_id, sensor)
|
||||
|
||||
finally:
|
||||
# 4) Clean up temp file
|
||||
# 4) 임시파일 정리
|
||||
try:
|
||||
if os.path.exists(photo_path):
|
||||
os.unlink(photo_path)
|
||||
|
|
@ -401,89 +397,50 @@ def _handle_leak_with_photo(sensor: dict):
|
|||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# GPIO interrupt callback (+ confirm-read debounce)
|
||||
# GPIO 인터럽트 콜백
|
||||
# ─────────────────────────────────────────────
|
||||
def _read_is_leak(gpio_pin: int) -> bool:
|
||||
"""Read the pin and map to a leak boolean per ACTIVE_LOW polarity."""
|
||||
gpio_val = GPIO.input(gpio_pin)
|
||||
# ACTIVE_LOW: GPIO=0 means leak / ACTIVE_HIGH (transistor-inverted): GPIO=1 means leak
|
||||
return (gpio_val == 0) if ACTIVE_LOW else (gpio_val == 1)
|
||||
|
||||
|
||||
# Sensors with a confirmation in progress — an edge storm must not stack threads.
|
||||
_confirm_pending = set()
|
||||
_confirm_lock = threading.Lock()
|
||||
|
||||
|
||||
def _confirm_state_change(sensor: dict, candidate: bool):
|
||||
"""Re-read the pin CONFIRM_READS times; apply the change only if every read agrees.
|
||||
Real water persists across ~1s of reads; sub-second EMI blips on the 10MΩ
|
||||
high-impedance line do not (server-room noise produced alert→recovery pairs
|
||||
0-1s apart in production logs)."""
|
||||
try:
|
||||
for _ in range(CONFIRM_READS):
|
||||
time.sleep(CONFIRM_INTERVAL_SEC)
|
||||
if _read_is_leak(sensor["gpio"]) != candidate:
|
||||
log.info(f"[NOISE REJECTED] sensor{sensor['id']} ({sensor['name']}) "
|
||||
f"transient {'wet' if candidate else 'dry'} blip discarded")
|
||||
return
|
||||
|
||||
changed = state.set_leak(sensor["id"], candidate)
|
||||
if not changed:
|
||||
return
|
||||
if candidate:
|
||||
log.warning(f"[LEAK DETECTED] sensor{sensor['id']} ({sensor['name']}) !!!")
|
||||
# Run leak_alert + photo capture/upload sequentially in one thread
|
||||
threading.Thread(
|
||||
target=_handle_leak_with_photo,
|
||||
args=(sensor,),
|
||||
daemon=True
|
||||
).start()
|
||||
else:
|
||||
log.info(f"[LEAK RECOVERED] sensor{sensor['id']} ({sensor['name']}) recovered to normal")
|
||||
threading.Thread(
|
||||
target=post_to_server,
|
||||
args=("leak_recovery", sensor, False),
|
||||
daemon=True
|
||||
).start()
|
||||
finally:
|
||||
with _confirm_lock:
|
||||
_confirm_pending.discard(sensor["id"])
|
||||
|
||||
|
||||
def make_callback(sensor: dict):
|
||||
def callback(channel):
|
||||
candidate = _read_is_leak(sensor["gpio"])
|
||||
gpio_val = GPIO.input(sensor["gpio"])
|
||||
|
||||
# No actual change vs confirmed state -> nothing to verify
|
||||
if candidate == state.is_leaking(sensor["id"]):
|
||||
return
|
||||
# ACTIVE_LOW: GPIO=0 이면 누수, GPIO=1 이면 정상
|
||||
# ACTIVE_HIGH(트랜지스터 반전): GPIO=1 이면 누수
|
||||
if ACTIVE_LOW:
|
||||
is_leak = (gpio_val == 0)
|
||||
else:
|
||||
is_leak = (gpio_val == 1)
|
||||
|
||||
# One confirmation at a time per sensor; the confirm thread reads the live pin,
|
||||
# so edges arriving meanwhile are naturally folded into its reads.
|
||||
with _confirm_lock:
|
||||
if sensor["id"] in _confirm_pending:
|
||||
return
|
||||
_confirm_pending.add(sensor["id"])
|
||||
changed = state.set_leak(sensor["id"], is_leak)
|
||||
|
||||
threading.Thread(
|
||||
target=_confirm_state_change,
|
||||
args=(sensor, candidate),
|
||||
daemon=True
|
||||
).start()
|
||||
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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Periodic reporting thread
|
||||
# 주기 보고 스레드
|
||||
# ─────────────────────────────────────────────
|
||||
def periodic_reporter():
|
||||
"""
|
||||
Normal state: report every REPORT_INTERVAL
|
||||
Leak state: repeat-report every LEAK_REPORT_INTERVAL (SMS resend suppression handled in PHP)
|
||||
정상 상태: REPORT_INTERVAL 주기로 보고
|
||||
누수 상태: LEAK_REPORT_INTERVAL 주기로 반복 보고 (SMS 재발송 방지는 PHP에서 처리)
|
||||
"""
|
||||
log.info("[REPORTER] periodic reporting thread started")
|
||||
log.info("[REPORTER] 주기 보고 스레드 시작")
|
||||
last_sent = {s["id"]: 0 for s in SENSORS}
|
||||
|
||||
while True:
|
||||
|
|
@ -501,26 +458,26 @@ def periodic_reporter():
|
|||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# GPIO initialization
|
||||
# GPIO 초기화
|
||||
# ─────────────────────────────────────────────
|
||||
def setup_gpio():
|
||||
GPIO.setmode(GPIO.BCM)
|
||||
|
||||
# Use only the external 10MΩ pull-down -> internal pull resistor must be PUD_OFF
|
||||
# (Enabling the internal 50kΩ pull-down prevents the 1.5MΩ sensor from pulling GPIO HIGH)
|
||||
# 외부 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)
|
||||
|
||||
# Read initial state
|
||||
# 초기 상태 읽기
|
||||
gpio_val = GPIO.input(gpio)
|
||||
is_leak = (gpio_val == 0) if ACTIVE_LOW else (gpio_val == 1)
|
||||
state.set_leak(sensor["id"], is_leak)
|
||||
log.info(f"[INIT] sensor{sensor['id']} GPIO{gpio} -> {'LEAK!' if is_leak else 'normal'}")
|
||||
log.info(f"[INIT] 센서{sensor['id']} GPIO{gpio} → {'누수!' if is_leak else '정상'}")
|
||||
|
||||
# Register the edge-detection interrupt
|
||||
# 엣지 감지 인터럽트 등록
|
||||
GPIO.add_event_detect(
|
||||
gpio,
|
||||
GPIO.BOTH,
|
||||
|
|
@ -528,46 +485,46 @@ def setup_gpio():
|
|||
bouncetime=DEBOUNCE_MS
|
||||
)
|
||||
|
||||
log.info(f"[INIT] GPIO setup complete. {len(SENSORS)} sensors total")
|
||||
log.info(f"[INIT] GPIO 설정 완료. 총 {len(SENSORS)}개 센서")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Main
|
||||
# 메인
|
||||
# ─────────────────────────────────────────────
|
||||
def main():
|
||||
log.info("=" * 60)
|
||||
log.info(f"Leak detection system v2 (Pi 4) starting | device={DEVICE_ID} | location={DEVICE_LOCATION}")
|
||||
log.info(f"platform={platform.machine()} | API={API_URL}")
|
||||
log.info(f"camera={'enabled' if CAMERA_ENABLED else 'disabled'}")
|
||||
log.info(f"sensors={[s['name'] for s in SENSORS]}")
|
||||
log.info(f"mode={'simulation' if SIMULATION else 'real GPIO'}")
|
||||
log.info(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()
|
||||
|
||||
# Report current state immediately at startup
|
||||
# 시작 시 현재 상태 즉시 보고
|
||||
for sensor in SENSORS:
|
||||
post_to_server("startup", sensor, state.is_leaking(sensor["id"]))
|
||||
|
||||
# Start the periodic reporting thread
|
||||
# 주기 보고 스레드 시작
|
||||
reporter_thread = threading.Thread(target=periodic_reporter, daemon=True)
|
||||
reporter_thread.start()
|
||||
|
||||
log.info("[MAIN] monitoring started. Press Ctrl+C to exit.")
|
||||
log.info("[MAIN] 모니터링 시작. Ctrl+C로 종료.")
|
||||
|
||||
# The main thread must stay alive for interrupts to work
|
||||
# 메인 스레드는 살아있어야 인터럽트 작동
|
||||
while True:
|
||||
time.sleep(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
log.info("[MAIN] terminated by user.")
|
||||
log.info("[MAIN] 사용자에 의해 종료됨.")
|
||||
except Exception as e:
|
||||
log.exception(f"[MAIN] exception occurred: {e}")
|
||||
log.exception(f"[MAIN] 예외 발생: {e}")
|
||||
raise
|
||||
finally:
|
||||
GPIO.cleanup()
|
||||
log.info("[MAIN] GPIO cleanup complete.")
|
||||
log.info("[MAIN] GPIO 정리 완료.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Pi 4 leak detection module Python dependencies (exact version pins for air-gapped deployment)
|
||||
# Offline install: pre-download into wheelhouse/ with vendor.sh, then
|
||||
# Pi 4 누수감지 모듈 Python 의존성 (폐쇄망 대비 정확 핀 고정)
|
||||
# 오프라인 설치: vendor.sh 로 wheelhouse/ 에 미리 받아두고
|
||||
# pip install --no-index --find-links=wheelhouse -r requirements.txt
|
||||
requests==2.32.3
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
# =============================================================================
|
||||
# /etc/systemd/system/leak-sensor.service
|
||||
# Service that auto-runs the leak-detection monitor at Raspberry Pi 4 boot
|
||||
# Raspberry Pi 4 부팅 시 누수감지 모니터를 자동 실행하는 서비스
|
||||
#
|
||||
# Installation:
|
||||
# 설치 방법:
|
||||
# sudo cp systemd/leak-sensor.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable leak-sensor
|
||||
# sudo systemctl start leak-sensor
|
||||
# sudo systemctl status leak-sensor
|
||||
#
|
||||
# NOTE: Adjust the WorkingDirectory/ExecStart paths to match the actual deployment path.
|
||||
# The values below assume deployment under /home/pi/raspberry_pi4.
|
||||
# ※ WorkingDirectory/ExecStart 경로는 실제 배치 경로에 맞게 수정.
|
||||
# 아래는 /home/pi/raspberry_pi4 에 배치한 경우 기준.
|
||||
# =============================================================================
|
||||
|
||||
[Unit]
|
||||
|
|
@ -22,8 +22,7 @@ Wants=network-online.target
|
|||
Type=simple
|
||||
User=pi
|
||||
Group=pi
|
||||
# rpi-lgpio needs the gpio group to access /dev/gpiochip0 (root:gpio 660); the camera needs the video group.
|
||||
SupplementaryGroups=video gpio
|
||||
SupplementaryGroups=video
|
||||
WorkingDirectory=/home/pi/raspberry_pi4
|
||||
ExecStart=/home/pi/raspberry_pi4/venv/bin/python leak_sensor.py
|
||||
Restart=always
|
||||
|
|
@ -32,41 +31,13 @@ StandardOutput=journal
|
|||
StandardError=journal
|
||||
SyslogIdentifier=leak-sensor
|
||||
|
||||
# Environment variables
|
||||
# 환경 변수
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
# lgpio (rpi-lgpio backend) creates a .lgd-nfy notification pipe in the working
|
||||
# directory on add_event_detect. Since ProtectHome=read-only below makes /home
|
||||
# read-only, the lgpio working directory is moved to PrivateTmp's writable /tmp.
|
||||
# (Otherwise leak interrupt registration fails -> daemon crash-loops -> no leak detection.) LG_WD is the env var the lg library reads.
|
||||
# NOTE: This hardening assumes the chardev backend (python3-rpi-lgpio). Reverting to
|
||||
# the sysfs-based python3-rpi.gpio conflicts with ProtectKernelTunables/ProtectHome,
|
||||
# so do not change the backend (keep the python3-rpi-lgpio pin in vendor.sh/install.sh).
|
||||
Environment=LG_WD=/tmp
|
||||
|
||||
# Isolate leak-capture temp files to the process-private /tmp (prevents exposure via shared /tmp)
|
||||
# 누수 촬영 임시파일을 프로세스 전용 /tmp 로 격리 (공용 /tmp 노출 방지)
|
||||
PrivateTmp=true
|
||||
|
||||
# -- Sandboxing (hardening) -- blocks root escalation / filesystem tampering if the daemon is compromised --------
|
||||
# User=pi is a NOPASSWD sudo account, so NoNewPrivileges blocks the setuid(sudo) escalation path.
|
||||
NoNewPrivileges=true
|
||||
# Root filesystem read-only + only /var/log writable (including log rotation). /tmp is PrivateTmp.
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/log
|
||||
# /home is read-only (reads its own code/config_local.py only; blocks writes and access to other accounts' homes).
|
||||
ProtectHome=read-only
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
RestrictRealtime=true
|
||||
# HTTPS(POST) + local sockets (DNS/journal) only. Camera/GPIO are /dev char (not sockets), so unaffected.
|
||||
# AF_NETLINK required -- glibc getaddrinfo/NSS uses netlink to enumerate local addresses
|
||||
# (omitting it makes DNS resolution fail intermittently -> server POST impossible). AF_INET/6=HTTPS, AF_UNIX=DNS/journal.
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
# NOTE: PrivateDevices is not used -- it would hide /dev/gpiochip* and the camera node, breaking operation.
|
||||
|
||||
# Restart cap (stop after 5 failures within 10 minutes)
|
||||
# 재시작 상한 (10분 내 5번 실패 시 중단)
|
||||
StartLimitBurst=5
|
||||
StartLimitInterval=600s
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,24 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Server connection test script — run on the Pi 4.
|
||||
Secrets (API_URL/API_KEY) are loaded from config_local.py (via config)."""
|
||||
"""서버 연결 테스트 스크립트 — Pi 4 에서 실행.
|
||||
비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다 (config 경유)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
|
||||
from config import API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION, SENSORS
|
||||
from config import API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION
|
||||
|
||||
signed_ok = False # whether the signed POST returned 200 — setup.sh gates hardening on this exit code
|
||||
|
||||
# Test with the first sensor configured on this device — hardcoding sensor_id (e.g. 1)
|
||||
# would pollute another site node's leak_sensor_status when testing from that site.
|
||||
TEST_SENSOR_ID = SENSORS[0]["id"]
|
||||
TEST_SENSOR_NAME = SENSORS[0]["name"]
|
||||
|
||||
# Extract the base path from API_URL (.../api/sensor_data.php → .../api)
|
||||
# API_URL 에서 베이스 경로 추출 (.../api/sensor_data.php → .../api)
|
||||
API_BASE = API_URL.rsplit("/", 1)[0]
|
||||
|
||||
print("=" * 50)
|
||||
print(" Server connection test (Pi 4)")
|
||||
print(" 서버 연결 테스트 (Pi 4)")
|
||||
print("=" * 50)
|
||||
print(f" API_BASE = {API_BASE}")
|
||||
|
||||
# ── Step 1: call test.php with curl ──
|
||||
print("\n[1/3] Checking test.php connectivity with curl...")
|
||||
# ── 1단계: curl로 test.php 호출 ──
|
||||
print("\n[1/3] curl로 test.php 연결 확인...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
|
|
@ -35,51 +27,50 @@ try:
|
|||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
code = result.stdout.strip()
|
||||
print(f" HTTP response code: {code}")
|
||||
print(f" HTTP 응답코드: {code}")
|
||||
if code == "200":
|
||||
print(" → Success! Server connection OK")
|
||||
print(" → 성공! 서버 연결 정상")
|
||||
elif code == "403":
|
||||
print(" → 403 Forbidden: the server/host itself is blocking")
|
||||
print(" → Check the hosting settings or the upstream .htaccess")
|
||||
print(" → 403 Forbidden: 서버/호스팅 자체가 차단 중")
|
||||
print(" → 호스팅 설정 또는 상위 .htaccess 확인 필요")
|
||||
else:
|
||||
print(f" → Unexpected response: {code}")
|
||||
print(f" → 예상 외 응답: {code}")
|
||||
except Exception as e:
|
||||
print(f" → curl failed: {e}")
|
||||
print(f" → curl 실패: {e}")
|
||||
|
||||
# ── Step 2: call sensor_data.php with curl ──
|
||||
print("\n[2/3] Checking sensor_data.php connectivity with curl...")
|
||||
# ── 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", f'{{"device_id":"test","sensor_id":{TEST_SENSOR_ID},"event_type":"startup","is_leak":0,"timestamp":1234567890}}'],
|
||||
"-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 response code: {code}")
|
||||
print(f" Response body: {body[:200]}")
|
||||
print(f" HTTP 응답코드: {code}")
|
||||
print(f" 응답 내용: {body[:200]}")
|
||||
except Exception as e:
|
||||
print(f" → curl failed: {e}")
|
||||
print(f" → curl 실패: {e}")
|
||||
|
||||
# ── Step 3: signed call via Python requests ──
|
||||
print("\n[3/3] Calling sensor_data.php with Python requests...")
|
||||
# ── 3단계: Python requests로 서명 포함 호출 ──
|
||||
print("\n[3/3] Python requests로 sensor_data.php 호출...")
|
||||
try:
|
||||
import requests
|
||||
|
||||
# raw-body X-Signature HMAC (same as the server's verify_signature_raw — build the body
|
||||
# string with the same fixed field order as leak_sensor.leak_event_body, sign those bytes,
|
||||
# then POST them verbatim).
|
||||
# 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":{TEST_SENSOR_ID},'
|
||||
f'"sensor_name":"{TEST_SENSOR_NAME} (connection test)",'
|
||||
f'"sensor_id":1,'
|
||||
f'"sensor_name":"테스트 센서",'
|
||||
f'"event_type":"startup",'
|
||||
f'"is_leak":0,'
|
||||
f'"timestamp":{ts}'
|
||||
|
|
@ -100,24 +91,22 @@ try:
|
|||
}
|
||||
)
|
||||
|
||||
print(f" HTTP response code: {resp.status_code}")
|
||||
print(f" Response body: {resp.text[:200]}")
|
||||
print(f" HTTP 응답코드: {resp.status_code}")
|
||||
print(f" 응답 내용: {resp.text[:200]}")
|
||||
|
||||
if resp.status_code == 200:
|
||||
signed_ok = True
|
||||
print(" → Success! All connections OK")
|
||||
print(" → 성공! 모든 연결 정상")
|
||||
elif resp.status_code == 403:
|
||||
print(" → 403: the requests library is blocked or the signature does not match")
|
||||
print(" → 403: requests 라이브러리가 차단됨 또는 서명 불일치")
|
||||
print(f" → User-Agent: {resp.request.headers.get('User-Agent', '?')}")
|
||||
else:
|
||||
print(f" → Response code: {resp.status_code}")
|
||||
print(f" → 응답코드: {resp.status_code}")
|
||||
|
||||
except ImportError:
|
||||
print(" → requests not installed. Run install.sh or sudo apt install python3-requests")
|
||||
print(" → requests 미설치. install.sh 실행 또는 sudo apt install python3-requests")
|
||||
except Exception as e:
|
||||
print(f" → Failed: {e}")
|
||||
print(f" → 실패: {e}")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(" Test complete" + ("" if signed_ok else " — signed POST failed (check the log above)"))
|
||||
print(" 테스트 완료")
|
||||
print("=" * 50)
|
||||
sys.exit(0 if signed_ok else 1)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# vendor.sh - Pre-download dependencies for air-gapped installation
|
||||
# Run on a build/staging machine that has internet access.
|
||||
# Output: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → transfer to target Pi 4.
|
||||
# vendor.sh - 폐쇄망 설치용 의존성 사전 다운로드
|
||||
# 인터넷에 연결된 빌드/스테이징 머신에서 실행한다.
|
||||
# 산출물: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → 타깃 Pi 4 로 전송.
|
||||
#
|
||||
# wheel/deb are architecture-dependent. The defaults are explicitly pinned to the target
|
||||
# (Raspberry Pi OS Bookworm 64-bit, aarch64, Python 3.11), so even downloading on an x86_64
|
||||
# build machine yields the correct aarch64 wheel (independent of the build host arch). Still, .deb
|
||||
# files are safest downloaded on the target OS/arch. Adjust the target via env vars if needed:
|
||||
# TARGET_PLATFORM (default manylinux2014_aarch64), TARGET_PYVER (default 311), CAM
|
||||
# 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")"
|
||||
|
|
@ -25,12 +25,12 @@ python3 -m pip download -r requirements.txt -d wheelhouse \
|
|||
--python-version "${TARGET_PYVER}" \
|
||||
--implementation cp \
|
||||
--abi "${TARGET_ABI}" \
|
||||
|| { echo "[ERROR] Target wheel download failed (check TARGET_PLATFORM/TARGET_PYVER)"; exit 1; }
|
||||
|| { echo "[ERROR] 타깃 wheel 다운로드 실패 (TARGET_PLATFORM/TARGET_PYVER 확인)"; exit 1; }
|
||||
python3 -m pip download -r requirements.txt -d wheelhouse --no-binary=:all: \
|
||||
|| echo "[WARN] sdist fallback partially failed (safe to ignore if all binary wheels are present)"
|
||||
|| echo "[WARN] sdist 폴백 일부 실패 (바이너리 wheel 이 모두 있으면 무시 가능)"
|
||||
WHEEL_COUNT="$(ls -A wheelhouse 2>/dev/null | wc -l)"
|
||||
echo " wheelhouse file count: ${WHEEL_COUNT}"
|
||||
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse is empty"; exit 1; }
|
||||
echo " wheelhouse 파일 수: ${WHEEL_COUNT}"
|
||||
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse 비어있음"; exit 1; }
|
||||
|
||||
echo "[vendor] (2/2) apt .deb → debs/"
|
||||
mkdir -p debs
|
||||
|
|
@ -38,41 +38,21 @@ 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
|
||||
# Include nftables and unattended-upgrades too → harden_pi.sh can run offline in an air-gapped network.
|
||||
BASE_PKGS="python3-rpi-lgpio python3-requests python3-venv nftables unattended-upgrades ${CAM}"
|
||||
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] Some .deb downloads failed (may be virtual packages / transitive deps — required packages are verified below)"
|
||||
|| echo "[WARN] 일부 .deb 다운로드 실패 (가상패키지/전이 의존성일 수 있음 — 필수 패키지는 아래에서 검증)"
|
||||
|
||||
MISS=0
|
||||
for p in ${BASE_PKGS}; do
|
||||
if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then
|
||||
echo "[ERROR] Missing required package .deb: ${p}"
|
||||
echo "[ERROR] 필수 패키지 .deb 누락: ${p}"
|
||||
MISS=1
|
||||
fi
|
||||
done
|
||||
[ "${MISS}" = 0 ] || { echo "[ERROR] Missing required .deb — you must run vendor.sh on the target arch (aarch64 Bookworm)."; exit 1; }
|
||||
echo " debs file count: $(ls debs/*.deb 2>/dev/null | wc -l) (all required packages confirmed)"
|
||||
[ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; }
|
||||
echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)"
|
||||
|
||||
# Real architecture check — validate the target (arm64/all) via the .deb's internal Architecture field, not the filename.
|
||||
# (If the host is amd64, apt-get download fetches amd64 .debs, and on the target Pi dpkg
|
||||
# breaks with "wrong architecture". A filename-only check cannot catch this.)
|
||||
BAD_ARCH=0
|
||||
for deb in debs/*.deb; do
|
||||
[ -e "$deb" ] || continue
|
||||
arch="$(dpkg-deb --field "$deb" Architecture 2>/dev/null || echo '?')"
|
||||
case "$arch" in
|
||||
arm64|all) : ;;
|
||||
*) echo "[ERROR] arch mismatch: $(basename "$deb") = ${arch} (not arm64/all)"; BAD_ARCH=1 ;;
|
||||
esac
|
||||
done
|
||||
if [ "${BAD_ARCH}" != 0 ]; then
|
||||
echo "[ERROR] There are .deb files that are not the target (aarch64/arm64). Host arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2
|
||||
echo " You must run vendor.sh on an arm64 Pi (or arm64 chroot/qemu)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " arch check passed (all .deb = arm64/all)"
|
||||
|
||||
echo "[vendor] Done. Transfer wheelhouse/ and debs/ to the target Pi 4, then run install.sh."
|
||||
echo "[vendor] 완료. wheelhouse/ 와 debs/ 를 타깃 Pi 4 로 전송 후 install.sh 실행."
|
||||
|
|
|
|||
14
raspberry_pi4_pms7003/.gitignore
vendored
14
raspberry_pi4_pms7003/.gitignore
vendored
|
|
@ -1,14 +0,0 @@
|
|||
# Operational secrets (never commit)
|
||||
config_local.py
|
||||
|
||||
# Python venv / cache
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Offline install bundle (vendor.sh output - large; transferred separately)
|
||||
wheelhouse/
|
||||
debs/
|
||||
|
||||
# Runtime logs
|
||||
*.log
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
# Particulate-Matter (PMS7003) Sensor Module — Raspberry Pi 4 Model B only
|
||||
|
||||
A module that reads a **Plantower PMS7003** laser particulate-matter sensor over the Raspberry Pi 4
|
||||
Model B **UART** and periodically reports **PM1.0 / PM2.5 / PM10** (µg/m³, atmospheric concentration)
|
||||
to the cafe24 server (`php/api/sensor_data.php`). The server stores the measurements and performs
|
||||
**high-PM2.5 / high-PM10 threshold evaluation, SMS, and dashboard display**.
|
||||
|
||||
> This is a **Pi-only** metric — there is **no STM32 firmware** for the PMS7003. The module is a
|
||||
> faithful mirror of the SHT30 module (`../raspberry_pi4_sht30/`): same measurement loop,
|
||||
> `startup→periodic` events, raw-body HMAC signing, and fail-closed secret handling. PM is a **new
|
||||
> metric** on the **existing metric pipeline** — distinguished only by `metric_type="pms7003"`.
|
||||
>
|
||||
> The PMS7003 **co-locates on an existing leak node** and reports under the **same `sensor_id`** as
|
||||
> that node's leak sensor (Pi-C = 3, the other node = 1), exactly like the SHT30 co-location.
|
||||
|
||||
---
|
||||
|
||||
## 1. Files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `pms7003_sensor.py` | Main — periodic measurement → body build/sign → POST (with retry), startup→periodic |
|
||||
| `pms7003_driver.py` | PMS7003 UART 32-byte frame read + checksum verification + PM decode (pyserial, simulation fallback) |
|
||||
| `wire.py` | Body JSON + raw-body HMAC signature (`fixed2`/`_esc`/`sign_raw` copied verbatim from the SHT30 module) |
|
||||
| `config.py` | Non-secret default settings (sensor id/interval/range/UART). Loads secrets from `config_local` |
|
||||
| `config_local.example.py` | Operational/secret-value template → copy to `config_local.py` for use (not committed) |
|
||||
| `requirements.txt` | Python dependencies (`requests`, `pyserial`, pinned) |
|
||||
| `install.sh` | Pi 4 install (air-gapped offline + online fallback, enables UART + disables the serial console) |
|
||||
| `vendor.sh` | Pre-download dependencies for air-gapped use (run on the build machine) |
|
||||
| `tests/test_pms_wire_and_frame.py` | Pure unit tests: body byte-string, known HMAC, frame decode + checksum rejection (no hardware) |
|
||||
| `systemd/pms7003-sensor.service` | Service unit for auto-start on boot |
|
||||
|
||||
---
|
||||
|
||||
## 2. Hardware (Pi 4 Model B baseline)
|
||||
|
||||
- **Raspberry Pi 4 Model B** (BCM2711) + Raspberry Pi OS **Bookworm 64-bit**
|
||||
- **PMS7003** laser PM sensor + its breakout/adapter cable
|
||||
- Power: **USB-C 5V/3A**, network: Pi 4 **Gigabit Ethernet** (wired) or WiFi
|
||||
|
||||
### 2.1 Pinout (primary UART)
|
||||
|
||||
| PMS7003 signal | Pi 4 pin | Note |
|
||||
|---|---|---|
|
||||
| VCC | **5V** (physical 2) | PMS7003 needs **5V** power (fan + laser) |
|
||||
| GND | GND (physical 6) | |
|
||||
| TXD (sensor → Pi) | GPIO15 / **RXD** (physical 10) | 3.3V logic — safe direct to the Pi |
|
||||
| RXD (Pi → sensor) | GPIO14 / TXD (physical 8) | optional (only needed for passive-mode commands) |
|
||||
| SET / RESET | — | optional; leave floating (internal pull-up = enabled/run) |
|
||||
|
||||
> The PMS7003 **TXD idles at 3.3V logic**, so it connects **directly** to the Pi RXD — no level
|
||||
> shifter needed. We only read the sensor's TXD (active streaming mode), so wiring the Pi TXD→sensor
|
||||
> RXD is optional.
|
||||
|
||||
---
|
||||
|
||||
## 3. Server compatibility (must verify)
|
||||
|
||||
This module reports to **the current repo's `php/` server**. The server requires a **raw-body HMAC signature**.
|
||||
|
||||
```
|
||||
X-Signature = lowercase_hex( HMAC-SHA256( key=API_KEY, msg=request body bytes ) )
|
||||
```
|
||||
|
||||
- `API_KEY` in `config_local.py` must be **exactly identical** to `API_KEY` in the server's `config.local.php`.
|
||||
- The server blocks replays via `timestamp` freshness (±600 seconds), so the Pi clock must be **NTP-synchronized** (enabled by default on Raspberry Pi OS).
|
||||
- It reports with the co-located leak node's `sensor_id` (**3** by default, or **1** on the other node) and `metric_type="pms7003"`.
|
||||
- Threshold alerts (high PM2.5 / PM10), SMS, and recovery evaluation are **all performed by the server**. This module only reports measurements.
|
||||
- The Pi-side `metric_status` is **physical plausibility only** (`normal` / `out_of_range`); the server never trusts it for alerting.
|
||||
|
||||
### 3.1 Wire body (fixed order)
|
||||
|
||||
```json
|
||||
{"device_id":"..","device_location":"..","sensor_id":3,"sensor_name":"..","event_type":"..","timestamp":0,"metric_type":"pms7003","pm1_0":8,"pm2_5":12,"pm10":18,"metric_status":"normal","app_version":".."}
|
||||
```
|
||||
|
||||
`pm1_0` / `pm2_5` / `pm10` are **bare integers** (µg/m³, atmospheric concentration, range 0..1000).
|
||||
The body is built directly and raw-POSTed so the **signed bytes == sent bytes**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Installation
|
||||
|
||||
### 4.1 Prepare configuration values
|
||||
|
||||
```bash
|
||||
cp config_local.example.py config_local.py
|
||||
# Open config_local.py and fill in API_URL / API_KEY / DEVICE_ID / DEVICE_LOCATION
|
||||
# On the id=1 node also set SENSOR_ID = 1 (Pi-C keeps the default 3).
|
||||
```
|
||||
|
||||
If `config_local.py` is missing, execution **stops immediately with a clear error** (fail closed).
|
||||
**Never commit** `config_local.py` or `API_KEY` (handled by `.gitignore`).
|
||||
|
||||
### 4.2 Online install (internet-capable environment)
|
||||
|
||||
```bash
|
||||
chmod +x install.sh
|
||||
./install.sh
|
||||
sudo reboot # reboot once — the installer enables UART and disables the serial console
|
||||
```
|
||||
|
||||
### 4.3 Air-gapped offline install
|
||||
|
||||
On an internet-connected machine of the **same architecture (aarch64, ideally the same Pi 4 + Bookworm 64-bit)**,
|
||||
download the dependencies first to build a bundle, then move it to the target Pi 4 and install.
|
||||
|
||||
```bash
|
||||
# (build machine, internet YES) generate wheelhouse/ + debs/
|
||||
./vendor.sh
|
||||
|
||||
# after copying wheelhouse/ and debs/ into this folder on the target Pi 4:
|
||||
# (target Pi 4, internet NO)
|
||||
./install.sh # if debs/ · wheelhouse/ exist, it installs offline automatically
|
||||
```
|
||||
|
||||
> `pyserial` is pure Python (arch-independent), so it can be downloaded on an x86 build machine.
|
||||
> Other wheels/debs such as `requests` must be the **aarch64** builds (running `vendor.sh` on the target arch is recommended).
|
||||
|
||||
---
|
||||
|
||||
## 5. Run / check
|
||||
|
||||
```bash
|
||||
# UART present? (should symlink to ttyS0 or ttyAMA0)
|
||||
ls -l /dev/serial0
|
||||
|
||||
# pure wire/frame self-check (passes without hardware or config_local.py)
|
||||
python3 tests/test_pms_wire_and_frame.py
|
||||
|
||||
# actual run (simulation mode if pyserial missing / UART unavailable)
|
||||
source venv/bin/activate
|
||||
python3 pms7003_sensor.py
|
||||
```
|
||||
|
||||
In environments without `pyserial` installed or with the UART unavailable (dev PC, etc.), it automatically runs in **simulation mode**.
|
||||
|
||||
---
|
||||
|
||||
## 6. Auto-start on boot (systemd)
|
||||
|
||||
```bash
|
||||
# assumes the folder is placed at /home/pi/raspberry_pi4_pms7003 (otherwise edit the path in the .service)
|
||||
sudo cp systemd/pms7003-sensor.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable pms7003-sensor
|
||||
sudo systemctl start pms7003-sensor
|
||||
sudo systemctl status pms7003-sensor
|
||||
journalctl -u pms7003-sensor -f
|
||||
```
|
||||
|
||||
`SupplementaryGroups=dialout` is required for `/dev/serial0` access.
|
||||
|
||||
---
|
||||
|
||||
## 7. Security notes
|
||||
|
||||
- Secrets (`API_KEY`), server URL, and device info live only in `config_local.py` (not committed).
|
||||
Committed files contain only placeholders.
|
||||
- The report body is protected against tampering by an `HMAC-SHA256(API_KEY, raw_body)` signature (`X-Signature` header)
|
||||
(immune to length-extension attacks). The legacy body-field sha256 fallback is disabled by default on the server.
|
||||
- The signed bytes and the transmitted bytes are **always identical** (`wire.py` builds the body directly,
|
||||
signs those bytes, then raw POSTs). `fixed2` / `_esc` / `sign_raw` are copied verbatim from the SHT30 module
|
||||
so signing stays byte-for-byte identical to the shared reference / server `verify_signature_raw`.
|
||||
- If `config_local.py` is exposed, immediately rotate `API_KEY` (change it on the server at the same time).
|
||||
|
|
@ -1,535 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PM Node Wiring & Pinmap — PMS7003 UART (sensor_id=3 / Pi-C)</title>
|
||||
<style>
|
||||
:root{
|
||||
--ink:#1a1d21; --muted:#5c6570; --line:#dfe3e8; --bg:#eef1f4; --card:#ffffff;
|
||||
--accent:#1971c2; --accent-d:#155a9c; --warn:#e03131; --warnbg:#fff5f5;
|
||||
--ok:#2f9e44; --gnd:#343a40; --chip:#eef2f6; --code:#0b3d2e;
|
||||
--mono:"D2Coding","Consolas","Menlo","Courier New",ui-monospace,monospace;
|
||||
--sans:"Malgun Gothic","Apple SD Gothic Neo","Noto Sans KR","Segoe UI",system-ui,-apple-system,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
html,body{margin:0;padding:0;}
|
||||
body{
|
||||
font-family:var(--sans); color:var(--ink); background:var(--bg);
|
||||
line-height:1.6; -webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.wrap{max-width:1000px; margin:0 auto; padding:22px 18px 60px;}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
.hero{
|
||||
background:linear-gradient(135deg,#1f2933 0%,#283746 55%,#2f4a5a 100%);
|
||||
color:#fff; border-radius:16px; padding:26px 28px;
|
||||
box-shadow:0 8px 24px rgba(20,30,40,.18);
|
||||
}
|
||||
.hero .eyebrow{font-size:12.5px; letter-spacing:.14em; text-transform:uppercase;
|
||||
color:#9fc3e0; font-weight:700; margin:0 0 6px;}
|
||||
.hero h1{margin:0 0 8px; font-size:27px; line-height:1.25; font-weight:800;}
|
||||
.hero p.sum{margin:0 0 16px; font-size:14.5px; color:#d7e2ec; max-width:72ch;}
|
||||
.chips{display:flex; flex-wrap:wrap; gap:8px;}
|
||||
.chip{
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
background:rgba(255,255,255,.12); border:1px solid rgba(255,255,255,.22);
|
||||
color:#eef5fb; font-size:12.5px; font-weight:600; padding:5px 11px; border-radius:999px;
|
||||
}
|
||||
.chip.mono{font-family:var(--mono); letter-spacing:.01em;}
|
||||
.chip b{color:#fff;}
|
||||
|
||||
/* ---------- Sections ---------- */
|
||||
section{
|
||||
background:var(--card); border:1px solid var(--line); border-radius:14px;
|
||||
padding:22px 24px; margin-top:20px; box-shadow:0 2px 10px rgba(20,30,40,.05);
|
||||
}
|
||||
h2{
|
||||
display:flex; align-items:center; gap:11px; margin:0 0 16px;
|
||||
font-size:19px; font-weight:800; color:var(--ink);
|
||||
}
|
||||
h2 .n{
|
||||
flex:0 0 auto; width:30px; height:30px; border-radius:9px;
|
||||
background:var(--accent); color:#fff; font-size:15px; font-weight:800;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
h2 small{font-weight:600; font-size:13px; color:var(--muted);}
|
||||
|
||||
/* ---------- Pinmap SVG ---------- */
|
||||
.svg-pin{display:block; width:100%; max-width:460px; height:auto; margin:2px auto 0;}
|
||||
.diagram-cap{text-align:center; color:var(--muted); font-size:12.5px; margin:12px 0 0;}
|
||||
.legend{
|
||||
display:flex; flex-wrap:wrap; justify-content:center; gap:12px 18px;
|
||||
margin:16px auto 2px; max-width:640px;
|
||||
}
|
||||
.legend span{display:inline-flex; align-items:center; gap:7px; font-size:12.5px; color:var(--ink);}
|
||||
.sw{width:15px; height:15px; border-radius:50%; flex:0 0 auto; border:1px solid rgba(0,0,0,.15);}
|
||||
.sw.pwr{background:#2f9e44;} .sw.uart{background:#1971c2;}
|
||||
.sw.gnd{background:#343a40;}
|
||||
.sw.opt{background:#ced4da; border:1.5px dashed #868e96;}
|
||||
.sw.off{background:#dee2e6;}
|
||||
|
||||
/* SVG element classes */
|
||||
.p-pwr{fill:#2f9e44; stroke:#248a3b; stroke-width:1.5;}
|
||||
.p-uart{fill:#1971c2; stroke:#155a9c; stroke-width:1.5;}
|
||||
.p-gnd{fill:#343a40; stroke:#1f2429; stroke-width:1.5;}
|
||||
.p-opt{fill:#ced4da; stroke:#868e96; stroke-width:1.5; stroke-dasharray:3 2;}
|
||||
.p-off{fill:#dee2e6; stroke:#c3c9cf; stroke-width:1;}
|
||||
.rail{fill:#f1f3f5; stroke:#ced4da; stroke-width:1.5;}
|
||||
.pnum{font-family:var(--mono); font-size:12px; font-weight:700; text-anchor:middle; dominant-baseline:central;}
|
||||
.pnum-l{fill:#ffffff;} .pnum-d{fill:#495057;}
|
||||
.plbl{font-family:var(--mono); font-size:12px; dominant-baseline:central;}
|
||||
.lbl-l{text-anchor:end;} .lbl-r{text-anchor:start;}
|
||||
.lbl-used{fill:#1a1d21; font-weight:700;}
|
||||
.lbl-alt{fill:#5c6570; font-weight:600;}
|
||||
.lbl-off{fill:#adb5bd; font-weight:500;}
|
||||
.cap-svg{font-family:var(--sans); font-size:12.5px; fill:#5c6570; font-weight:600; text-anchor:middle;}
|
||||
|
||||
/* ---------- Table ---------- */
|
||||
table{width:100%; border-collapse:collapse; font-size:14px;}
|
||||
thead th{
|
||||
background:#f1f4f7; color:#2b3541; text-align:left; font-weight:700;
|
||||
padding:10px 12px; border-bottom:2px solid #ccd4dc; font-size:13px;
|
||||
}
|
||||
tbody td{padding:11px 12px; border-bottom:1px solid var(--line); vertical-align:top;}
|
||||
tbody tr:last-child td{border-bottom:none;}
|
||||
.mono{font-family:var(--mono);}
|
||||
.pinbadge{
|
||||
display:inline-block; min-width:26px; text-align:center; font-family:var(--mono);
|
||||
font-weight:700; font-size:13px; color:#fff; background:var(--accent);
|
||||
border-radius:6px; padding:2px 7px;
|
||||
}
|
||||
.pinbadge.gnd{background:var(--gnd);} .pinbadge.pwr{background:var(--ok);}
|
||||
.pinbadge.na{background:#adb5bd;}
|
||||
td .warn-inline{color:var(--warn); font-weight:700;}
|
||||
|
||||
/* ---------- Schematic ---------- */
|
||||
.svg-sch{display:block; width:100%; max-width:840px; height:auto; margin:0 auto;}
|
||||
.box{fill:#f8f9fa; stroke:#495057; stroke-width:2; rx:8;}
|
||||
.boxtitle{font-family:var(--sans); font-size:14px; font-weight:800; fill:#1a1d21; text-anchor:middle;}
|
||||
.term{font-family:var(--mono); font-size:12.5px; font-weight:700; fill:#2b3541;}
|
||||
.w-pwr{stroke:#e03131; stroke-width:3; fill:none; stroke-linecap:round; stroke-linejoin:round;}
|
||||
.w-uart{stroke:#1971c2; stroke-width:3; fill:none; stroke-linecap:round; stroke-linejoin:round;}
|
||||
.w-gnd{stroke:#343a40; stroke-width:3; fill:none; stroke-linecap:round; stroke-linejoin:round;}
|
||||
.w-opt{stroke:#868e96; stroke-width:2.5; fill:none; stroke-dasharray:6 4; stroke-linecap:round;}
|
||||
.wlbl{font-family:var(--sans); font-size:12px; font-weight:700; fill:#2b3541;}
|
||||
.jn{fill:#212529;}
|
||||
.sch-note{color:var(--muted); font-size:12.5px; text-align:center; margin:12px 0 0;}
|
||||
|
||||
/* ---------- Callouts ---------- */
|
||||
.warn{
|
||||
background:var(--warnbg); border:1.5px solid #ffc9c9; border-left:6px solid var(--warn);
|
||||
border-radius:12px; padding:16px 18px; margin:0 0 16px; display:flex; gap:14px; align-items:flex-start;
|
||||
}
|
||||
.warn .ic{flex:0 0 auto; width:34px; height:34px;}
|
||||
.warn h3{margin:0 0 4px; color:#c92a2a; font-size:16px; font-weight:800;}
|
||||
.warn p{margin:0; font-size:14px; color:#5a2020;}
|
||||
.warn b{color:#c92a2a;}
|
||||
ul.notes{margin:6px 0 0; padding:0; list-style:none;}
|
||||
ul.notes li{position:relative; padding:7px 0 7px 24px; border-bottom:1px dashed var(--line); font-size:14px;}
|
||||
ul.notes li:last-child{border-bottom:none;}
|
||||
ul.notes li::before{content:""; position:absolute; left:4px; top:14px; width:8px; height:8px;
|
||||
background:var(--accent); border-radius:2px; transform:rotate(45deg);}
|
||||
code, .kbd{font-family:var(--mono); background:#eef2f6; color:#0b3d2e; padding:1px 6px; border-radius:5px; font-size:13px;}
|
||||
.cmd{
|
||||
font-family:var(--mono); background:#12211b; color:#c8f7d8; border-radius:10px;
|
||||
padding:12px 14px; font-size:13.5px; margin:12px 0 0; overflow-x:auto;
|
||||
}
|
||||
.cmd .cm{color:#7fd6a3;} .cmd .ok{color:#ffd43b;}
|
||||
|
||||
/* ---------- Checklist ---------- */
|
||||
ol.check{margin:0; padding:0; list-style:none; counter-reset:ck;}
|
||||
ol.check li{
|
||||
counter-increment:ck; position:relative; display:flex; align-items:flex-start; gap:12px;
|
||||
padding:11px 4px 11px 0; border-bottom:1px solid var(--line); font-size:14px;
|
||||
}
|
||||
ol.check li:last-child{border-bottom:none;}
|
||||
ol.check li .num{
|
||||
flex:0 0 auto; width:24px; height:24px; border-radius:50%; background:#e7eef5; color:#1f5f97;
|
||||
font-weight:800; font-size:12.5px; display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
ol.check li .num::before{content:counter(ck);}
|
||||
ol.check li .cbox{flex:0 0 auto; width:18px; height:18px; border:2px solid #9aa6b1; border-radius:4px; margin-top:1px;}
|
||||
ol.check li .txt{flex:1;}
|
||||
ol.check li .txt b{color:var(--warn);}
|
||||
|
||||
footer{margin-top:26px; text-align:center; color:var(--muted); font-size:12px;}
|
||||
|
||||
/* ---------- Print ---------- */
|
||||
@media print{
|
||||
body{background:#fff;}
|
||||
.wrap{max-width:none; padding:0;}
|
||||
.hero{background:#fff !important; color:#111; border:2px solid #111; box-shadow:none;}
|
||||
.hero .eyebrow{color:#444;} .hero p.sum{color:#222;}
|
||||
.chip{background:#f2f2f2; color:#111; border:1px solid #999;}
|
||||
.chip b{color:#000;}
|
||||
h2 .n{background:#111;}
|
||||
section{box-shadow:none; border:1px solid #999; break-inside:avoid; page-break-inside:avoid;}
|
||||
table, tr, td, th, .warn, .card{break-inside:avoid; page-break-inside:avoid;}
|
||||
.svg-pin, .svg-sch{max-width:100%;}
|
||||
.cmd{background:#f2f2f2; color:#111; border:1px solid #999;}
|
||||
a{color:#000;}
|
||||
}
|
||||
@media (max-width:560px){
|
||||
.hero h1{font-size:22px;} h2{font-size:17px;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<!-- ============ 1. HEADER ============ -->
|
||||
<header class="hero">
|
||||
<p class="eyebrow">Field Installation Doc · Wiring & Pinmap</p>
|
||||
<h1>Particulate-Matter Node — Site (Pi-C, sensor_id=3)</h1>
|
||||
<p class="sum">
|
||||
A measurement node connecting the Plantower PMS7003 laser PM sensor to the Raspberry Pi over the primary UART (9600 8N1).
|
||||
The sensor co-locates on an existing leak node and reports PM1.0/PM2.5/PM10 (ug/m3) every <b>10 minutes</b> under the same
|
||||
<code>sensor_id</code> as that node's leak sensor; high-PM2.5/PM10 threshold decisions and SMS dispatch are handled by the server.
|
||||
</p>
|
||||
<div class="chips">
|
||||
<span class="chip">Raspberry Pi 4 Model B</span>
|
||||
<span class="chip mono">BCM2711</span>
|
||||
<span class="chip">Raspberry Pi OS Bookworm 64-bit</span>
|
||||
<span class="chip">Plantower PMS7003</span>
|
||||
<span class="chip mono">UART <b>/dev/serial0</b></span>
|
||||
<span class="chip mono">9600 8N1</span>
|
||||
<span class="chip mono">metric_type=<b>pms7003</b></span>
|
||||
<span class="chip">Report interval 10 min</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ============ 2. PINMAP SVG ============ -->
|
||||
<section>
|
||||
<h2><span class="n">2</span>40-Pin Header Pinmap Diagram <small>Used pins highlighted · others in gray</small></h2>
|
||||
|
||||
<svg class="svg-pin" viewBox="0 0 520 672" role="img" aria-label="Raspberry Pi 40-pin GPIO header pinmap">
|
||||
<!-- caption -->
|
||||
<text class="cap-svg" x="260" y="20">40-pin GPIO header (J8) · physical pin numbers</text>
|
||||
<!-- rails -->
|
||||
<rect class="rail" x="186" y="46" width="28" height="600" rx="14"/>
|
||||
<rect class="rail" x="306" y="46" width="28" height="600" rx="14"/>
|
||||
|
||||
<!-- Row 1 : pin1 (square = pin1 marker) / pin2 (5V power = used) -->
|
||||
<rect class="p-off" x="188" y="48" width="24" height="24" rx="5"/>
|
||||
<text class="pnum pnum-d" x="200" y="60">1</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="60">3V3</text>
|
||||
<circle class="p-pwr" cx="320" cy="60" r="12"/>
|
||||
<text class="pnum pnum-l" x="320" y="60">2</text>
|
||||
<text class="plbl lbl-r lbl-used" x="344" y="60">5V (VCC)</text>
|
||||
|
||||
<!-- Row 2 : pin3 / pin4 -->
|
||||
<circle class="p-off" cx="200" cy="90" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="90">3</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="90">GPIO2 (SDA1)</text>
|
||||
<circle class="p-off" cx="320" cy="90" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="90">4</text>
|
||||
<text class="plbl lbl-r lbl-off" x="344" y="90">5V</text>
|
||||
|
||||
<!-- Row 3 : pin5 / pin6 (GND = used) -->
|
||||
<circle class="p-off" cx="200" cy="120" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="120">5</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="120">GPIO3 (SCL1)</text>
|
||||
<circle class="p-gnd" cx="320" cy="120" r="12"/>
|
||||
<text class="pnum pnum-l" x="320" y="120">6</text>
|
||||
<text class="plbl lbl-r lbl-used" x="344" y="120">GND</text>
|
||||
|
||||
<!-- Row 4 : pin7 / pin8 (GPIO14 TXD = optional) -->
|
||||
<circle class="p-off" cx="200" cy="150" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="150">7</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="150">GPIO4</text>
|
||||
<circle class="p-opt" cx="320" cy="150" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="150">8</text>
|
||||
<text class="plbl lbl-r lbl-alt" x="344" y="150">GPIO14 (TXD)</text>
|
||||
|
||||
<!-- Row 5 : pin9 / pin10 (GPIO15 RXD = used, sensor data) -->
|
||||
<circle class="p-off" cx="200" cy="180" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="180">9</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="180">GND</text>
|
||||
<circle class="p-uart" cx="320" cy="180" r="12"/>
|
||||
<text class="pnum pnum-l" x="320" y="180">10</text>
|
||||
<text class="plbl lbl-r lbl-used" x="344" y="180">GPIO15 (RXD)</text>
|
||||
|
||||
<!-- Row 6 : pin11 / pin12 -->
|
||||
<circle class="p-off" cx="200" cy="210" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="210">11</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="210">GPIO17</text>
|
||||
<circle class="p-off" cx="320" cy="210" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="210">12</text>
|
||||
<text class="plbl lbl-r lbl-off" x="344" y="210">GPIO18</text>
|
||||
|
||||
<!-- Row 7 : pin13 / pin14 -->
|
||||
<circle class="p-off" cx="200" cy="240" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="240">13</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="240">GPIO27</text>
|
||||
<circle class="p-off" cx="320" cy="240" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="240">14</text>
|
||||
<text class="plbl lbl-r lbl-off" x="344" y="240">GND</text>
|
||||
|
||||
<!-- Row 8 : pin15 / pin16 -->
|
||||
<circle class="p-off" cx="200" cy="270" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="270">15</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="270">GPIO22</text>
|
||||
<circle class="p-off" cx="320" cy="270" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="270">16</text>
|
||||
<text class="plbl lbl-r lbl-off" x="344" y="270">GPIO23</text>
|
||||
|
||||
<!-- Row 9 : pin17 / pin18 -->
|
||||
<circle class="p-off" cx="200" cy="300" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="300">17</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="300">3V3</text>
|
||||
<circle class="p-off" cx="320" cy="300" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="300">18</text>
|
||||
<text class="plbl lbl-r lbl-off" x="344" y="300">GPIO24</text>
|
||||
|
||||
<!-- Row 10 : pin19 / pin20 -->
|
||||
<circle class="p-off" cx="200" cy="330" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="330">19</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="330">GPIO10 (MOSI)</text>
|
||||
<circle class="p-off" cx="320" cy="330" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="330">20</text>
|
||||
<text class="plbl lbl-r lbl-off" x="344" y="330">GND</text>
|
||||
|
||||
<!-- Row 11 : pin21 / pin22 -->
|
||||
<circle class="p-off" cx="200" cy="360" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="360">21</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="360">GPIO9 (MISO)</text>
|
||||
<circle class="p-off" cx="320" cy="360" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="360">22</text>
|
||||
<text class="plbl lbl-r lbl-off" x="344" y="360">GPIO25</text>
|
||||
|
||||
<!-- Row 12 : pin23 / pin24 -->
|
||||
<circle class="p-off" cx="200" cy="390" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="390">23</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="390">GPIO11 (SCLK)</text>
|
||||
<circle class="p-off" cx="320" cy="390" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="390">24</text>
|
||||
<text class="plbl lbl-r lbl-off" x="344" y="390">GPIO8 (CE0)</text>
|
||||
|
||||
<!-- Row 13 : pin25 / pin26 -->
|
||||
<circle class="p-off" cx="200" cy="420" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="420">25</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="420">GND</text>
|
||||
<circle class="p-off" cx="320" cy="420" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="420">26</text>
|
||||
<text class="plbl lbl-r lbl-off" x="344" y="420">GPIO7 (CE1)</text>
|
||||
|
||||
<!-- Row 14 : pin27 / pin28 -->
|
||||
<circle class="p-off" cx="200" cy="450" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="450">27</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="450">GPIO0 (ID_SD)</text>
|
||||
<circle class="p-off" cx="320" cy="450" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="450">28</text>
|
||||
<text class="plbl lbl-r lbl-off" x="344" y="450">GPIO1 (ID_SC)</text>
|
||||
|
||||
<!-- Row 15 : pin29 / pin30 -->
|
||||
<circle class="p-off" cx="200" cy="480" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="480">29</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="480">GPIO5</text>
|
||||
<circle class="p-off" cx="320" cy="480" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="480">30</text>
|
||||
<text class="plbl lbl-r lbl-off" x="344" y="480">GND</text>
|
||||
|
||||
<!-- Row 16 : pin31 / pin32 -->
|
||||
<circle class="p-off" cx="200" cy="510" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="510">31</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="510">GPIO6</text>
|
||||
<circle class="p-off" cx="320" cy="510" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="510">32</text>
|
||||
<text class="plbl lbl-r lbl-off" x="344" y="510">GPIO12</text>
|
||||
|
||||
<!-- Row 17 : pin33 / pin34 -->
|
||||
<circle class="p-off" cx="200" cy="540" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="540">33</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="540">GPIO13</text>
|
||||
<circle class="p-off" cx="320" cy="540" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="540">34</text>
|
||||
<text class="plbl lbl-r lbl-off" x="344" y="540">GND</text>
|
||||
|
||||
<!-- Row 18 : pin35 / pin36 -->
|
||||
<circle class="p-off" cx="200" cy="570" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="570">35</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="570">GPIO19</text>
|
||||
<circle class="p-off" cx="320" cy="570" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="570">36</text>
|
||||
<text class="plbl lbl-r lbl-off" x="344" y="570">GPIO16</text>
|
||||
|
||||
<!-- Row 19 : pin37 / pin38 -->
|
||||
<circle class="p-off" cx="200" cy="600" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="600">37</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="600">GPIO26</text>
|
||||
<circle class="p-off" cx="320" cy="600" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="600">38</text>
|
||||
<text class="plbl lbl-r lbl-off" x="344" y="600">GPIO20</text>
|
||||
|
||||
<!-- Row 20 : pin39 / pin40 -->
|
||||
<circle class="p-off" cx="200" cy="630" r="12"/>
|
||||
<text class="pnum pnum-d" x="200" y="630">39</text>
|
||||
<text class="plbl lbl-l lbl-off" x="176" y="630">GND</text>
|
||||
<circle class="p-off" cx="320" cy="630" r="12"/>
|
||||
<text class="pnum pnum-d" x="320" y="630">40</text>
|
||||
<text class="plbl lbl-r lbl-off" x="344" y="630">GPIO21</text>
|
||||
|
||||
<!-- pin1 hint -->
|
||||
<text class="cap-svg" x="260" y="662" style="font-size:11px;">[#] square marker = pin 1 (relative to board orientation)</text>
|
||||
</svg>
|
||||
|
||||
<div class="legend">
|
||||
<span><i class="sw pwr"></i>5V power — pin 2</span>
|
||||
<span><i class="sw uart"></i>UART RXD (sensor data) — pin 10</span>
|
||||
<span><i class="sw gnd"></i>GND — pin 6</span>
|
||||
<span><i class="sw opt"></i>UART TXD (optional) — pin 8</span>
|
||||
<span><i class="sw off"></i>Unused pins</span>
|
||||
</div>
|
||||
<p class="diagram-cap">The pin numbers and signals in this diagram match the standard Raspberry Pi 4 Model B (BCM2711) 40-pin header layout.</p>
|
||||
</section>
|
||||
|
||||
<!-- ============ 3. PINMAP TABLE ============ -->
|
||||
<section>
|
||||
<h2><span class="n">3</span>Pinmap Table <small>PMS7003 → 40-pin header</small></h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th style="width:30%">PMS7003 signal</th><th style="width:24%">Direction</th><th style="width:14%">Phys pin</th><th>Notes</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><b>VCC</b> (power)</td>
|
||||
<td class="mono">Pi → sensor</td>
|
||||
<td><span class="pinbadge pwr">2</span></td>
|
||||
<td><span class="warn-inline">* 5V required</span> — the PMS7003 fan + laser need 5V (pins 2·4)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>GND</b></td>
|
||||
<td class="mono">common</td>
|
||||
<td><span class="pinbadge gnd">6</span></td>
|
||||
<td>Any GND works: <span class="mono">6 · 9 · 14 · 20 · 25 · 30 · 34 · 39</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>TXD</b> (sensor data out)</td>
|
||||
<td class="mono">sensor → Pi RXD</td>
|
||||
<td><span class="pinbadge">10</span></td>
|
||||
<td>GPIO15 (RXD). 3.3V logic — connects <b>directly</b>, no level shifter</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>RXD</b> (command in)</td>
|
||||
<td class="mono">Pi TXD → sensor</td>
|
||||
<td><span class="pinbadge na">8</span></td>
|
||||
<td><b>Optional</b> — only for passive-mode/sleep commands. Active streaming needs only TXD</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>SET / RESET</b></td>
|
||||
<td class="mono">—</td>
|
||||
<td><span class="pinbadge na">n/c</span></td>
|
||||
<td>Optional. Leave floating — internal pull-up keeps the sensor enabled/running</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- ============ 4. SCHEMATIC SVG ============ -->
|
||||
<section>
|
||||
<h2><span class="n">4</span>Wiring Schematic <small>sensor ↔ header</small></h2>
|
||||
|
||||
<svg class="svg-sch" viewBox="0 0 840 380" role="img" aria-label="PMS7003 wiring schematic">
|
||||
<!-- component boxes -->
|
||||
<rect class="box" x="70" y="90" width="160" height="250" rx="8"/>
|
||||
<rect class="box" x="640" y="90" width="150" height="250" rx="8"/>
|
||||
<text class="boxtitle" x="150" y="116">PMS7003 sensor</text>
|
||||
<text class="boxtitle" x="715" y="116">Pi 40-pin header</text>
|
||||
|
||||
<!-- PMS7003 terminal labels (right side) -->
|
||||
<text class="term" x="220" y="150" text-anchor="end">VCC</text>
|
||||
<text class="term" x="220" y="205" text-anchor="end">GND</text>
|
||||
<text class="term" x="220" y="260" text-anchor="end">TXD</text>
|
||||
<text class="term" x="220" y="315" text-anchor="end">RXD</text>
|
||||
|
||||
<!-- Pi terminal labels (left side) -->
|
||||
<text class="term" x="650" y="150">5V (pin 2)</text>
|
||||
<text class="term" x="650" y="205">GND (pin 6)</text>
|
||||
<text class="term" x="650" y="260">RXD (pin 10, GPIO15)</text>
|
||||
<text class="term" x="650" y="315">TXD (pin 8, GPIO14)</text>
|
||||
|
||||
<!-- POWER wire -->
|
||||
<path class="w-pwr" d="M230 150 H640"/>
|
||||
<text class="wlbl" x="330" y="140" fill="#b02a2a">Power 5V</text>
|
||||
|
||||
<!-- GND wire -->
|
||||
<path class="w-gnd" d="M230 205 H640"/>
|
||||
<text class="wlbl" x="330" y="195" fill="#2b3541">Ground GND</text>
|
||||
|
||||
<!-- TXD (sensor) -> RXD (Pi) : the sensor data line -->
|
||||
<path class="w-uart" d="M230 260 H640"/>
|
||||
<text class="wlbl" x="300" y="250" fill="#155a9c">sensor TXD → Pi RXD (GPIO15)</text>
|
||||
|
||||
<!-- RXD (sensor) <- TXD (Pi) : optional command line -->
|
||||
<path class="w-opt" d="M230 315 H640"/>
|
||||
<text class="wlbl" x="300" y="333" fill="#5c6570">Pi TXD → sensor RXD (optional)</text>
|
||||
</svg>
|
||||
|
||||
<p class="sch-note">
|
||||
Active streaming mode uses only <b>VCC · GND · sensor TXD → Pi RXD</b>. The dashed Pi TXD → sensor RXD
|
||||
line is optional (needed only to send passive-mode / sleep commands). The PMS7003 TXD is 3.3V logic,
|
||||
so it wires <b>directly</b> to the Pi — no level shifter.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ============ 5. ELECTRICAL NOTES ============ -->
|
||||
<section>
|
||||
<h2><span class="n">5</span>Electrical Design · Cautions</h2>
|
||||
|
||||
<div class="warn">
|
||||
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 2 L23 21 H1 Z" fill="#ffe3e3" stroke="#e03131" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
<line x1="12" y1="9" x2="12" y2="15" stroke="#e03131" stroke-width="2.2" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="18" r="1.4" fill="#e03131"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h3>Critical — power VCC from 5V, but NEVER wire the sensor TXD to a Pi 5V pin</h3>
|
||||
<p>The PMS7003 <b>VCC must be 5V (pin 2 or 4)</b> — its fan and laser will not run on 3.3V. However the
|
||||
UART data lines are <b>3.3V logic</b>: the sensor's TXD connects <b>directly</b> to the Pi RXD (pin 10).
|
||||
<b>Do not connect any 5V line to a GPIO</b> — that would damage the 3.3V-only Pi GPIO. Double-check pin numbers before power-up.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="notes">
|
||||
<li><b>Enable the UART:</b> add <code>enable_uart=1</code> to <code>/boot/firmware/config.txt</code> (Bookworm) or use <code>raspi-config</code> → Interface Options → Serial Port.</li>
|
||||
<li><b>Disable the serial login console:</b> answer "No" to the login-shell question in <code>raspi-config</code>, or remove <code>console=serial0,115200</code> from <code>cmdline.txt</code>. Otherwise the console fights the sensor for <code>/dev/serial0</code>. <code>install.sh</code> does both automatically.</li>
|
||||
<li><b>Port:</b> the module reads <code>/dev/serial0</code> (alias to the primary UART; also <code>/dev/ttyS0</code> / <code>/dev/ttyAMA0</code>). 9600 baud, 8 data bits, no parity, 1 stop bit.</li>
|
||||
<li><b>dialout group:</b> the service user needs the <code>dialout</code> group to open <code>/dev/serial0</code> (<code>systemd</code> unit sets <code>SupplementaryGroups=dialout</code>).</li>
|
||||
<li><b>Warm-up:</b> the PMS7003 needs a few seconds of fan/laser warm-up after power-on for stable readings; the driver simply reads until a valid checksummed frame arrives.</li>
|
||||
<li><b>Role split:</b> this node only reports PM1.0/PM2.5/PM10 every 10 minutes. High-PM2.5/PM10 <b>threshold decisions and SMS dispatch are done by the server</b>.</li>
|
||||
<li><b>Config file:</b> <code>raspberry_pi4_pms7003/config.py</code> (<code>UART_PORT=/dev/serial0</code>, <code>UART_BAUD=9600</code>). Per-device values (SENSOR_ID 1 vs 3) are overridden in <code>config_local.py</code>.</li>
|
||||
</ul>
|
||||
|
||||
<div class="cmd">
|
||||
<span class="cm"># Verify the UART device exists (symlink to ttyS0 / ttyAMA0)</span>
|
||||
$ ls -l /dev/serial0
|
||||
lrwxrwxrwx 1 root root 5 ... /dev/serial0 -> <span class="ok">ttyS0</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 6. INSTALL CHECKLIST ============ -->
|
||||
<section>
|
||||
<h2><span class="n">6</span>Installation Checklist</h2>
|
||||
<ol class="check">
|
||||
<li><span class="num"></span><span class="cbox"></span><span class="txt">Do wiring work with <b>power disconnected</b> (no hot-plugging)</span></li>
|
||||
<li><span class="num"></span><span class="cbox"></span><span class="txt">Connect VCC → physical pin 2 (5V) — the sensor fan/laser need <b>5V</b></span></li>
|
||||
<li><span class="num"></span><span class="cbox"></span><span class="txt">Connect GND → physical pin 6 (or any other GND pin)</span></li>
|
||||
<li><span class="num"></span><span class="cbox"></span><span class="txt">Connect sensor <b>TXD → Pi RXD (pin 10, GPIO15)</b> — this is the data line</span></li>
|
||||
<li><span class="num"></span><span class="cbox"></span><span class="txt">(Optional) Pi TXD (pin 8) → sensor RXD, only if sending passive-mode commands</span></li>
|
||||
<li><span class="num"></span><span class="cbox"></span><span class="txt">Leave SET/RESET floating (sensor stays enabled/running)</span></li>
|
||||
<li><span class="num"></span><span class="cbox"></span><span class="txt">Re-check all wiring — confirm <b>no 5V line touches a GPIO</b> — then power up the Pi</span></li>
|
||||
<li><span class="num"></span><span class="cbox"></span><span class="txt">Enable UART (<code>enable_uart=1</code>) and <b>disable the serial console</b> (raspi-config / install.sh), then reboot</span></li>
|
||||
<li><span class="num"></span><span class="cbox"></span><span class="txt">Run <code>ls -l /dev/serial0</code> → confirm it symlinks to <code>ttyS0</code>/<code>ttyAMA0</code></span></li>
|
||||
<li><span class="num"></span><span class="cbox"></span><span class="txt">Verify UART_PORT / UART_BAUD in config.py, and SENSOR_ID (1 or 3) in config_local.py</span></li>
|
||||
<li><span class="num"></span><span class="cbox"></span><span class="txt">Check measurement log and server reception (10-min PM reporting OK)</span></li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Particulate-Matter Node · PMS7003 UART wiring doc (co-located on a leak node, sensor_id 1 or 3) — for on-site printing
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
# =============================================================================
|
||||
# config.py - Particulate-matter (PMS7003) monitoring module (Pi 4 Model B) config — non-secret defaults
|
||||
#
|
||||
# Operational/secret values such as server URL / API_KEY / device identifiers (DEVICE_ID/LOCATION)
|
||||
# are loaded from config_local.py. config_local.py is excluded from commits via .gitignore.
|
||||
# Setup: cp config_local.example.py config_local.py → fill in the real values
|
||||
#
|
||||
# This module integrates a PMS7003 PM sensor onto an EXISTING leak node (co-located, same
|
||||
# sensor_id as that node's leak sensor, distinguished by metric_type="pms7003" — exactly like
|
||||
# the SHT30 co-location). It attaches to BOTH leak nodes: Pi-C (sensor_id=3, the default here)
|
||||
# and the id=1 node (override SENSOR_ID=1 in config_local.py). Threshold/SMS is server-side.
|
||||
# =============================================================================
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Sensor identity (for server dashboard display; not secret)
|
||||
# SENSOR_ID is the key in the server's leak_sensor_metric / leak_sensor_status. The PMS row
|
||||
# shares the co-located leak sensor's id (1 or 3), separated by metric_type. Default = Pi-C (3).
|
||||
# ─────────────────────────────────────────────
|
||||
SENSOR_ID = 3
|
||||
SENSOR_NAME = "Sensor 3 (PMS7003)"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# UART config (Pi 4 40-pin header primary UART)
|
||||
# PMS7003 TXD → Pi RXD = GPIO15 (physical pin 10). 3.3V logic, 9600 8N1.
|
||||
# Port: /dev/serial0 (alias to the primary UART). Also usable: /dev/ttyS0 / /dev/ttyAMA0.
|
||||
# Requires: enable UART + DISABLE the serial login console (raspi-config / enable_uart=1).
|
||||
# ─────────────────────────────────────────────
|
||||
UART_PORT = "/dev/serial0"
|
||||
UART_BAUD = 9600
|
||||
UART_TIMEOUT = 2.0 # pyserial per-read timeout (seconds)
|
||||
FRAME_WAIT_TIMEOUT = 5.0 # max time to wait for a valid frame in one measurement (seconds)
|
||||
|
||||
# Force simulation. If True, runs with dummy measurements without hardware (for server integration checks).
|
||||
# Even if False, the driver auto-falls back to simulation when pyserial is not installed / UART is unavailable.
|
||||
FORCE_SIMULATION = False
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Measurement/report intervals
|
||||
# ─────────────────────────────────────────────
|
||||
REPORT_INTERVAL = 600 # Normal measure/report interval (seconds) = 10 minutes
|
||||
# Matches the co-located leak node's SHT30 cadence (600s).
|
||||
# Server offline detection (METRIC_HEARTBEAT_TIMEOUT_SEC) must exceed this.
|
||||
# Override per-node in config_local.py if a different cadence is needed.
|
||||
MEASURE_RETRY_DELAY = 5 # Wait before retry after a failed measurement (seconds)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Physical plausibility range for PM values (µg/m³, atmospheric concentration)
|
||||
# Values outside this range are reported as metric_status="out_of_range".
|
||||
# * Operational threshold judgments (high PM2.5 / PM10) and SMS are all handled by the
|
||||
# server (config.php METRIC_PM*). This module only faithfully reports measurements.
|
||||
# ─────────────────────────────────────────────
|
||||
PM_VALUE_MIN = 0
|
||||
PM_VALUE_MAX = 1000
|
||||
|
||||
# Module identity version (server app_version field; the server only records it, does not validate)
|
||||
APP_VERSION = "v2606-pms7003-rpi4"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Logging config
|
||||
# ─────────────────────────────────────────────
|
||||
LOG_FILE = "/var/log/pms7003_sensor.log"
|
||||
LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB
|
||||
LOG_BACKUP_COUNT = 3
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# HTTP retry config
|
||||
# ─────────────────────────────────────────────
|
||||
HTTP_TIMEOUT = 10 # Single request timeout (seconds)
|
||||
HTTP_RETRY_COUNT = 3 # Number of retries
|
||||
HTTP_RETRY_DELAY = 5 # Wait between retries (seconds)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Load operational/secret values (fail closed)
|
||||
# If config_local.py is missing, abort immediately with a clear error instead of
|
||||
# starting incorrectly with placeholder/hardcoded secrets.
|
||||
# ─────────────────────────────────────────────
|
||||
try:
|
||||
from config_local import ( # noqa: F401 (pms7003_sensor.py re-imports from config)
|
||||
API_URL,
|
||||
API_KEY,
|
||||
DEVICE_ID,
|
||||
DEVICE_LOCATION,
|
||||
)
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"config_local.py not found. "
|
||||
"Run `cp config_local.example.py config_local.py`, then fill in the real server URL / API_KEY / "
|
||||
"device info. (Never commit secret values.)"
|
||||
) from e
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Optional override — per-device values from config_local.py.
|
||||
# Editing the tracked config.py per device causes deployment drift, so each leak node
|
||||
# overrides SENSOR_ID / SENSOR_NAME here instead (Pi-C keeps id 3; the id=1 node sets
|
||||
# SENSOR_ID=1). If config_local defines neither, the defaults above (SENSOR_ID=3) are kept.
|
||||
# ─────────────────────────────────────────────
|
||||
try:
|
||||
from config_local import SENSOR_ID # noqa: F811 — per-device sensor_id override
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from config_local import SENSOR_NAME # noqa: F811 — per-device display name override
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
# Per-device report cadence. Defaults to 600s (10 min) to match the co-located leak
|
||||
# node's SHT30 cadence; override here if a node needs a different interval.
|
||||
from config_local import REPORT_INTERVAL # noqa: F811 — per-device report interval override
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from config_local import UART_PORT # noqa: F811 — per-device UART device override
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Validate (fail closed) — a typo must not silently start with a wrong/plausible id.
|
||||
if not (isinstance(SENSOR_ID, int) and 1 <= SENSOR_ID <= 255):
|
||||
raise RuntimeError(f"SENSOR_ID must be int 1..255 (check config_local.py): {SENSOR_ID!r}")
|
||||
if not (isinstance(SENSOR_NAME, str) and SENSOR_NAME):
|
||||
raise RuntimeError("SENSOR_NAME must be a non-empty string (check config_local.py).")
|
||||
if not (isinstance(REPORT_INTERVAL, int) and REPORT_INTERVAL > 0):
|
||||
raise RuntimeError(f"REPORT_INTERVAL must be a positive int (seconds) (check config_local.py): {REPORT_INTERVAL!r}")
|
||||
if not (isinstance(UART_PORT, str) and UART_PORT):
|
||||
raise RuntimeError("UART_PORT must be a non-empty string (check config_local.py).")
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
# =============================================================================
|
||||
# config_local.example.py - Operational / secret-value template (Pi 4 PMS7003 particulate-matter module)
|
||||
#
|
||||
# Usage: cp config_local.example.py config_local.py
|
||||
# -> Fill in the real values and save. Never commit config_local.py
|
||||
# (it is excluded via .gitignore).
|
||||
#
|
||||
# WARNING: Server compatibility
|
||||
# This module reports PMS7003 particulate-matter (PM1.0/PM2.5/PM10, ug/m3) readings to the
|
||||
# cafe24 server php/api/sensor_data.php. The server requires a raw-body signature:
|
||||
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))
|
||||
# -> API_KEY must be "exactly identical" to API_KEY in the server config.local.php.
|
||||
# Threshold decisions (high PM2.5 / PM10), SMS, and the dashboard are all done by the server.
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------
|
||||
# Server API endpoint (receiving server = cafe24 php/)
|
||||
# Web root per INSTALL_PI_SERVER.md: public_html/raspi_leck_detecter/
|
||||
# -----------------------------------------------
|
||||
API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php"
|
||||
|
||||
# -----------------------------------------------
|
||||
# Shared secret key - must be exactly identical to API_KEY in the server config.local.php. Never commit.
|
||||
# -----------------------------------------------
|
||||
API_KEY = "CHANGE_ME"
|
||||
|
||||
# -----------------------------------------------
|
||||
# Device identification. The PMS7003 co-locates on an EXISTING leak node and reports under the
|
||||
# SAME sensor_id as that node's leak sensor (distinguished by metric_type="pms7003").
|
||||
# - Pi-C leak node: keep the config.py default (SENSOR_ID=3) - leave SENSOR_ID commented out.
|
||||
# - id=1 leak node: uncomment and set SENSOR_ID=1 (and an appropriate SENSOR_NAME).
|
||||
# -----------------------------------------------
|
||||
DEVICE_ID = "rpi4-pms7003-01"
|
||||
DEVICE_LOCATION = "Server Room"
|
||||
|
||||
# -----------------------------------------------
|
||||
# Per-device overrides (optional). Uncomment on the id=1 node.
|
||||
# -----------------------------------------------
|
||||
# SENSOR_ID = 1
|
||||
# SENSOR_NAME = "Sensor 1 (PMS7003)"
|
||||
# REPORT_INTERVAL = 600
|
||||
# UART_PORT = "/dev/serial0"
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Particulate-matter (PMS7003) module v2 (Raspberry Pi 4 Model B) install script
|
||||
# Target OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
|
||||
#
|
||||
# Air-gapped support:
|
||||
# - If debs/ or wheelhouse/ is present, automatically switch to "offline mode".
|
||||
# - In offline mode, never access the network. If the bundle is only partial,
|
||||
# do not silently fall back to online; stop with a clear error (air-gapped rule). Pre-
|
||||
# download is done on the build machine via vendor.sh.
|
||||
# - Can be forced via the OFFLINE=1/0 environment variable.
|
||||
# - Log file owner: SERVICE_USER (default pi).
|
||||
#
|
||||
# This script also enables the UART (enable_uart=1) and DISABLES the serial login console
|
||||
# (so the PMS7003 has exclusive use of /dev/serial0), and adds the user to the dialout group.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
SERVICE_USER="${SERVICE_USER:-pi}"
|
||||
|
||||
echo "=========================================="
|
||||
echo " Particulate-matter (PMS7003) module v2 (Pi 4) install"
|
||||
echo "=========================================="
|
||||
|
||||
# 0) Board model notice (not enforced)
|
||||
if [ -r /proc/device-tree/model ]; then
|
||||
MODEL="$(tr -d '\0' < /proc/device-tree/model)"
|
||||
echo "[INFO] Board: ${MODEL}"
|
||||
case "${MODEL}" in
|
||||
*"Raspberry Pi 4"*) : ;;
|
||||
*) echo "[WARN] This folder is for Raspberry Pi 4 Model B only (you may continue anyway)." ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Offline mode detection
|
||||
OFFLINE="${OFFLINE:-}"
|
||||
if [ -z "${OFFLINE}" ]; then
|
||||
if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then
|
||||
OFFLINE=1
|
||||
echo "[INFO] Offline bundle detected -> air-gapped offline install mode (no network)"
|
||||
else
|
||||
OFFLINE=0
|
||||
echo "[INFO] No offline bundle -> online install mode (internet required)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1) System packages: requests + venv
|
||||
echo "[1/4] Installing system packages..."
|
||||
if [ "${OFFLINE}" = "1" ]; then
|
||||
if ! compgen -G "debs/*.deb" >/dev/null; then
|
||||
echo "[ERROR] Offline mode but debs/ is empty. Place the vendor.sh output (debs/) in this folder and re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Offline .deb install (apt-get --no-download - dependency ordering/resolution automatic, no network)"
|
||||
# --no-install-recommends: vendor.sh does not bundle Recommends (--no-recommends), so match that.
|
||||
sudo apt-get install -y --no-install-recommends --no-download ./debs/*.deb
|
||||
else
|
||||
echo " Online apt install (internet required)"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3-requests python3-venv
|
||||
fi
|
||||
|
||||
# 2) Enable UART + disable the serial login console
|
||||
echo "[2/4] Enabling UART (and disabling the serial console)..."
|
||||
# Boot config path: Bookworm=/boot/firmware/config.txt, older=/boot/config.txt
|
||||
BOOT_CFG=""
|
||||
for c in /boot/firmware/config.txt /boot/config.txt; do
|
||||
if [ -f "$c" ]; then BOOT_CFG="$c"; break; fi
|
||||
done
|
||||
if [ -n "${BOOT_CFG}" ]; then
|
||||
if ! grep -q "^enable_uart=1" "${BOOT_CFG}"; then
|
||||
echo " Adding enable_uart=1 to ${BOOT_CFG} (applied after reboot)"
|
||||
echo "enable_uart=1" | sudo tee -a "${BOOT_CFG}" >/dev/null
|
||||
NEED_REBOOT=1
|
||||
else
|
||||
echo " enable_uart=1 already set"
|
||||
fi
|
||||
else
|
||||
echo "[WARN] config.txt not found - enable UART manually via raspi-config"
|
||||
fi
|
||||
# Disable the serial login console so /dev/serial0 is free for the sensor.
|
||||
# Bookworm=/boot/firmware/cmdline.txt, older=/boot/cmdline.txt
|
||||
CMDLINE=""
|
||||
for c in /boot/firmware/cmdline.txt /boot/cmdline.txt; do
|
||||
if [ -f "$c" ]; then CMDLINE="$c"; break; fi
|
||||
done
|
||||
if [ -n "${CMDLINE}" ] && grep -q "console=serial0" "${CMDLINE}"; then
|
||||
echo " Removing 'console=serial0,115200' from ${CMDLINE} (frees the UART for PMS7003)"
|
||||
sudo sed -i 's/console=serial0,[0-9]* //' "${CMDLINE}"
|
||||
NEED_REBOOT=1
|
||||
fi
|
||||
# Disable the getty on the primary UART (harmless if already masked).
|
||||
sudo systemctl disable --now serial-getty@ttyS0.service 2>/dev/null || true
|
||||
sudo systemctl disable --now serial-getty@ttyAMA0.service 2>/dev/null || true
|
||||
# Add the service user to the dialout group (/dev/serial0 access permission)
|
||||
if getent group dialout >/dev/null 2>&1; then
|
||||
sudo usermod -aG dialout "${SERVICE_USER}" || echo "[WARN] usermod failed - check SERVICE_USER=${SERVICE_USER}"
|
||||
fi
|
||||
|
||||
# 3) Python venv + requests + pyserial
|
||||
echo "[3/4] Setting up Python virtual environment..."
|
||||
if [ ! -d venv ]; then
|
||||
python3 -m venv --system-site-packages venv
|
||||
echo " venv created"
|
||||
else
|
||||
echo " venv already exists - skipping"
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
source venv/bin/activate
|
||||
if [ "${OFFLINE}" = "1" ]; then
|
||||
if [ ! -d wheelhouse ]; then
|
||||
echo "[ERROR] Offline mode but wheelhouse/ is missing. Place the vendor.sh output (wheelhouse/) and re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Offline wheel install (--no-index - no network)"
|
||||
pip install --no-index --find-links=wheelhouse -r requirements.txt
|
||||
else
|
||||
echo " Online pip install (internet required)"
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
fi
|
||||
echo " Packages installed"
|
||||
|
||||
# 4) Log file (least privilege: owner-only write)
|
||||
echo "[4/4] Creating log file..."
|
||||
sudo mkdir -p /var/log
|
||||
sudo touch /var/log/pms7003_sensor.log
|
||||
if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/pms7003_sensor.log; then
|
||||
echo "[WARN] chown failed - check whether SERVICE_USER=${SERVICE_USER} exists"
|
||||
fi
|
||||
sudo chmod 640 /var/log/pms7003_sensor.log
|
||||
|
||||
# Lock config_local.py immediately (contains API_KEY - prevent world-readable until harden)
|
||||
# Hardening (harden_pi.sh) tightens it again later, but to avoid exposure even in
|
||||
# that window, lock it to 600 right at install time. Protected regardless of harden.
|
||||
if [ -f config_local.py ]; then
|
||||
chmod 600 config_local.py
|
||||
sudo chown "${SERVICE_USER}:${SERVICE_USER}" config_local.py 2>/dev/null || true
|
||||
echo " config_local.py set to permission 600 (owner ${SERVICE_USER})"
|
||||
fi
|
||||
|
||||
# config_local notice (fail closed - if missing, execution stops immediately)
|
||||
if [ ! -f config_local.py ]; then
|
||||
echo ""
|
||||
echo "[!] config_local.py is missing. Run the following, then fill in real values:"
|
||||
echo " cp config_local.example.py config_local.py"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Install complete! (OFFLINE=${OFFLINE})"
|
||||
echo "=========================================="
|
||||
if [ "${NEED_REBOOT:-0}" = "1" ]; then
|
||||
echo " [!] UART was just enabled / serial console disabled -> reboot to apply: 'sudo reboot'."
|
||||
fi
|
||||
echo " Port check: ls -l /dev/serial0 (should symlink to ttyS0 or ttyAMA0)"
|
||||
echo " Run: source venv/bin/activate && python3 pms7003_sensor.py"
|
||||
echo " Service: see the systemd section in README.md"
|
||||
echo ""
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
# =============================================================================
|
||||
# pms7003_driver.py - PMS7003 UART measurement driver (Raspberry Pi 4, /dev/serial0)
|
||||
#
|
||||
# [Sensor] Plantower PMS7003 laser particulate-matter sensor.
|
||||
# Streams a fixed 32-byte frame over UART (3.3V logic, 9600 8N1). By default the
|
||||
# sensor powers up in ACTIVE mode and pushes frames continuously (~1 frame/sec in
|
||||
# stable mode). We do not switch it to passive mode — we just sync on the header and
|
||||
# read one complete valid frame per measurement, discarding partials/stale bytes.
|
||||
#
|
||||
# [Frame layout] (big-endian words; total 32 bytes)
|
||||
# byte 0 : 0x42 start char 1
|
||||
# byte 1 : 0x4D start char 2
|
||||
# byte 2-3 : frame length = 2*13 + 2 = 28 (0x001C)
|
||||
# byte 4-29 : 13 uint16 data words (big-endian)
|
||||
# data[0..2] = PM1.0 / PM2.5 / PM10, CF=1 (standard particle)
|
||||
# data[3] = PM1.0 atmospheric (bytes 10-11) <- reported
|
||||
# data[4] = PM2.5 atmospheric (bytes 12-13) <- reported
|
||||
# data[5] = PM10 atmospheric (bytes 14-15) <- reported
|
||||
# data[6..11]= particle counts (>0.3/0.5/1.0/2.5/5.0/10 µm)
|
||||
# data[12] = reserved (version / error code)
|
||||
# byte 30-31 : checksum = uint16(sum of bytes 0..29)
|
||||
#
|
||||
# We report the ATMOSPHERIC concentration words (µg/m³) — the value meant for ambient
|
||||
# air-quality reporting (the CF=1 words are for controlled/lab conditions).
|
||||
#
|
||||
# [Simulation] If pyserial is not installed or the UART device is absent (dev PC), it
|
||||
# switches to SIMULATION=True and returns plausible dummy PM readings (for server
|
||||
# integration/format checks) — same pattern as sht30_driver's SMBUS_AVAILABLE fallback.
|
||||
# =============================================================================
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
# ── Constants ────────────────────────────────────────────────────────────────
|
||||
START1 = 0x42 # frame start char 1 ('B')
|
||||
START2 = 0x4D # frame start char 2 ('M')
|
||||
FRAME_LEN = 32 # total frame length (bytes)
|
||||
BODY_LEN_FIELD = 28 # value of the length field (bytes 2-3) = 2*13 data + 2 checksum
|
||||
DATA_WORD_COUNT = 13 # number of uint16 data words (bytes 4..29)
|
||||
|
||||
# Index of the reported ATMOSPHERIC concentration words within the 13-word data array.
|
||||
IDX_PM1_0 = 3 # bytes 10-11
|
||||
IDX_PM2_5 = 4 # bytes 12-13
|
||||
IDX_PM10 = 5 # bytes 14-15
|
||||
|
||||
try:
|
||||
import serial # pyserial (pure Python, arch-independent py3-none-any wheel)
|
||||
SERIAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
SERIAL_AVAILABLE = False
|
||||
|
||||
|
||||
# ── Frame parsing (pure; importable without serial hardware) ─────────────────
|
||||
def parse_frame(frame: bytes):
|
||||
"""Verify + decode a 32-byte PMS7003 frame -> (pm1_0, pm2_5, pm10) ints (µg/m³).
|
||||
|
||||
Raises ValueError on wrong length / bad header / length-field mismatch / checksum
|
||||
mismatch (caller retries/skips). Reports the atmospheric concentration words.
|
||||
"""
|
||||
if len(frame) != FRAME_LEN:
|
||||
raise ValueError(f"frame length error: {len(frame)} (expected {FRAME_LEN})")
|
||||
if frame[0] != START1 or frame[1] != START2:
|
||||
raise ValueError(f"bad header: {frame[0]:#04x} {frame[1]:#04x} "
|
||||
f"(expected {START1:#04x} {START2:#04x})")
|
||||
|
||||
length_field = (frame[2] << 8) | frame[3]
|
||||
if length_field != BODY_LEN_FIELD:
|
||||
raise ValueError(f"frame length field mismatch: {length_field} (expected {BODY_LEN_FIELD})")
|
||||
|
||||
# Checksum = sum of bytes 0..29, compared to the uint16 at bytes 30-31.
|
||||
checksum = sum(frame[0:30]) & 0xFFFF
|
||||
expected = (frame[30] << 8) | frame[31]
|
||||
if checksum != expected:
|
||||
raise ValueError(f"checksum mismatch: {checksum:#06x} (expected {expected:#06x})")
|
||||
|
||||
# Decode the 13 big-endian data words (bytes 4..29).
|
||||
data = [
|
||||
(frame[4 + 2 * i] << 8) | frame[5 + 2 * i]
|
||||
for i in range(DATA_WORD_COUNT)
|
||||
]
|
||||
pm1_0 = data[IDX_PM1_0]
|
||||
pm2_5 = data[IDX_PM2_5]
|
||||
pm10 = data[IDX_PM10]
|
||||
return pm1_0, pm2_5, pm10
|
||||
|
||||
|
||||
class PMS7003:
|
||||
"""PMS7003 UART measurement driver (active mode, frame-synced read).
|
||||
|
||||
port: serial device (Pi 4: /dev/serial0 -> primary UART; also /dev/ttyS0 / /dev/ttyAMA0)
|
||||
baud: 9600 (PMS7003 fixed)
|
||||
simulate: if True, returns dummy values without hardware (forced simulation)
|
||||
read_timeout: pyserial per-read timeout (seconds)
|
||||
frame_wait: max time to wait for a valid frame in one measure() call (seconds)
|
||||
"""
|
||||
|
||||
def __init__(self, port: str = "/dev/serial0", baud: int = 9600,
|
||||
simulate: bool = False, read_timeout: float = 2.0,
|
||||
frame_wait: float = 5.0):
|
||||
self.port = port
|
||||
self.baud = baud
|
||||
self.simulate = simulate or not SERIAL_AVAILABLE
|
||||
self.read_timeout = read_timeout
|
||||
self.frame_wait = frame_wait
|
||||
self._ser = None
|
||||
self._sim_tick = 0
|
||||
|
||||
def open(self):
|
||||
if self.simulate:
|
||||
return
|
||||
# If the UART device is missing (serial console still enabled / UART not enabled),
|
||||
# report a clear, actionable error.
|
||||
try:
|
||||
self._ser = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baud,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
timeout=self.read_timeout,
|
||||
)
|
||||
except (FileNotFoundError, PermissionError, OSError) as e:
|
||||
raise RuntimeError(
|
||||
f"Failed to open UART {self.port}: {e}. "
|
||||
f"Enable UART + disable the serial console via `sudo raspi-config` "
|
||||
f"(Interface Options -> Serial Port), and verify the user's dialout group."
|
||||
) from e
|
||||
# Discard any stale/partial bytes buffered before we started reading.
|
||||
try:
|
||||
self._ser.reset_input_buffer()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
if self._ser is not None:
|
||||
try:
|
||||
self._ser.close()
|
||||
finally:
|
||||
self._ser = None
|
||||
|
||||
def measure(self):
|
||||
"""Read one valid frame -> (pm1_0, pm2_5, pm10) ints. Raises on failure/timeout.
|
||||
|
||||
In simulation mode, returns dummy values in a plausible indoor range.
|
||||
Syncs on the 0x42 0x4D header, reads the remaining 30 bytes, and validates via
|
||||
parse_frame(). Bad/partial frames are discarded and retried until frame_wait elapses.
|
||||
"""
|
||||
if self.simulate:
|
||||
self._sim_tick += 1
|
||||
pm1_0 = 5 + (self._sim_tick % 6) # 5..10
|
||||
pm2_5 = 10 + (self._sim_tick % 11) # 10..20
|
||||
pm10 = 18 + (self._sim_tick % 13) # 18..30
|
||||
return pm1_0, pm2_5, pm10
|
||||
|
||||
if self._ser is None:
|
||||
self.open()
|
||||
|
||||
deadline = time.time() + self.frame_wait
|
||||
last_err = None
|
||||
while time.time() < deadline:
|
||||
# 1) Sync on start char 1.
|
||||
b = self._ser.read(1)
|
||||
if not b:
|
||||
last_err = "read timeout (no data on UART)"
|
||||
continue
|
||||
if b[0] != START1:
|
||||
continue
|
||||
# 2) Expect start char 2 immediately after.
|
||||
b2 = self._ser.read(1)
|
||||
if not b2 or b2[0] != START2:
|
||||
continue
|
||||
# 3) Read the remaining 30 bytes of the frame.
|
||||
rest = self._ser.read(FRAME_LEN - 2)
|
||||
if len(rest) != FRAME_LEN - 2:
|
||||
last_err = "partial frame (short read)"
|
||||
continue
|
||||
frame = bytes([START1, START2]) + rest
|
||||
# 4) Validate + decode (bad checksum -> keep trying within frame_wait).
|
||||
try:
|
||||
return parse_frame(frame)
|
||||
except ValueError as e:
|
||||
last_err = str(e)
|
||||
continue
|
||||
|
||||
raise ValueError(f"no valid PMS7003 frame within {self.frame_wait}s "
|
||||
f"(last: {last_err or 'unknown'})")
|
||||
|
||||
def __enter__(self):
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
return False
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
# =============================================================================
|
||||
# pms7003_sensor.py - PMS7003 particulate-matter monitoring main (Raspberry Pi 4 only)
|
||||
#
|
||||
# [Target hardware] Raspberry Pi 4 Model B (BCM2711, 40-pin GPIO header)
|
||||
# - Plantower PMS7003 laser PM sensor connected to the primary UART.
|
||||
# - PMS7003 TXD -> Pi RXD = GPIO15 (physical 10). VCC=5V (pin 2), GND (pin 6). 3.3V logic, 9600 8N1.
|
||||
#
|
||||
# [Design] PM is a NEW metric on the EXISTING metric pipeline; this module mirrors the SHT30
|
||||
# module (sht30_sensor.py) exactly - measurement loop, startup->periodic, raw-body signing,
|
||||
# POST + retry. There is no STM32 firmware for PMS (Pi-only).
|
||||
# wire.py (build body -> sign) -> wire.pms_event_body / wire.sign_raw
|
||||
# pms7003_driver.py (UART frame read / checksum / decode) -> PMS7003.measure()
|
||||
#
|
||||
# [Operation flow]
|
||||
# 1. Open PMS7003 driver (fall back to simulation on failure/unavailability)
|
||||
# 2. Measure every interval (REPORT_INTERVAL) -> POST to server
|
||||
# 3. Only the "first success" measurement uses event_type="startup", afterwards "periodic"
|
||||
# 4. Measurement failure (comm/checksum) does not crash; log, sleep briefly, then retry
|
||||
# 5. Out of physical range -> metric_status="out_of_range" (server re-evaluates operational thresholds)
|
||||
#
|
||||
# [Auth] The server requires a raw-body HMAC signature (legacy body-sig disabled).
|
||||
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=sent body bytes))
|
||||
# -> Build the body directly and raw-POST it so the signed bytes match the sent bytes.
|
||||
#
|
||||
# [Config/secrets] config.py holds only non-secret defaults; the server URL/API_KEY/device
|
||||
# identifiers are loaded from config_local.py (uncommitted). If missing, fail-closed and abort at startup.
|
||||
# =============================================================================
|
||||
|
||||
import time
|
||||
import math
|
||||
import logging
|
||||
import logging.handlers
|
||||
import signal
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
from config import (
|
||||
SENSOR_ID, SENSOR_NAME,
|
||||
UART_PORT, UART_BAUD, UART_TIMEOUT, FRAME_WAIT_TIMEOUT, FORCE_SIMULATION,
|
||||
API_URL, API_KEY,
|
||||
DEVICE_ID, DEVICE_LOCATION,
|
||||
REPORT_INTERVAL, MEASURE_RETRY_DELAY,
|
||||
PM_VALUE_MIN, PM_VALUE_MAX,
|
||||
APP_VERSION,
|
||||
LOG_FILE, LOG_MAX_BYTES, LOG_BACKUP_COUNT,
|
||||
HTTP_TIMEOUT, HTTP_RETRY_COUNT, HTTP_RETRY_DELAY,
|
||||
)
|
||||
import wire
|
||||
from pms7003_driver import PMS7003, SERIAL_AVAILABLE
|
||||
|
||||
|
||||
# -----------------------------------------------
|
||||
# Logger setup
|
||||
# -----------------------------------------------
|
||||
def setup_logger():
|
||||
logger = logging.getLogger("pms7003_sensor")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
fmt = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
|
||||
try:
|
||||
fh = logging.handlers.RotatingFileHandler(
|
||||
LOG_FILE, maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUP_COUNT,
|
||||
encoding="utf-8"
|
||||
)
|
||||
fh.setFormatter(fmt)
|
||||
logger.addHandler(fh)
|
||||
except PermissionError:
|
||||
pass # No permission -> console only
|
||||
|
||||
ch = logging.StreamHandler(sys.stdout)
|
||||
ch.setFormatter(fmt)
|
||||
logger.addHandler(ch)
|
||||
|
||||
return logger
|
||||
|
||||
log = setup_logger()
|
||||
|
||||
|
||||
# -----------------------------------------------
|
||||
# Shutdown signal handling (systemd stop = SIGTERM clean shutdown)
|
||||
# -----------------------------------------------
|
||||
class StopFlag:
|
||||
def __init__(self):
|
||||
self.stop = False
|
||||
|
||||
def request(self, signum, frame):
|
||||
self.stop = True
|
||||
log.info(f"[SIGNAL] {signal.Signals(signum).name} received -> preparing to shut down")
|
||||
|
||||
stop_flag = StopFlag()
|
||||
|
||||
|
||||
# -----------------------------------------------
|
||||
# PM physical range classification (mirrors the SHT30 classify_metric_status)
|
||||
# The server does not trust this value and re-evaluates with operational thresholds, so
|
||||
# here we only check sensor physical plausibility (range / NaN / Inf).
|
||||
# -----------------------------------------------
|
||||
def classify_metric_status(pm1_0, pm2_5, pm10) -> str:
|
||||
for v in (pm1_0, pm2_5, pm10):
|
||||
if not math.isfinite(v):
|
||||
return "out_of_range"
|
||||
if v < PM_VALUE_MIN or v > PM_VALUE_MAX:
|
||||
return "out_of_range"
|
||||
return "normal"
|
||||
|
||||
|
||||
# -----------------------------------------------
|
||||
# Server reporting (raw-body + X-Signature HMAC)
|
||||
# -----------------------------------------------
|
||||
def post_reading(event_type: str, pm1_0: int, pm2_5: int, pm10: int, metric_status: str) -> bool:
|
||||
"""POST the PMS7003 measurement to the server. Returns True on success (HTTP 200).
|
||||
|
||||
Build the body directly, sign those bytes, and send those exact bytes
|
||||
(guarantees signed bytes == sent bytes -> matches the server's raw-body verification).
|
||||
"""
|
||||
timestamp = int(time.time())
|
||||
body = wire.pms_event_body(
|
||||
device_id=DEVICE_ID,
|
||||
device_location=DEVICE_LOCATION,
|
||||
sensor_id=SENSOR_ID,
|
||||
sensor_name=SENSOR_NAME,
|
||||
event_type=event_type,
|
||||
timestamp=timestamp,
|
||||
pm1_0=pm1_0,
|
||||
pm2_5=pm2_5,
|
||||
pm10=pm10,
|
||||
metric_status=metric_status,
|
||||
app_version=APP_VERSION,
|
||||
)
|
||||
body_bytes = body.encode("utf-8")
|
||||
signature = wire.sign_raw(API_KEY, body)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Signature": signature,
|
||||
"X-Device-Id": DEVICE_ID,
|
||||
"User-Agent": "PMS7003Sensor/2.0",
|
||||
}
|
||||
|
||||
for attempt in range(1, HTTP_RETRY_COUNT + 1):
|
||||
try:
|
||||
# Send the raw body as-is via data=body_bytes (do not re-serialize with json=).
|
||||
resp = requests.post(
|
||||
API_URL, data=body_bytes, headers=headers, timeout=HTTP_TIMEOUT
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
log.info(f"[POST OK] {event_type} PM1.0={pm1_0} PM2.5={pm2_5} PM10={pm10} ug/m3 "
|
||||
f"status={metric_status} -> {resp.text[:100]}")
|
||||
return True
|
||||
log.warning(f"[POST {resp.status_code}] attempt {attempt}/{HTTP_RETRY_COUNT}: "
|
||||
f"{resp.text[:160]}")
|
||||
except requests.exceptions.Timeout:
|
||||
log.warning(f"[POST TIMEOUT] attempt {attempt}/{HTTP_RETRY_COUNT}")
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
log.warning(f"[POST CONN ERR] attempt {attempt}/{HTTP_RETRY_COUNT}: {e}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
log.warning(f"[POST ERR] attempt {attempt}/{HTTP_RETRY_COUNT}: {e}")
|
||||
|
||||
if attempt < HTTP_RETRY_COUNT and not stop_flag.stop:
|
||||
time.sleep(HTTP_RETRY_DELAY)
|
||||
|
||||
log.error(f"[POST FAIL] all {HTTP_RETRY_COUNT} attempts failed (event={event_type})")
|
||||
return False
|
||||
|
||||
|
||||
# -----------------------------------------------
|
||||
# Measurement loop (mirrors the SHT30 measure loop)
|
||||
# -----------------------------------------------
|
||||
def measure_loop(sensor: PMS7003):
|
||||
# Only the first successful measurement is startup, afterwards periodic (server records startup once in leak_sensor_log)
|
||||
first_success = True
|
||||
|
||||
log.info(f"[LOOP] measurement started (interval={REPORT_INTERVAL}s, port={UART_PORT}, baud={UART_BAUD})")
|
||||
|
||||
while not stop_flag.stop:
|
||||
try:
|
||||
pm1_0, pm2_5, pm10 = sensor.measure()
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
# Measurement failure (comm/checksum): no crash. Log, sleep briefly, then retry.
|
||||
log.error(f"[MEASURE FAIL] {e} -> retry after {MEASURE_RETRY_DELAY}s")
|
||||
_interruptible_sleep(MEASURE_RETRY_DELAY)
|
||||
continue
|
||||
|
||||
metric_status = classify_metric_status(pm1_0, pm2_5, pm10)
|
||||
event_type = "startup" if first_success else "periodic"
|
||||
|
||||
log.info(f"[MEASURE] {event_type}: PM1.0={pm1_0} PM2.5={pm2_5} PM10={pm10} ug/m3 status={metric_status}")
|
||||
|
||||
ok = post_reading(event_type, pm1_0, pm2_5, pm10, metric_status)
|
||||
if ok and first_success:
|
||||
# Switch startup->periodic only after the first successful report (first report is always startup)
|
||||
first_success = False
|
||||
|
||||
# Wait for the normal report interval (wakes immediately on a shutdown signal)
|
||||
_interruptible_sleep(REPORT_INTERVAL)
|
||||
|
||||
|
||||
def _interruptible_sleep(seconds: float):
|
||||
"""Sleep in 1-second chunks to react quickly to shutdown signals."""
|
||||
end = time.time() + seconds
|
||||
while not stop_flag.stop and time.time() < end:
|
||||
time.sleep(min(1.0, end - time.time()))
|
||||
|
||||
|
||||
# -----------------------------------------------
|
||||
# Main
|
||||
# -----------------------------------------------
|
||||
def main():
|
||||
signal.signal(signal.SIGTERM, stop_flag.request)
|
||||
signal.signal(signal.SIGINT, stop_flag.request)
|
||||
|
||||
simulate = FORCE_SIMULATION or not SERIAL_AVAILABLE
|
||||
log.info("=" * 60)
|
||||
log.info(f"Particulate-matter (PMS7003) monitoring v2 (Pi 4) starting")
|
||||
log.info(f"device={DEVICE_ID} | location={DEVICE_LOCATION} | sensor={SENSOR_NAME}(id={SENSOR_ID})")
|
||||
log.info(f"API={API_URL}")
|
||||
log.info(f"mode={'simulation' if simulate else f'real UART (port={UART_PORT}, baud={UART_BAUD})'}")
|
||||
if simulate and not FORCE_SIMULATION:
|
||||
log.warning("pyserial not installed / UART unavailable -> simulation mode. For real readings, run install.sh then restart.")
|
||||
log.info("=" * 60)
|
||||
|
||||
sensor = PMS7003(port=UART_PORT, baud=UART_BAUD, simulate=simulate,
|
||||
read_timeout=UART_TIMEOUT, frame_wait=FRAME_WAIT_TIMEOUT)
|
||||
try:
|
||||
sensor.open()
|
||||
except RuntimeError as e:
|
||||
log.error(f"[INIT] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
measure_loop(sensor)
|
||||
except Exception as e: # noqa: BLE001 - top-level safety net (log then exit)
|
||||
log.exception(f"[MAIN] exception occurred: {e}")
|
||||
raise
|
||||
finally:
|
||||
sensor.close()
|
||||
log.info("[MAIN] shutdown complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
# Pi 4 particulate-matter (PMS7003) module Python dependencies (exact version pins for air-gapped networks)
|
||||
# Offline install: pre-download into wheelhouse/ with vendor.sh, then
|
||||
# pip install --no-index --find-links=wheelhouse -r requirements.txt
|
||||
#
|
||||
# pyserial is pure Python (arch-independent py3-none-any wheel), so even if fetched on an x86 build
|
||||
# machine it runs as-is on Pi 4 (aarch64). requests is version-pinned as well.
|
||||
requests==2.32.3
|
||||
pyserial==3.5
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
# =============================================================================
|
||||
# /etc/systemd/system/pms7003-sensor.service
|
||||
# Service that auto-runs the particulate-matter (PMS7003) monitor on Raspberry Pi 4 boot
|
||||
#
|
||||
# Installation:
|
||||
# sudo cp systemd/pms7003-sensor.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable pms7003-sensor
|
||||
# sudo systemctl start pms7003-sensor
|
||||
# sudo systemctl status pms7003-sensor
|
||||
#
|
||||
# * Adjust the WorkingDirectory/ExecStart paths to match the actual deployment path.
|
||||
# The example below assumes deployment under /home/pi/raspberry_pi4_pms7003.
|
||||
# =============================================================================
|
||||
|
||||
[Unit]
|
||||
Description=PMS7003 Particulate-Matter Sensor Monitor v2 (Pi 4)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
Group=pi
|
||||
# dialout group required to access /dev/serial0 (the primary UART)
|
||||
SupplementaryGroups=dialout
|
||||
WorkingDirectory=/home/pi/raspberry_pi4_pms7003
|
||||
ExecStart=/home/pi/raspberry_pi4_pms7003/venv/bin/python pms7003_sensor.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=pms7003-sensor
|
||||
|
||||
# Environment variables
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
# Process-private /tmp isolation
|
||||
PrivateTmp=true
|
||||
|
||||
# -- Sandboxing (hardening) -- blocks root escalation and filesystem tampering if the daemon is compromised --------
|
||||
# User=pi is a NOPASSWD sudo account, so NoNewPrivileges blocks the setuid(sudo) escalation path.
|
||||
NoNewPrivileges=true
|
||||
# Root filesystem read-only + only /var/log writable (including log rotation). /tmp is PrivateTmp.
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/log
|
||||
# /home is read-only (reads own code/config_local.py only; blocks writes and access to other accounts' homes).
|
||||
ProtectHome=read-only
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
RestrictRealtime=true
|
||||
# HTTPS(POST) + local sockets (DNS/journal) only. UART is unaffected since /dev/serial0 is a char device (not a socket).
|
||||
# AF_NETLINK required -- glibc getaddrinfo/NSS uses netlink to enumerate local addresses
|
||||
# (omitting it makes DNS resolution intermittently fail -> server POST impossible). AF_INET/6=HTTPS, AF_UNIX=DNS/journal.
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
# * Do not use PrivateDevices -- it hides /dev/serial0 and makes the sensor inaccessible.
|
||||
|
||||
# Restart cap (stop after 5 failures within 10 minutes)
|
||||
StartLimitBurst=5
|
||||
StartLimitInterval=600s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Server connection test — run on the Pi 4 PMS7003 (미세먼지) module.
|
||||
|
||||
Builds the body with wire.py, attaches a raw-body HMAC (X-Signature), and
|
||||
actually sends one signed POST. Secrets (API_URL/API_KEY) are loaded from config_local.py.
|
||||
The combined-node setup.sh gates hardening on this script's exit code (0 = signed 200).
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
|
||||
import requests
|
||||
|
||||
signed_ok = False # whether the signed POST returned 200 — setup.sh gates hardening on this exit code
|
||||
|
||||
from config import (
|
||||
API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION,
|
||||
SENSOR_ID, SENSOR_NAME, APP_VERSION,
|
||||
)
|
||||
import wire
|
||||
|
||||
print("=" * 50)
|
||||
print(" Server connection test (Pi 4 PMS7003 / 미세먼지 module)")
|
||||
print("=" * 50)
|
||||
print(f" API_URL = {API_URL}")
|
||||
|
||||
# ── Step 1: check endpoint reachability with curl (no signature → 403 auth failure is expected) ──
|
||||
print("\n[1/2] Checking sensor_data.php reachability with curl (no signature → 403 auth failure is expected)...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"-A", "PMS7003Sensor/2.0", "-X", "POST", API_URL,
|
||||
"-H", "Content-Type: application/json", "-d", "{}"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
code = result.stdout.strip()
|
||||
print(f" HTTP status code: {code}")
|
||||
if code in ("400", "403"):
|
||||
print(" → Endpoint reachable OK (rejected without signature/fields — expected)")
|
||||
elif code == "200":
|
||||
print(" → 200? Server accepted without a signature — check server configuration")
|
||||
elif code == "404":
|
||||
print(" → 404: check the API_URL path")
|
||||
else:
|
||||
print(f" → Response: {code} (check hosting/firewall/.htaccess)")
|
||||
except Exception as e:
|
||||
print(f" → curl failed: {e}")
|
||||
|
||||
# ── Step 2: real measurement POST signed with wire.py (startup, dummy PM values) ──
|
||||
print("\n[2/2] Signed measurement POST (startup, dummy PM1.0=8 PM2.5=12 PM10=18)...")
|
||||
try:
|
||||
ts = int(time.time())
|
||||
body = wire.pms_event_body(
|
||||
DEVICE_ID, DEVICE_LOCATION, SENSOR_ID, SENSOR_NAME,
|
||||
"startup", ts, 8, 12, 18, "normal", APP_VERSION,
|
||||
)
|
||||
sig = wire.sign_raw(API_KEY, body)
|
||||
print(f" Body: {body}")
|
||||
print(f" X-Signature: {sig[:16]}...")
|
||||
resp = requests.post(
|
||||
API_URL, data=body.encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Signature": sig,
|
||||
"X-Device-Id": DEVICE_ID,
|
||||
"User-Agent": "PMS7003Sensor/2.0",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
print(f" HTTP status code: {resp.status_code}")
|
||||
print(f" Response body: {resp.text[:200]}")
|
||||
if resp.status_code == 200:
|
||||
signed_ok = True
|
||||
print(f" → Success! Signature verified, measurement stored (check dashboard sensor_id={SENSOR_ID})")
|
||||
elif resp.status_code == 403:
|
||||
print(" → 403 auth failure: API_KEY differs from the server's config.local.php, or signature mismatch")
|
||||
elif resp.status_code == 400:
|
||||
print(" → 400: check timestamp freshness (±600s) — verify Pi clock (NTP) sync")
|
||||
else:
|
||||
print(f" → Status code: {resp.status_code}")
|
||||
except ImportError:
|
||||
print(" → requests not installed. Run install.sh or sudo apt install python3-requests")
|
||||
except Exception as e:
|
||||
print(f" → Failed: {e}")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(" Test complete" + ("" if signed_ok else " — signed POST failed (see log above)"))
|
||||
print("=" * 50)
|
||||
sys.exit(0 if signed_ok else 1)
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
# =============================================================================
|
||||
# tests/test_pms_wire_and_frame.py - pure unit tests for the PMS7003 wire format + frame parser
|
||||
#
|
||||
# These tests must pass WITHOUT any serial hardware and WITHOUT config_local.py
|
||||
# (they import only wire.py and pms7003_driver.py, whose pyserial import is guarded).
|
||||
#
|
||||
# Coverage:
|
||||
# (a) wire.pms_event_body() produces the exact fixed-order contract byte string
|
||||
# (b) wire.sign_raw() matches a known HMAC-SHA256 digest
|
||||
# (c) pms7003_driver.parse_frame() decodes a hand-built valid 32-byte frame and
|
||||
# rejects a corrupted (bad-checksum) frame
|
||||
#
|
||||
# Runs standalone (python tests/test_pms_wire_and_frame.py) or under pytest.
|
||||
# =============================================================================
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Make the module importable when run from anywhere (tests/ is a subdirectory).
|
||||
_MODULE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _MODULE_DIR not in sys.path:
|
||||
sys.path.insert(0, _MODULE_DIR)
|
||||
|
||||
import wire
|
||||
from pms7003_driver import parse_frame, START1, START2, BODY_LEN_FIELD, FRAME_LEN
|
||||
|
||||
|
||||
# ── Fixtures / helpers ───────────────────────────────────────────────────────
|
||||
# Fixed known inputs (shared with the "known HMAC" expectation below).
|
||||
_KNOWN_ARGS = ("dev", "loc", 3, "s3", "periodic", 1700000000, 8, 12, 18, "normal", "vX")
|
||||
|
||||
# The exact contract byte string these inputs must serialize to.
|
||||
_EXPECTED_BODY = (
|
||||
'{"device_id":"dev","device_location":"loc","sensor_id":3,'
|
||||
'"sensor_name":"s3","event_type":"periodic","timestamp":1700000000,'
|
||||
'"metric_type":"pms7003","pm1_0":8,"pm2_5":12,"pm10":18,'
|
||||
'"metric_status":"normal","app_version":"vX"}'
|
||||
)
|
||||
|
||||
# HMAC-SHA256(key="test", msg=_EXPECTED_BODY) — precomputed known-answer.
|
||||
_KNOWN_KEY = "test"
|
||||
_KNOWN_SIG = "96f3e8cdfcc6e48545806a9562554b26f18313ffa247a5b1e449c164256526dd"
|
||||
|
||||
|
||||
def build_frame(words, checksum=None):
|
||||
"""Build a 32-byte PMS7003 frame from 13 data words.
|
||||
|
||||
If checksum is None, compute the correct checksum (sum of bytes 0..29);
|
||||
pass an explicit value to force a bad checksum.
|
||||
"""
|
||||
assert len(words) == 13
|
||||
frame = bytearray([START1, START2, (BODY_LEN_FIELD >> 8) & 0xFF, BODY_LEN_FIELD & 0xFF])
|
||||
for w in words:
|
||||
frame += bytes([(w >> 8) & 0xFF, w & 0xFF])
|
||||
chk = (sum(frame[0:30]) & 0xFFFF) if checksum is None else (checksum & 0xFFFF)
|
||||
frame += bytes([(chk >> 8) & 0xFF, chk & 0xFF])
|
||||
return bytes(frame)
|
||||
|
||||
|
||||
# ── (a) body byte-string is exactly the contract string ──────────────────────
|
||||
def test_pms_event_body_exact_string():
|
||||
body = wire.pms_event_body(*_KNOWN_ARGS)
|
||||
assert body == _EXPECTED_BODY, f"body mismatch:\n got={body!r}\n want={_EXPECTED_BODY!r}"
|
||||
# Bare integers (no quotes, no decimals) for pm values.
|
||||
assert '"pm1_0":8,' in body
|
||||
assert '"pm2_5":12,' in body
|
||||
assert '"pm10":18,' in body
|
||||
assert '"metric_type":"pms7003"' in body
|
||||
# The signed/sent bytes are the UTF-8 encoding of this exact string.
|
||||
assert body.encode("utf-8") == _EXPECTED_BODY.encode("utf-8")
|
||||
|
||||
|
||||
# ── (b) sign_raw matches a known HMAC ────────────────────────────────────────
|
||||
def test_sign_raw_known_hmac():
|
||||
body = wire.pms_event_body(*_KNOWN_ARGS)
|
||||
sig = wire.sign_raw(_KNOWN_KEY, body)
|
||||
assert sig == _KNOWN_SIG, f"signature mismatch:\n got={sig}\n want={_KNOWN_SIG}"
|
||||
assert len(sig) == 64 and all(c in "0123456789abcdef" for c in sig)
|
||||
|
||||
|
||||
# ── (c) frame parser: valid frame decodes; bad checksum rejected ─────────────
|
||||
def test_parse_frame_valid():
|
||||
# data[3..5] = atmospheric PM1.0/PM2.5/PM10 = 8/12/18 (the reported words).
|
||||
words = [6, 10, 15, 8, 12, 18, 500, 140, 30, 5, 1, 0, 0]
|
||||
frame = build_frame(words)
|
||||
assert len(frame) == FRAME_LEN
|
||||
assert parse_frame(frame) == (8, 12, 18)
|
||||
|
||||
|
||||
def test_parse_frame_bad_checksum_rejected():
|
||||
words = [6, 10, 15, 8, 12, 18, 500, 140, 30, 5, 1, 0, 0]
|
||||
good = build_frame(words)
|
||||
# Corrupt a data byte without fixing the checksum → must raise ValueError.
|
||||
bad = bytearray(good)
|
||||
bad[10] ^= 0xFF
|
||||
try:
|
||||
parse_frame(bytes(bad))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("parse_frame accepted a corrupted (bad-checksum) frame")
|
||||
|
||||
|
||||
def test_parse_frame_bad_header_rejected():
|
||||
words = [6, 10, 15, 8, 12, 18, 500, 140, 30, 5, 1, 0, 0]
|
||||
good = bytearray(build_frame(words))
|
||||
good[0] = 0x00 # break the 0x42 start byte
|
||||
try:
|
||||
parse_frame(bytes(good))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("parse_frame accepted a frame with a bad header")
|
||||
|
||||
|
||||
def test_parse_frame_wrong_length_rejected():
|
||||
try:
|
||||
parse_frame(b"\x42\x4d\x00\x1c") # far too short
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("parse_frame accepted a wrong-length frame")
|
||||
|
||||
|
||||
# ── Standalone runner (no pytest required) ───────────────────────────────────
|
||||
def _run():
|
||||
tests = [
|
||||
test_pms_event_body_exact_string,
|
||||
test_sign_raw_known_hmac,
|
||||
test_parse_frame_valid,
|
||||
test_parse_frame_bad_checksum_rejected,
|
||||
test_parse_frame_bad_header_rejected,
|
||||
test_parse_frame_wrong_length_rejected,
|
||||
]
|
||||
failed = 0
|
||||
for t in tests:
|
||||
try:
|
||||
t()
|
||||
print(f" [OK ] {t.__name__}")
|
||||
except AssertionError as e:
|
||||
failed += 1
|
||||
print(f" [FAIL] {t.__name__}: {e}")
|
||||
print("=" * 50)
|
||||
print(f" Result: {'ALL PASSED' if failed == 0 else str(failed) + ' FAILED'} ({len(tests)} tests)")
|
||||
return failed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 50)
|
||||
print(" PMS7003 wire/frame self-check")
|
||||
print("=" * 50)
|
||||
sys.exit(1 if _run() else 0)
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# vendor.sh - Pre-download dependencies for air-gapped install (PMS7003 particulate-matter module)
|
||||
# Run this on a build/staging machine that has internet access.
|
||||
# Outputs: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) -> transfer to the target Pi 4.
|
||||
#
|
||||
# requests has few arch dependencies, and pyserial is pure Python (py3-none-any), so it works
|
||||
# on the Pi 4 no matter which build machine downloads it. Pin wheels to the target
|
||||
# (Bookworm 64-bit, aarch64, Python 3.11). The .deb files are safest downloaded on the target OS/arch.
|
||||
# Env vars: TARGET_PLATFORM (default manylinux2014_aarch64), TARGET_PYVER (default 311)
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TARGET_PLATFORM="${TARGET_PLATFORM:-manylinux2014_aarch64}"
|
||||
TARGET_PYVER="${TARGET_PYVER:-311}"
|
||||
TARGET_ABI="${TARGET_ABI:-cp${TARGET_PYVER}}"
|
||||
|
||||
echo "[vendor] (1/2) Python wheel -> wheelhouse/ (platform=${TARGET_PLATFORM}, py=${TARGET_PYVER})"
|
||||
mkdir -p wheelhouse
|
||||
# requests and other arch-dependent wheels: pin the explicit target platform.
|
||||
python3 -m pip download -r requirements.txt -d wheelhouse \
|
||||
--only-binary=:all: \
|
||||
--platform "${TARGET_PLATFORM}" \
|
||||
--python-version "${TARGET_PYVER}" \
|
||||
--implementation cp \
|
||||
--abi "${TARGET_ABI}" \
|
||||
|| echo "[WARN] Target-platform pin failed for some wheels - pure-Python (any) fallback is downloaded below."
|
||||
# pyserial (py3-none-any) and other pure-Python wheels plus sdist fallback.
|
||||
python3 -m pip download -r requirements.txt -d wheelhouse \
|
||||
|| echo "[WARN] Some sdist/any fallbacks failed (safe to ignore if all binary wheels are present)"
|
||||
WHEEL_COUNT="$(ls -A wheelhouse 2>/dev/null | wc -l)"
|
||||
echo " wheelhouse file count: ${WHEEL_COUNT}"
|
||||
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse is empty"; exit 1; }
|
||||
|
||||
echo "[vendor] (2/2) apt .deb -> debs/"
|
||||
mkdir -p debs
|
||||
# nftables and unattended-upgrades are included too -> harden_pi.sh can run offline in the air-gapped env.
|
||||
BASE_PKGS="python3-requests python3-venv nftables unattended-upgrades"
|
||||
DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
|
||||
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
|
||||
| awk '/^\w/{print $1}' | sort -u)"
|
||||
( cd debs && apt-get download ${BASE_PKGS} ${DEPS} ) 2>/dev/null \
|
||||
|| echo "[WARN] Some .deb downloads failed (may be virtual/transitive deps - required packages are verified below)"
|
||||
|
||||
MISS=0
|
||||
for p in ${BASE_PKGS}; do
|
||||
if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then
|
||||
echo "[ERROR] Required package .deb missing: ${p}"
|
||||
MISS=1
|
||||
fi
|
||||
done
|
||||
[ "${MISS}" = 0 ] || { echo "[ERROR] Required .deb missing - you must run vendor.sh on the target arch (aarch64 Bookworm)."; exit 1; }
|
||||
echo " debs file count: $(ls debs/*.deb 2>/dev/null | wc -l) (all required packages verified)"
|
||||
|
||||
# Real architecture check - verify the target (arm64/all) via the .deb's internal Architecture field, not the filename.
|
||||
# (If the host is amd64, apt-get download fetches amd64 .deb files, and dpkg on the target Pi
|
||||
# breaks with "wrong architecture". A filename-only check can't catch this.)
|
||||
BAD_ARCH=0
|
||||
for deb in debs/*.deb; do
|
||||
[ -e "$deb" ] || continue
|
||||
arch="$(dpkg-deb --field "$deb" Architecture 2>/dev/null || echo '?')"
|
||||
case "$arch" in
|
||||
arm64|all) : ;;
|
||||
*) echo "[ERROR] arch mismatch: $(basename "$deb") = ${arch} (not arm64/all)"; BAD_ARCH=1 ;;
|
||||
esac
|
||||
done
|
||||
if [ "${BAD_ARCH}" != 0 ]; then
|
||||
echo "[ERROR] There are .deb files that are not the target (aarch64/arm64). host arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2
|
||||
echo " You must run vendor.sh on an arm64 Pi (or an arm64 chroot/qemu)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " arch check passed (all .deb = arm64/all)"
|
||||
|
||||
echo "[vendor] Done. Transfer wheelhouse/ and debs/ to the target Pi 4, then run install.sh."
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue