같은 cafe24 DB의 다른 시스템 테이블과 혼동/충돌을 막기 위해 5개 테이블을 leak_ 네임스페이스로 격리: sensor_log→leak_sensor_log, sensor_status→leak_sensor_status, sensor_metric→leak_sensor_metric, sms_log→leak_sms_log, ingest_nonce→leak_ingest_nonce (leak_incident/leak_photo는 이미 접두사). 컬럼명은 불변(sensor_id, is_leak 등) — 테이블명만 변경. sql/migration_add_leak.sql, migration_drop_leak.sql 삭제: 리네임 후 구버전 무접두 테이블 대상이라 실행 즉시 에러. 신규 설치는 schema_sht30.sql 하나로 완결. BREAKING CHANGE: 기존 무접두 테이블 배포와 호환 안 됨(신규 설치 전용). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC
189 lines
7.6 KiB
PHP
189 lines
7.6 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();
|
|
|
|
$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();
|
|
|
|
// 각 센서를 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 ($offline_sensors as $sensor) {
|
|
$claim->execute([
|
|
':sensor_id' => $sensor['sensor_id'],
|
|
':timeout' => HEARTBEAT_TIMEOUT_SEC,
|
|
]);
|
|
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());
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 독립 실행 시에만 아래 코드 동작 ──────────────────────────
|
|
// 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());
|
|
} 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);
|
|
}
|
|
}
|