From 2a469dc3ccc8416b32903f8acc74da4197ef424c Mon Sep 17 00:00:00 2001 From: cw2007 Date: Mon, 13 Jul 2026 20:57:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(alert):=20=EB=88=84=EC=88=98=20=EA=B0=90?= =?UTF-8?q?=EC=A7=80/=EB=B3=B5=EA=B5=AC=20=EB=94=94=EB=B0=94=EC=9A=B4?= =?UTF-8?q?=EC=8A=A4=20=C2=B7=20=EC=98=A8=EC=8A=B5=EB=8F=84=201=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=A3=BC=EA=B8=B0=20=C2=B7=20=EC=84=BC=EC=84=9C?= =?UTF-8?q?=EB=B3=84=20=EC=88=98=EC=8B=A0=EC=9E=90=20=EA=B7=B8=EB=A3=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 누수 감지↔복구 플래핑 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 --- ...7-13-leak-debounce-sensor-config-design.md | 218 +++++++++++++++ php/api/sensor_data.php | 25 +- php/assets/app.css | 2 + php/config.local.example.php | 23 +- php/config.php | 123 ++++++++- php/cron_heartbeat.php | 40 ++- php/dashboard_data.php | 61 ++++- php/sms_send.php | 258 +++++++++++++++++- php/tabs/live.php | 7 +- raspberry_pi4_sht30/config.py | 5 +- sql/migration_leak_debounce.sql | 26 ++ sql/schema_sht30.sql | 4 + tests/heartbeat_timeout_test.php | 50 ++++ tests/leak_debounce_test.php | 89 ++++++ tests/recipients_group_test.php | 50 ++++ 15 files changed, 941 insertions(+), 40 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-13-leak-debounce-sensor-config-design.md create mode 100644 sql/migration_leak_debounce.sql create mode 100644 tests/heartbeat_timeout_test.php create mode 100644 tests/leak_debounce_test.php create mode 100644 tests/recipients_group_test.php diff --git a/docs/superpowers/specs/2026-07-13-leak-debounce-sensor-config-design.md b/docs/superpowers/specs/2026-07-13-leak-debounce-sensor-config-design.md new file mode 100644 index 0000000..2daebc4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-leak-debounce-sensor-config-design.md @@ -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 유지). +- 플래핑 횟수 기반 별도 억제(디바운스로 충분). diff --git a/php/api/sensor_data.php b/php/api/sensor_data.php index 2a47ff2..b87637f 100644 --- a/php/api/sensor_data.php +++ b/php/api/sensor_data.php @@ -266,6 +266,10 @@ $log_id = null; try { $db = get_db(); + // 누수 디바운스 사용 여부(마스터 스위치 + 컬럼 존재). 컬럼 없는 구배포는 레거시 + // 즉시 발송 경로로 graceful degrade. 아래 사고추적/누수 SMS 분기에서 참조. + $use_debounce = LEAK_DEBOUNCE_ENABLED && leak_status_has_debounce_cols($db); + // ── 재전송(replay) 멱등 선점 ── // 인증·신선도를 통과한 서명을 nonce 로 원자적 기록한다. 같은 서명 본문이 시계오차 // 창 안에서 다시 오면(펌웨어 재시도/캡처 재생) 중복 저장·중복 SMS 없이 멱등 200. @@ -365,11 +369,13 @@ try { $metric_saved = true; } - // ── 누수 사고(에피소드) 추적 (leak_incident) ────────── + // ── 누수 사고(에피소드) 추적 (leak_incident) — 레거시(디바운스 미사용) 경로 ── // leak_alert: 미복구 사고가 없으면 새 사고를 연다(중복 leak_alert 는 새로 열지 않음). // leak_recovery: 같은 센서의 가장 최근 미복구 사고를 닫는다(recovered_at/복구 log_id 채움). // leak_incident 테이블이 없으면(누수 미통합 서버) graceful 하게 건너뛴다. - if ($is_leak_event && api_table_exists($db, 'leak_incident')) { + // 디바운스 사용 시엔 사고 open/close 를 '확정 엣지'(apply_leak_debounce)로 옮겨 처리하므로 + // 여기서는 건너뛴다(raw 이벤트마다 사고가 열려 월간보고서가 부풀지 않게). + if (!$use_debounce && $is_leak_event && api_table_exists($db, 'leak_incident')) { if ($event_type === 'leak_alert') { $open = $db->prepare(" SELECT id FROM leak_incident @@ -467,10 +473,17 @@ if ($has_metric_payload) { } // ── 누수 경보 / 복구 SMS (1번 모듈) ──────────────────────── -// leak_alert: [누수감지] SMS(쿨다운). 누수 지속 중 주기 보고가 와도 쿨다운으로 1회. -// leak_recovery: 직전에 누수 경보가 있었을 때만 [누수복구] SMS 발송. -// sms_send.php 에 헬퍼가 없거나(미통합 배포) 함수 미정의면 graceful 하게 건너뛴다. -if ($is_leak_event) { +// [디바운스 경로] 감지/복구 전이를 확인창 동안 지속돼야 확정하고, 확정 엣지에서만 SMS 1회. +// 감지↔복구 채터링(플래핑)이 SMS 폭탄으로 번지는 것을 원천 차단한다. 누수 센서의 주기 +// 보고(periodic, is_leak 포함)도 확정 타이머를 전진시키므로 이벤트/주기 모두에서 호출한다. +// [레거시 경로] 디바운스 컬럼이 없거나 비활성이면 종전대로 즉시 발송(쿨다운/복구 판정). +if ($use_debounce) { + if ($is_leak_event || in_array($sensor_id, LEAK_SENSOR_IDS, true)) { + if (apply_leak_debounce($db, $sensor_id, $is_leak, $sensor_info)) { + $sms_sent = true; + } + } +} elseif ($is_leak_event) { if ($event_type === 'leak_alert' && function_exists('send_leak_alert_sms')) { if (send_leak_alert_sms($sensor_info)) { $sms_sent = true; diff --git a/php/assets/app.css b/php/assets/app.css index edc00e9..2ecacf4 100644 --- a/php/assets/app.css +++ b/php/assets/app.css @@ -132,6 +132,7 @@ header h1::before { content: ""; width: 10px; height: 10px; border-radius: 50%; .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; } @@ -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.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); } diff --git a/php/config.local.example.php b/php/config.local.example.php index 45e51d7..8a3b75c 100644 --- a/php/config.local.example.php +++ b/php/config.local.example.php @@ -28,6 +28,14 @@ return [ '01000000000', ], + // 센서별 수신자 그룹 — 지정한 센서의 경보 SMS 는 그 그룹으로만 발송된다. 지정 없는 + // 센서는 위 전역 SMS_RECIPIENTS 로 폴백한다(헬스/잔액 등 시스템 통지도 전역 사용). + // 예: 누수(1번)는 시설팀, 3번은 고객센터로. 비우면(기본) 전역만 사용. + 'SENSOR_RECIPIENTS' => [ + // 1 => ['010-1111-1111', '010-2222-2222'], + // 3 => ['010-3333-3333'], + ], + 'ADMIN_USER' => 'admin', 'ADMIN_PASSWORD_HASH' => '$2y$10$replace.with.password_hash.output', // Base32 TOTP secret. MUST be left EMPTY here and set to a UNIQUE generated value in @@ -45,6 +53,19 @@ return [ // 신규 누수 지점(예: Pi-C, sensor_id 3) 투입 시: [1, 3] '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). 운영 환경에 맞게 조정. 'METRIC_TEMP_HIGH_C' => 27, // 고온 임계(℃) 'METRIC_TEMP_LOW_C' => 10, // 저온 임계(℃) @@ -69,7 +90,7 @@ return [ 'ESCALATE_AFTER_SEC' => 1800, // 경보 미복귀 30분 후 2차 통지 // 아래는 기본값으로 동작(필요 시에만 override): // 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, 'SENSOR_LOG_RETENTION_DAYS' => 365, diff --git a/php/config.php b/php/config.php index 4025d13..0f08ee0 100644 --- a/php/config.php +++ b/php/config.php @@ -72,7 +72,59 @@ $sms_recipients = cfg('SMS_RECIPIENTS', ['01000000000']); if (is_string($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바이트까지만 단문으로 발송된다. 초과하면 // 게이트웨이가 잘라/거부하므로, 발송 직전에 EUC-KR 바이트 기준으로 안전하게 자른다 @@ -147,6 +199,17 @@ define('METRIC_ALERT_COOLDOWN_SEC', (int)cfg('METRIC_ALERT_COOLDOWN_SEC', 1800)) // 계속 보내므로, 같은 누수 에피소드에서 [누수감지] SMS 가 반복 발송되지 않도록 한다. 기본 30분. define('LEAK_ALERT_COOLDOWN_SEC', (int)cfg('LEAK_ALERT_COOLDOWN_SEC', 1800)); +// ─── 누수 감지/복구 디바운스 (감지↔복구 플래핑 SMS 폭탄 방지) ────────────────── +// 센서에 닿는 물이 애매하면 GPIO 가 leak/dry 사이를 초 단위로 진동(채터링)한다. 상태 +// 전이를 즉시 발송하지 않고, 확인창(감지/복구) 동안 raw 상태가 지속돼야 "확정"하고 +// 확정 엣지에서만 SMS 를 1회 보낸다. 누수 지속 중 짧은 dry 블립(< 복구 확인창)은 복구를 +// 확정하지 못하므로, 확정 상태가 유지되어 재감지·복구 SMS 가 둘 다 나가지 않는다(폭탄 차단). +// leak_sensor_status.leak_confirmed / leak_pending_since 컬럼으로 상태를 저장한다. +// 해당 컬럼이 없는 구배포에서는 자동으로 레거시(즉시 발송) 경로로 graceful degrade. +define('LEAK_DEBOUNCE_ENABLED', (bool)cfg('LEAK_DEBOUNCE_ENABLED', true)); +define('LEAK_ALERT_CONFIRM_SEC', (int)cfg('LEAK_ALERT_CONFIRM_SEC', 60)); // 감지 확인창(초) +define('LEAK_RECOVERY_CONFIRM_SEC', (int)cfg('LEAK_RECOVERY_CONFIRM_SEC', 180)); // 복구 확인창(초) + // ─── 결로(이슬점) 경보 ──────────────────────────────────────────────────────── // 온도·습도가 각각 정상이어도 (현재온도 − 이슬점)=결로마진 이 좁으면 차가운 표면에 // 결로가 생긴다(장비 손상). 마진이 이 임계 이하이면 결로주의 경보를 보낸다. @@ -216,7 +279,9 @@ define('ESCALATE_AFTER_SEC', (int)cfg('ESCALATE_AFTER_SEC', 1800)); // STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장(같은 값 반복)을 의심해 알린다. // (하트비트는 '수신 끊김'만 본다 — I2C 는 살아있는데 값이 고착된 무성실패를 놓침.) define('STUCK_DETECT_ENABLED', (bool)cfg('STUCK_DETECT_ENABLED', true)); -define('STUCK_DETECT_SAMPLES', (int)cfg('STUCK_DETECT_SAMPLES', 6)); // 6 × 5분 = 30분 +// 온습도 보고 주기가 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_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 가 일시적으로 켜서 검증한다. define('SMS_FORCE_FAIL', (bool)cfg('SMS_FORCE_FAIL', false)); -// ─── Heartbeat (장비 오프라인 감지) ──────────────────────── -// SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300), 누수 모듈 -// 정상 보고 주기는 10분(raspberry_pi4/config.py REPORT_INTERVAL=600). 기본 오프라인 -// 판정 20분은 SHT30 4회 / 누수 2회 누락에 해당한다. config.local.php 로 조정 가능. -// ⚠️ 이 타임아웃은 전 센서 공통 — 어떤 모듈의 보고 주기보다도 길어야 오탐이 없다. -define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200)); +// ─── Heartbeat (장비 오프라인 감지) — 센서별 타임아웃 ────────────────────────── +// 온습도(SHT30) 보고 주기는 1시간(raspberry_pi4_sht30/config.py REPORT_INTERVAL=3600), +// 누수 모듈 정상 보고 주기는 10분(raspberry_pi4/config.py REPORT_INTERVAL=600). 보고 주기가 +// 센서마다 크게 다르므로 오프라인 판정 타임아웃을 센서별로 해소한다(전역 단일값이면 +// 온습도 1시간 주기가 20분 타임아웃에 매시간 오프라인 오탐, 또는 전역을 1시간+로 올리면 +// 누수 노드 다운을 늦게 감지). +// heartbeat_timeout_for(sid): 명시 오버라이드 > 온습도(METRIC) 기본 > 전역 기본. +// ⚠️ 각 값은 해당 센서의 보고 주기보다 넉넉히 길어야 오탐이 없다. +define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200)); // 전역 기본(누수 등 20분) +// 온습도 센서(METRIC_SENSOR_IDS) 오프라인 판정 기본. 1시간 주기 2회 누락 + 여유 ≈ 125분. +define('METRIC_HEARTBEAT_TIMEOUT_SEC', (int)cfg('METRIC_HEARTBEAT_TIMEOUT_SEC', 7500)); +// 센서별 명시 오버라이드 맵: [sensor_id => 초]. 예: 'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [3 => 900] +$sensor_hb_raw = cfg('SENSOR_HEARTBEAT_TIMEOUT_SEC', []); +$sensor_hb = []; +if (is_array($sensor_hb_raw)) { + foreach ($sensor_hb_raw as $sid => $sec) { + $sid = (int)$sid; + $sec = (int)$sec; + if ($sid >= 1 && $sid <= 255 && $sec > 0) { + $sensor_hb[$sid] = $sec; + } + } +} +define('SENSOR_HEARTBEAT_TIMEOUT_SEC', $sensor_hb); define('HEARTBEAT_CHECK_INTERVAL_SEC', (int)cfg('HEARTBEAT_CHECK_INTERVAL_SEC', 300)); // 오프라인 체크 실행 주기 define('CRON_SECRET', hash_hmac('sha256', 'cron', API_KEY)); // cron HTTP 접근용 시크릿(API_KEY 파생, HMAC) +/** + * 센서별 오프라인 판정 타임아웃 해소(순수 로직). 명시 오버라이드 > 온습도 기본 > 전역 기본. + * 테스트 용이성을 위해 상수 의존 없는 순수 함수로 분리한다. + */ +function resolve_heartbeat_timeout(int $sid, array $overrides, array $metricIds, int $metricDefault, int $globalDefault): int { + if (array_key_exists($sid, $overrides)) { + return (int)$overrides[$sid]; + } + if (in_array($sid, $metricIds, true)) { + return $metricDefault; + } + return $globalDefault; +} + +/** + * 이 센서의 오프라인 판정 타임아웃(초). cron_heartbeat / dashboard 신선도 판정 공용. + */ +function heartbeat_timeout_for(int $sid): int { + return resolve_heartbeat_timeout( + $sid, SENSOR_HEARTBEAT_TIMEOUT_SEC, METRIC_SENSOR_IDS, + METRIC_HEARTBEAT_TIMEOUT_SEC, HEARTBEAT_TIMEOUT_SEC + ); +} + // ─── 누수 모듈 sensor_id 목록 ──────────────────────────── // 대시보드 누수 상태 카드 표시 대상. 신규 누수 지점 추가 시 config.local.php 로 // 오버라이드한다. 예: 'LEAK_SENSOR_IDS' => [1, 3] (문자열 "1,3" 도 허용) diff --git a/php/cron_heartbeat.php b/php/cron_heartbeat.php index 905c8c4..d63fbab 100644 --- a/php/cron_heartbeat.php +++ b/php/cron_heartbeat.php @@ -22,19 +22,22 @@ require_once __DIR__ . '/sms_send.php'; function check_offline_sensors(): int { $db = get_db(); - $stmt = $db->prepare(" - SELECT * FROM leak_sensor_status - WHERE last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND) - AND offline_alerted = 0 - "); - $stmt->execute([':timeout' => HEARTBEAT_TIMEOUT_SEC]); - $offline_sensors = $stmt->fetchAll(); + // 전 센서를 읽어 센서별 타임아웃(heartbeat_timeout_for)으로 오프라인을 판정한다. 보고 + // 주기가 센서마다 달라(온습도 1시간 / 누수 10분) 전역 단일 타임아웃으로는 온습도 오탐 + // 또는 누수 지연이 생긴다. silent_sec 로 무수신 경과를 함께 읽어 PHP 에서 비교한다. + $rows = $db->query(" + SELECT sensor_id, sensor_name, device_id, device_location, + last_seen, offline_alerted, + TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS silent_sec + FROM leak_sensor_status + ")->fetchAll(); // 각 센서를 SMS 발송 전에 "원자적으로 선점"한다(0→1 전이가 한 프로세스에서만 // 성공). 여러 진입점(sensor_data 쓰로틀, 로그인 시 즉시 체크, 대시보드)이 // 동시에 실행돼도 오프라인 SMS 가 중복 발송되지 않도록 한다. // last_seen 조건을 함께 둬, SELECT 와 UPDATE 사이에 장비가 복귀 보고를 하면 // (last_seen 갱신) 선점이 실패하여 오탐 오프라인 SMS 가 나가지 않도록 한다. + // 타임아웃은 센서별로 바인딩한다. $claim = $db->prepare(" UPDATE leak_sensor_status SET offline_alerted = 1 WHERE sensor_id = :sensor_id @@ -44,10 +47,18 @@ function check_offline_sensors(): int { $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([ - ':sensor_id' => $sensor['sensor_id'], - ':timeout' => HEARTBEAT_TIMEOUT_SEC, + ':sensor_id' => $sid, + ':timeout' => $timeout, ]); if ($claim->rowCount() === 0) { // 다른 동시 실행이 이미 선점했거나, 그 사이 장비가 복귀 보고를 했다. @@ -140,6 +151,12 @@ function check_offline_sensors_throttled(): void { } catch (\Throwable $e) { error_log("[Heartbeat] 에스컬레이션/고착 검출 실패: " . $e->getMessage()); } + // 누수 디바운스: 보고가 끊겨도 미확정 전이(복구/감지)를 시간 경과로 확정 해소. + try { + sweep_pending_leak_transitions(get_db()); + } catch (\Throwable $e) { + error_log("[Heartbeat] 누수 디바운스 sweep 실패: " . $e->getMessage()); + } } } @@ -174,8 +191,9 @@ if ($_is_direct) { maybe_send_daily_health_sms(get_db()); maybe_escalate_alerts(get_db()); maybe_detect_stuck_sensors(get_db()); + sweep_pending_leak_transitions(get_db()); } catch (\Throwable $e) { - error_log("[Heartbeat] 헬스/에스컬레이션/고착 실패: " . $e->getMessage()); + error_log("[Heartbeat] 헬스/에스컬레이션/고착/디바운스 실패: " . $e->getMessage()); } if (!$is_cli) { diff --git a/php/dashboard_data.php b/php/dashboard_data.php index 6def8de..fce26b8 100644 --- a/php/dashboard_data.php +++ b/php/dashboard_data.php @@ -22,6 +22,20 @@ require_once __DIR__ . '/config.php'; require_once __DIR__ . '/ops_checks.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 = [ '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'); $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); $status_code = $s['latest_metric']['metric_status'] ?? 'normal'; $s['metric_status'] = $status_code; $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 와 동일 표시로 통일 [$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; $g['leak_active_count'] = count(array_filter( $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) @@ -331,13 +358,13 @@ function load_global_data(PDO $db, bool $fresh_ops = false): array { $g['forecasts'][$sid] = $fc; // 신선도 — 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']; if ($fr['offline']) { $fr['imminent'] = false; } $g['risk_items_all'] = array_merge($g['risk_items_all'], risk_items( ['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 )); } @@ -428,8 +455,8 @@ function dash_hero_alert_lines(PDO $db, array $g): array { foreach ($g['statuses'] as $s) { $sid = (int)$s['sensor_id']; - // 누수 중(오프라인 아님) - if ((int)($s['is_leak'] ?? 0) === 1 && !$s['is_offline']) { + // 누수 중(오프라인 아님) — 디바운스 확정 기준(SMS 와 일치) + if ((int)($s['leak_confirmed'] ?? $s['is_leak'] ?? 0) === 1 && !$s['is_offline']) { $start = null; foreach ($g['unrecovered_incidents'] as $inc) { 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(); // 임계·설정 요약(읽기 전용 — config.local.php 로 조정) - $t['config_rows'] = [ + $config_rows = [ ['고온 임계', METRIC_TEMP_HIGH_C . ' ℃'], ['저온 임계', METRIC_TEMP_LOW_C . ' ℃'], ['고습 임계', 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_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; } diff --git a/php/sms_send.php b/php/sms_send.php index c00b8ce..1b02bf8 100644 --- a/php/sms_send.php +++ b/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); $allSuccess = true; $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) { $receiverDigits = preg_replace('/\D+/', '', $receiver); @@ -436,6 +438,50 @@ function alert_cooldown_suppresses( 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 발송 (종류별 쿨다운 포함). * @@ -973,3 +1019,213 @@ function maybe_detect_stuck_sensors(PDO $db): void { error_log("[SMS] 센서고착 의심 - 센서{$sid} (최근 {$n}회 평탄)"); } } + +// ─── 누수 디바운스 (감지/복구 확정 상태머신) ────────────────────────────────── + +/** + * leak_sensor_status 에 디바운스 컬럼(leak_confirmed/leak_pending_since)이 있는지. + * 없으면(구배포) 디바운스를 비활성하고 레거시 즉시 발송 경로로 graceful degrade 한다. + * 1회 조회 후 캐시. + */ +function leak_status_has_debounce_cols(PDO $db): bool { + static $cached = null; + if ($cached !== null) { + return $cached; + } + try { + $stmt = $db->prepare(" + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'leak_sensor_status' + AND COLUMN_NAME IN ('leak_confirmed', 'leak_pending_since') + "); + $stmt->execute(); + $cached = ((int)$stmt->fetchColumn() === 2); + } catch (Throwable $e) { + error_log("[Leak] 디바운스 컬럼 확인 실패: " . $e->getMessage()); + $cached = false; + } + return $cached; +} + +/** + * 확정 누수 사고(leak_incident)를 연다(미복구 사고 없을 때만). started_at=확정 시각(NOW), + * log_id_alert=해당 센서의 최근 leak_alert 로그. 사고 테이블 없으면 조용히 스킵. + */ +function leak_incident_open(PDO $db, int $sensor_id, array $info): void { + try { + $open = $db->prepare(" + SELECT id FROM leak_incident + WHERE sensor_id = :sid AND recovered_at IS NULL + ORDER BY started_at DESC, id DESC LIMIT 1 + "); + $open->execute([':sid' => $sensor_id]); + if ($open->fetchColumn() !== false) { + return; // 이미 미복구 사고 있음 + } + $lg = $db->prepare(" + SELECT id FROM leak_sensor_log + WHERE sensor_id = :sid AND event_type = 'leak_alert' + ORDER BY id DESC LIMIT 1 + "); + $lg->execute([':sid' => $sensor_id]); + $logId = $lg->fetchColumn(); + [$loc, $name] = sensor_label($sensor_id, $info['device_location'] ?? null, $info['sensor_name'] ?? null); + $ins = $db->prepare(" + INSERT INTO leak_incident + (sensor_id, device_id, sensor_name, device_location, started_at, log_id_alert, created_at) + VALUES + (:sid, :dev, :name, :loc, NOW(), :log, NOW()) + "); + $ins->execute([ + ':sid' => $sensor_id, + ':dev' => $info['device_id'] ?? '', + ':name' => $name, + ':loc' => $loc, + ':log' => $logId !== false ? (int)$logId : null, + ]); + } catch (PDOException $e) { + if ($e->getCode() !== '42S02') { // 테이블 없음이면 조용히 + error_log("[Leak] incident open 오류: " . $e->getMessage()); + } + } +} + +/** + * 해당 센서의 가장 최근 미복구 사고를 닫는다(recovered_at=NOW, log_id_recovery 링크). + */ +function leak_incident_close(PDO $db, int $sensor_id): void { + try { + $lg = $db->prepare(" + SELECT id FROM leak_sensor_log + WHERE sensor_id = :sid AND event_type = 'leak_recovery' + ORDER BY id DESC LIMIT 1 + "); + $lg->execute([':sid' => $sensor_id]); + $logId = $lg->fetchColumn(); + $close = $db->prepare(" + UPDATE leak_incident + SET recovered_at = NOW(), log_id_recovery = :log + WHERE sensor_id = :sid AND recovered_at IS NULL + ORDER BY started_at DESC, id DESC LIMIT 1 + "); + $close->execute([ + ':log' => $logId !== false ? (int)$logId : null, + ':sid' => $sensor_id, + ]); + } catch (PDOException $e) { + if ($e->getCode() !== '42S02') { + error_log("[Leak] incident close 오류: " . $e->getMessage()); + } + } +} + +/** + * 누수 디바운스 상태머신 1스텝. raw(현재 실제 누수) 상태를 확정 상태와 비교해 확정 엣지에서만 + * SMS 를 1회 보낸다(감지→[누수감지], 복구→[누수복구]). 확정 시 leak_incident 도 함께 갱신. + * (센서) 네임드 락으로 read-decide-update 를 직렬화해 동시 보고 경쟁을 막는다. + * + * 디바운스 비활성/컬럼 부재/상태행 부재면 false 를 반환하고, 호출자가 레거시 경로로 처리한다. + * + * @return bool 이번 호출에서 확정 SMS 를 발송했는지 + */ +function apply_leak_debounce(PDO $db, int $sensor_id, int $raw, array $sensor_info): bool { + if (!LEAK_DEBOUNCE_ENABLED || !leak_status_has_debounce_cols($db)) { + return false; + } + + // ── 센서 단위 직렬화 ── + $lockName = substr("leak_debounce_{$sensor_id}", 0, 64); + $lockState = 'unsupported'; + try { + $lk = $db->prepare('SELECT GET_LOCK(:n, 0)'); + $lk->execute([':n' => $lockName]); + $res = $lk->fetchColumn(); + $lockState = ($res === null || $res === false) + ? 'unsupported' + : (((int)$res === 1) ? 'acquired' : 'busy'); + } catch (Throwable $e) { + $lockState = 'unsupported'; + } + if ($lockState === 'busy') { + return false; // 다른 프로세스가 이 센서를 처리 중 + } + + try { + $sel = $db->prepare(" + SELECT leak_confirmed, UNIX_TIMESTAMP(leak_pending_since) AS pend + FROM leak_sensor_status WHERE sensor_id = :sid + "); + $sel->execute([':sid' => $sensor_id]); + $row = $sel->fetch(); + if ($row === false) { + return false; // 상태행 없음(업서트 전) — 다음 보고에서 처리 + } + $confirmed = (int)$row['leak_confirmed']; + $pend = ($row['pend'] !== null) ? (int)$row['pend'] : null; + + $d = leak_debounce_decide( + $raw, $confirmed, $pend, + LEAK_ALERT_CONFIRM_SEC, LEAK_RECOVERY_CONFIRM_SEC, time() + ); + + // 상태 저장(변경 없어도 pending 해제/유지 반영 위해 항상 갱신). + $upd = $db->prepare(" + UPDATE leak_sensor_status + SET leak_confirmed = :c, leak_pending_since = :p + WHERE sensor_id = :sid + "); + $upd->execute([ + ':c' => $d['confirmed'], + ':p' => $d['pending_since'] !== null ? date('Y-m-d H:i:s', $d['pending_since']) : null, + ':sid' => $sensor_id, + ]); + + if ($d['sms'] === 'leak') { + leak_incident_open($db, $sensor_id, $sensor_info); + return function_exists('send_leak_alert_sms') ? send_leak_alert_sms($sensor_info) : false; + } + if ($d['sms'] === 'recovery') { + leak_incident_close($db, $sensor_id); + return function_exists('send_leak_recovery_sms') ? send_leak_recovery_sms($sensor_info) : false; + } + return false; + } finally { + if ($lockState === 'acquired') { + try { + $db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => $lockName]); + } catch (Throwable $e) { + /* 해제 실패 무시 */ + } + } + } +} + +/** + * 보고가 끊겨도(예: 복구 대기 중 장기 주기, 장비 무응답) 미확정 전이를 확정으로 해소한다. + * leak_pending_since 가 세팅된 센서를 raw=is_leak 로 재평가한다. cron_heartbeat 가 주기 호출. + */ +function sweep_pending_leak_transitions(PDO $db): void { + if (!LEAK_DEBOUNCE_ENABLED || !leak_status_has_debounce_cols($db)) { + return; + } + try { + $rows = $db->query(" + SELECT sensor_id, sensor_name, device_id, device_location, is_leak + FROM leak_sensor_status WHERE leak_pending_since IS NOT NULL + ")->fetchAll(); + } catch (Throwable $e) { + error_log("[Leak] pending sweep 조회 실패: " . $e->getMessage()); + return; + } + foreach ($rows as $r) { + $sid = (int)$r['sensor_id']; + $info = [ + 'sensor_id' => $sid, + 'sensor_name' => $r['sensor_name'], + 'device_id' => $r['device_id'], + 'device_location' => $r['device_location'], + ]; + apply_leak_debounce($db, $sid, (int)$r['is_leak'], $info); + } +} diff --git a/php/tabs/live.php b/php/tabs/live.php index ac1b667..15906fa 100644 --- a/php/tabs/live.php +++ b/php/tabs/live.php @@ -48,12 +48,17 @@ ksort($sensor_cards); $card_class = ''; $badge_class = 'ok'; $badge_text = '정상'; } } 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']) { $card_class = 'offline'; $badge_class = 'offline'; $badge_text = $is_leaking ? '오프라인 (누수 중 끊김)' : '오프라인'; } elseif ($is_leaking) { $card_class = 'leak'; $badge_class = 'leak'; $badge_text = '누수'; + } elseif ($is_pending) { + // raw 감지됐으나 확인창 미충족 → 확정 전(SMS 아직 안 나감)임을 명시 + $card_class = 'pending'; $badge_class = 'pending'; $badge_text = '확인중(디바운스)'; } else { $card_class = ''; $badge_class = 'ok'; $badge_text = '정상'; } diff --git a/raspberry_pi4_sht30/config.py b/raspberry_pi4_sht30/config.py index 25b204c..aabbc32 100644 --- a/raspberry_pi4_sht30/config.py +++ b/raspberry_pi4_sht30/config.py @@ -31,7 +31,10 @@ FORCE_SIMULATION = False # ───────────────────────────────────────────── # 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) # ───────────────────────────────────────────── diff --git a/sql/migration_leak_debounce.sql b/sql/migration_leak_debounce.sql new file mode 100644 index 0000000..04a4422 --- /dev/null +++ b/sql/migration_leak_debounce.sql @@ -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회 재확정한다(사실상 재확인). diff --git a/sql/schema_sht30.sql b/sql/schema_sht30.sql index 5284a98..c5eb9ce 100644 --- a/sql/schema_sht30.sql +++ b/sql/schema_sht30.sql @@ -53,6 +53,10 @@ CREATE TABLE IF NOT EXISTS `leak_sensor_status` ( ON UPDATE CURRENT_TIMESTAMP, `offline_alerted` TINYINT(1) NOT NULL DEFAULT 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`) ) ENGINE=InnoDB diff --git a/tests/heartbeat_timeout_test.php b/tests/heartbeat_timeout_test.php new file mode 100644 index 0000000..afaea96 --- /dev/null +++ b/tests/heartbeat_timeout_test.php @@ -0,0 +1,50 @@ + 온습도(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"; diff --git a/tests/leak_debounce_test.php b/tests/leak_debounce_test.php new file mode 100644 index 0000000..35b3654 --- /dev/null +++ b/tests/leak_debounce_test.php @@ -0,0 +1,89 @@ + 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"; diff --git a/tests/recipients_group_test.php b/tests/recipients_group_test.php new file mode 100644 index 0000000..472cd9c --- /dev/null +++ b/tests/recipients_group_test.php @@ -0,0 +1,50 @@ + ['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";