feat(alert): 누수 감지/복구 디바운스 · 온습도 1시간 주기 · 센서별 수신자 그룹
누수 감지↔복구 플래핑 SMS 폭탄 방지, 온습도 측정 주기 1시간화(부수 오탐
차단), 센서별 SMS 수신자 그룹 지정 3종을 함께 반영한다.
1) 누수 디바운스 (서버 전용, 펌웨어 무변경)
- leak_sensor_status 에 leak_confirmed / leak_pending_since 컬럼 추가.
- leak_debounce_decide(): 단일 대칭 타이머 순수 함수. raw 상태가 확인창
동안 지속돼야 확정하고 확정 엣지에서만 SMS 1회. dry 블립<복구창이면
복구 미확정 → 확정 유지 → 재감지·복구 SMS 둘 다 차단(폭탄 원천 봉쇄).
- apply_leak_debounce()/sweep_pending_leak_transitions(): 센서 네임드 락으로
직렬화, cron sweep 으로 보고 끊겨도 확정 해소. 사고(leak_incident)
open/close 도 확정 엣지로 이동(월간보고 사고건수 정합). raw 이벤트는
leak_sensor_log 에 그대로 보존(플래핑 진단).
- 컬럼 없는 구배포는 레거시 즉시 발송으로 graceful degrade.
- 확인창 기본: 감지 60s / 복구 180s (config.local.php 조정).
2) 온습도 1시간 주기 + 센서별 히트비트 타임아웃
- raspberry_pi4_sht30/config.py REPORT_INTERVAL 300→3600.
- heartbeat_timeout_for(): 명시 오버라이드 > 온습도 기본(7500s) > 전역(1200s).
cron_heartbeat / dashboard 오프라인 판정을 센서별로. 매시간 오탐 제거,
누수 오프라인은 20분 유지.
- STUCK_DETECT_SAMPLES 6→3(1시간 주기 ≈3시간), 관련 주석 정정.
3) 센서별 수신자 그룹
- SENSOR_RECIPIENTS 맵 + recipients_for_sensor() 전역 폴백.
- sms_dispatch_to_recipients 가 센서별로 수신자 해소(에스컬레이션 TIER2
명시 전달은 유지, 헬스/잔액은 전역).
대시보드: 누수 카드 '확인중(디바운스)' 배지, 시스템 탭에 확인창·센서별
타임아웃·수신자 그룹 표시.
마이그레이션: sql/migration_leak_debounce.sql (컬럼 2개), schema_sht30.sql 갱신.
테스트: leak_debounce(12) / recipients_group / heartbeat_timeout 순수 단위 추가.
설계: docs/superpowers/specs/2026-07-13-leak-debounce-sensor-config-design.md
This commit is contained in:
parent
01ed3164a6
commit
2a469dc3cc
15 changed files with 941 additions and 40 deletions
|
|
@ -0,0 +1,218 @@
|
||||||
|
# 누수 디바운스 · 온습도 1시간 주기 · 센서별 수신자 그룹 설계
|
||||||
|
|
||||||
|
- 날짜: 2026-07-13
|
||||||
|
- 브랜치: feat/pi-c-sensor3-leak-node
|
||||||
|
- 대상: leak_sms_v3 (통합 모니터링 — 누수 1번 모듈 / SHT30 온습도 2번 모듈, 단일 Cafe24 서버)
|
||||||
|
|
||||||
|
## 배경 · 목표
|
||||||
|
|
||||||
|
운영 중 세 가지 요구가 나왔다.
|
||||||
|
|
||||||
|
1. **누수 감지↔복구 플래핑 SMS 폭탄 방지.** 센서에 닿는 물이 애매하면 GPIO가 leak/dry 사이를 진동한다(펌웨어 500ms 바운스로도 못 막는 초 단위 채터링). 현재 서버는 `[누수복구]` 뒤 재감지를 "새 에피소드"로 보고 쿨다운을 무시해 즉시 발송하므로, 사이클마다 `[누수감지]`+`[누수복구]` 2건이 폭탄처럼 쏟아진다.
|
||||||
|
2. **온습도(SHT30) 측정 주기를 1시간으로.** 현재 5분(`REPORT_INTERVAL=300`).
|
||||||
|
3. **센서별 SMS 수신자 그룹 지정.** 현재 `SMS_RECIPIENTS` 전역 하나뿐.
|
||||||
|
|
||||||
|
**폐쇄망 제약:** 모든 운영 설정은 `config.local.php`(또는 환경변수)로 주입한다. 관리자용 설정 UI는 없다(읽기 전용 표시만). DB 스키마 변경은 마이그레이션 SQL 파일로 전달하고, 컬럼 부재 시 graceful degrade(구배포에서 자동 비활성)를 유지한다.
|
||||||
|
|
||||||
|
**공통 원칙:** `leak_sms_log.message`의 태그 문자열이 사실상의 경보 상태 키다(별도 상태 테이블 없음). 태그는 `config.php`의 `SMS_TAG_*` 상수 단일 출처. 원천 이벤트 로그(`leak_sensor_log`)는 진단을 위해 raw 그대로 보존한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기능 1 — 누수 감지/복구 디바운스 (서버 전용, 펌웨어 무변경)
|
||||||
|
|
||||||
|
### 결정된 접근
|
||||||
|
|
||||||
|
**디바운스/확인 지연.** 상태 전이를 즉시 발송하지 않고, 확인창 동안 지속되어야 확정한다. 펌웨어는 이미 누수 지속 중 `periodic`을 10초 주기로 재보고(`LEAK_REPORT_INTERVAL=10`)하므로, 서버가 이 신호로 확정 타이머를 전진시킨다. **펌웨어 변경 불필요.**
|
||||||
|
|
||||||
|
### 상태 저장 (leak_sensor_status에 컬럼 2개 추가)
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 의미 |
|
||||||
|
|---|---|---|
|
||||||
|
| `leak_confirmed` | `TINYINT(1) NOT NULL DEFAULT 0` | 디바운스로 **확정**된 누수 상태. SMS·`leak_incident`·대시보드 헤드라인의 기준. |
|
||||||
|
| `leak_pending_since` | `DATETIME NULL DEFAULT NULL` | raw 상태(`is_leak`)가 확정 상태와 어긋나기 시작한 시각. `NULL`=안정(raw==confirmed). |
|
||||||
|
|
||||||
|
`is_leak`(raw 최신)은 그대로 두고, 확정 상태를 별도로 둔다.
|
||||||
|
|
||||||
|
### 순수 판정 함수 (단위 테스트 대상)
|
||||||
|
|
||||||
|
`alert_cooldown_suppresses()`와 같은 위치(`sms_send.php`)에 DB/네트워크 없는 순수 함수를 둔다.
|
||||||
|
|
||||||
|
```
|
||||||
|
leak_debounce_decide(
|
||||||
|
int $raw, // 0/1 현재 raw 상태
|
||||||
|
int $confirmed, // 0/1 현재 확정 상태
|
||||||
|
?int $pendingSinceTs, // 미확정 전이 시작 Unix (없으면 null)
|
||||||
|
int $alertConfirmSec, // raw=1 확정 임계
|
||||||
|
int $recoveryConfirmSec,// raw=0 확정 임계
|
||||||
|
int $now // 현재 Unix (테스트 주입)
|
||||||
|
): array // ['confirmed'=>int, 'pending_since'=>?int, 'sms'=>null|'leak'|'recovery']
|
||||||
|
```
|
||||||
|
|
||||||
|
판정표 (단일 타이머, 대칭):
|
||||||
|
|
||||||
|
| 조건 | confirmed | pending_since | sms |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `raw == confirmed` | 유지 | `null`(해제) | `null` |
|
||||||
|
| `raw != confirmed`, pending 없음 | 유지 | `now`(시작) | `null` |
|
||||||
|
| `raw != confirmed`, 경과 `< threshold` | 유지 | 유지 | `null` |
|
||||||
|
| `raw != confirmed`, 경과 `≥ threshold` | `raw`(확정) | `null`(해제) | raw=1→`leak`, raw=0→`recovery` |
|
||||||
|
|
||||||
|
`threshold = (raw==1) ? alertConfirmSec : recoveryConfirmSec`.
|
||||||
|
|
||||||
|
**왜 이 설계가 폭탄을 원천 차단하나:** 누수 지속 중 짧은 dry 블립이 와도(raw 0→1→0…), dry 구간이 `recoveryConfirmSec`(기본 180s)보다 짧으면 복구가 확정되지 않는다 → 확정 상태는 계속 `leak` 유지 → **재감지 SMS도, 복구 SMS도 안 나간다.** 진짜 해소(연속 dry ≥ 180s) 때만 `[누수복구]` 1건. 진짜 신규 누수(연속 leak ≥ 60s)만 `[누수감지]` 1건.
|
||||||
|
|
||||||
|
### DB 래퍼 · 통합 지점
|
||||||
|
|
||||||
|
`apply_leak_debounce(PDO $db, int $sensor_id, int $raw, array $sensor_info): bool`
|
||||||
|
1. `(센서)` 단위 네임드 락 `GET_LOCK("leak_debounce_{sid}", 0)`으로 read-decide-update 직렬화(동시 보고 경쟁 방지, 미지원 시 락 없이 진행).
|
||||||
|
2. `leak_sensor_status`에서 `leak_confirmed`, `UNIX_TIMESTAMP(leak_pending_since)` 읽기.
|
||||||
|
3. `leak_debounce_decide(...)` 호출.
|
||||||
|
4. `UPDATE leak_sensor_status SET leak_confirmed=?, leak_pending_since=?`.
|
||||||
|
5. `sms==='leak'` → `leak_incident` open(미개봉 시, 최근 `leak_alert` log_id 링크) + `send_leak_alert_sms()`.
|
||||||
|
`sms==='recovery'` → `leak_incident` 최근 미복구 close + `send_leak_recovery_sms()`.
|
||||||
|
6. SMS 발송 여부 반환.
|
||||||
|
|
||||||
|
기존 `send_leak_alert_sms()`/`send_leak_recovery_sms()`(포맷·발송·로그·자체 쿨다운 백스톱)는 그대로 재사용한다. 이제 이들을 호출하는 **결정**만 raw 이벤트가 아니라 확정 엣지에서 내려온다.
|
||||||
|
|
||||||
|
**호출 게이트 (api/sensor_data.php):** `leak_alert`/`leak_recovery` 이벤트이거나 `sensor_id ∈ LEAK_SENSOR_IDS`인 보고에서 `apply_leak_debounce($db, $sid, $is_leak, $sensor_info)` 호출. SHT30(비누수) 센서는 스킵. 누수 센서의 `periodic`(is_leak=1)이 확정 타이머를 전진시킨다.
|
||||||
|
|
||||||
|
**cron sweep:** `sweep_pending_leak_transitions(PDO $db)` — `leak_pending_since IS NOT NULL`인 센서를 raw=`is_leak`로 재평가해 보고가 끊겨도(예: 복구 대기 중 10분 주기, 장비 무응답) 확정을 해소한다. `check_offline_sensors_throttled()`의 실행 블록(5분 쓰로틀)에 추가.
|
||||||
|
|
||||||
|
### leak_incident (사고 추적)을 확정 기준으로
|
||||||
|
|
||||||
|
기존은 raw `leak_alert`에 open, raw `leak_recovery`에 close → 플래핑 시 1초짜리 사고가 다수 생겨 월간보고서 누수 건수가 부풀었다. 사고 open/close를 **확정 엣지**로 옮겨(위 5단계) 사고 이력이 확정 에피소드만 담게 한다. raw 이벤트는 `leak_sensor_log`에 그대로 남아 플래핑 진단이 가능하다.
|
||||||
|
|
||||||
|
### graceful degrade
|
||||||
|
|
||||||
|
`leak_status_has_debounce_cols(PDO $db): bool`(1회 캐시, information_schema 조회)로 컬럼 존재를 확인한다. **없으면 레거시 즉시 발송 경로**(현행 `leak_alert`→즉시 SMS + raw 기준 incident)를 그대로 탄다. 즉 마이그레이션 미적용 배포에서도 동작은 종전과 동일하고 디바운스만 비활성.
|
||||||
|
|
||||||
|
배포 시 이미 누수 중(`is_leak=1`)인 센서: `leak_confirmed` 초기 0 → 다음 보고에서 pending 시작 → 60s 후 `[누수감지]` 1회 재확정. (허용 가능한 엣지, 사실상 재확인.)
|
||||||
|
|
||||||
|
### 설정 상수 (config.php)
|
||||||
|
|
||||||
|
```
|
||||||
|
LEAK_DEBOUNCE_ENABLED (bool, 기본 true) // 마스터 스위치 (false면 레거시 즉시)
|
||||||
|
LEAK_ALERT_CONFIRM_SEC (int, 기본 60) // 감지 확인창
|
||||||
|
LEAK_RECOVERY_CONFIRM_SEC (int, 기본 180) // 복구 확인창
|
||||||
|
```
|
||||||
|
|
||||||
|
### 대시보드 표시
|
||||||
|
|
||||||
|
- `dashboard_data.php`: `leak_active_count`·히어로 판정을 컬럼 존재 시 `leak_confirmed` 기준으로(=SMS와 헤드라인 일치). 컬럼 부재 시 기존 `is_leak` 폴백.
|
||||||
|
- 누수 상태 카드: `leak_pending_since`가 세팅되고 raw가 확정과 어긋나면 **"확인중(디바운스)"** 배지 노출 — 관리자가 "감지됐는데 왜 SMS 안 왔나"를 즉시 이해.
|
||||||
|
- 시스템 탭 `config_rows`: `['누수 확인 지연', "감지 60s / 복구 180s"]` 행 추가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기능 2 — 온습도 1시간 주기 + 센서별 히트비트 타임아웃
|
||||||
|
|
||||||
|
### 펌웨어
|
||||||
|
|
||||||
|
`raspberry_pi4_sht30/config.py`: `REPORT_INTERVAL = 300 → 3600` (기본값 자체 변경, 주석 갱신). `config_local.py`로 현장 오버라이드 가능.
|
||||||
|
|
||||||
|
### 센서별 오프라인 타임아웃
|
||||||
|
|
||||||
|
전역 단일 `HEARTBEAT_TIMEOUT_SEC`이 5분 주기 전제였으므로 1시간 온습도 센서를 매시간 오프라인 오탐한다. 히트비트 타임아웃을 센서별로 해소한다.
|
||||||
|
|
||||||
|
`heartbeat_timeout_for(int $sid): int` (config.php):
|
||||||
|
1. `SENSOR_HEARTBEAT_TIMEOUT_SEC[$sid]` 존재 → 그 값 (명시 오버라이드)
|
||||||
|
2. `$sid ∈ METRIC_SENSOR_IDS` → `METRIC_HEARTBEAT_TIMEOUT_SEC` (기본 7500 ≈ 1시간 2회 누락 + 여유)
|
||||||
|
3. 그 외(누수 등) → `HEARTBEAT_TIMEOUT_SEC` (기본 1200 유지)
|
||||||
|
|
||||||
|
`check_offline_sensors()` 수정: 단일 인터벌 WHERE 대신 센서를 순회하며 센서별 타임아웃으로 판정·선점한다. 선점 UPDATE의 `last_seen < NOW() - INTERVAL :timeout SECOND`도 해당 센서 타임아웃을 바인딩. (원자적 0→1 선점·복귀 시 오탐 방지 로직은 유지.)
|
||||||
|
|
||||||
|
`dashboard_data.php` freshness(현 line 334 `HEARTBEAT_TIMEOUT_SEC`)도 `heartbeat_timeout_for($sid)`로 교체.
|
||||||
|
|
||||||
|
### 부수 영향 정리
|
||||||
|
|
||||||
|
- `STUCK_DETECT_SAMPLES`: 6(×5분=30분) → 1시간에선 6시간. 기본 **3**(=3시간 평탄)으로 낮추고 주석 갱신. config로 조정 가능.
|
||||||
|
- `FORECAST_RECENT_POINTS`(주석 "≈1시간")·급변 기준 등 예보 상수 주석을 1시간 주기 기준으로 갱신(예보는 대시보드 전용·SMS 무관 — 로직 변경 없이 주석·의미만 정정, YAGNI).
|
||||||
|
- config.php 히트비트 섹션 주석(5분/10분 서술)을 1시간/센서별로 정정.
|
||||||
|
- 누수 오프라인은 여전히 20분 내 감지(중요도 유지).
|
||||||
|
|
||||||
|
### 설정 상수 (config.php)
|
||||||
|
|
||||||
|
```
|
||||||
|
METRIC_HEARTBEAT_TIMEOUT_SEC (int, 기본 7500) // 온습도 센서 오프라인 판정
|
||||||
|
SENSOR_HEARTBEAT_TIMEOUT_SEC (map, 기본 []) // sid=>sec 명시 오버라이드
|
||||||
|
STUCK_DETECT_SAMPLES (int, 기본 3) // 6→3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 대시보드 표시
|
||||||
|
|
||||||
|
시스템 탭 `config_rows`의 `['오프라인 판정', ...]`을 센서별로: 예 `누수 20분 / 온습도 125분 무수신`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기능 3 — 센서별 수신자 그룹 (config.local.php 맵)
|
||||||
|
|
||||||
|
### 설정 파싱 (config.php)
|
||||||
|
|
||||||
|
`SENSOR_LABELS`와 동일 패턴으로 `SENSOR_RECIPIENTS` 맵을 파싱·검증한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
'SENSOR_RECIPIENTS' => [
|
||||||
|
1 => ['010-1111-1111', '010-2222-2222'], // 누수 → 시설팀
|
||||||
|
3 => ['010-3333-3333'], // 3번 → 고객센터
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- 키: 1..255 정수 sid. 값: 문자열 배열 또는 쉼표문자열(정제: `preg_replace('/\D+/','')`, 빈 항목 제거).
|
||||||
|
- 유효 번호가 하나도 없는 그룹은 버림(전역 폴백되도록).
|
||||||
|
- `define('SENSOR_RECIPIENTS', $parsed)`.
|
||||||
|
|
||||||
|
### 해소 헬퍼
|
||||||
|
|
||||||
|
`recipients_for_sensor(int $sid): array` (config.php) → `SENSOR_RECIPIENTS[$sid] ?? SMS_RECIPIENTS`. `sid=0`(헬스/잔액/시스템)은 그룹 없음 → 전역 폴백.
|
||||||
|
|
||||||
|
### 발송 통합 (sms_send.php)
|
||||||
|
|
||||||
|
`sms_dispatch_to_recipients(...)`의 `$targets = $recipients ?? SMS_RECIPIENTS`를 `$targets = $recipients ?? recipients_for_sensor((int)($sensor_data['sensor_id'] ?? 0))`로 변경. 명시 전달(에스컬레이션 TIER2)은 그대로 우선. 잔액경고(`maybe_warn_sms_balance`)·일일헬스는 시스템 전역이라 `SMS_RECIPIENTS` 유지.
|
||||||
|
|
||||||
|
**에스컬레이션 TIER2:** 전역 `SMS_RECIPIENTS_TIER2` 유지(YAGNI — 센서별 2차 그룹은 이번 범위 밖).
|
||||||
|
|
||||||
|
### 대시보드 표시
|
||||||
|
|
||||||
|
시스템 탭 `config_rows`: 센서별 수신자 그룹 요약 행 추가(번호는 `mask_phone()`로 마스킹). 예 `['수신자 · 1번', '010-****-1111 외 1명']`, 미지정 센서는 `전역(N명)` 표기.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DB 마이그레이션
|
||||||
|
|
||||||
|
`sql/migration_leak_debounce.sql` 신규 + canonical `sql/schema_sht30.sql` & `deploy/01_server_cafe24/sql/schema.sql`의 `leak_sensor_status` 정의 갱신.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE `leak_sensor_status`
|
||||||
|
ADD COLUMN `leak_confirmed` TINYINT(1) NOT NULL DEFAULT 0
|
||||||
|
COMMENT '디바운스 확정 누수 상태(SMS/사고추적 기준)',
|
||||||
|
ADD COLUMN `leak_pending_since` DATETIME NULL DEFAULT NULL
|
||||||
|
COMMENT '미확정 전이 시작 시각(NULL=안정)';
|
||||||
|
```
|
||||||
|
|
||||||
|
멱등(`ADD COLUMN IF NOT EXISTS` 미지원 MySQL 대비 재실행 시 오류 무시 안내 주석 포함). 컬럼 부재 배포는 graceful degrade로 계속 동작.
|
||||||
|
|
||||||
|
## 테스트 (무프레임워크 PHP — 기존 tests/ 스타일)
|
||||||
|
|
||||||
|
- `tests/leak_debounce_test.php`: `leak_debounce_decide` 순수함수.
|
||||||
|
- 플래핑(leak→dry→leak, 각 구간 < 확인창) → sms 없음, 확정 불변.
|
||||||
|
- 지속 누수(연속 leak ≥ 60s) → 1회 `leak` 확정.
|
||||||
|
- 확정 누수 중 짧은 dry(<180s) 블립 → 복구 확정 안 됨, sms 없음.
|
||||||
|
- 진짜 복구(연속 dry ≥ 180s) → 1회 `recovery`.
|
||||||
|
- pending 시작/해제/유지 경계값.
|
||||||
|
- `tests/recipients_group_test.php`: `recipients_for_sensor` (그룹 히트/전역 폴백/sid=0/빈 그룹 버림).
|
||||||
|
- `tests/heartbeat_timeout_test.php`: `heartbeat_timeout_for` (명시>METRIC>전역 우선순위).
|
||||||
|
- 기존 `tests/cooldown_episode_test.php`·`threshold_sms_test.php` 회귀 확인(레거시 경로·metric 경보 불변).
|
||||||
|
|
||||||
|
## 배포 · 폐쇄망 노트
|
||||||
|
|
||||||
|
1. SQL 마이그레이션을 서버에서 1회 실행(`SOURCE .../migration_leak_debounce.sql`).
|
||||||
|
2. `php/config.local.php`에 필요 시 `SENSOR_RECIPIENTS`, `SENSOR_HEARTBEAT_TIMEOUT_SEC` 등 추가(선택 — 미설정 시 전역/기본 폴백).
|
||||||
|
3. 온습도 Pi 노드: `raspberry_pi4_sht30/config.py` 갱신 반영(재배포) 또는 `config_local.py`에 `REPORT_INTERVAL=3600`.
|
||||||
|
4. `deploy/01_server_cafe24/asmdkwqdmqwertn/` 미러(php 배포본)에 동일 변경 반영.
|
||||||
|
|
||||||
|
## 범위 밖 (YAGNI)
|
||||||
|
|
||||||
|
- 온습도(metric) 경보 디바운스 — 이미 히스테리시스+쿨다운 보유, 1시간 주기로 채터링 사실상 소멸.
|
||||||
|
- 센서별 TIER2 에스컬레이션 그룹.
|
||||||
|
- 관리자 설정 편집 UI(폐쇄망 — config.local.php 유지).
|
||||||
|
- 플래핑 횟수 기반 별도 억제(디바운스로 충분).
|
||||||
|
|
@ -266,6 +266,10 @@ $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.
|
||||||
|
|
@ -365,11 +369,13 @@ try {
|
||||||
$metric_saved = true;
|
$metric_saved = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 누수 사고(에피소드) 추적 (leak_incident) ──────────
|
// ── 누수 사고(에피소드) 추적 (leak_incident) — 레거시(디바운스 미사용) 경로 ──
|
||||||
// leak_alert: 미복구 사고가 없으면 새 사고를 연다(중복 leak_alert 는 새로 열지 않음).
|
// leak_alert: 미복구 사고가 없으면 새 사고를 연다(중복 leak_alert 는 새로 열지 않음).
|
||||||
// leak_recovery: 같은 센서의 가장 최근 미복구 사고를 닫는다(recovered_at/복구 log_id 채움).
|
// leak_recovery: 같은 센서의 가장 최근 미복구 사고를 닫는다(recovered_at/복구 log_id 채움).
|
||||||
// leak_incident 테이블이 없으면(누수 미통합 서버) graceful 하게 건너뛴다.
|
// leak_incident 테이블이 없으면(누수 미통합 서버) graceful 하게 건너뛴다.
|
||||||
if ($is_leak_event && api_table_exists($db, 'leak_incident')) {
|
// 디바운스 사용 시엔 사고 open/close 를 '확정 엣지'(apply_leak_debounce)로 옮겨 처리하므로
|
||||||
|
// 여기서는 건너뛴다(raw 이벤트마다 사고가 열려 월간보고서가 부풀지 않게).
|
||||||
|
if (!$use_debounce && $is_leak_event && api_table_exists($db, 'leak_incident')) {
|
||||||
if ($event_type === 'leak_alert') {
|
if ($event_type === 'leak_alert') {
|
||||||
$open = $db->prepare("
|
$open = $db->prepare("
|
||||||
SELECT id FROM leak_incident
|
SELECT id FROM leak_incident
|
||||||
|
|
@ -467,10 +473,17 @@ if ($has_metric_payload) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 누수 경보 / 복구 SMS (1번 모듈) ────────────────────────
|
// ── 누수 경보 / 복구 SMS (1번 모듈) ────────────────────────
|
||||||
// leak_alert: [누수감지] SMS(쿨다운). 누수 지속 중 주기 보고가 와도 쿨다운으로 1회.
|
// [디바운스 경로] 감지/복구 전이를 확인창 동안 지속돼야 확정하고, 확정 엣지에서만 SMS 1회.
|
||||||
// leak_recovery: 직전에 누수 경보가 있었을 때만 [누수복구] SMS 발송.
|
// 감지↔복구 채터링(플래핑)이 SMS 폭탄으로 번지는 것을 원천 차단한다. 누수 센서의 주기
|
||||||
// sms_send.php 에 헬퍼가 없거나(미통합 배포) 함수 미정의면 graceful 하게 건너뛴다.
|
// 보고(periodic, is_leak 포함)도 확정 타이머를 전진시키므로 이벤트/주기 모두에서 호출한다.
|
||||||
if ($is_leak_event) {
|
// [레거시 경로] 디바운스 컬럼이 없거나 비활성이면 종전대로 즉시 발송(쿨다운/복구 판정).
|
||||||
|
if ($use_debounce) {
|
||||||
|
if ($is_leak_event || in_array($sensor_id, LEAK_SENSOR_IDS, true)) {
|
||||||
|
if (apply_leak_debounce($db, $sensor_id, $is_leak, $sensor_info)) {
|
||||||
|
$sms_sent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($is_leak_event) {
|
||||||
if ($event_type === 'leak_alert' && function_exists('send_leak_alert_sms')) {
|
if ($event_type === 'leak_alert' && function_exists('send_leak_alert_sms')) {
|
||||||
if (send_leak_alert_sms($sensor_info)) {
|
if (send_leak_alert_sms($sensor_info)) {
|
||||||
$sms_sent = true;
|
$sms_sent = true;
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ header h1::before { content: ""; width: 10px; height: 10px; border-radius: 50%;
|
||||||
.sensor-card small { color: var(--ink-3); }
|
.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.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.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-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, .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; }
|
||||||
|
|
@ -139,6 +140,7 @@ header h1::before { content: ""; width: 10px; height: 10px; border-radius: 50%;
|
||||||
.badge.ok, .pill.ok { background: var(--normal-bg); color: var(--normal-ink); }
|
.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.alert,.pill.alert { background: var(--leak-bg); color: var(--leak-ink); }
|
||||||
.badge.offline,.pill.offline{ background: var(--offline-bg); color: var(--offline-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.high_temp { background: var(--hot-bg); color: var(--hot-ink); }
|
||||||
.pill.low_temp { background: var(--cold-bg); color: var(--cold-ink); }
|
.pill.low_temp { background: var(--cold-bg); color: var(--cold-ink); }
|
||||||
.pill.high_humidity{ background: var(--humid-bg); color: var(--humid-ink); }
|
.pill.high_humidity{ background: var(--humid-bg); color: var(--humid-ink); }
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,14 @@ return [
|
||||||
'01000000000',
|
'01000000000',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// 센서별 수신자 그룹 — 지정한 센서의 경보 SMS 는 그 그룹으로만 발송된다. 지정 없는
|
||||||
|
// 센서는 위 전역 SMS_RECIPIENTS 로 폴백한다(헬스/잔액 등 시스템 통지도 전역 사용).
|
||||||
|
// 예: 누수(1번)는 시설팀, 3번은 고객센터로. 비우면(기본) 전역만 사용.
|
||||||
|
'SENSOR_RECIPIENTS' => [
|
||||||
|
// 1 => ['010-1111-1111', '010-2222-2222'],
|
||||||
|
// 3 => ['010-3333-3333'],
|
||||||
|
],
|
||||||
|
|
||||||
'ADMIN_USER' => 'admin',
|
'ADMIN_USER' => 'admin',
|
||||||
'ADMIN_PASSWORD_HASH' => '$2y$10$replace.with.password_hash.output',
|
'ADMIN_PASSWORD_HASH' => '$2y$10$replace.with.password_hash.output',
|
||||||
// Base32 TOTP secret. MUST be left EMPTY here and set to a UNIQUE generated value in
|
// Base32 TOTP secret. MUST be left EMPTY here and set to a UNIQUE generated value in
|
||||||
|
|
@ -45,6 +53,19 @@ return [
|
||||||
// 신규 누수 지점(예: Pi-C, sensor_id 3) 투입 시: [1, 3]
|
// 신규 누수 지점(예: Pi-C, sensor_id 3) 투입 시: [1, 3]
|
||||||
'LEAK_SENSOR_IDS' => [1],
|
'LEAK_SENSOR_IDS' => [1],
|
||||||
|
|
||||||
|
// 누수 감지/복구 디바운스(감지↔복구 플래핑 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,
|
||||||
|
// 'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [3 => 1800],
|
||||||
|
|
||||||
// 온습도 임계 경보 (서버 판정 → SMS). 운영 환경에 맞게 조정.
|
// 온습도 임계 경보 (서버 판정 → SMS). 운영 환경에 맞게 조정.
|
||||||
'METRIC_TEMP_HIGH_C' => 27, // 고온 임계(℃)
|
'METRIC_TEMP_HIGH_C' => 27, // 고온 임계(℃)
|
||||||
'METRIC_TEMP_LOW_C' => 10, // 저온 임계(℃)
|
'METRIC_TEMP_LOW_C' => 10, // 저온 임계(℃)
|
||||||
|
|
@ -69,7 +90,7 @@ return [
|
||||||
'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(6), SMS_RETRY_OFFLINE(true)
|
// STUCK_DETECT_SAMPLES(3 = 온습도 1시간 주기 기준 ≈3시간), SMS_RETRY_OFFLINE(true)
|
||||||
|
|
||||||
'SMS_LOG_RETENTION_DAYS' => 365,
|
'SMS_LOG_RETENTION_DAYS' => 365,
|
||||||
'SENSOR_LOG_RETENTION_DAYS' => 365,
|
'SENSOR_LOG_RETENTION_DAYS' => 365,
|
||||||
|
|
|
||||||
123
php/config.php
123
php/config.php
|
|
@ -72,7 +72,59 @@ $sms_recipients = cfg('SMS_RECIPIENTS', ['01000000000']);
|
||||||
if (is_string($sms_recipients)) {
|
if (is_string($sms_recipients)) {
|
||||||
$sms_recipients = array_filter(array_map('trim', explode(',', $sms_recipients)));
|
$sms_recipients = array_filter(array_map('trim', explode(',', $sms_recipients)));
|
||||||
}
|
}
|
||||||
define('SMS_RECIPIENTS', $sms_recipients);
|
define('SMS_RECIPIENTS', array_values($sms_recipients));
|
||||||
|
|
||||||
|
// ─── 센서별 수신자 그룹 (config.local.php 맵) ────────────────────────────────
|
||||||
|
// 센서(sensor_id)별로 SMS 를 받을 수신자 그룹을 지정한다. 지정이 없는 센서는 전역
|
||||||
|
// SMS_RECIPIENTS 로 폴백한다(SENSOR_LABELS 와 동일 원칙 — Pi 재배포 없이 config.local.php
|
||||||
|
// 만 바꾸면 됨). 값은 문자열 배열 또는 쉼표문자열(숫자만 정제). 예:
|
||||||
|
// 'SENSOR_RECIPIENTS' => [
|
||||||
|
// 1 => ['010-1111-1111', '010-2222-2222'], // 누수 → 시설팀
|
||||||
|
// 3 => ['010-3333-3333'], // 3번 → 고객센터
|
||||||
|
// ]
|
||||||
|
$sensor_recipients_raw = cfg('SENSOR_RECIPIENTS', []);
|
||||||
|
$sensor_recipients = [];
|
||||||
|
if (is_array($sensor_recipients_raw)) {
|
||||||
|
foreach ($sensor_recipients_raw as $sid => $list) {
|
||||||
|
$sid = (int)$sid;
|
||||||
|
if ($sid < 1 || $sid > 255) {
|
||||||
|
continue; // 유효 sensor_id(1..255) 만
|
||||||
|
}
|
||||||
|
if (is_string($list)) {
|
||||||
|
$list = explode(',', $list);
|
||||||
|
}
|
||||||
|
if (!is_array($list)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$nums = [];
|
||||||
|
foreach ($list as $num) {
|
||||||
|
$d = preg_replace('/\D+/', '', (string)$num);
|
||||||
|
if ($d !== '') {
|
||||||
|
$nums[] = $d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($nums !== []) { // 유효 번호가 하나도 없으면 버림(전역 폴백되게)
|
||||||
|
$sensor_recipients[$sid] = $nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
define('SENSOR_RECIPIENTS', $sensor_recipients);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 센서별 수신자 그룹 해소(순수 로직). 그룹 지정 시 그 그룹, 없으면 전역 폴백.
|
||||||
|
* 테스트 용이성을 위해 상수 의존 없는 순수 함수로 분리한다.
|
||||||
|
*/
|
||||||
|
function resolve_sensor_recipients(int $sid, array $groups, array $global): array {
|
||||||
|
return $groups[$sid] ?? $global;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이 센서의 SMS 수신자 목록. SENSOR_RECIPIENTS[sid] 우선, 없으면 전역 SMS_RECIPIENTS.
|
||||||
|
* sid=0(헬스/잔액/시스템 통지)은 그룹이 없으므로 전역으로 폴백한다.
|
||||||
|
*/
|
||||||
|
function recipients_for_sensor(int $sid): array {
|
||||||
|
return resolve_sensor_recipients($sid, SENSOR_RECIPIENTS, SMS_RECIPIENTS);
|
||||||
|
}
|
||||||
|
|
||||||
// Cafe24 SMS('S' 타입)는 EUC-KR 기준 약 90바이트까지만 단문으로 발송된다. 초과하면
|
// Cafe24 SMS('S' 타입)는 EUC-KR 기준 약 90바이트까지만 단문으로 발송된다. 초과하면
|
||||||
// 게이트웨이가 잘라/거부하므로, 발송 직전에 EUC-KR 바이트 기준으로 안전하게 자른다
|
// 게이트웨이가 잘라/거부하므로, 발송 직전에 EUC-KR 바이트 기준으로 안전하게 자른다
|
||||||
|
|
@ -147,6 +199,17 @@ define('METRIC_ALERT_COOLDOWN_SEC', (int)cfg('METRIC_ALERT_COOLDOWN_SEC', 1800))
|
||||||
// 계속 보내므로, 같은 누수 에피소드에서 [누수감지] 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)); // 복구 확인창(초)
|
||||||
|
|
||||||
// ─── 결로(이슬점) 경보 ────────────────────────────────────────────────────────
|
// ─── 결로(이슬점) 경보 ────────────────────────────────────────────────────────
|
||||||
// 온도·습도가 각각 정상이어도 (현재온도 − 이슬점)=결로마진 이 좁으면 차가운 표면에
|
// 온도·습도가 각각 정상이어도 (현재온도 − 이슬점)=결로마진 이 좁으면 차가운 표면에
|
||||||
// 결로가 생긴다(장비 손상). 마진이 이 임계 이하이면 결로주의 경보를 보낸다.
|
// 결로가 생긴다(장비 손상). 마진이 이 임계 이하이면 결로주의 경보를 보낸다.
|
||||||
|
|
@ -216,7 +279,9 @@ define('ESCALATE_AFTER_SEC', (int)cfg('ESCALATE_AFTER_SEC', 1800));
|
||||||
// STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장(같은 값 반복)을 의심해 알린다.
|
// STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장(같은 값 반복)을 의심해 알린다.
|
||||||
// (하트비트는 '수신 끊김'만 본다 — I2C 는 살아있는데 값이 고착된 무성실패를 놓침.)
|
// (하트비트는 '수신 끊김'만 본다 — I2C 는 살아있는데 값이 고착된 무성실패를 놓침.)
|
||||||
define('STUCK_DETECT_ENABLED', (bool)cfg('STUCK_DETECT_ENABLED', true));
|
define('STUCK_DETECT_ENABLED', (bool)cfg('STUCK_DETECT_ENABLED', true));
|
||||||
define('STUCK_DETECT_SAMPLES', (int)cfg('STUCK_DETECT_SAMPLES', 6)); // 6 × 5분 = 30분
|
// 온습도 보고 주기가 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시간
|
||||||
|
|
||||||
|
|
@ -224,15 +289,57 @@ define('STUCK_ALERT_COOLDOWN_SEC', (int)cfg('STUCK_ALERT_COOLDOWN_SEC', 21600));
|
||||||
// 운영에서는 반드시 false. tests/chaos_drill.php 가 일시적으로 켜서 검증한다.
|
// 운영에서는 반드시 false. tests/chaos_drill.php 가 일시적으로 켜서 검증한다.
|
||||||
define('SMS_FORCE_FAIL', (bool)cfg('SMS_FORCE_FAIL', false));
|
define('SMS_FORCE_FAIL', (bool)cfg('SMS_FORCE_FAIL', false));
|
||||||
|
|
||||||
// ─── Heartbeat (장비 오프라인 감지) ────────────────────────
|
// ─── Heartbeat (장비 오프라인 감지) — 센서별 타임아웃 ──────────────────────────
|
||||||
// SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300), 누수 모듈
|
// 온습도(SHT30) 보고 주기는 1시간(raspberry_pi4_sht30/config.py REPORT_INTERVAL=3600),
|
||||||
// 정상 보고 주기는 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" 도 허용)
|
||||||
|
|
|
||||||
|
|
@ -22,19 +22,22 @@ require_once __DIR__ . '/sms_send.php';
|
||||||
function check_offline_sensors(): int {
|
function check_offline_sensors(): int {
|
||||||
$db = get_db();
|
$db = get_db();
|
||||||
|
|
||||||
$stmt = $db->prepare("
|
// 전 센서를 읽어 센서별 타임아웃(heartbeat_timeout_for)으로 오프라인을 판정한다. 보고
|
||||||
SELECT * FROM leak_sensor_status
|
// 주기가 센서마다 달라(온습도 1시간 / 누수 10분) 전역 단일 타임아웃으로는 온습도 오탐
|
||||||
WHERE last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND)
|
// 또는 누수 지연이 생긴다. silent_sec 로 무수신 경과를 함께 읽어 PHP 에서 비교한다.
|
||||||
AND offline_alerted = 0
|
$rows = $db->query("
|
||||||
");
|
SELECT sensor_id, sensor_name, device_id, device_location,
|
||||||
$stmt->execute([':timeout' => HEARTBEAT_TIMEOUT_SEC]);
|
last_seen, offline_alerted,
|
||||||
$offline_sensors = $stmt->fetchAll();
|
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS silent_sec
|
||||||
|
FROM leak_sensor_status
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
// 각 센서를 SMS 발송 전에 "원자적으로 선점"한다(0→1 전이가 한 프로세스에서만
|
// 각 센서를 SMS 발송 전에 "원자적으로 선점"한다(0→1 전이가 한 프로세스에서만
|
||||||
// 성공). 여러 진입점(sensor_data 쓰로틀, 로그인 시 즉시 체크, 대시보드)이
|
// 성공). 여러 진입점(sensor_data 쓰로틀, 로그인 시 즉시 체크, 대시보드)이
|
||||||
// 동시에 실행돼도 오프라인 SMS 가 중복 발송되지 않도록 한다.
|
// 동시에 실행돼도 오프라인 SMS 가 중복 발송되지 않도록 한다.
|
||||||
// last_seen 조건을 함께 둬, SELECT 와 UPDATE 사이에 장비가 복귀 보고를 하면
|
// last_seen 조건을 함께 둬, SELECT 와 UPDATE 사이에 장비가 복귀 보고를 하면
|
||||||
// (last_seen 갱신) 선점이 실패하여 오탐 오프라인 SMS 가 나가지 않도록 한다.
|
// (last_seen 갱신) 선점이 실패하여 오탐 오프라인 SMS 가 나가지 않도록 한다.
|
||||||
|
// 타임아웃은 센서별로 바인딩한다.
|
||||||
$claim = $db->prepare("
|
$claim = $db->prepare("
|
||||||
UPDATE 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
|
||||||
|
|
@ -44,10 +47,18 @@ function check_offline_sensors(): int {
|
||||||
|
|
||||||
$count = 0;
|
$count = 0;
|
||||||
|
|
||||||
foreach ($offline_sensors as $sensor) {
|
foreach ($rows as $sensor) {
|
||||||
|
if ((int)$sensor['offline_alerted'] !== 0) {
|
||||||
|
continue; // 이미 오프라인 알림 발송됨
|
||||||
|
}
|
||||||
|
$sid = (int)$sensor['sensor_id'];
|
||||||
|
$timeout = heartbeat_timeout_for($sid);
|
||||||
|
if ($sensor['silent_sec'] === null || (int)$sensor['silent_sec'] < $timeout) {
|
||||||
|
continue; // 이 센서의 타임아웃 이내 → 온라인
|
||||||
|
}
|
||||||
$claim->execute([
|
$claim->execute([
|
||||||
':sensor_id' => $sensor['sensor_id'],
|
':sensor_id' => $sid,
|
||||||
':timeout' => HEARTBEAT_TIMEOUT_SEC,
|
':timeout' => $timeout,
|
||||||
]);
|
]);
|
||||||
if ($claim->rowCount() === 0) {
|
if ($claim->rowCount() === 0) {
|
||||||
// 다른 동시 실행이 이미 선점했거나, 그 사이 장비가 복귀 보고를 했다.
|
// 다른 동시 실행이 이미 선점했거나, 그 사이 장비가 복귀 보고를 했다.
|
||||||
|
|
@ -140,6 +151,12 @@ function check_offline_sensors_throttled(): void {
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
error_log("[Heartbeat] 에스컬레이션/고착 검출 실패: " . $e->getMessage());
|
error_log("[Heartbeat] 에스컬레이션/고착 검출 실패: " . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
// 누수 디바운스: 보고가 끊겨도 미확정 전이(복구/감지)를 시간 경과로 확정 해소.
|
||||||
|
try {
|
||||||
|
sweep_pending_leak_transitions(get_db());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("[Heartbeat] 누수 디바운스 sweep 실패: " . $e->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,8 +191,9 @@ if ($_is_direct) {
|
||||||
maybe_send_daily_health_sms(get_db());
|
maybe_send_daily_health_sms(get_db());
|
||||||
maybe_escalate_alerts(get_db());
|
maybe_escalate_alerts(get_db());
|
||||||
maybe_detect_stuck_sensors(get_db());
|
maybe_detect_stuck_sensors(get_db());
|
||||||
|
sweep_pending_leak_transitions(get_db());
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
error_log("[Heartbeat] 헬스/에스컬레이션/고착 실패: " . $e->getMessage());
|
error_log("[Heartbeat] 헬스/에스컬레이션/고착/디바운스 실패: " . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$is_cli) {
|
if (!$is_cli) {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,20 @@ require_once __DIR__ . '/config.php';
|
||||||
require_once __DIR__ . '/ops_checks.php';
|
require_once __DIR__ . '/ops_checks.php';
|
||||||
require_once __DIR__ . '/forecast.php';
|
require_once __DIR__ . '/forecast.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수신자 번호 목록을 마스킹하여 짧게 요약(시스템 탭 설정 표시용). 개인정보 최소표시.
|
||||||
|
* 예: ['010...1111','010...2222'] → "010-****-1111 외 1명". 비면 "없음".
|
||||||
|
*/
|
||||||
|
function dash_mask_recipients(array $list): string {
|
||||||
|
$list = array_values(array_filter($list, fn($v) => trim((string)$v) !== ''));
|
||||||
|
$n = count($list);
|
||||||
|
if ($n === 0) {
|
||||||
|
return '없음';
|
||||||
|
}
|
||||||
|
$first = mask_phone((string)$list[0]);
|
||||||
|
return $n === 1 ? $first : "{$first} 외 " . ($n - 1) . '명';
|
||||||
|
}
|
||||||
|
|
||||||
// ── 탭 정의 (단일 출처) ─────────────────────────────────────
|
// ── 탭 정의 (단일 출처) ─────────────────────────────────────
|
||||||
const DASH_TABS = [
|
const DASH_TABS = [
|
||||||
'live' => '실시간 현황',
|
'live' => '실시간 현황',
|
||||||
|
|
@ -152,13 +166,26 @@ function load_global_data(PDO $db, bool $fresh_ops = false): array {
|
||||||
|
|
||||||
$last_seen_ts = strtotime($s['last_seen'] ?? '2000-01-01');
|
$last_seen_ts = strtotime($s['last_seen'] ?? '2000-01-01');
|
||||||
$elapsed = $now - $last_seen_ts;
|
$elapsed = $now - $last_seen_ts;
|
||||||
$s['is_offline'] = ($s['offline_alerted'] == 1) || ($elapsed >= HEARTBEAT_TIMEOUT_SEC);
|
// 오프라인 판정도 센서별 타임아웃(온습도 1시간 주기 오탐 방지) — cron 과 동일 기준.
|
||||||
|
$s['is_offline'] = ($s['offline_alerted'] == 1) || ($elapsed >= heartbeat_timeout_for($sid));
|
||||||
$s['offline_elapsed_min'] = (int)round($elapsed / 60);
|
$s['offline_elapsed_min'] = (int)round($elapsed / 60);
|
||||||
|
|
||||||
$status_code = $s['latest_metric']['metric_status'] ?? 'normal';
|
$status_code = $s['latest_metric']['metric_status'] ?? 'normal';
|
||||||
$s['metric_status'] = $status_code;
|
$s['metric_status'] = $status_code;
|
||||||
$s['is_alerting'] = ($status_code !== 'normal');
|
$s['is_alerting'] = ($status_code !== 'normal');
|
||||||
|
|
||||||
|
// 누수 확정 상태(디바운스): 컬럼이 있으면 leak_confirmed 를, 없으면 raw is_leak 를
|
||||||
|
// 헤드라인/집계의 단일 기준으로 삼는다(SMS 는 확정 엣지에서만 나가므로 표시와 일치).
|
||||||
|
// leak_pending: raw 가 확정과 어긋나 확인창 대기 중(카드에 '확인중' 배지).
|
||||||
|
$raw_leak = (int)($s['is_leak'] ?? 0);
|
||||||
|
if (array_key_exists('leak_confirmed', $s)) {
|
||||||
|
$s['leak_confirmed'] = (int)$s['leak_confirmed'];
|
||||||
|
$s['leak_pending'] = (($s['leak_pending_since'] ?? null) !== null) && ($raw_leak !== $s['leak_confirmed']);
|
||||||
|
} else {
|
||||||
|
$s['leak_confirmed'] = $raw_leak; // 구배포(컬럼 없음) 폴백
|
||||||
|
$s['leak_pending'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
// 서버측 라벨 오버라이드(위치/관리주체) — SMS 와 동일 표시로 통일
|
// 서버측 라벨 오버라이드(위치/관리주체) — SMS 와 동일 표시로 통일
|
||||||
[$s['device_location'], $s['sensor_name']] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null);
|
[$s['device_location'], $s['sensor_name']] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
@ -175,7 +202,7 @@ function load_global_data(PDO $db, bool $fresh_ops = false): array {
|
||||||
? (int)round($g['online_count'] / $g['sensor_total'] * 100) : 100;
|
? (int)round($g['online_count'] / $g['sensor_total'] * 100) : 100;
|
||||||
$g['leak_active_count'] = count(array_filter(
|
$g['leak_active_count'] = count(array_filter(
|
||||||
$statuses,
|
$statuses,
|
||||||
fn($s) => (int)($s['is_leak'] ?? 0) === 1 && !$s['is_offline']
|
fn($s) => (int)($s['leak_confirmed'] ?? $s['is_leak'] ?? 0) === 1 && !$s['is_offline']
|
||||||
));
|
));
|
||||||
|
|
||||||
// (3) 카드 맵 — 온습도(METRIC_SENSOR_IDS 는 미수신도 placeholder) / 누수(LEAK_SENSOR_IDS)
|
// (3) 카드 맵 — 온습도(METRIC_SENSOR_IDS 는 미수신도 placeholder) / 누수(LEAK_SENSOR_IDS)
|
||||||
|
|
@ -331,13 +358,13 @@ function load_global_data(PDO $db, bool $fresh_ops = false): array {
|
||||||
$g['forecasts'][$sid] = $fc;
|
$g['forecasts'][$sid] = $fc;
|
||||||
|
|
||||||
// 신선도 — offline 은 대시보드 기존 판정($s['is_offline'])을 단일 진실원으로 사용
|
// 신선도 — offline 은 대시보드 기존 판정($s['is_offline'])을 단일 진실원으로 사용
|
||||||
$fr = forecast_freshness(strtotime($s['last_seen'] ?? '') ?: null, $now, HEARTBEAT_TIMEOUT_SEC);
|
$fr = forecast_freshness(strtotime($s['last_seen'] ?? '') ?: null, $now, heartbeat_timeout_for($sid));
|
||||||
$fr['offline'] = (bool)$s['is_offline'];
|
$fr['offline'] = (bool)$s['is_offline'];
|
||||||
if ($fr['offline']) { $fr['imminent'] = false; }
|
if ($fr['offline']) { $fr['imminent'] = false; }
|
||||||
|
|
||||||
$g['risk_items_all'] = array_merge($g['risk_items_all'], risk_items(
|
$g['risk_items_all'] = array_merge($g['risk_items_all'], risk_items(
|
||||||
['sensor_id' => $sid, 'name' => $s['sensor_name'], 'location' => $s['device_location'],
|
['sensor_id' => $sid, 'name' => $s['sensor_name'], 'location' => $s['device_location'],
|
||||||
'is_leak' => (int)($s['is_leak'] ?? 0)],
|
'is_leak' => (int)($s['leak_confirmed'] ?? $s['is_leak'] ?? 0)],
|
||||||
$fc, $fr
|
$fc, $fr
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -428,8 +455,8 @@ function dash_hero_alert_lines(PDO $db, array $g): array {
|
||||||
foreach ($g['statuses'] as $s) {
|
foreach ($g['statuses'] as $s) {
|
||||||
$sid = (int)$s['sensor_id'];
|
$sid = (int)$s['sensor_id'];
|
||||||
|
|
||||||
// 누수 중(오프라인 아님)
|
// 누수 중(오프라인 아님) — 디바운스 확정 기준(SMS 와 일치)
|
||||||
if ((int)($s['is_leak'] ?? 0) === 1 && !$s['is_offline']) {
|
if ((int)($s['leak_confirmed'] ?? $s['is_leak'] ?? 0) === 1 && !$s['is_offline']) {
|
||||||
$start = null;
|
$start = null;
|
||||||
foreach ($g['unrecovered_incidents'] as $inc) {
|
foreach ($g['unrecovered_incidents'] as $inc) {
|
||||||
if ((int)$inc['sensor_id'] === $sid) { $start = $inc['started_at']; break; }
|
if ((int)$inc['sensor_id'] === $sid) { $start = $inc['started_at']; break; }
|
||||||
|
|
@ -738,7 +765,7 @@ function load_tab_system(PDO $db, array $g): array {
|
||||||
")->fetchColumn();
|
")->fetchColumn();
|
||||||
|
|
||||||
// 임계·설정 요약(읽기 전용 — config.local.php 로 조정)
|
// 임계·설정 요약(읽기 전용 — config.local.php 로 조정)
|
||||||
$t['config_rows'] = [
|
$config_rows = [
|
||||||
['고온 임계', METRIC_TEMP_HIGH_C . ' ℃'],
|
['고온 임계', METRIC_TEMP_HIGH_C . ' ℃'],
|
||||||
['저온 임계', METRIC_TEMP_LOW_C . ' ℃'],
|
['저온 임계', METRIC_TEMP_LOW_C . ' ℃'],
|
||||||
['고습 임계', METRIC_RH_HIGH . ' %'],
|
['고습 임계', METRIC_RH_HIGH . ' %'],
|
||||||
|
|
@ -746,11 +773,23 @@ function load_tab_system(PDO $db, array $g): array {
|
||||||
['결로 마진 임계', (METRIC_DEWPOINT_MARGIN_C > 0 ? METRIC_DEWPOINT_MARGIN_C . ' ℃' : '비활성')],
|
['결로 마진 임계', (METRIC_DEWPOINT_MARGIN_C > 0 ? METRIC_DEWPOINT_MARGIN_C . ' ℃' : '비활성')],
|
||||||
['경보 재발송 쿨다운', (METRIC_ALERT_COOLDOWN_SEC / 60) . '분'],
|
['경보 재발송 쿨다운', (METRIC_ALERT_COOLDOWN_SEC / 60) . '분'],
|
||||||
['누수 재발송 쿨다운', (LEAK_ALERT_COOLDOWN_SEC / 60) . '분'],
|
['누수 재발송 쿨다운', (LEAK_ALERT_COOLDOWN_SEC / 60) . '분'],
|
||||||
['오프라인 판정', (HEARTBEAT_TIMEOUT_SEC / 60) . '분 무수신'],
|
|
||||||
['SMS 잔액 경고 임계', SMS_BALANCE_WARN_THRESHOLD . '건'],
|
|
||||||
['온습도 센서', implode(', ', array_map(fn($i) => $i . '번', METRIC_SENSOR_IDS))],
|
|
||||||
['누수 센서', implode(', ', array_map(fn($i) => $i . '번', LEAK_SENSOR_IDS))],
|
|
||||||
];
|
];
|
||||||
|
// 누수 디바운스 확인 지연(활성 시)
|
||||||
|
if (LEAK_DEBOUNCE_ENABLED) {
|
||||||
|
$config_rows[] = ['누수 확인 지연', '감지 ' . LEAK_ALERT_CONFIRM_SEC . 's / 복구 ' . LEAK_RECOVERY_CONFIRM_SEC . 's'];
|
||||||
|
}
|
||||||
|
// 오프라인 판정 — 센서 유형별 타임아웃(온습도 1시간 주기 대응)
|
||||||
|
$config_rows[] = ['오프라인 판정',
|
||||||
|
'누수 ' . (int)round(HEARTBEAT_TIMEOUT_SEC / 60) . '분 / 온습도 ' . (int)round(METRIC_HEARTBEAT_TIMEOUT_SEC / 60) . '분 무수신'];
|
||||||
|
$config_rows[] = ['SMS 잔액 경고 임계', SMS_BALANCE_WARN_THRESHOLD . '건'];
|
||||||
|
$config_rows[] = ['온습도 센서', implode(', ', array_map(fn($i) => $i . '번', METRIC_SENSOR_IDS))];
|
||||||
|
$config_rows[] = ['누수 센서', implode(', ', array_map(fn($i) => $i . '번', LEAK_SENSOR_IDS))];
|
||||||
|
// 수신자: 전역 + 센서별 그룹(지정된 것만, 번호 마스킹)
|
||||||
|
$config_rows[] = ['수신자 · 전역', dash_mask_recipients(SMS_RECIPIENTS)];
|
||||||
|
foreach (SENSOR_RECIPIENTS as $rsid => $rlist) {
|
||||||
|
$config_rows[] = ["수신자 · {$rsid}번", dash_mask_recipients($rlist)];
|
||||||
|
}
|
||||||
|
$t['config_rows'] = $config_rows;
|
||||||
|
|
||||||
return $t;
|
return $t;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
258
php/sms_send.php
258
php/sms_send.php
|
|
@ -185,7 +185,9 @@ function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message
|
||||||
[, $name] = sensor_label((int)($sensor_data['sensor_id'] ?? 0), null, $sensor_data['sensor_name'] ?? null);
|
[, $name] = sensor_label((int)($sensor_data['sensor_id'] ?? 0), null, $sensor_data['sensor_name'] ?? null);
|
||||||
$allSuccess = true;
|
$allSuccess = true;
|
||||||
$lastRemaining = null; // 게이트웨이가 알려준 최신 잔여 건수(잔액 사전경보용)
|
$lastRemaining = null; // 게이트웨이가 알려준 최신 잔여 건수(잔액 사전경보용)
|
||||||
$targets = $recipients ?? SMS_RECIPIENTS; // 기본 1차, 에스컬레이션은 TIER2 전달
|
// 명시 전달($recipients: 에스컬레이션 TIER2)이 우선. 없으면 센서별 수신자 그룹
|
||||||
|
// (SENSOR_RECIPIENTS[sid]) → 지정 없으면 전역 SMS_RECIPIENTS 로 폴백.
|
||||||
|
$targets = $recipients ?? recipients_for_sensor((int)($sensor_data['sensor_id'] ?? 0));
|
||||||
|
|
||||||
foreach ($targets as $receiver) {
|
foreach ($targets as $receiver) {
|
||||||
$receiverDigits = preg_replace('/\D+/', '', $receiver);
|
$receiverDigits = preg_replace('/\D+/', '', $receiver);
|
||||||
|
|
@ -436,6 +438,50 @@ function alert_cooldown_suppresses(
|
||||||
return ($now - strtotime($latestSentAt)) < $cooldownSec;
|
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 발송 (종류별 쿨다운 포함).
|
||||||
*
|
*
|
||||||
|
|
@ -973,3 +1019,213 @@ function maybe_detect_stuck_sensors(PDO $db): void {
|
||||||
error_log("[SMS] 센서고착 의심 - 센서{$sid} (최근 {$n}회 평탄)");
|
error_log("[SMS] 센서고착 의심 - 센서{$sid} (최근 {$n}회 평탄)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 누수 디바운스 (감지/복구 확정 상태머신) ──────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* leak_sensor_status 에 디바운스 컬럼(leak_confirmed/leak_pending_since)이 있는지.
|
||||||
|
* 없으면(구배포) 디바운스를 비활성하고 레거시 즉시 발송 경로로 graceful degrade 한다.
|
||||||
|
* 1회 조회 후 캐시.
|
||||||
|
*/
|
||||||
|
function leak_status_has_debounce_cols(PDO $db): bool {
|
||||||
|
static $cached = null;
|
||||||
|
if ($cached !== null) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'leak_sensor_status'
|
||||||
|
AND COLUMN_NAME IN ('leak_confirmed', 'leak_pending_since')
|
||||||
|
");
|
||||||
|
$stmt->execute();
|
||||||
|
$cached = ((int)$stmt->fetchColumn() === 2);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("[Leak] 디바운스 컬럼 확인 실패: " . $e->getMessage());
|
||||||
|
$cached = false;
|
||||||
|
}
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 확정 누수 사고(leak_incident)를 연다(미복구 사고 없을 때만). started_at=확정 시각(NOW),
|
||||||
|
* log_id_alert=해당 센서의 최근 leak_alert 로그. 사고 테이블 없으면 조용히 스킵.
|
||||||
|
*/
|
||||||
|
function leak_incident_open(PDO $db, int $sensor_id, array $info): void {
|
||||||
|
try {
|
||||||
|
$open = $db->prepare("
|
||||||
|
SELECT id FROM leak_incident
|
||||||
|
WHERE sensor_id = :sid AND recovered_at IS NULL
|
||||||
|
ORDER BY started_at DESC, id DESC LIMIT 1
|
||||||
|
");
|
||||||
|
$open->execute([':sid' => $sensor_id]);
|
||||||
|
if ($open->fetchColumn() !== false) {
|
||||||
|
return; // 이미 미복구 사고 있음
|
||||||
|
}
|
||||||
|
$lg = $db->prepare("
|
||||||
|
SELECT id FROM leak_sensor_log
|
||||||
|
WHERE sensor_id = :sid AND event_type = 'leak_alert'
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
");
|
||||||
|
$lg->execute([':sid' => $sensor_id]);
|
||||||
|
$logId = $lg->fetchColumn();
|
||||||
|
[$loc, $name] = sensor_label($sensor_id, $info['device_location'] ?? null, $info['sensor_name'] ?? null);
|
||||||
|
$ins = $db->prepare("
|
||||||
|
INSERT INTO leak_incident
|
||||||
|
(sensor_id, device_id, sensor_name, device_location, started_at, log_id_alert, created_at)
|
||||||
|
VALUES
|
||||||
|
(:sid, :dev, :name, :loc, NOW(), :log, NOW())
|
||||||
|
");
|
||||||
|
$ins->execute([
|
||||||
|
':sid' => $sensor_id,
|
||||||
|
':dev' => $info['device_id'] ?? '',
|
||||||
|
':name' => $name,
|
||||||
|
':loc' => $loc,
|
||||||
|
':log' => $logId !== false ? (int)$logId : null,
|
||||||
|
]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
if ($e->getCode() !== '42S02') { // 테이블 없음이면 조용히
|
||||||
|
error_log("[Leak] incident open 오류: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 해당 센서의 가장 최근 미복구 사고를 닫는다(recovered_at=NOW, log_id_recovery 링크).
|
||||||
|
*/
|
||||||
|
function leak_incident_close(PDO $db, int $sensor_id): void {
|
||||||
|
try {
|
||||||
|
$lg = $db->prepare("
|
||||||
|
SELECT id FROM leak_sensor_log
|
||||||
|
WHERE sensor_id = :sid AND event_type = 'leak_recovery'
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
");
|
||||||
|
$lg->execute([':sid' => $sensor_id]);
|
||||||
|
$logId = $lg->fetchColumn();
|
||||||
|
$close = $db->prepare("
|
||||||
|
UPDATE leak_incident
|
||||||
|
SET recovered_at = NOW(), log_id_recovery = :log
|
||||||
|
WHERE sensor_id = :sid AND recovered_at IS NULL
|
||||||
|
ORDER BY started_at DESC, id DESC LIMIT 1
|
||||||
|
");
|
||||||
|
$close->execute([
|
||||||
|
':log' => $logId !== false ? (int)$logId : null,
|
||||||
|
':sid' => $sensor_id,
|
||||||
|
]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
if ($e->getCode() !== '42S02') {
|
||||||
|
error_log("[Leak] incident close 오류: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 누수 디바운스 상태머신 1스텝. raw(현재 실제 누수) 상태를 확정 상태와 비교해 확정 엣지에서만
|
||||||
|
* SMS 를 1회 보낸다(감지→[누수감지], 복구→[누수복구]). 확정 시 leak_incident 도 함께 갱신.
|
||||||
|
* (센서) 네임드 락으로 read-decide-update 를 직렬화해 동시 보고 경쟁을 막는다.
|
||||||
|
*
|
||||||
|
* 디바운스 비활성/컬럼 부재/상태행 부재면 false 를 반환하고, 호출자가 레거시 경로로 처리한다.
|
||||||
|
*
|
||||||
|
* @return bool 이번 호출에서 확정 SMS 를 발송했는지
|
||||||
|
*/
|
||||||
|
function apply_leak_debounce(PDO $db, int $sensor_id, int $raw, array $sensor_info): bool {
|
||||||
|
if (!LEAK_DEBOUNCE_ENABLED || !leak_status_has_debounce_cols($db)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 센서 단위 직렬화 ──
|
||||||
|
$lockName = substr("leak_debounce_{$sensor_id}", 0, 64);
|
||||||
|
$lockState = 'unsupported';
|
||||||
|
try {
|
||||||
|
$lk = $db->prepare('SELECT GET_LOCK(:n, 0)');
|
||||||
|
$lk->execute([':n' => $lockName]);
|
||||||
|
$res = $lk->fetchColumn();
|
||||||
|
$lockState = ($res === null || $res === false)
|
||||||
|
? 'unsupported'
|
||||||
|
: (((int)$res === 1) ? 'acquired' : 'busy');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$lockState = 'unsupported';
|
||||||
|
}
|
||||||
|
if ($lockState === 'busy') {
|
||||||
|
return false; // 다른 프로세스가 이 센서를 처리 중
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sel = $db->prepare("
|
||||||
|
SELECT leak_confirmed, UNIX_TIMESTAMP(leak_pending_since) AS pend
|
||||||
|
FROM leak_sensor_status WHERE sensor_id = :sid
|
||||||
|
");
|
||||||
|
$sel->execute([':sid' => $sensor_id]);
|
||||||
|
$row = $sel->fetch();
|
||||||
|
if ($row === false) {
|
||||||
|
return false; // 상태행 없음(업서트 전) — 다음 보고에서 처리
|
||||||
|
}
|
||||||
|
$confirmed = (int)$row['leak_confirmed'];
|
||||||
|
$pend = ($row['pend'] !== null) ? (int)$row['pend'] : null;
|
||||||
|
|
||||||
|
$d = leak_debounce_decide(
|
||||||
|
$raw, $confirmed, $pend,
|
||||||
|
LEAK_ALERT_CONFIRM_SEC, LEAK_RECOVERY_CONFIRM_SEC, time()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 상태 저장(변경 없어도 pending 해제/유지 반영 위해 항상 갱신).
|
||||||
|
$upd = $db->prepare("
|
||||||
|
UPDATE leak_sensor_status
|
||||||
|
SET leak_confirmed = :c, leak_pending_since = :p
|
||||||
|
WHERE sensor_id = :sid
|
||||||
|
");
|
||||||
|
$upd->execute([
|
||||||
|
':c' => $d['confirmed'],
|
||||||
|
':p' => $d['pending_since'] !== null ? date('Y-m-d H:i:s', $d['pending_since']) : null,
|
||||||
|
':sid' => $sensor_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($d['sms'] === 'leak') {
|
||||||
|
leak_incident_open($db, $sensor_id, $sensor_info);
|
||||||
|
return function_exists('send_leak_alert_sms') ? send_leak_alert_sms($sensor_info) : false;
|
||||||
|
}
|
||||||
|
if ($d['sms'] === 'recovery') {
|
||||||
|
leak_incident_close($db, $sensor_id);
|
||||||
|
return function_exists('send_leak_recovery_sms') ? send_leak_recovery_sms($sensor_info) : false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if ($lockState === 'acquired') {
|
||||||
|
try {
|
||||||
|
$db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => $lockName]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
/* 해제 실패 무시 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보고가 끊겨도(예: 복구 대기 중 장기 주기, 장비 무응답) 미확정 전이를 확정으로 해소한다.
|
||||||
|
* leak_pending_since 가 세팅된 센서를 raw=is_leak 로 재평가한다. cron_heartbeat 가 주기 호출.
|
||||||
|
*/
|
||||||
|
function sweep_pending_leak_transitions(PDO $db): void {
|
||||||
|
if (!LEAK_DEBOUNCE_ENABLED || !leak_status_has_debounce_cols($db)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$rows = $db->query("
|
||||||
|
SELECT sensor_id, sensor_name, device_id, device_location, is_leak
|
||||||
|
FROM leak_sensor_status WHERE leak_pending_since IS NOT NULL
|
||||||
|
")->fetchAll();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("[Leak] pending sweep 조회 실패: " . $e->getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$sid = (int)$r['sensor_id'];
|
||||||
|
$info = [
|
||||||
|
'sensor_id' => $sid,
|
||||||
|
'sensor_name' => $r['sensor_name'],
|
||||||
|
'device_id' => $r['device_id'],
|
||||||
|
'device_location' => $r['device_location'],
|
||||||
|
];
|
||||||
|
apply_leak_debounce($db, $sid, (int)$r['is_leak'], $info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,17 @@ ksort($sensor_cards);
|
||||||
$card_class = ''; $badge_class = 'ok'; $badge_text = '정상';
|
$card_class = ''; $badge_class = 'ok'; $badge_text = '정상';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$is_leaking = (int)($s['is_leak'] ?? 0) === 1;
|
// 디바운스 확정 상태 기준(SMS 와 일치). leak_pending: 확인창 대기 중(블립 평가).
|
||||||
|
$is_leaking = (int)($s['leak_confirmed'] ?? $s['is_leak'] ?? 0) === 1;
|
||||||
|
$is_pending = !empty($s['leak_pending']);
|
||||||
if ($s['is_offline']) {
|
if ($s['is_offline']) {
|
||||||
$card_class = 'offline'; $badge_class = 'offline';
|
$card_class = 'offline'; $badge_class = 'offline';
|
||||||
$badge_text = $is_leaking ? '오프라인 (누수 중 끊김)' : '오프라인';
|
$badge_text = $is_leaking ? '오프라인 (누수 중 끊김)' : '오프라인';
|
||||||
} elseif ($is_leaking) {
|
} elseif ($is_leaking) {
|
||||||
$card_class = 'leak'; $badge_class = 'leak'; $badge_text = '누수';
|
$card_class = 'leak'; $badge_class = 'leak'; $badge_text = '누수';
|
||||||
|
} elseif ($is_pending) {
|
||||||
|
// raw 감지됐으나 확인창 미충족 → 확정 전(SMS 아직 안 나감)임을 명시
|
||||||
|
$card_class = 'pending'; $badge_class = 'pending'; $badge_text = '확인중(디바운스)';
|
||||||
} else {
|
} else {
|
||||||
$card_class = ''; $badge_class = 'ok'; $badge_text = '정상';
|
$card_class = ''; $badge_class = 'ok'; $badge_text = '정상';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,10 @@ FORCE_SIMULATION = False
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Measurement/report intervals (same defaults as firmware app_config.h)
|
# Measurement/report intervals (same defaults as firmware app_config.h)
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
REPORT_INTERVAL = 300 # Normal measure/report interval (seconds) = 5 minutes
|
REPORT_INTERVAL = 3600 # Normal measure/report interval (seconds) = 1 hour
|
||||||
|
# Server offline detection must exceed this: METRIC_HEARTBEAT_TIMEOUT_SEC
|
||||||
|
# (default 7500s ≈ 2 missed hourly reports + margin) covers it.
|
||||||
|
# Override per-node in config_local.py if a different cadence is needed.
|
||||||
MEASURE_RETRY_DELAY = 5 # Wait before retry after a failed measurement (seconds)
|
MEASURE_RETRY_DELAY = 5 # Wait before retry after a failed measurement (seconds)
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
|
||||||
26
sql/migration_leak_debounce.sql
Normal file
26
sql/migration_leak_debounce.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- migration_leak_debounce.sql
|
||||||
|
-- 누수 감지/복구 디바운스: 기존 배포의 leak_sensor_status 에 확정 상태 컬럼 2개 추가.
|
||||||
|
-- leak_confirmed : 디바운스로 "확정"된 누수 상태(SMS/사고추적 기준). is_leak 은 raw 최신.
|
||||||
|
-- leak_pending_since : raw 가 확정과 어긋나기 시작한 시각(NULL=안정). 확인창 경과로 확정.
|
||||||
|
--
|
||||||
|
-- 신규 설치는 schema_sht30.sql 에 이미 포함되어 있으므로 실행 불필요. 이 파일은 "기존
|
||||||
|
-- 운영 DB" 업그레이드 전용이다.
|
||||||
|
-- 실행: SOURCE /path/to/sql/migration_leak_debounce.sql;
|
||||||
|
--
|
||||||
|
-- 멱등성: MySQL 8 은 ADD COLUMN IF NOT EXISTS 를 지원하지 않으므로, 이미 적용된 DB 에서
|
||||||
|
-- 재실행하면 "Duplicate column name" 오류가 난다(무해 — 이미 컬럼이 있다는 뜻이니 무시).
|
||||||
|
-- MariaDB 라면 아래 문에 `IF NOT EXISTS` 를 넣어 안전하게 재실행할 수 있다.
|
||||||
|
--
|
||||||
|
-- 참고: 이 컬럼이 없어도 서버는 레거시(즉시 발송) 경로로 계속 동작한다(graceful degrade).
|
||||||
|
-- 디바운스를 켜려면 이 마이그레이션을 적용하면 된다.
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
ALTER TABLE `leak_sensor_status`
|
||||||
|
ADD COLUMN `leak_confirmed` TINYINT(1) NOT NULL DEFAULT 0
|
||||||
|
COMMENT '디바운스 확정 누수 상태(SMS/사고추적 기준; is_leak 은 raw 최신)',
|
||||||
|
ADD COLUMN `leak_pending_since` DATETIME NULL DEFAULT NULL
|
||||||
|
COMMENT '미확정 전이 시작 시각(NULL=안정, raw==confirmed)';
|
||||||
|
|
||||||
|
-- 적용 후: 이미 누수(is_leak=1) 중인 센서는 leak_confirmed=0 으로 시작하므로, 다음 보고에서
|
||||||
|
-- 감지 확인창(LEAK_ALERT_CONFIRM_SEC) 경과 후 [누수감지] 를 1회 재확정한다(사실상 재확인).
|
||||||
|
|
@ -53,6 +53,10 @@ CREATE TABLE IF NOT EXISTS `leak_sensor_status` (
|
||||||
ON UPDATE CURRENT_TIMESTAMP,
|
ON UPDATE CURRENT_TIMESTAMP,
|
||||||
`offline_alerted` TINYINT(1) NOT NULL DEFAULT 0
|
`offline_alerted` TINYINT(1) NOT NULL DEFAULT 0
|
||||||
COMMENT '오프라인 알림 발송 여부 (1=발송됨, 0=미발송)',
|
COMMENT '오프라인 알림 발송 여부 (1=발송됨, 0=미발송)',
|
||||||
|
`leak_confirmed` TINYINT(1) NOT NULL DEFAULT 0
|
||||||
|
COMMENT '디바운스 확정 누수 상태(SMS/사고추적 기준; is_leak 은 raw 최신)',
|
||||||
|
`leak_pending_since` DATETIME NULL DEFAULT NULL
|
||||||
|
COMMENT '미확정 전이 시작 시각(NULL=안정, raw==confirmed)',
|
||||||
|
|
||||||
PRIMARY KEY (`sensor_id`)
|
PRIMARY KEY (`sensor_id`)
|
||||||
) ENGINE=InnoDB
|
) ENGINE=InnoDB
|
||||||
|
|
|
||||||
50
tests/heartbeat_timeout_test.php
Normal file
50
tests/heartbeat_timeout_test.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// 센서별 히트비트(오프라인 판정) 타임아웃 해소 순수 로직 테스트 (DB/네트워크 없음).
|
||||||
|
// resolve_heartbeat_timeout(): 명시 오버라이드 > 온습도(METRIC) 기본 > 전역 기본.
|
||||||
|
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
require_once $root . '/php/config.php'; // resolve_heartbeat_timeout() 포함
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
function check(bool $cond, string $msg): void {
|
||||||
|
global $failures;
|
||||||
|
if (!$cond) { $failures[] = $msg; }
|
||||||
|
}
|
||||||
|
|
||||||
|
check(function_exists('resolve_heartbeat_timeout'), 'resolve_heartbeat_timeout() should exist.');
|
||||||
|
|
||||||
|
$overrides = [3 => 900]; // 3번만 명시 오버라이드
|
||||||
|
$metricIds = [2, 4]; // 온습도 센서
|
||||||
|
$metricDef = 7500;
|
||||||
|
$globalDef = 1200;
|
||||||
|
|
||||||
|
// ── 1) 명시 오버라이드 최우선(온습도여도) ──
|
||||||
|
check(resolve_heartbeat_timeout(3, $overrides, $metricIds, $metricDef, $globalDef) === 900,
|
||||||
|
'explicit override wins');
|
||||||
|
|
||||||
|
// ── 2) 온습도 센서 → METRIC 기본 ──
|
||||||
|
check(resolve_heartbeat_timeout(2, $overrides, $metricIds, $metricDef, $globalDef) === 7500,
|
||||||
|
'metric sensor -> metric default');
|
||||||
|
check(resolve_heartbeat_timeout(4, $overrides, $metricIds, $metricDef, $globalDef) === 7500,
|
||||||
|
'metric sensor 4 -> metric default');
|
||||||
|
|
||||||
|
// ── 3) 그 외(누수 등) → 전역 기본 ──
|
||||||
|
check(resolve_heartbeat_timeout(1, $overrides, $metricIds, $metricDef, $globalDef) === 1200,
|
||||||
|
'leak/other sensor -> global default');
|
||||||
|
|
||||||
|
// ── 4) 명시 오버라이드가 온습도 기본을 이긴다 ──
|
||||||
|
$ov2 = [2 => 4000];
|
||||||
|
check(resolve_heartbeat_timeout(2, $ov2, $metricIds, $metricDef, $globalDef) === 4000,
|
||||||
|
'explicit override beats metric default for a metric sensor');
|
||||||
|
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Heartbeat timeout test failures:\n");
|
||||||
|
foreach ($failures as $f) {
|
||||||
|
fwrite(STDERR, "- {$f}\n");
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Heartbeat timeout resolution checks passed.\n";
|
||||||
89
tests/leak_debounce_test.php
Normal file
89
tests/leak_debounce_test.php
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// 누수 감지/복구 디바운스 순수 판정 테스트 (DB/네트워크 없음).
|
||||||
|
// leak_debounce_decide(): raw 상태가 확인창(감지/복구) 동안 지속돼야 확정한다.
|
||||||
|
// 플래핑(감지↔복구 채터링)이 SMS 폭탄으로 번지지 않음을 회귀 방지.
|
||||||
|
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
require_once $root . '/php/sms_send.php'; // leak_debounce_decide() 포함
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
function check(bool $cond, string $msg): void {
|
||||||
|
global $failures;
|
||||||
|
if (!$cond) { $failures[] = $msg; }
|
||||||
|
}
|
||||||
|
|
||||||
|
check(function_exists('leak_debounce_decide'), 'leak_debounce_decide() should exist.');
|
||||||
|
|
||||||
|
$ALERT = 60; // 감지 확인창
|
||||||
|
$RECOV = 180; // 복구 확인창
|
||||||
|
$NOW = 1_700_000_000;
|
||||||
|
|
||||||
|
// helper: 결과 비교
|
||||||
|
$eq = function (array $got, int $confirmed, ?int $pending, ?string $sms): bool {
|
||||||
|
return $got['confirmed'] === $confirmed
|
||||||
|
&& $got['pending_since'] === $pending
|
||||||
|
&& $got['sms'] === $sms;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 1) 안정(정상): raw==confirmed==0, pending 없음 → 무변화 ──
|
||||||
|
check($eq(leak_debounce_decide(0, 0, null, $ALERT, $RECOV, $NOW), 0, null, null),
|
||||||
|
'stable normal -> no change, no sms');
|
||||||
|
|
||||||
|
// ── 2) 안정(누수): raw==confirmed==1, 잔여 pending → pending 해제 ──
|
||||||
|
check($eq(leak_debounce_decide(1, 1, $NOW - 40, $ALERT, $RECOV, $NOW), 1, null, null),
|
||||||
|
'stable leak with stale pending -> clears pending, no sms');
|
||||||
|
|
||||||
|
// ── 3) 누수 감지 시작: raw=1, confirmed=0, pending 없음 → 타이머 시작 ──
|
||||||
|
check($eq(leak_debounce_decide(1, 0, null, $ALERT, $RECOV, $NOW), 0, $NOW, null),
|
||||||
|
'leak onset -> start pending timer, no sms yet');
|
||||||
|
|
||||||
|
// ── 4) 감지 확인창 이내(경과 30 < 60) → 대기 유지 ──
|
||||||
|
check($eq(leak_debounce_decide(1, 0, $NOW - 30, $ALERT, $RECOV, $NOW), 0, $NOW - 30, null),
|
||||||
|
'leak within confirm window -> keep waiting, no sms');
|
||||||
|
|
||||||
|
// ── 5) 감지 확인(경과 60 >= 60) → 확정 + [누수감지] ──
|
||||||
|
check($eq(leak_debounce_decide(1, 0, $NOW - 60, $ALERT, $RECOV, $NOW), 1, null, 'leak'),
|
||||||
|
'leak sustained >= alert window -> confirm leak, sms=leak');
|
||||||
|
|
||||||
|
// ── 6) 감지 경계값: 경과 59 → 아직 대기(엄격 <) ──
|
||||||
|
check($eq(leak_debounce_decide(1, 0, $NOW - 59, $ALERT, $RECOV, $NOW), 0, $NOW - 59, null),
|
||||||
|
'leak window-1 -> still waiting');
|
||||||
|
|
||||||
|
// ── 7) 복구 시작: raw=0, confirmed=1, pending 없음 → 타이머 시작 ──
|
||||||
|
check($eq(leak_debounce_decide(0, 1, null, $ALERT, $RECOV, $NOW), 1, $NOW, null),
|
||||||
|
'recovery onset -> start pending timer, no sms yet');
|
||||||
|
|
||||||
|
// ── 8) 복구 확인창 이내(경과 100 < 180) → 대기 유지 ──
|
||||||
|
check($eq(leak_debounce_decide(0, 1, $NOW - 100, $ALERT, $RECOV, $NOW), 1, $NOW - 100, null),
|
||||||
|
'recovery within confirm window -> keep waiting, no sms');
|
||||||
|
|
||||||
|
// ── 9) 복구 확인(경과 180 >= 180) → 확정 + [누수복구] ──
|
||||||
|
check($eq(leak_debounce_decide(0, 1, $NOW - 180, $ALERT, $RECOV, $NOW), 0, null, 'recovery'),
|
||||||
|
'recovery sustained >= recovery window -> confirm normal, sms=recovery');
|
||||||
|
|
||||||
|
// ── 10) [핵심] 확정 누수 중 짧은 dry 블립: 확정상태로 raw 복귀 → pending 해제(복구 확정 안 됨) ──
|
||||||
|
// 시퀀스: 누수 확정(confirmed=1). dry 블립 와서 pending 시작(경과 50<180). 다시 leak 들어옴.
|
||||||
|
// leak 재수신: raw=1==confirmed=1 → pending 해제, sms 없음. => 복구/재감지 SMS 둘 다 안 나감.
|
||||||
|
check($eq(leak_debounce_decide(1, 1, $NOW - 50, $ALERT, $RECOV, $NOW), 1, null, null),
|
||||||
|
'leak resumes before recovery confirms -> clears pending, NO recovery/re-alert sms (bomb guard)');
|
||||||
|
|
||||||
|
// ── 11) [핵심] 정상 중 짧은 leak 블립: 확정상태로 raw 복귀 → pending 해제(감지 확정 안 됨) ──
|
||||||
|
check($eq(leak_debounce_decide(0, 0, $NOW - 20, $ALERT, $RECOV, $NOW), 0, null, null),
|
||||||
|
'dry resumes before alert confirms -> clears pending, NO alert sms (blip filtered)');
|
||||||
|
|
||||||
|
// ── 12) 비대칭 임계 검증: 복구는 alert 창(60)이 아니라 recovery 창(180) 사용 ──
|
||||||
|
// 경과 100: alert 창(60)이면 확정되겠지만 recovery 이므로 아직 대기여야 한다.
|
||||||
|
check($eq(leak_debounce_decide(0, 1, $NOW - 100, $ALERT, $RECOV, $NOW), 1, $NOW - 100, null),
|
||||||
|
'recovery uses recovery window (100 < 180) not alert window (60)');
|
||||||
|
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Leak debounce test failures:\n");
|
||||||
|
foreach ($failures as $f) {
|
||||||
|
fwrite(STDERR, "- {$f}\n");
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Leak debounce decision checks passed.\n";
|
||||||
50
tests/recipients_group_test.php
Normal file
50
tests/recipients_group_test.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// 센서별 수신자 그룹 해소 순수 로직 테스트 (DB/네트워크 없음).
|
||||||
|
// resolve_sensor_recipients(): 센서 그룹 지정 시 그 그룹, 없으면 전역 폴백.
|
||||||
|
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
require_once $root . '/php/config.php'; // resolve_sensor_recipients() 포함
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
function check(bool $cond, string $msg): void {
|
||||||
|
global $failures;
|
||||||
|
if (!$cond) { $failures[] = $msg; }
|
||||||
|
}
|
||||||
|
|
||||||
|
check(function_exists('resolve_sensor_recipients'), 'resolve_sensor_recipients() should exist.');
|
||||||
|
|
||||||
|
$global = ['01000000001', '01000000002'];
|
||||||
|
$groups = [
|
||||||
|
1 => ['01011111111', '01022222222'], // 누수 → 시설팀
|
||||||
|
3 => ['01033333333'], // 3번 → 고객센터
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── 1) 그룹 지정 센서 → 그 그룹 ──
|
||||||
|
check(resolve_sensor_recipients(1, $groups, $global) === ['01011111111', '01022222222'],
|
||||||
|
'sensor 1 -> its own group');
|
||||||
|
check(resolve_sensor_recipients(3, $groups, $global) === ['01033333333'],
|
||||||
|
'sensor 3 -> its own group');
|
||||||
|
|
||||||
|
// ── 2) 미지정 센서 → 전역 폴백 ──
|
||||||
|
check(resolve_sensor_recipients(2, $groups, $global) === $global,
|
||||||
|
'unlisted sensor 2 -> global fallback');
|
||||||
|
|
||||||
|
// ── 3) sid=0(시스템/헬스/잔액) → 전역 폴백 ──
|
||||||
|
check(resolve_sensor_recipients(0, $groups, $global) === $global,
|
||||||
|
'sid 0 (system) -> global fallback');
|
||||||
|
|
||||||
|
// ── 4) 그룹 맵이 비면 전역 ──
|
||||||
|
check(resolve_sensor_recipients(1, [], $global) === $global,
|
||||||
|
'empty groups -> global fallback');
|
||||||
|
|
||||||
|
if ($failures) {
|
||||||
|
fwrite(STDERR, "Recipients group test failures:\n");
|
||||||
|
foreach ($failures as $f) {
|
||||||
|
fwrite(STDERR, "- {$f}\n");
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Recipients group resolution checks passed.\n";
|
||||||
Loading…
Reference in a new issue