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