POSA_LEAKSMS/php/cron_heartbeat.php
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

207 lines
8.7 KiB
PHP

<?php
// =============================================================================
// cron_heartbeat.php - RPi 오프라인 감지
//
// [사용법 1] 독립 실행 (cron 가능 시):
// CLI: php cron_heartbeat.php
// HTTP: cron_heartbeat.php?key=CRON_SECRET
//
// [사용법 2] Piggyback (cron 불가 시):
// 다른 PHP에서 require_once 후 check_offline_sensors_throttled() 호출
// → 파일 타임스탬프 기반으로 5분에 1회만 실제 실행
// =============================================================================
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/sms_send.php';
/**
* 오프라인 센서를 조회하여 SMS 발송 + offline_alerted 플래그 설정
*
* @return int 오프라인 감지된 센서 수
*/
function check_offline_sensors(): int {
$db = get_db();
// 전 센서를 읽어 센서별 타임아웃(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
AND offline_alerted = 0
AND last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND)
");
$count = 0;
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' => $sid,
':timeout' => $timeout,
]);
if ($claim->rowCount() === 0) {
// 다른 동시 실행이 이미 선점했거나, 그 사이 장비가 복귀 보고를 했다.
continue;
}
$sensor_info = [
'sensor_id' => $sensor['sensor_id'],
'sensor_name' => $sensor['sensor_name'],
'device_id' => $sensor['device_id'],
'device_location' => $sensor['device_location'],
'last_seen' => $sensor['last_seen'],
];
$sent = send_device_offline_sms($sensor_info);
if (!$sent && SMS_RETRY_OFFLINE) {
// 일회성 오프라인 SMS 가 실패하면 선점(offline_alerted=1)을 되돌려, 다음 주기에
// 재시도되게 한다(critical: 게이트웨이 일시오류로 장비 다운을 영영 못 알리던 갭).
$db->prepare("UPDATE leak_sensor_status SET offline_alerted = 0 WHERE sensor_id = :sid")
->execute([':sid' => $sensor['sensor_id']]);
error_log("[Heartbeat] 오프라인 SMS 실패 - 센서{$sensor['sensor_id']} 재시도 예약");
}
$count++;
error_log("[Heartbeat] 오프라인 감지 - 센서{$sensor['sensor_id']} ({$sensor['sensor_name']})");
}
if ($count > 0) {
error_log("[Heartbeat] " . date('Y-m-d H:i:s') . " - 오프라인 감지: {$count}건");
}
return $count;
}
/**
* 쓰로틀 기반 오프라인 체크 (HEARTBEAT_CHECK_INTERVAL_SEC 간격으로 1회만 실행)
* sensor_data.php, dashboard.php 등에서 호출
*/
function check_offline_sensors_throttled(): void {
$lock_file = __DIR__ . '/var/heartbeat_last.txt';
// 주기 판정을 원자적으로: 락을 쥔 채 마지막 실행 시각을 읽고, 주기가 도래했으면
// 타임스탬프를 먼저 갱신한 뒤 락을 풀고 실행한다. 이렇게 하면 동시 호출(여러
// 디바이스 POST / 대시보드 로드)이 몰려도 한 주기에 단 한 번만 스캔이 돈다.
// (기존 비원자 read-then-write 는 동시 호출이 모두 stale 로 읽혀 중복 스캔됐다.)
$fh = @fopen($lock_file, 'c+');
if ($fh === false) {
// 파일을 못 열면 가용성 우선: 잠금 없이 한 번 실행.
try {
check_offline_sensors();
} catch (\Throwable $e) {
error_log("[Heartbeat] 체크 실패: " . $e->getMessage());
}
return;
}
$should_run = false;
if (flock($fh, LOCK_EX)) {
$raw = stream_get_contents($fh);
$last = ($raw !== false && $raw !== '') ? (int)$raw : 0;
if ($last === 0 || (time() - $last) >= HEARTBEAT_CHECK_INTERVAL_SEC) {
ftruncate($fh, 0);
rewind($fh);
fwrite($fh, (string)time());
fflush($fh);
$should_run = true;
}
flock($fh, LOCK_UN);
}
fclose($fh);
if ($should_run) {
try {
// 스캔(DB 작업)은 파일 락을 푼 뒤 실행해 락 보유 시간을 최소화한다.
check_offline_sensors();
} catch (\Throwable $e) {
// heartbeat 실패가 호출자(dashboard, sensor_data)를 죽이면 안 됨
error_log("[Heartbeat] 체크 실패: " . $e->getMessage());
}
// 일일 헬스(생존신호) SMS — 하루 1회 자체 dedup. 실패해도 호출자에 영향 없음.
try {
maybe_send_daily_health_sms(get_db());
} catch (\Throwable $e) {
error_log("[Heartbeat] 헬스 SMS 실패: " . $e->getMessage());
}
// 경보 신뢰성: 미복귀 에스컬레이션(opt-in) + 측정값 고착 검출(각자 쿨다운).
try {
maybe_escalate_alerts(get_db());
maybe_detect_stuck_sensors(get_db());
} catch (\Throwable $e) {
error_log("[Heartbeat] 에스컬레이션/고착 검출 실패: " . $e->getMessage());
}
// 누수 디바운스: 보고가 끊겨도 미확정 전이(복구/감지)를 시간 경과로 확정 해소.
try {
sweep_pending_leak_transitions(get_db());
} catch (\Throwable $e) {
error_log("[Heartbeat] 누수 디바운스 sweep 실패: " . $e->getMessage());
}
}
}
// ── 독립 실행 시에만 아래 코드 동작 ──────────────────────────
// require_once로 불러올 때는 함수 정의만 로드됨
$_is_direct = (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__));
if ($_is_direct) {
$is_cli = (php_sapi_name() === 'cli');
if (!$is_cli) {
// 기본 API_KEY 미교체 상태에서 HTTP 트리거를 허용하면 CRON_SECRET도 예측
// 가능한 기본값(hash_hmac('sha256','cron','change-this-api-key'))이 되므로,
// 신규 설치에서 인증 전 SMS/DB 부작용이 발생하는 것을 차단한다.
if (api_key_is_default()) {
http_response_code(503);
header('Content-Type: text/plain; charset=utf-8');
echo 'server not configured';
exit;
}
$key = $_GET['key'] ?? '';
if (!hash_equals(CRON_SECRET, $key)) {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo 'Forbidden';
exit;
}
}
$count = check_offline_sensors();
try {
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());
}
if (!$is_cli) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'status' => 'ok',
'offline_count' => $count,
'checked_at' => date('Y-m-d H:i:s'),
], JSON_UNESCAPED_UNICODE);
}
}