POSA_LEAKSMS/docs/superpowers/specs/2026-07-13-leak-debounce-sensor-config-design.md
cw2007 2a469dc3cc 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
2026-07-13 20:57:17 +09:00

13 KiB
Raw Blame History

누수 디바운스 · 온습도 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 정의 갱신.

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