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

513 lines
25 KiB
PHP

<?php
// =============================================================================
// api/sensor_data.php - 센서 보드로부터 측정/이벤트 데이터를 받는 통합 수집 API
// (2번 모듈=SHT30 온습도 sensor_id=2, 1번 모듈=누수+카메라 sensor_id=1)
//
// [요청 형식 — 온습도(SHT30, 2번 모듈)] POST application/json
// {
// "device_id": "rpi4-sht30-01", // 배포=Raspberry Pi 4 (STM32 펌웨어면 "stm32-sht30-01")
// "device_location": "서버실",
// "sensor_id": 2,
// "sensor_name": "2번 센서 (SHT30)",
// "event_type": "periodic" | "startup",
// "timestamp": 1700000000,
// "metric_type": "sht30",
// "temperature_c": 24.50,
// "humidity_percent": 48.30,
// "metric_status": "normal", // 펌웨어 plausibility 판정(참고용; 서버가 재판정)
// "app_version": "v2606-sht30-rpi4" // Pi 배포 (STM32 펌웨어면 "v2606-sht30"); 서버는 검증 안 함
// }
//
// [요청 형식 — 누수(1번 모듈)] POST application/json
// {
// "device_id": "rpi4-leak-01",
// "device_location": "서버실",
// "sensor_id": 1,
// "sensor_name": "1번 센서 (누수)",
// "event_type": "leak_alert" | "leak_recovery" | "periodic" | "startup",
// "is_leak": 1, // 누수 여부(0/1)
// "timestamp": 1700000000
// }
// - leak_alert 응답에 log_id 를 실어 보내면, 1번 모듈이 그 log_id 로 사진을
// api/photo_upload.php 에 업로드한다.
//
// [인증 방식 2가지]
// 1) 신규(STM32 펌웨어): HTTP 헤더 X-Signature = HMAC-SHA256(key=API_KEY, msg=raw_body)
// - 본문 바이트 그대로 서명 → JSON 재직렬화/부동소수 포맷 호환 문제 없음
// - timestamp 신선도(INGEST_MAX_CLOCK_SKEW_SEC)로 재전송(replay) 방어
// 2) 레거시: 본문 "signature" 필드 = sha256(API_KEY . ksort(json)) (하위호환, deprecated)
//
// [온습도 임계 경보] config.php 의 METRIC_* 임계로 서버가 고온/저온/고습/저습을
// 판정하고 종류별 쿨다운으로 SMS를 발송한다. 정상 복귀 시 1회 복귀 SMS를 보낸다.
// (판정/발송 로직은 sms_send.php)
//
// [응답 형식] 200 OK application/json
// {"status":"ok","message":"저장 완료", ...}
// =============================================================================
// CORS 헤더 없음 — 기기(서버측 cURL/requests) → 서버 POST 전용이라 브라우저
// same-origin 정책 대상이 아니며, Allow-Origin 헤더는 도메인 지문만 노출한다.
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
// POST만 허용
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['status' => 'error', 'message' => 'Method Not Allowed']);
exit;
}
require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/sms_send.php';
require_once dirname(__DIR__) . '/cron_heartbeat.php';
// ── 운영 비밀값 강제: 기본 API_KEY 로는 수신을 거부(fail-closed) ──────────
// config.local.php 미설치/미설정 상태에서 공개된 기본 키로 위조 데이터가
// 수신되는 것을 차단한다. (security: 시작 시 비밀값 존재 검증)
if (api_key_is_default()) {
error_log("[API] 거부: API_KEY 가 기본값입니다. config.local.php 에 운영 키를 설정하세요.");
record_ingest_rejection('key_default');
json_response(['status' => 'error', 'message' => '서버 구성 오류'], 503);
}
// 디바이스가 보낸 문자열에서 제어문자(개행 등)를 제거해 SMS/로그 라인 주입을 막는다.
// (서명은 raw 본문에 대해 이미 검증되었으므로 파싱 후 정제해도 안전하다.)
function api_sanitize_str(string $s): string {
return preg_replace('/[[:cntrl:]]+/', ' ', $s);
}
function api_table_exists(PDO $db, string $table): bool {
try {
$stmt = $db->prepare("
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table
");
$stmt->execute([':table' => $table]);
return (int)$stmt->fetchColumn() > 0;
} catch (Throwable $e) {
error_log("[API] table check failed: {$table} " . $e->getMessage());
return false;
}
}
// ── 재전송(replay) 멱등 선점 ──────────────────────────────────
// 인증·신선도(timestamp)를 통과한 요청의 서명을 nonce 로 원자적 기록한다. 시계오차
// 창 안에서 같은 서명 본문이 다시 오면(펌웨어 재시도/캡처 재생) 한 번만 반영된다.
// 'claimed' : 이번에 선점 → 계속 처리(저장/발송)
// 'duplicate' : 이미 처리됨 → 멱등 200 으로 종료(재저장/재발송 없음)
// 'unavailable' : nonce 비활성/테이블 없음/오류 → 종전대로 진행(graceful degrade)
function ingest_nonce_claim(PDO $db, string $sig): string {
if (!INGEST_NONCE_ENABLED || $sig === '') {
return 'unavailable';
}
try {
$ins = $db->prepare("INSERT INTO leak_ingest_nonce (signature, seen_at) VALUES (:sig, NOW())");
$ins->execute([':sig' => substr($sig, 0, 128)]);
} catch (PDOException $e) {
$code = $e->getCode();
if ($code === '23000') {
return 'duplicate'; // PK 충돌 = 동일 서명 재수신
}
if ($code !== '42S02') { // 42S02 = 테이블 없음(기존 배포) → 조용히 비활성
error_log("[API] nonce claim 오류({$code}): " . $e->getMessage());
}
return 'unavailable';
}
// 선점 성공 후, 신선도 창의 2배보다 오래된 nonce 정리(베스트 에포트, 인덱스로 가벼움).
try {
$ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2);
$db->exec("DELETE FROM leak_ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
} catch (Throwable $e) {
/* 정리는 실패해도 무방 */
}
return 'claimed';
}
// 처리 중 저장이 실패하면(예: DB 오류) 선점한 nonce 를 되돌려, 디바이스 재시도가
// 다시 처리될 수 있게 한다(데이터 유실 방지).
function ingest_nonce_release(PDO $db, string $sig): void {
if ($sig === '') {
return;
}
try {
$db->prepare("DELETE FROM leak_ingest_nonce WHERE signature = :sig")
->execute([':sig' => substr($sig, 0, 128)]);
} catch (Throwable $e) {
error_log("[API] nonce release 오류: " . $e->getMessage());
}
}
// ── 요청 파싱 ─────────────────────────────────────────────
$raw = file_get_contents('php://input');
// 본문 크기 상한: 인증 전 대용량 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 차단.
if (strlen($raw) > INGEST_MAX_BODY_BYTES) {
error_log("[API] 본문 과대(" . strlen($raw) . "B > " . INGEST_MAX_BODY_BYTES . "B) from "
. ($_SERVER['REMOTE_ADDR'] ?? '?'));
record_ingest_rejection('body_too_large');
json_response(['status' => 'error', 'message' => '본문이 너무 큽니다'], 413);
}
$data = json_decode($raw, true);
if (!$data) {
record_ingest_rejection('invalid_json');
json_response(['status' => 'error', 'message' => '잘못된 JSON 형식'], 400);
}
// ── 서명 검증 ─────────────────────────────────────────────
// X-Signature 헤더가 있으면 raw-body HMAC 서명(STM32 펌웨어)으로 검증한다.
// 레거시 본문 필드 서명(키-접두 sha256)은 기본 비활성(ALLOW_LEGACY_BODY_SIGNATURE).
// 운영 클라이언트(STM32)는 HMAC 만 쓰므로 약한 다운그레이드 경로를 닫아 둔다.
$has_raw_sig = isset($_SERVER['HTTP_X_SIGNATURE']) && $_SERVER['HTTP_X_SIGNATURE'] !== '';
if ($has_raw_sig) {
$sig_ok = verify_signature_raw($raw);
$sig_mode = 'raw';
} elseif (ALLOW_LEGACY_BODY_SIGNATURE) {
$sig_ok = verify_signature($data);
$sig_mode = 'legacy';
} else {
$sig_ok = false; // X-Signature 헤더 필수 (레거시 비활성)
$sig_mode = 'none';
}
if (!$sig_ok) {
error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?') . " (mode={$sig_mode})");
// 출처 식별: 서명모드(raw=키불일치 / none=헤더스트립)와 본문 device_id(신뢰불가)를 함께
// 링에 남긴다 → 대시보드에서 '우리 노드 오설정 vs 외부 스캐너' 판별.
record_ingest_rejection('signature', ['mode' => $sig_mode, 'device_id' => (string)($data['device_id'] ?? '')]);
json_response(['status' => 'error', 'message' => '인증 실패'], 403);
}
// ── 필수 필드 검증 ────────────────────────────────────────
$required = ['device_id', 'sensor_id', 'event_type', 'timestamp'];
foreach ($required as $field) {
if (!isset($data[$field])) {
record_ingest_rejection('missing_field');
json_response(['status' => 'error', 'message' => "필드 누락: {$field}"], 400);
}
}
// ── 값 추출 및 타입 변환 ──────────────────────────────────
$device_id = api_sanitize_str(substr(trim($data['device_id']), 0, 64));
$device_location = api_sanitize_str(substr(trim($data['device_location'] ?? ''), 0, 100));
$sensor_id = (int)$data['sensor_id'];
// sensor_id 범위 검증(스키마 TINYINT UNSIGNED: 1..255). 범위를 벗어나면 DB INSERT
// 단계에서 500(DB 저장 실패)이 되므로, 경계에서 깔끔하게 400 으로 거른다.
if ($sensor_id < 1 || $sensor_id > 255) {
record_ingest_rejection('sensor_id_range');
json_response(['status' => 'error', 'message' => 'sensor_id 범위 오류(1..255)'], 400);
}
$sensor_name = api_sanitize_str(substr(trim($data['sensor_name'] ?? "센서{$sensor_id}"), 0, 100));
// 통합 수집: 온습도(startup/periodic) + 누수(leak_alert/leak_recovery) 이벤트 허용.
$event_type = in_array($data['event_type'], ['startup', 'periodic', 'leak_alert', 'leak_recovery'], true)
? $data['event_type'] : 'periodic';
// 누수 여부(0/1). 1번 모듈만 보낸다. leak_alert 는 항상 누수=1, leak_recovery 는 0 으로
// 강제해 이벤트와 상태의 일관성을 보장한다(필드 누락/오값 방어).
$is_leak = !empty($data['is_leak']) ? 1 : 0;
if ($event_type === 'leak_alert') {
$is_leak = 1;
} elseif ($event_type === 'leak_recovery') {
$is_leak = 0;
}
$is_leak_event = ($event_type === 'leak_alert' || $event_type === 'leak_recovery');
$ts = (int)$data['timestamp'];
// ── 재전송(replay) 방어: timestamp 신선도 검증 ────────────
// 캡처한 유효 서명 요청의 무한 재사용을 막는다. 펌웨어는 SNTP 동기 후 정확한
// Unix 시각을 싣는다(reporter.c). 허용 오차를 벗어나면 거부.
$now = time();
if ($ts <= 0 || abs($now - $ts) > INGEST_MAX_CLOCK_SKEW_SEC) {
error_log("[API] timestamp 신선도 위반 (ts={$ts}, now={$now}, max=" . INGEST_MAX_CLOCK_SKEW_SEC . "s) from "
. ($_SERVER['REMOTE_ADDR'] ?? '?'));
record_ingest_rejection('timestamp');
json_response(['status' => 'error', 'message' => '요청 시각이 허용 범위를 벗어났습니다'], 400);
}
$sensor_dt = date('Y-m-d H:i:s', $ts);
$metric_type = api_sanitize_str(substr(trim($data['metric_type'] ?? ''), 0, 30));
$temperature_c = isset($data['temperature_c']) && is_numeric($data['temperature_c']) ? round((float)$data['temperature_c'], 2) : null;
$humidity_percent = isset($data['humidity_percent']) && is_numeric($data['humidity_percent']) ? round((float)$data['humidity_percent'], 2) : null;
// 센서 물리 범위를 벗어난 값은 결측(null)으로 처리(센서 오류 방어)
if ($temperature_c !== null && ($temperature_c < -40 || $temperature_c > 125)) {
$temperature_c = null;
}
if ($humidity_percent !== null && ($humidity_percent < 0 || $humidity_percent > 100)) {
$humidity_percent = null;
}
$has_metric_payload = ($metric_type !== '') && ($temperature_c !== null || $humidity_percent !== null);
// ── 서버측 임계 판정 (config.php METRIC_*) ─────────────────
// 펌웨어 metric_status 는 물리 plausibility 판정이라 운영 경보로는 신뢰하지 않고,
// 서버가 운영 임계로 다시 판정하여 leak_sensor_metric.metric_status 에 저장한다.
$metric_eval = $has_metric_payload
? evaluate_metric_thresholds($temperature_c, $humidity_percent)
: ['breaches' => [], 'status' => 'normal'];
$metric_status = $metric_eval['status'];
$metric_saved = false;
// ── DB 저장 ───────────────────────────────────────────────
// 주기(periodic) 보고는 순수 하트비트 — leak_sensor_log 에 남기지 않고 상태 upsert
// (+온습도면 leak_sensor_metric)만 갱신한다. 누수 노드가 10분 주기로 늘어나면
// periodic 행이 이벤트 로그 창을 밀어내므로(지점 2개 = 일 288행) 전부 스킵.
// startup 은 1회 기록. 누수 이벤트(leak_alert/leak_recovery)는 항상 기록한다
// (leak_alert 는 log_id 가 사진 업로드에 필요하고, 누수 이력 추적 대상).
$skip_log = ($event_type === 'periodic');
$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.
$req_sig = $has_raw_sig ? (string)$_SERVER['HTTP_X_SIGNATURE'] : (string)($data['signature'] ?? '');
$nonce_state = ingest_nonce_claim($db, $req_sig);
if ($nonce_state === 'duplicate') {
json_response([
'status' => 'ok',
'message' => '이미 처리된 요청(멱등)',
'duplicate' => true,
]);
}
if (!$skip_log) {
$stmt = $db->prepare("
INSERT INTO leak_sensor_log
(device_id, device_location, sensor_id, sensor_name,
event_type, is_leak, sensor_time, created_at)
VALUES
(:device_id, :device_location, :sensor_id, :sensor_name,
:event_type, :is_leak, :sensor_time, NOW())
");
$stmt->execute([
':device_id' => $device_id,
':device_location' => $device_location,
':sensor_id' => $sensor_id,
':sensor_name' => $sensor_name,
':event_type' => $event_type,
':is_leak' => $is_leak,
':sensor_time' => $sensor_dt,
]);
$log_id = $db->lastInsertId();
}
// ── 오프라인 복구 감지: UPSERT 전에 offline_alerted 확인 ──
$was_offline = false;
$chk = $db->prepare("SELECT offline_alerted FROM leak_sensor_status WHERE sensor_id = :sensor_id");
$chk->execute([':sensor_id' => $sensor_id]);
$offline_flag = $chk->fetchColumn();
if ($offline_flag === '1' || $offline_flag === 1) {
$was_offline = true;
}
// ── 최신 센서 상태 업서트 (항상 갱신, 보고 = 온라인) ──
// is_leak 은 누수 이벤트(leak_alert/leak_recovery)일 때만 갱신한다. 온습도 보고
// (startup/periodic)는 누수 상태를 건드리지 않도록 기존 값을 보존한다(통합 서버에서
// 2번 모듈 보고가 1번 모듈 누수 상태를 덮어쓰지 않게).
$stmt2 = $db->prepare("
INSERT INTO leak_sensor_status
(sensor_id, device_id, sensor_name, device_location,
last_event_type, is_leak, last_seen, offline_alerted)
VALUES
(:sensor_id, :device_id, :sensor_name, :device_location,
:event_type, :is_leak_ins, NOW(), 0)
ON DUPLICATE KEY UPDATE
device_id = VALUES(device_id),
sensor_name = VALUES(sensor_name),
device_location = VALUES(device_location),
last_event_type = VALUES(last_event_type),
is_leak = IF(:is_leak_event, VALUES(is_leak), is_leak),
last_seen = NOW(),
offline_alerted = 0
");
$stmt2->execute([
':sensor_id' => $sensor_id,
':device_id' => $device_id,
':sensor_name' => $sensor_name,
':device_location' => $device_location,
':event_type' => $event_type,
':is_leak_ins' => $is_leak,
':is_leak_event' => $is_leak_event ? 1 : 0,
]);
if ($has_metric_payload && api_table_exists($db, 'leak_sensor_metric')) {
$metric_stmt = $db->prepare("
INSERT INTO leak_sensor_metric
(device_id, device_location, sensor_id, sensor_name,
metric_type, temperature_c, humidity_percent, metric_status,
measured_at, created_at)
VALUES
(:device_id, :device_location, :sensor_id, :sensor_name,
:metric_type, :temperature_c, :humidity_percent, :metric_status,
:measured_at, NOW())
");
$metric_stmt->execute([
':device_id' => $device_id,
':device_location' => $device_location,
':sensor_id' => $sensor_id,
':sensor_name' => $sensor_name,
':metric_type' => $metric_type,
':temperature_c' => $temperature_c,
':humidity_percent' => $humidity_percent,
':metric_status' => $metric_status,
':measured_at' => $sensor_dt,
]);
$metric_saved = true;
}
// ── 누수 사고(에피소드) 추적 (leak_incident) — 레거시(디바운스 미사용) 경로 ──
// leak_alert: 미복구 사고가 없으면 새 사고를 연다(중복 leak_alert 는 새로 열지 않음).
// leak_recovery: 같은 센서의 가장 최근 미복구 사고를 닫는다(recovered_at/복구 log_id 채움).
// leak_incident 테이블이 없으면(누수 미통합 서버) graceful 하게 건너뛴다.
// 디바운스 사용 시엔 사고 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
WHERE sensor_id = :sensor_id AND recovered_at IS NULL
ORDER BY started_at DESC, id DESC LIMIT 1
");
$open->execute([':sensor_id' => $sensor_id]);
if ($open->fetchColumn() === false) {
$ins_inc = $db->prepare("
INSERT INTO leak_incident
(sensor_id, device_id, sensor_name, device_location,
started_at, log_id_alert, created_at)
VALUES
(:sensor_id, :device_id, :sensor_name, :device_location,
:started_at, :log_id_alert, NOW())
");
$ins_inc->execute([
':sensor_id' => $sensor_id,
':device_id' => $device_id,
':sensor_name' => $sensor_name,
':device_location' => $device_location,
':started_at' => $sensor_dt,
':log_id_alert' => $log_id !== null ? (int)$log_id : null,
]);
}
} else { // leak_recovery
$close = $db->prepare("
UPDATE leak_incident
SET recovered_at = :recovered_at, log_id_recovery = :log_id_recovery
WHERE sensor_id = :sensor_id AND recovered_at IS NULL
ORDER BY started_at DESC, id DESC
LIMIT 1
");
$close->execute([
':recovered_at' => $sensor_dt,
':log_id_recovery' => $log_id !== null ? (int)$log_id : null,
':sensor_id' => $sensor_id,
]);
}
}
// ── 오프라인→온라인 복구 SMS 발송 ─────────────────────
if ($was_offline) {
$online_info = [
'sensor_id' => $sensor_id,
'sensor_name' => $sensor_name,
'device_id' => $device_id,
'device_location' => $device_location,
];
send_device_online_sms($online_info);
error_log("[API] 장비 온라인 복구 - 센서{$sensor_id}");
}
} catch (PDOException $e) {
error_log("[API] DB 오류: " . $e->getMessage());
// 저장 실패 시 선점한 nonce 를 되돌려, 디바이스 재시도가 재처리되도록 한다(유실 방지).
if (isset($nonce_state, $req_sig) && $nonce_state === 'claimed') {
ingest_nonce_release($db, $req_sig);
}
json_response(['status' => 'error', 'message' => 'DB 저장 실패'], 500);
}
// ── 온습도 임계 경보 / 정상 복귀 SMS ───────────────────────
$sms_sent = false;
$sensor_info = [
'sensor_id' => $sensor_id,
'sensor_name' => $sensor_name,
'device_id' => $device_id,
'device_location' => $device_location,
];
if ($has_metric_payload) {
if (!empty($metric_eval['breaches'])) {
// 임계 초과: 종류별로 SMS 발송(각자 쿨다운 적용)
foreach ($metric_eval['breaches'] as $kind) {
if ($kind === 'condensation_risk') {
$value = condensation_margin_c($temperature_c, $humidity_percent); // 표시값=결로마진(℃)
} elseif (in_array($kind, ['high_temp', 'low_temp'], true)) {
$value = $temperature_c;
} else {
$value = $humidity_percent;
}
if ($value !== null && send_metric_alert_sms($sensor_info, $kind, (float)$value)) {
$sms_sent = true;
}
}
} elseif (metric_was_alerting($db, $sensor_id) && metric_is_recovered($temperature_c, $humidity_percent)) {
// 직전까지 경보 상태였고 이제 (히스테리시스 포함) 정상으로 복귀 → 복귀 SMS 1회
$sms_sent = send_metric_recovery_sms(
$sensor_info,
(float)($temperature_c ?? 0.0),
(float)($humidity_percent ?? 0.0)
);
}
}
// ── 누수 경보 / 복구 SMS (1번 모듈) ────────────────────────
// [디바운스 경로] 감지/복구 전이를 확인창 동안 지속돼야 확정하고, 확정 엣지에서만 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;
}
} elseif ($event_type === 'leak_recovery' && function_exists('send_leak_recovery_sms')) {
if (send_leak_recovery_sms($sensor_info)) {
$sms_sent = true;
}
}
}
// ── Heartbeat: 다른 센서 오프라인 여부 체크 (5분 쓰로틀) ──
check_offline_sensors_throttled();
// ── 응답 ──────────────────────────────────────────────────
json_response([
'status' => 'ok',
'message' => $skip_log ? '상태 업데이트 완료' : '저장 완료',
'log_id' => $log_id ? (int)$log_id : null,
'metric_saved' => $metric_saved,
'metric_status' => $metric_status,
'sms_sent' => $sms_sent,
// 서버 현재 시각(Unix). 펌웨어가 RTC 드리프트 보정/시계 헬스 점검에 사용할 수 있도록
// 응답에 싣는다(폐쇄망에서 SNTP 가 간헐적일 때 서버시각으로 RTC 를 미세보정하는 후속
// 펌웨어 작업의 입력). 보안 영향 없음(인증된 200 응답에만 포함).
'server_now' => $now,
]);