Compare commits

..

No commits in common. "6da90ccb36457443a3986ddd13d2967a7abec17c" and "de51d1c3d45714678f356aafbda26196db4e3fcc" have entirely different histories.

138 changed files with 2840 additions and 16587 deletions

6
.gitignore vendored
View file

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

View file

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

View file

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

View file

@ -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)
#######################################

View file

@ -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=*

View file

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

View file

@ -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

View file

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

View file

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

View file

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

View file

@ -1,32 +1,32 @@
# 서버실 온습도(SHT30) 모니터링 시스템 v2606 # 서버실 온습도(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 ```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] [MySQL DB]
| |
[Cafe24 SMS / Dashboard] [Cafe24 SMS / Dashboard]
``` ```
- Raspberry Pi 4 노드 `raspberry_pi4_sht30`(= `deploy/03_pi_b_sht30_id2`) — `sensor_id=2`, `device_id=rpi4-sht30-01`. - 단일 보드 `sht30_fw` (`sensor_id=2`, `device_id=stm32-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).) - 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분(`raspberry_pi4_sht30/config.py REPORT_INTERVAL=300`). 최초 1회 startup, 이후 periodic. - 측정 주기: 5분(펌웨어 `APP_SHT30_REPORT_INTERVAL_SEC=300`). 최초 1회 startup, 이후 periodic.
- 인증: Pi 가 `X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`(raw-body) 헤더로 서명하고 서버가 검증합니다. 관리자 로그인은 비밀번호 해시 + TOTP MFA + 감사로그로 보호합니다. - 인증: 펌웨어가 `X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`(raw-body) 헤더로 서명하고 서버가 검증합니다. 관리자 로그인은 비밀번호 해시 + TOTP MFA + 감사로그로 보호합니다.
## 폴더 구조 ## 폴더 구조
```text ```text
docs/ docs/
README.md # 설치/운영 허브 문서 README.md # 설치/운영 허브 문서
INSTALL_PI_SERVER.md # Cafe24 서버 설치 설명서 (Pi/STM32 공통) INSTALL_PI_SERVER.md # STM32 펌웨어 및 Cafe24 서버 설치 설명서
OPERATIONS_SECURITY_CHECKLIST.md # 월간 운영·보안 점검표 OPERATIONS_SECURITY_CHECKLIST.md # 월간 운영·보안 점검표
SECURITY_EVIDENCE_PACKAGE.md # 보안대책서 첨부자료 패키지 SECURITY_EVIDENCE_PACKAGE.md # 보안대책서 첨부자료 패키지
wiring_diagram.md # SHT30 I2C 배선도 wiring_diagram.md # SHT30 I2C 배선도
SHT30_SENSOR2_SETUP.md # SHT30 설치 가이드(Raspberry Pi 4 기본 / STM32 마이그레이션 대안) SHT30_SENSOR2_SETUP.md # SHT30 설치 가이드(STM32 기본 / RPi 대안)
solutions/ # 실행-검토 사이클에서 얻은 교훈/반복 방지 기록 solutions/ # 실행-검토 사이클에서 얻은 교훈/반복 방지 기록
firmware/ # STM32F407VGT6 베어메탈 펌웨어 (sht30_fw) firmware/ # STM32F407VGT6 베어메탈 펌웨어 (sht30_fw)
common/ # 이식 코어(sha256/sig/jsonbody/sht30_convert/httpapi/net 등) common/ # 이식 코어(sha256/sig/jsonbody/sht30_convert/httpapi/net 등)

View file

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

View file

@ -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/...`)을 고친 뒤 이 폴더로 다시 복사해서 커밋.
(전역이 원본, 이 폴더는 배포용 사본.)

View file

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

View file

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

View file

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

View file

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

View file

@ -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 는 예시·문서만, 실값 라인은 제시(에이전트 미커밋). ✓

View file

@ -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).

View file

@ -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은 하드웨어 도착 후.

View file

@ -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 유지).
- 플래핑 횟수 기반 별도 억제(디바운스로 충분).

View file

@ -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분 복구 지연(리포트 굶주림) 수정 — 별개 이슈(이번 범위 밖).

View file

@ -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/서버 코드 구조는 그대로.

View file

@ -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 기반 자동 카메라 캡처 트리거 — 별개 이슈(이번 범위 밖).
- 카메라 조명 밝기 제어/스위칭 — 상시점등이라 불필요.

View file

@ -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에 연결합니다: 상세 핀맵·전원·클럭원은 [firmware/docs/HARDWARE.md](../firmware/docs/HARDWARE.md) §3을 따릅니다. 본 문서는 SHT30 I2C 결선 요약입니다.
> **VCC→3.3V(핀 1), GND→핀 6, SDA→GPIO2/핀 3, SCL→GPIO3/핀 5, ADDR→GND(0x44)**, SDA/SCL 4.7kΩ 풀업→3.3V(모듈 내장 시 생략).
> 상세 도해는 `deploy/03_pi_b_sht30_id2/WIRING.html`(SVG 핀맵) 및 [SHT30_SENSOR2_SETUP.md](./SHT30_SENSOR2_SETUP.md) §2.1 을 보세요.
> `sensor_id=2`, `device_id=rpi4-sht30-01`, `raspberry_pi4_sht30/config.py REPORT_INTERVAL=300`(5분).
>
> **아래 §는 STM32F407VGT6 변형**(마이그레이션 트랙 `firmware/`, 하드웨어 미검증·현재 배포와 무관)의 I2C1(PB6/PB7) 배선입니다. 상세 핀맵·전원·클럭원은 [firmware/docs/HARDWARE.md](../firmware/docs/HARDWARE.md) §3.
## 센서 동작 개요 ## 센서 동작 개요

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,15 +7,10 @@ $time = date('c');
?> ?>
<!doctype html><html lang="ko"><head> <!doctype html><html lang="ko"><head>
<meta charset="utf-8"><title>접근 거부</title> <meta charset="utf-8"><title>접근 거부</title>
<link rel="stylesheet" href="assets/app.css">
<meta name="robots" content="noindex"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="robots" content="noindex"><meta name="viewport" content="width=device-width,initial-scale=1">
<style> <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">
body { min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4); }
.blocked-card { text-align: center; }
</style>
</head><body><div class="login-box blocked-card">
<h1>접근이 거부되었습니다</h1> <h1>접근이 거부되었습니다</h1>
<p class="sub">관리자에게 연락하세요.</p> <p>관리자에게 연락하세요.</p>
<p class="caption">요청 ID: <code><?=htmlspecialchars($id)?></code></p> <p class="small">요청 ID: <code><?=htmlspecialchars($id)?></code></p>
</div></body></html> </div></body></html>

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

@ -162,27 +162,62 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$is_locked && !$configuration_erro
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그인 - 누수감지 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title> <title>로그인 - 누수감지 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title>
<link rel="stylesheet" href="assets/app.css">
<style> <style>
/* 로그인 페이지 전용: 카드 화면 중앙 정렬 (그 외 크롬/폼 스타일은 app.css) */ * { box-sizing: border-box; margin: 0; padding: 0; }
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: var(--sp-4); } 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> </style>
</head> </head>
<body> <body>
<main class="login-box"> <div class="login-box">
<h1>누수감지 모니터링 <?= htmlspecialchars(APP_VERSION) ?></h1> <h1>누수감지 모니터링 <?= htmlspecialchars(APP_VERSION) ?></h1>
<p class="sub">대시보드에 접근하려면 로그인하세요.</p> <p class="sub">대시보드에 접근하려면 로그인하세요.</p>
<?php if ($expired): ?> <?php if ($expired): ?>
<div class="expired" role="status">세션이 만료되었습니다. 다시 로그인해주세요.</div> <div class="expired">세션이 만료되었습니다. 다시 로그인해주세요.</div>
<?php endif; ?> <?php endif; ?>
<?php if ($error): ?> <?php if ($error): ?>
<div class="error" role="alert"><?= htmlspecialchars($error) ?></div> <div class="error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($configuration_error): ?> <?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; ?> <?php endif; ?>
<form method="POST" action=""> <form method="POST" action="">
@ -205,6 +240,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$is_locked && !$configuration_erro
</div> </div>
<button type="submit" class="btn" <?= ($is_locked || $configuration_error) ? 'disabled' : '' ?>>로그인</button> <button type="submit" class="btn" <?= ($is_locked || $configuration_error) ? 'disabled' : '' ?>>로그인</button>
</form> </form>
</main> </div>
</body> </body>
</html> </html>

View file

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

View file

@ -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 실패·임계 경보·오프라인 없음';
}

View file

@ -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 &amp; SIGN-OFF</span></div>
<div class="basis-grid">
<div class="basis-block">
<h3>판정 · 임계 기준</h3>
<ul>
<li><b>임계</b> 고온 <?= $h(rc_fmt((float)METRIC_TEMP_HIGH_C)) ?>℃ · 저온 <?= $h(rc_fmt((float)METRIC_TEMP_LOW_C)) ?>℃ · 고습 <?= $h(rc_fmt((float)METRIC_RH_HIGH)) ?>% · 저습 <?= $h($rh_low_txt) ?></li>
<li><b>판정 규칙</b> 미복구 누수 또는 SMS 실패 &gt; 0 → 경보 · 임계 경보 또는 오프라인 발생 → 주의 · 그 외 → 안정</li>
<li><b>오프라인 판정</b> <?= (int)(HEARTBEAT_TIMEOUT_SEC / 60) ?>분 무수신 · <b>경보 쿨다운</b> 온습도 <?= (int)(METRIC_ALERT_COOLDOWN_SEC / 60) ?>분 / 누수 <?= (int)(LEAK_ALERT_COOLDOWN_SEC / 60) ?>분</li>
</ul>
</div>
<div class="basis-block">
<h3>데이터 · 주석</h3>
<ul>
<li><b>출처</b> leak_sms_log(태그 접두 집계) · leak_sensor_metric(측정) · leak_sensor_log/leak_incident(누수)</li>
<li><b>집계 범위</b> <?= $h($period_start) ?> 00:00 ~ <?= $h($period_end) ?> 24:00 (센서는 sensor_id 기준, 표시명은 관리주체 라벨)</li>
<li><b>개인정보</b> 수신번호는 표시 단계에서 마스킹(mask_phone) · 본 문서 분류: <span style="color:#b11f1f;font-weight:700">대외비</span></li>
</ul>
</div>
</div>
<div class="sign-row">
<div class="sign-box"><div class="sk">작성</div><div class="sv"></div><div class="sn">직/성명 · 일자</div></div>
<div class="sign-box"><div class="sk">검토</div><div class="sv"></div><div class="sn">직/성명 · 일자</div></div>
<div class="sign-box"><div class="sk">승인</div><div class="sv"></div><div class="sn">직/성명 · 일자</div></div>
</div>
</section>
</div><!-- /report-body -->
</div><!-- /report-paper -->
</div><!-- /report-canvas -->
</body>
</html>

View file

@ -94,7 +94,7 @@ function collect_ops_checks(): array {
} }
$default_recipients = count(SMS_RECIPIENTS) === 1 && preg_replace('/\D+/', '', SMS_RECIPIENTS[0]) === '01000000000'; $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('설정', '관리자 비밀번호', ADMIN_PASSWORD_HASH !== '', 'ADMIN_PASSWORD_HASH 설정 여부', 'php setup_hash.php "new-password" 실행 후 config.local.php에 반영', 'critical', '관리자 해시 설정 확인');
$checks[] = ops_check('관리자 인증', '관리자 MFA', admin_mfa_configured(), 'ADMIN_TOTP_SECRET 설정 여부', '인증 앱용 Base32 비밀키를 config.local.php에 설정', 'critical', 'MFA 등록 확인표'); $checks[] = ops_check('관리자 인증', '관리자 MFA', admin_mfa_configured(), 'ADMIN_TOTP_SECRET 설정 여부', '인증 앱용 Base32 비밀키를 config.local.php에 설정', 'critical', 'MFA 등록 확인표');
$checks[] = ops_check('관리자 인증', 'MFA 설정 토큰 정리', MFA_SETUP_TOKEN === '', MFA_SETUP_TOKEN === '' ? '정리됨(비어 있음)' : '설정됨 — 등록 후 제거 필요', 'MFA 등록을 마쳤으면 config.local.php의 MFA_SETUP_TOKEN을 빈 값으로 비우세요. 남아 있으면 토큰 보유자가 setup_mfa.php로 새 TOTP 비밀키를 발급·검증할 수 있습니다.', 'warn', 'config.local.php MFA_SETUP_TOKEN 미설정 확인'); $checks[] = ops_check('관리자 인증', 'MFA 설정 토큰 정리', MFA_SETUP_TOKEN === '', MFA_SETUP_TOKEN === '' ? '정리됨(비어 있음)' : '설정됨 — 등록 후 제거 필요', 'MFA 등록을 마쳤으면 config.local.php의 MFA_SETUP_TOKEN을 빈 값으로 비우세요. 남아 있으면 토큰 보유자가 setup_mfa.php로 새 TOTP 비밀키를 발급·검증할 수 있습니다.', 'warn', 'config.local.php MFA_SETUP_TOKEN 미설정 확인');

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

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

View file

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

View file

@ -1,9 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="ko">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Leak Detection Node · Wiring &amp; Pinmap (Raspberry Pi 4)</title> <title>누수 감지 노드 · 배선 &amp; 핀맵 (Raspberry Pi 4)</title>
<style> <style>
:root{ :root{
--bg:#eef1f5; --card:#ffffff; --ink:#212529; --muted:#6b7280; --line:#e5e8ec; --bg:#eef1f5; --card:#ffffff; --ink:#212529; --muted:#6b7280; --line:#e5e8ec;
@ -124,26 +124,26 @@
<!-- 1) HEADER --> <!-- 1) HEADER -->
<header class="hero"> <header class="hero">
<p class="eyebrow">Field Wiring &amp; Pinmap · Leak Detection Node</p> <p class="eyebrow">Field Wiring &amp; Pinmap · 누수 감지 노드</p>
<h1>Leak Detection Node · Wiring &amp; Pinmap</h1> <h1>누수 감지 노드 · 배선 &amp; 핀맵</h1>
<p>Standard wiring diagram for connecting the e-con 5C-08 resistive leak sensor to <b>GPIO17 (physical pin 11)</b>. <p>e-con 5C-08 저항형 누수감지센서를 <b>GPIO17(물리 핀 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> Pi-A(sensor_id=1 · 1번 지점 · 서버실)와 Pi-C(sensor_id=3 · 3번 지점 · 신규)는 배선이 동일한 <b>공통 노드</b>입니다.</p>
<div class="chips"> <div class="chips">
<span class="chip">Raspberry Pi 4 Model B</span> <span class="chip">Raspberry Pi 4 Model B</span>
<span class="chip mono">BCM2711</span> <span class="chip mono">BCM2711</span>
<span class="chip">Raspberry Pi OS Bookworm 64-bit</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">e-con 5C-08 · 저항형 ~1.5MΩ</span>
<span class="chip mono">GPIO17 / Pin 11</span> <span class="chip mono">GPIO17 / 핀11</span>
<span class="chip">External 10MΩ pull-down required</span> <span class="chip">외부 4.7MΩ 풀다운 필수</span>
</div> </div>
</header> </header>
<!-- 2) 40-pin header pinmap diagram --> <!-- 2) 40핀 헤더 핀맵 도해 -->
<section> <section>
<h2><span class="num">2</span>40-Pin Header Pinmap Diagram</h2> <h2><span class="num">2</span>40핀 헤더 핀맵 도해</h2>
<div class="pinwrap"> <div class="pinwrap">
<div class="diagram"> <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;"> <svg viewBox="0 0 600 681" role="img" aria-label="Raspberry Pi 4 40핀 GPIO 헤더 핀맵" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;max-width:600px;">
<rect x="70" y="69" width="460" height="31" fill="#f6f8fa"/> <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="131" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="193" width="460" height="31" fill="#f6f8fa"/> <rect x="70" y="193" width="460" height="31" fill="#f6f8fa"/>
@ -156,8 +156,8 @@
<rect x="70" y="627" width="460" height="31" fill="#f6f8fa"/> <rect x="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="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"/> <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="252" y="22" text-anchor="middle" font-size="10" fill="#868e96">홀수 핀</text>
<text x="330" y="22" text-anchor="middle" font-size="10" fill="#868e96">Even pins</text> <text x="330" y="22" text-anchor="middle" font-size="10" fill="#868e96">짝수 핀</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="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> <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="252" cy="52" r="12" fill="#e03131" stroke="#c92a2a" stroke-width="1.5"/><text x="252" y="55.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#ffffff">1</text>
@ -241,381 +241,174 @@
</svg> </svg>
</div> </div>
<div class="legend"> <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 sig"></span><b>신호 (GPIO17 · 핀 11)<span>센서 신호선 입력</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 pwr"></span><b>전원 3V3 (핀 1)<span>센서 전원 공급</span></b></div>
<div class="lg"><span class="sw gnd"></span><b>GND (Pin 6) · 풀다운용<span>10MΩ 풀다운 리턴 · 센서 선 아님</span></b></div> <div class="lg"><span class="sw gnd"></span><b>접지 GND (핀 6 · 사용)<span>센서 GND 기본 연결</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 alt"></span><b>접지 대체 가능<span>핀 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 exp"></span><b>확장 예시 (선택)<span>GPIO27·핀 13, GPIO22·핀 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="lg"><span class="sw off"></span><b>미사용 핀<span>이 노드에서 연결하지 않음</span></b></div>
<div class="hint">The red dashed square marks the <b>Pin 1</b> position. Always verify the header orientation (relative to Pin 1) before wiring. Only three pins are actually used: <b>1 · 6 · 11</b>.</div> <div class="hint">붉은 점선 사각형이 <b>핀 1</b> 위치입니다. 헤더 방향(핀 1 기준)을 반드시 확인하고 배선하세요. 실제 사용 핀은 <b>1 · 6 · 11</b> 세 개뿐입니다.</div>
</div> </div>
</div> </div>
</section> </section>
<!-- 3) Pinmap table --> <!-- 3) 핀맵 표 -->
<section> <section>
<h2><span class="num">3</span>Pinmap Table</h2> <h2><span class="num">3</span>핀맵 표</h2>
<table> <table>
<thead> <thead>
<tr><th>Signal</th><th class="mono">BCM</th><th class="mono">Physical Pin</th><th>Notes</th></tr> <tr><th>신호</th><th class="mono">BCM</th><th class="mono">물리 핀</th><th>비고</th></tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td><span class="tag sig">Signal</span> Sensor signal line</td> <td><span class="tag sig">신호</span> 센서 신호선</td>
<td class="mono">GPIO17</td> <td class="mono">GPIO17</td>
<td class="mono">Pin 11</td> <td class="mono">핀 11</td>
<td>Sensor signal input. Connect an external 10MΩ pull-down between <b>Pin 11 ↔ GND</b>.</td> <td>센서 신호 입력. 외부 4.7MΩ 풀다운을 <b>핀 11 ↔ GND</b> 사이에 연결.</td>
</tr> </tr>
<tr> <tr>
<td><span class="tag pwr">Power</span> Sensor power 3.3V</td> <td><span class="tag pwr">전원</span> 센서 전원 3.3V</td>
<td class="mono">3V3</td> <td class="mono">3V3</td>
<td class="mono">Pin 1</td> <td class="mono">핀 1</td>
<td>Sensor VCC supply. (Spare 3V3 = Pin 17)</td> <td>센서 VCC 공급. (예비 3V3 = 핀 17)</td>
</tr> </tr>
<tr> <tr>
<td><span class="tag gnd">Ground</span> 풀다운 GND <span style="color:#868e96">(센서 선 아님)</span></td> <td><span class="tag gnd">접지</span> 센서 GND</td>
<td class="mono">GND</td> <td class="mono">GND</td>
<td class="mono">Pin 6</td> <td class="mono">핀 6</td>
<td>10MΩ 풀다운(GPIO17↔GND) 리턴용. Alternatives: any GND among Pin 9 / 14 / 20 / 25 / 30 / 34 / 39.</td> <td>대체 가능: 핀 9 / 14 / 20 / 25 / 30 / 34 / 39 중 아무 GND.</td>
</tr> </tr>
<tr class="exprow"> <tr class="exprow">
<td><span class="tag exp">Expansion</span> Additional sensor example</td> <td><span class="tag exp">확장</span> 추가 센서 예시</td>
<td class="mono">GPIO27</td> <td class="mono">GPIO27</td>
<td class="mono">Pin 13</td> <td class="mono">핀 13</td>
<td>Optional. Add by BCM number to SENSORS in <span class="inline-code">config.py</span>.</td> <td>선택. <span class="inline-code">config.py</span>의 SENSORS에 BCM 번호로 추가.</td>
</tr> </tr>
<tr class="exprow"> <tr class="exprow">
<td><span class="tag exp">Expansion</span> Additional sensor example</td> <td><span class="tag exp">확장</span> 추가 센서 예시</td>
<td class="mono">GPIO22</td> <td class="mono">GPIO22</td>
<td class="mono">Pin 15</td> <td class="mono">핀 15</td>
<td>Optional. Add the same way as above (each channel needs its own external pull-down).</td> <td>선택. 위와 동일한 방법으로 추가(각 채널마다 외부 풀다운 필요).</td>
</tr> </tr>
</tbody> </tbody>
<caption>Three pins used (Pin 1 · 6 · 11). The expansion rows are reference examples for multi-sensor setups. Per-device sensor_id / location is set in <span class="inline-code">config_local.py</span>.</caption> <caption>사용 핀은 3개(핀 1 · 6 · 11). 확장 행은 다중 센서 구성 시 참고용 예시입니다. 기기별 sensor_id / location은 <span class="inline-code">config_local.py</span>에서 설정.</caption>
</table> </table>
</section> </section>
<!-- 4) Wiring schematic --> <!-- 4) 배선 스키매틱 -->
<section> <section>
<h2><span class="num">4</span>Wiring Schematic</h2> <h2><span class="num">4</span>배선 스키매틱</h2>
<div class="diagram"> <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"> <svg viewBox="0 0 640 400" role="img" aria-label="누수센서 배선 스키매틱 — 3V3·GPIO17·GND 및 외부 4.7MΩ 풀다운" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto">
<!-- Pi header block --> <!-- Pi header block -->
<rect x="36" y="70" width="150" height="250" rx="10" fill="#f1f3f5" stroke="#adb5bd" stroke-width="1.5"/> <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> <text x="111" y="60" text-anchor="middle" font-size="13" font-weight="700" fill="#212529">Raspberry Pi 4 · 40핀 헤더</text>
<!-- header pin rows --> <!-- header pin rows -->
<circle cx="60" cy="120" r="5" fill="#e03131"/> <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> <text x="74" y="124" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">핀1 · 3V3</text>
<rect x="182" y="114" width="10" height="12" rx="2" fill="#e03131"/> <rect x="182" y="114" width="10" height="12" rx="2" fill="#e03131"/>
<circle cx="60" cy="195" r="5" fill="#2f9e44"/> <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> <text x="74" y="199" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">핀11 · GPIO17</text>
<rect x="182" y="189" width="10" height="12" rx="2" fill="#2f9e44"/> <rect x="182" y="189" width="10" height="12" rx="2" fill="#2f9e44"/>
<circle cx="60" cy="270" r="5" fill="#1971c2"/> <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 6 · GND</text> <text x="74" y="274" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">핀6 · GND</text>
<rect x="182" y="264" width="10" height="12" rx="2" fill="#1971c2"/> <rect x="182" y="264" width="10" height="12" rx="2" fill="#1971c2"/>
<!-- sensor block --> <!-- sensor block -->
<rect x="470" y="92" width="164" height="150" rx="10" fill="#ffffff" stroke="#495057" stroke-width="1.5"/> <rect x="470" y="95" width="150" height="190" 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="545" y="84" 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"/> <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> <text x="484" y="124" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">VCC 3.3V</text>
<rect x="462" y="189" width="10" height="12" rx="2" fill="#2f9e44"/> <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> <text x="484" y="199" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">SIG 신호</text>
<rect x="556" y="150" width="9" height="58" rx="2" fill="#e03131"/> <rect x="462" y="264" width="10" height="12" rx="2" fill="#1971c2"/>
<rect x="580" y="150" width="9" height="58" rx="2" fill="#2f9e44"/> <text x="484" y="274" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">GND</text>
<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 --> <!-- wires -->
<path d="M192,120 H462" stroke="#e03131" stroke-width="2.5" fill="none"/> <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,195 H462" stroke="#2f9e44" stroke-width="2.5" fill="none"/>
<path d="M192,270 H340" stroke="#1971c2" stroke-width="2.5" fill="none"/> <path d="M192,270 H462" 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="327" y="111" text-anchor="middle" font-size="11" fill="#c92a2a">3.3V 전원 · 핀1</text>
<text x="248" y="186" text-anchor="middle" font-size="11" fill="#2b8a3e">② 전극 B → GPIO17 · Pin 11</text> <text x="250" y="186" text-anchor="middle" font-size="11" fill="#2b8a3e">신호 · GPIO17 · 핀11</text>
<text x="228" y="288" text-anchor="middle" font-size="11" fill="#1864ab">GND · Pin 6 (풀다운용)</text> <text x="250" y="261" text-anchor="middle" font-size="11" fill="#1864ab">GND · 핀6</text>
<!-- external pulldown between GPIO17 wire and GND wire (board part, not a sensor lead) --> <!-- external pulldown between SIG wire and GND wire -->
<circle cx="340" cy="195" r="4" fill="#2f9e44"/> <circle cx="340" cy="195" r="4" fill="#2f9e44"/>
<circle cx="340" cy="270" r="4" fill="#1971c2"/> <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"/> <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="360" y="226" text-anchor="start" font-size="13" font-weight="700" fill="#343a40">4.7MΩ</text>
<text x="356" y="238" text-anchor="start" font-size="10" fill="#c92a2a">(보드 부품·센서 선 아님)</text> <text x="360" y="242" text-anchor="start" font-size="11" fill="#495057">외부 풀다운(필수)</text>
<!-- internal pull off note --> <!-- 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"/> <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> <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 --> <!-- logic box -->
<rect x="60" y="350" width="540" height="30" rx="6" fill="#f1f3f5" stroke="#ced4da"/> <rect x="60" y="350" width="520" 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> <text x="320" y="369" text-anchor="middle" font-size="11.5" fill="#343a40">건조 → GPIO=LOW(0) · 누수 → GPIO=HIGH(1) · ACTIVE_LOW=False</text>
</svg> </svg>
</div> </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> <div class="hint">동작 원리: 건조 시 센서는 초고임피던스(거의 개방) 상태라 외부 4.7MΩ 풀다운이 GPIO17을 <b>LOW</b>로 유지합니다. 누수로 물이 전극을 연결하면 3.3V가 ~1.5MΩ을 통해 신호선을 끌어올려 GPIO17이 <b>HIGH</b>로 전환됩니다.</div>
</section> </section>
<!-- 5) Electrical design · cautions --> <!-- 5) 전기 설계 · 주의 -->
<section> <section>
<h2><span class="num">5</span>Electrical Design · Cautions</h2> <h2><span class="num">5</span>전기 설계 · 주의</h2>
<div class="crit"> <div class="crit">
<span class="badge">★ Critical Caution · CRITICAL</span> <span class="badge">★ 절대 주의 · CRITICAL</span>
<h4>Do not enable the internal pull-down</h4> <h4>내부 풀다운을 켜지 마세요</h4>
<p>If you enable the internal <b>50kΩ</b> pull-down, the ~1.5MΩ high-impedance sensor cannot pull the GPIO HIGH, <p>내부 <b>50kΩ</b> 풀다운을 활성화하면, ~1.5MΩ 고임피던스 센서가 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> <b>누수를 감지하지 못합니다(감지 실패)</b>. 반드시 내부 풀은 <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> <b>외부 4.7MΩ 풀다운만</b> 사용하세요. (<span class="inline-code">USE_INTERNAL_PULL = False</span>)</p>
</div> </div>
<div class="notes" style="margin-top:14px"> <div class="notes" style="margin-top:14px">
<div class="note"> <div class="note">
<h4>Polarity · Logic Level</h4> <h4>극성 · 로직 레벨</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> <p><span class="inline-code">ACTIVE_LOW = False</span> → 건조 시 <b>GPIO=LOW(0)</b>, 누수 시 <b>GPIO=HIGH(1)</b>.</p>
</div> </div>
<div class="note"> <div class="note">
<h4>External Pull-down (required)</h4> <h4>외부 풀다운 (필수)</h4>
<p>Connect a 10MΩ resistor between <b>GPIO17 (Pin 11) ↔ GND</b>. Essential for the high-impedance sensor.</p> <p>4.7MΩ 저항을 <b>GPIO17(핀 11) ↔ GND</b> 사이에 연결. 고임피던스 센서용으로 반드시 필요.</p>
</div> </div>
<div class="note"> <div class="note">
<h4>Internal Pull Setting</h4> <h4>내부 풀 설정</h4>
<p><span class="inline-code">USE_INTERNAL_PULL = False</span> — keep the internal pull at <span class="inline-code">PUD_OFF</span>.</p> <p><span class="inline-code">USE_INTERNAL_PULL = False</span> — 내부 풀은 <span class="inline-code">PUD_OFF</span> 유지.</p>
</div> </div>
<div class="note"> <div class="note">
<h4>Debounce</h4> <h4>디바운스</h4>
<p><span class="inline-code">DEBOUNCE_MS = 500</span> — set generously given the high-impedance behavior.</p> <p><span class="inline-code">DEBOUNCE_MS = 500</span> — 고임피던스 특성상 넉넉하게 설정.</p>
</div> </div>
<div class="note" style="grid-column:1 / -1"> <div class="note" style="grid-column:1 / -1">
<h4>Configuration Files</h4> <h4>설정 파일</h4>
<p>Electrical parameters live in <span class="inline-code">raspberry_pi4/config.py</span> (SENSORS · ACTIVE_LOW · USE_INTERNAL_PULL · DEBOUNCE_MS). <p>전기 파라미터는 <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> 기기별 <b>sensor_id / location</b>은 <span class="inline-code">config_local.py</span>
(Pi-A = id 1 · Site 1 · Server Room / Pi-C = id 3 · Site 3 · New).</p> (Pi-A = id 1 · 1번 지점 · 서버실 / Pi-C = id 3 · 3번 지점 · 신규).</p>
</div> </div>
</div> </div>
<div class="cam"> <div class="cam">
<h4>Camera (optional · CAMERA_ENABLED=True)</h4> <h4>카메라 (선택 · 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), <p>Raspberry Pi 4는 <b>15핀(1.0mm 피치) 표준 CSI 커넥터</b>입니다. Zero 2 W는 22핀(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>. 리본·카메라보드가 <b>비호환</b> → Pi 4용 <b>15핀 리본</b> 또는 <b>15↔22 변환 어댑터</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> 촬영은 <span class="inline-code">rpicam-still</span>(Bookworm) / <span class="inline-code">libcamera-still</span>(Bullseye), <b>640×480 JPEG</b>.</p>
</div> </div>
</section> </section>
<!-- 6) Installation checklist --> <!-- 6) 설치 체크리스트 -->
<section> <section>
<h2><span class="num">6</span>Installation Checklist</h2> <h2><span class="num">6</span>설치 체크리스트</h2>
<ol class="check"> <ol class="check">
<li>Start wiring with the Raspberry Pi power <b>fully disconnected</b>.</li> <li>Raspberry Pi 전원을 <b>완전히 차단</b>한 상태에서 배선을 시작한다.</li>
<li>Verify the header orientation (Pin 1 position). Red dashed marker = Pin 1.</li> <li>헤더 방향(핀 1 위치)을 확인한다. 붉은 점선 표시 = 핀 1.</li>
<li>Connect the sensor signal line to <b>Pin 11 (GPIO17)</b>.</li> <li>센서 신호선을 <b>핀 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>센서 전원(3.3V)을 <b>핀 1(3V3)</b>, 센서 GND를 <b>핀 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">외부 <b>4.7MΩ 풀다운</b>을 <b>핀 11 ↔ GND</b> 사이에 연결한다. <b>(필수 · 생략 시 감지 실패)</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 class="key">내부 풀이 꺼져 있는지 확인한다 — <span class="inline-code">USE_INTERNAL_PULL=False</span> (PUD_OFF). <b>내부 풀다운 절대 금지.</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><span class="inline-code">config.py</span> 확인: <span class="inline-code">ACTIVE_LOW=False</span>, <span class="inline-code">DEBOUNCE_MS=500</span>, SENSORS에 GPIO17 등록.</li>
<li>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><span class="inline-code">config_local.py</span>에서 기기별 <b>sensor_id / location</b> 설정 (Pi-A=1/서버실, Pi-C=3/신규).</li>
<li>After powering on, confirm via the log that GPIO17 = <b>LOW(0)</b> in the <b>dry state</b>.</li> <li>전원 인가 후, <b>건조 상태</b>에서 GPIO17 = <b>LOW(0)</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>물(또는 젖은 천)로 센서를 적셔 GPIO17이 <b>HIGH(1)</b>로 전환되고 누수 이벤트가 기록되는지 확인한다.</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> <li>(선택) 카메라 사용 시 리본 방향·커넥터 규격(15핀 / 어댑터)을 확인하고 640×480 촬영 테스트를 수행한다.</li>
</ol> </ol>
</section> </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> </div>
</body> </body>
</html> </html>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

@ -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).

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

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